9 Commits

9 changed files with 121 additions and 39 deletions
+26 -6
View File
@@ -14,11 +14,28 @@ _project_dir() {
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
}
# Normalize a path to be relative to project root.
# Strips the project_dir prefix if the LLM passes an absolute path.
# Usage: local rel_path; rel_path=$(_normalize_path "/abs/or/rel/path")
_normalize_path() {
local input_path="$1"
local project_dir
project_dir=$(_project_dir)
if [[ "${input_path}" == /* ]]; then
input_path="${input_path#"${project_dir}"/}"
fi
input_path="${input_path#./}"
echo "${input_path}"
}
# @cmd Read a file's contents before modifying
# @option --path! Path to the file (relative to project root)
read_file() {
local file_path
# shellcheck disable=SC2154
local file_path="${argc_path}"
file_path=$(_normalize_path "${argc_path}")
local project_dir
project_dir=$(_project_dir)
local full_path="${project_dir}/${file_path}"
@@ -39,7 +56,8 @@ read_file() {
# @option --path! Path for the file (relative to project root)
# @option --content! Complete file contents to write
write_file() {
local file_path="${argc_path}"
local file_path
file_path=$(_normalize_path "${argc_path}")
# shellcheck disable=SC2154
local content="${argc_content}"
local project_dir
@@ -47,7 +65,7 @@ write_file() {
local full_path="${project_dir}/${file_path}"
mkdir -p "$(dirname "${full_path}")"
echo "${content}" > "${full_path}"
printf '%s' "${content}" > "${full_path}"
green "Wrote: ${file_path}" >> "$LLM_OUTPUT"
}
@@ -55,7 +73,8 @@ write_file() {
# @cmd Find files similar to a given path (for pattern matching)
# @option --path! Path to find similar files for
find_similar_files() {
local file_path="${argc_path}"
local file_path
file_path=$(_normalize_path "${argc_path}")
local project_dir
project_dir=$(_project_dir)
@@ -71,14 +90,14 @@ find_similar_files() {
! -name "$(basename "${file_path}")" \
! -name "*test*" \
! -name "*spec*" \
2>/dev/null | head -3)
2>/dev/null | sed "s|^${project_dir}/||" | head -3)
if [[ -z "${results}" ]]; then
results=$(find "${project_dir}/src" -type f -name "*.${ext}" \
! -name "*test*" \
! -name "*spec*" \
-not -path '*/target/*' \
2>/dev/null | head -3)
2>/dev/null | sed "s|^${project_dir}/||" | head -3)
fi
if [[ -n "${results}" ]]; then
@@ -186,6 +205,7 @@ search_code() {
grep -v '/target/' | \
grep -v '/node_modules/' | \
grep -v '/.git/' | \
sed "s|^${project_dir}/||" | \
head -20) || true
if [[ -n "${results}" ]]; then
+22 -4
View File
@@ -14,6 +14,21 @@ _project_dir() {
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
}
# Normalize a path to be relative to project root.
# Strips the project_dir prefix if the LLM passes an absolute path.
_normalize_path() {
local input_path="$1"
local project_dir
project_dir=$(_project_dir)
if [[ "${input_path}" == /* ]]; then
input_path="${input_path#"${project_dir}"/}"
fi
input_path="${input_path#./}"
echo "${input_path}"
}
# @cmd Get project structure and layout
get_structure() {
local project_dir
@@ -78,6 +93,7 @@ search_content() {
grep -v '/node_modules/' | \
grep -v '/.git/' | \
grep -v '/dist/' | \
sed "s|^${project_dir}/||" | \
head -30) || true
if [[ -n "${results}" ]]; then
@@ -91,8 +107,9 @@ search_content() {
# @option --path! Path to the file (relative to project root)
# @option --lines Maximum lines to read (default: 200)
read_file() {
local file_path
# shellcheck disable=SC2154
local file_path="${argc_path}"
file_path=$(_normalize_path "${argc_path}")
local max_lines="${argc_lines:-200}"
local project_dir
project_dir=$(_project_dir)
@@ -122,7 +139,8 @@ read_file() {
# @cmd Find similar files to a given file (for pattern matching)
# @option --path! Path to the reference file
find_similar() {
local file_path="${argc_path}"
local file_path
file_path=$(_normalize_path "${argc_path}")
local project_dir
project_dir=$(_project_dir)
@@ -138,7 +156,7 @@ find_similar() {
! -name "$(basename "${file_path}")" \
! -name "*test*" \
! -name "*spec*" \
2>/dev/null | head -5)
2>/dev/null | sed "s|^${project_dir}/||" | head -5)
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
@@ -147,7 +165,7 @@ find_similar() {
! -name "$(basename "${file_path}")" \
! -name "*test*" \
-not -path '*/target/*' \
2>/dev/null | head -5)
2>/dev/null | sed "s|^${project_dir}/||" | head -5)
if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT"
else
+23 -4
View File
@@ -14,21 +14,38 @@ _project_dir() {
(cd "${dir}" 2>/dev/null && pwd) || echo "${dir}"
}
# Normalize a path to be relative to project root.
# Strips the project_dir prefix if the LLM passes an absolute path.
_normalize_path() {
local input_path="$1"
local project_dir
project_dir=$(_project_dir)
if [[ "${input_path}" == /* ]]; then
input_path="${input_path#"${project_dir}"/}"
fi
input_path="${input_path#./}"
echo "${input_path}"
}
# @cmd Read a file for analysis
# @option --path! Path to the file (relative to project root)
read_file() {
local project_dir
project_dir=$(_project_dir)
local file_path
# shellcheck disable=SC2154
local full_path="${project_dir}/${argc_path}"
file_path=$(_normalize_path "${argc_path}")
local full_path="${project_dir}/${file_path}"
if [[ ! -f "${full_path}" ]]; then
error "File not found: ${argc_path}" >> "$LLM_OUTPUT"
error "File not found: ${file_path}" >> "$LLM_OUTPUT"
return 1
fi
{
info "Reading: ${argc_path}"
info "Reading: ${file_path}"
echo ""
cat "${full_path}"
} >> "$LLM_OUTPUT"
@@ -80,6 +97,7 @@ search_code() {
grep -v '/target/' | \
grep -v '/node_modules/' | \
grep -v '/.git/' | \
sed "s|^${project_dir}/||" | \
head -30) || true
if [[ -n "${results}" ]]; then
@@ -113,7 +131,8 @@ analyze_with_command() {
# @cmd List directory contents
# @option --path Path to list (default: project root)
list_directory() {
local dir_path="${argc_path:-.}"
local dir_path
dir_path=$(_normalize_path "${argc_path:-.}")
local project_dir
project_dir=$(_project_dir)
local full_path="${project_dir}/${dir_path}"
+1 -1
View File
@@ -16,7 +16,7 @@
},
"atlassian": {
"command": "npx",
"args": ["-y", "mcp-remote@0.1.13", "https://mcp.atlassian.com/v1/sse"]
"args": ["-y", "mcp-remote@0.1.13", "https://mcp.atlassian.com/v1/mcp"]
},
"docker": {
"command": "uvx",
+19
View File
@@ -142,6 +142,25 @@ temporary localhost server to capture the callback automatically (e.g. Gemini) o
code back into the terminal (e.g. Claude). Loki stores the tokens in `~/.cache/loki/oauth` and automatically refreshes
them when they expire.
#### Gemini OAuth Note
Loki uses the following scopes for OAuth with Gemini:
* https://www.googleapis.com/auth/generative-language.peruserquota
* https://www.googleapis.com/auth/userinfo.email
* https://www.googleapis.com/auth/generative-language.retriever (Sensitive)
Since the `generative-language.retriever` scope is a sensitive scope, Google needs to verify Loki, which requires full
branding (logo, official website, privacy policy, terms of service, etc.). The Loki app is open-source and is designed
to be used as a simple CLI. As such, there's no terms of service or privacy policy associated with it, and thus Google
cannot verify Loki.
So, when you kick off OAuth with Gemini, you may see a page similar to the following:
![](../images/clients/gemini-oauth-page.png)
Simply click the `Advanced` link and click `Go to Loki (unsafe)` to continue the OAuth flow.
![](../images/clients/gemini-oauth-unverified.png)
![](../images/clients/gemini-oauth-unverified-allow.png)
**Step 3: Use normally**
Once authenticated, the client works like any other. Loki uses the stored OAuth tokens automatically:
+7
View File
@@ -3,6 +3,13 @@
# - https://platform.openai.com/docs/api-reference/chat
- provider: openai
models:
- name: gpt-5.2
max_input_tokens: 400000
max_output_tokens: 128000
input_price: 1.75
output_price: 14
supports_vision: true
supports_function_calling: true
- name: gpt-5.1
max_input_tokens: 400000
max_output_tokens: 128000
+1 -2
View File
@@ -2,7 +2,6 @@ use super::oauth::{OAuthProvider, TokenRequestFormat};
pub struct GeminiOAuthProvider;
// TODO: Replace with real credentials after registering Loki with Google Cloud Console
const GEMINI_CLIENT_ID: &str =
"50826443741-upqcebrs4gctqht1f08ku46qlbirkdsj.apps.googleusercontent.com";
const GEMINI_CLIENT_SECRET: &str = "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";
@@ -29,7 +28,7 @@ impl OAuthProvider for GeminiOAuthProvider {
}
fn scopes(&self) -> &str {
"https://www.googleapis.com/auth/cloud-platform.readonly https://www.googleapis.com/auth/userinfo.email"
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email"
}
fn client_secret(&self) -> Option<&str> {
+20 -21
View File
@@ -1,12 +1,12 @@
use super::ClientConfig;
use super::access_token::{is_valid_access_token, set_access_token};
use crate::config::Config;
use anyhow::{Result, bail};
use anyhow::{Result, anyhow, bail};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use inquire::Text;
use reqwest::Client as ReqwestClient;
use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
@@ -76,7 +76,6 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let uri = format!("http://127.0.0.1:{port}/callback");
// Drop the listener so run_oauth_flow can re-bind below
drop(listener);
uri
} else {
@@ -149,15 +148,15 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
let access_token = response["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing access_token in response: {response}"))?
.ok_or_else(|| anyhow!("Missing access_token in response: {response}"))?
.to_string();
let refresh_token = response["refresh_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing refresh_token in response: {response}"))?
.ok_or_else(|| anyhow!("Missing refresh_token in response: {response}"))?
.to_string();
let expires_in = response["expires_in"]
.as_i64()
.ok_or_else(|| anyhow::anyhow!("Missing expires_in in response: {response}"))?;
.ok_or_else(|| anyhow!("Missing expires_in in response: {response}"))?;
let expires_at = Utc::now().timestamp() + expires_in;
@@ -214,7 +213,7 @@ pub async fn refresh_oauth_token(
let access_token = response["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing access_token in refresh response: {response}"))?
.ok_or_else(|| anyhow!("Missing access_token in refresh response: {response}"))?
.to_string();
let refresh_token = response["refresh_token"]
.as_str()
@@ -222,7 +221,7 @@ pub async fn refresh_oauth_token(
.unwrap_or_else(|| tokens.refresh_token.clone());
let expires_in = response["expires_in"]
.as_i64()
.ok_or_else(|| anyhow::anyhow!("Missing expires_in in refresh response: {response}"))?;
.ok_or_else(|| anyhow!("Missing expires_in in refresh response: {response}"))?;
let expires_at = Utc::now().timestamp() + expires_in;
@@ -266,7 +265,7 @@ fn build_token_request(
client: &ReqwestClient,
provider: &(impl OAuthProvider + ?Sized),
params: &[(&str, &str)],
) -> reqwest::RequestBuilder {
) -> RequestBuilder {
let mut request = match provider.token_request_format() {
TokenRequestFormat::Json => {
let body: serde_json::Map<String, Value> = params
@@ -308,7 +307,7 @@ fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> {
let host = url.host_str().unwrap_or("127.0.0.1");
let port = url
.port()
.ok_or_else(|| anyhow::anyhow!("No port in redirect URI"))?;
.ok_or_else(|| anyhow!("No port in redirect URI"))?;
let path = url.path();
println!("Waiting for OAuth callback on {redirect_uri} ...\n");
@@ -323,19 +322,11 @@ fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> {
let request_path = request_line
.split_whitespace()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("Malformed HTTP request from OAuth callback"))?;
.ok_or_else(|| anyhow!("Malformed HTTP request from OAuth callback"))?;
let full_url = format!("http://{host}:{port}{request_path}");
let parsed: Url = full_url.parse()?;
let response_body = "<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to your terminal.</p></body></html>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream.write_all(response.as_bytes())?;
if !parsed.path().starts_with(path) {
bail!("Unexpected callback path: {}", parsed.path());
}
@@ -350,14 +341,22 @@ fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> {
.find(|(k, _)| k == "error")
.map(|(_, v)| v.to_string())
.unwrap_or_else(|| "unknown".to_string());
anyhow::anyhow!("OAuth callback returned error: {error}")
anyhow!("OAuth callback returned error: {error}")
})?;
let returned_state = parsed
.query_pairs()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.to_string())
.ok_or_else(|| anyhow::anyhow!("Missing state parameter in OAuth callback"))?;
.ok_or_else(|| anyhow!("Missing state parameter in OAuth callback"))?;
let response_body = "<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to your terminal.</p></body></html>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream.write_all(response.as_bytes())?;
Ok((code, returned_state))
}
+2 -1
View File
@@ -428,7 +428,8 @@ pub async fn run_repl_command(
None => println!("Usage: .model <name>"),
},
".authenticate" => {
let client = init_client(config, None)?;
let current_model = config.read().current_model().clone();
let client = init_client(config, Some(current_model))?;
if !client.supports_oauth() {
bail!(
"Client '{}' doesn't either support OAuth or isn't configured to use it (i.e. uses an API key instead)",