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}" (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 # @cmd Read a file's contents before modifying
# @option --path! Path to the file (relative to project root) # @option --path! Path to the file (relative to project root)
read_file() { read_file() {
local file_path
# shellcheck disable=SC2154 # shellcheck disable=SC2154
local file_path="${argc_path}" file_path=$(_normalize_path "${argc_path}")
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
local full_path="${project_dir}/${file_path}" local full_path="${project_dir}/${file_path}"
@@ -39,7 +56,8 @@ read_file() {
# @option --path! Path for the file (relative to project root) # @option --path! Path for the file (relative to project root)
# @option --content! Complete file contents to write # @option --content! Complete file contents to write
write_file() { write_file() {
local file_path="${argc_path}" local file_path
file_path=$(_normalize_path "${argc_path}")
# shellcheck disable=SC2154 # shellcheck disable=SC2154
local content="${argc_content}" local content="${argc_content}"
local project_dir local project_dir
@@ -47,7 +65,7 @@ write_file() {
local full_path="${project_dir}/${file_path}" local full_path="${project_dir}/${file_path}"
mkdir -p "$(dirname "${full_path}")" mkdir -p "$(dirname "${full_path}")"
echo "${content}" > "${full_path}" printf '%s' "${content}" > "${full_path}"
green "Wrote: ${file_path}" >> "$LLM_OUTPUT" green "Wrote: ${file_path}" >> "$LLM_OUTPUT"
} }
@@ -55,7 +73,8 @@ write_file() {
# @cmd Find files similar to a given path (for pattern matching) # @cmd Find files similar to a given path (for pattern matching)
# @option --path! Path to find similar files for # @option --path! Path to find similar files for
find_similar_files() { find_similar_files() {
local file_path="${argc_path}" local file_path
file_path=$(_normalize_path "${argc_path}")
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
@@ -71,14 +90,14 @@ find_similar_files() {
! -name "$(basename "${file_path}")" \ ! -name "$(basename "${file_path}")" \
! -name "*test*" \ ! -name "*test*" \
! -name "*spec*" \ ! -name "*spec*" \
2>/dev/null | head -3) 2>/dev/null | sed "s|^${project_dir}/||" | head -3)
if [[ -z "${results}" ]]; then if [[ -z "${results}" ]]; then
results=$(find "${project_dir}/src" -type f -name "*.${ext}" \ results=$(find "${project_dir}/src" -type f -name "*.${ext}" \
! -name "*test*" \ ! -name "*test*" \
! -name "*spec*" \ ! -name "*spec*" \
-not -path '*/target/*' \ -not -path '*/target/*' \
2>/dev/null | head -3) 2>/dev/null | sed "s|^${project_dir}/||" | head -3)
fi fi
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
@@ -186,6 +205,7 @@ search_code() {
grep -v '/target/' | \ grep -v '/target/' | \
grep -v '/node_modules/' | \ grep -v '/node_modules/' | \
grep -v '/.git/' | \ grep -v '/.git/' | \
sed "s|^${project_dir}/||" | \
head -20) || true head -20) || true
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
+22 -4
View File
@@ -14,6 +14,21 @@ _project_dir() {
(cd "${dir}" 2>/dev/null && pwd) || echo "${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 # @cmd Get project structure and layout
get_structure() { get_structure() {
local project_dir local project_dir
@@ -78,6 +93,7 @@ search_content() {
grep -v '/node_modules/' | \ grep -v '/node_modules/' | \
grep -v '/.git/' | \ grep -v '/.git/' | \
grep -v '/dist/' | \ grep -v '/dist/' | \
sed "s|^${project_dir}/||" | \
head -30) || true head -30) || true
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
@@ -91,8 +107,9 @@ search_content() {
# @option --path! Path to the file (relative to project root) # @option --path! Path to the file (relative to project root)
# @option --lines Maximum lines to read (default: 200) # @option --lines Maximum lines to read (default: 200)
read_file() { read_file() {
local file_path
# shellcheck disable=SC2154 # shellcheck disable=SC2154
local file_path="${argc_path}" file_path=$(_normalize_path "${argc_path}")
local max_lines="${argc_lines:-200}" local max_lines="${argc_lines:-200}"
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
@@ -122,7 +139,8 @@ read_file() {
# @cmd Find similar files to a given file (for pattern matching) # @cmd Find similar files to a given file (for pattern matching)
# @option --path! Path to the reference file # @option --path! Path to the reference file
find_similar() { find_similar() {
local file_path="${argc_path}" local file_path
file_path=$(_normalize_path "${argc_path}")
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
@@ -138,7 +156,7 @@ find_similar() {
! -name "$(basename "${file_path}")" \ ! -name "$(basename "${file_path}")" \
! -name "*test*" \ ! -name "*test*" \
! -name "*spec*" \ ! -name "*spec*" \
2>/dev/null | head -5) 2>/dev/null | sed "s|^${project_dir}/||" | head -5)
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT" echo "${results}" >> "$LLM_OUTPUT"
@@ -147,7 +165,7 @@ find_similar() {
! -name "$(basename "${file_path}")" \ ! -name "$(basename "${file_path}")" \
! -name "*test*" \ ! -name "*test*" \
-not -path '*/target/*' \ -not -path '*/target/*' \
2>/dev/null | head -5) 2>/dev/null | sed "s|^${project_dir}/||" | head -5)
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
echo "${results}" >> "$LLM_OUTPUT" echo "${results}" >> "$LLM_OUTPUT"
else else
+23 -4
View File
@@ -14,21 +14,38 @@ _project_dir() {
(cd "${dir}" 2>/dev/null && pwd) || echo "${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 # @cmd Read a file for analysis
# @option --path! Path to the file (relative to project root) # @option --path! Path to the file (relative to project root)
read_file() { read_file() {
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
local file_path
# shellcheck disable=SC2154 # 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 if [[ ! -f "${full_path}" ]]; then
error "File not found: ${argc_path}" >> "$LLM_OUTPUT" error "File not found: ${file_path}" >> "$LLM_OUTPUT"
return 1 return 1
fi fi
{ {
info "Reading: ${argc_path}" info "Reading: ${file_path}"
echo "" echo ""
cat "${full_path}" cat "${full_path}"
} >> "$LLM_OUTPUT" } >> "$LLM_OUTPUT"
@@ -80,6 +97,7 @@ search_code() {
grep -v '/target/' | \ grep -v '/target/' | \
grep -v '/node_modules/' | \ grep -v '/node_modules/' | \
grep -v '/.git/' | \ grep -v '/.git/' | \
sed "s|^${project_dir}/||" | \
head -30) || true head -30) || true
if [[ -n "${results}" ]]; then if [[ -n "${results}" ]]; then
@@ -113,7 +131,8 @@ analyze_with_command() {
# @cmd List directory contents # @cmd List directory contents
# @option --path Path to list (default: project root) # @option --path Path to list (default: project root)
list_directory() { list_directory() {
local dir_path="${argc_path:-.}" local dir_path
dir_path=$(_normalize_path "${argc_path:-.}")
local project_dir local project_dir
project_dir=$(_project_dir) project_dir=$(_project_dir)
local full_path="${project_dir}/${dir_path}" local full_path="${project_dir}/${dir_path}"
+1 -1
View File
@@ -16,7 +16,7 @@
}, },
"atlassian": { "atlassian": {
"command": "npx", "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": { "docker": {
"command": "uvx", "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 code back into the terminal (e.g. Claude). Loki stores the tokens in `~/.cache/loki/oauth` and automatically refreshes
them when they expire. 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** **Step 3: Use normally**
Once authenticated, the client works like any other. Loki uses the stored OAuth tokens automatically: 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 # - https://platform.openai.com/docs/api-reference/chat
- provider: openai - provider: openai
models: 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 - name: gpt-5.1
max_input_tokens: 400000 max_input_tokens: 400000
max_output_tokens: 128000 max_output_tokens: 128000
+1 -2
View File
@@ -2,7 +2,6 @@ use super::oauth::{OAuthProvider, TokenRequestFormat};
pub struct GeminiOAuthProvider; pub struct GeminiOAuthProvider;
// TODO: Replace with real credentials after registering Loki with Google Cloud Console
const GEMINI_CLIENT_ID: &str = const GEMINI_CLIENT_ID: &str =
"50826443741-upqcebrs4gctqht1f08ku46qlbirkdsj.apps.googleusercontent.com"; "50826443741-upqcebrs4gctqht1f08ku46qlbirkdsj.apps.googleusercontent.com";
const GEMINI_CLIENT_SECRET: &str = "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"; const GEMINI_CLIENT_SECRET: &str = "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";
@@ -29,7 +28,7 @@ impl OAuthProvider for GeminiOAuthProvider {
} }
fn scopes(&self) -> &str { 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> { fn client_secret(&self) -> Option<&str> {
+20 -21
View File
@@ -1,12 +1,12 @@
use super::ClientConfig; use super::ClientConfig;
use super::access_token::{is_valid_access_token, set_access_token}; use super::access_token::{is_valid_access_token, set_access_token};
use crate::config::Config; use crate::config::Config;
use anyhow::{Result, bail}; use anyhow::{Result, anyhow, bail};
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc; use chrono::Utc;
use inquire::Text; use inquire::Text;
use reqwest::Client as ReqwestClient; use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use sha2::{Digest, Sha256}; 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 listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
let uri = format!("http://127.0.0.1:{port}/callback"); let uri = format!("http://127.0.0.1:{port}/callback");
// Drop the listener so run_oauth_flow can re-bind below
drop(listener); drop(listener);
uri uri
} else { } else {
@@ -149,15 +148,15 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
let access_token = response["access_token"] let access_token = response["access_token"]
.as_str() .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(); .to_string();
let refresh_token = response["refresh_token"] let refresh_token = response["refresh_token"]
.as_str() .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(); .to_string();
let expires_in = response["expires_in"] let expires_in = response["expires_in"]
.as_i64() .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; let expires_at = Utc::now().timestamp() + expires_in;
@@ -214,7 +213,7 @@ pub async fn refresh_oauth_token(
let access_token = response["access_token"] let access_token = response["access_token"]
.as_str() .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(); .to_string();
let refresh_token = response["refresh_token"] let refresh_token = response["refresh_token"]
.as_str() .as_str()
@@ -222,7 +221,7 @@ pub async fn refresh_oauth_token(
.unwrap_or_else(|| tokens.refresh_token.clone()); .unwrap_or_else(|| tokens.refresh_token.clone());
let expires_in = response["expires_in"] let expires_in = response["expires_in"]
.as_i64() .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; let expires_at = Utc::now().timestamp() + expires_in;
@@ -266,7 +265,7 @@ fn build_token_request(
client: &ReqwestClient, client: &ReqwestClient,
provider: &(impl OAuthProvider + ?Sized), provider: &(impl OAuthProvider + ?Sized),
params: &[(&str, &str)], params: &[(&str, &str)],
) -> reqwest::RequestBuilder { ) -> RequestBuilder {
let mut request = match provider.token_request_format() { let mut request = match provider.token_request_format() {
TokenRequestFormat::Json => { TokenRequestFormat::Json => {
let body: serde_json::Map<String, Value> = params 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 host = url.host_str().unwrap_or("127.0.0.1");
let port = url let port = url
.port() .port()
.ok_or_else(|| anyhow::anyhow!("No port in redirect URI"))?; .ok_or_else(|| anyhow!("No port in redirect URI"))?;
let path = url.path(); let path = url.path();
println!("Waiting for OAuth callback on {redirect_uri} ...\n"); 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 let request_path = request_line
.split_whitespace() .split_whitespace()
.nth(1) .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 full_url = format!("http://{host}:{port}{request_path}");
let parsed: Url = full_url.parse()?; 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) { if !parsed.path().starts_with(path) {
bail!("Unexpected callback path: {}", parsed.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") .find(|(k, _)| k == "error")
.map(|(_, v)| v.to_string()) .map(|(_, v)| v.to_string())
.unwrap_or_else(|| "unknown".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 let returned_state = parsed
.query_pairs() .query_pairs()
.find(|(k, _)| k == "state") .find(|(k, _)| k == "state")
.map(|(_, v)| v.to_string()) .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)) Ok((code, returned_state))
} }
+2 -1
View File
@@ -428,7 +428,8 @@ pub async fn run_repl_command(
None => println!("Usage: .model <name>"), None => println!("Usage: .model <name>"),
}, },
".authenticate" => { ".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() { if !client.supports_oauth() {
bail!( bail!(
"Client '{}' doesn't either support OAuth or isn't configured to use it (i.e. uses an API key instead)", "Client '{}' doesn't either support OAuth or isn't configured to use it (i.e. uses an API key instead)",