Skip to content

Commit 25cbfd7

Browse files
nshepperdclaude
andcommitted
feat(auth)!: store config at XDG location instead of cwd .kagi.toml
Credentials were saved to a cwd-relative `.kagi.toml`, so the file location depended on where `kagi` was invoked from and a key saved in one directory was invisible from another. Resolve the config path in this order instead: 1. `$KAGI_CONFIG` (explicit full file path override) 2. `$XDG_CONFIG_HOME/kagi-cli/config.toml` 3. `$HOME/.config/kagi-cli/config.toml` The atomic writer now creates the config directory on first run, and the file keeps its 0600 permissions. Help text and the auth wizard no longer reference `.kagi.toml` by name. BREAKING CHANGE: existing credentials in a cwd-relative `.kagi.toml` are no longer read. Re-run `kagi auth set` (or the wizard) once to migrate. The `KAGI_API_KEY`/`KAGI_SESSION_TOKEN` env vars are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 356330f commit 25cbfd7

4 files changed

Lines changed: 120 additions & 23 deletions

File tree

src/auth.rs

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ use serde::Deserialize;
1515

1616
use crate::error::KagiError;
1717

18-
const DEFAULT_CONFIG_PATH: &str = ".kagi.toml";
18+
/// Environment variable that overrides the full path to the config file.
19+
pub const CONFIG_PATH_ENV: &str = "KAGI_CONFIG";
20+
/// Directory name, under the XDG config home, that holds the config file.
21+
const CONFIG_DIR_NAME: &str = "kagi-cli";
22+
/// File name of the config file within the config directory.
23+
const CONFIG_FILE_NAME: &str = "config.toml";
1924
pub const API_KEY_ENV: &str = "KAGI_API_KEY";
2025
pub const API_TOKEN_ENV: &str = "KAGI_API_TOKEN";
2126
pub const SESSION_TOKEN_ENV: &str = "KAGI_SESSION_TOKEN";
@@ -262,6 +267,41 @@ pub struct ConfigAuthSnapshot {
262267
pub search_preference: SearchAuthPreference,
263268
}
264269

270+
/// Resolves the path to the kagi config file.
271+
///
272+
/// Resolution order:
273+
/// 1. The `KAGI_CONFIG` environment variable, used verbatim as the full file
274+
/// path when set to a non-empty value.
275+
/// 2. `$XDG_CONFIG_HOME/kagi-cli/config.toml`.
276+
/// 3. `$HOME/.config/kagi-cli/config.toml` when `XDG_CONFIG_HOME` is unset.
277+
pub fn default_config_path() -> PathBuf {
278+
if let Some(path) = env::var_os(CONFIG_PATH_ENV)
279+
.map(PathBuf::from)
280+
.filter(|value| !value.as_os_str().is_empty())
281+
{
282+
return path;
283+
}
284+
285+
xdg_config_home()
286+
.join(CONFIG_DIR_NAME)
287+
.join(CONFIG_FILE_NAME)
288+
}
289+
290+
fn xdg_config_home() -> PathBuf {
291+
env::var_os("XDG_CONFIG_HOME")
292+
.map(PathBuf::from)
293+
.filter(|value| !value.as_os_str().is_empty())
294+
.unwrap_or_else(|| home_dir().join(".config"))
295+
}
296+
297+
fn home_dir() -> PathBuf {
298+
env::var_os("HOME")
299+
.or_else(|| env::var_os("USERPROFILE"))
300+
.map(PathBuf::from)
301+
.filter(|value| !value.as_os_str().is_empty())
302+
.unwrap_or_else(|| PathBuf::from("."))
303+
}
304+
265305
/// Loads the credential inventory from the default config path and environment variables.
266306
///
267307
/// # Returns
@@ -282,7 +322,7 @@ pub fn load_credential_inventory() -> Result<CredentialInventory, KagiError> {
282322
pub fn load_credential_inventory_for_profile(
283323
profile: Option<&str>,
284324
) -> Result<CredentialInventory, KagiError> {
285-
load_credential_inventory_from_path(Path::new(DEFAULT_CONFIG_PATH), profile)
325+
load_credential_inventory_from_path(&default_config_path(), profile)
286326
}
287327

288328
fn load_credential_inventory_from_path(
@@ -388,7 +428,7 @@ pub fn format_status(inventory: &CredentialInventory) -> String {
388428
/// # Errors
389429
/// Returns `KagiError::Config` if the config file cannot be read or parsed.
390430
pub fn load_config_auth_snapshot() -> Result<ConfigAuthSnapshot, KagiError> {
391-
load_config_auth_snapshot_from_path(Path::new(DEFAULT_CONFIG_PATH))
431+
load_config_auth_snapshot_from_path(&default_config_path())
392432
}
393433

394434
fn load_config_auth_snapshot_from_path(
@@ -464,7 +504,7 @@ fn select_auth_config<'a>(
464504
.and_then(|profiles| profiles.get(profile))
465505
.ok_or_else(|| {
466506
KagiError::Config(format!(
467-
"profile `{profile}` was not found in .kagi.toml. Run `kagi auth status --profile {profile}` to confirm the name, or add [profiles.{profile}.auth]"
507+
"profile `{profile}` was not found in the kagi config file. Run `kagi auth status --profile {profile}` to confirm the name, or add [profiles.{profile}.auth]"
468508
))
469509
})?;
470510

@@ -593,7 +633,7 @@ fn save_credentials_with_preference_for_profile(
593633
preferred_auth: Option<SearchAuthPreference>,
594634
) -> Result<CredentialInventory, KagiError> {
595635
save_credentials_with_preference_to_path(
596-
Path::new(DEFAULT_CONFIG_PATH),
636+
&default_config_path(),
597637
profile,
598638
api_key,
599639
api_token,
@@ -733,6 +773,14 @@ fn read_config_file(path: &Path) -> Result<ConfigFile, KagiError> {
733773

734774
fn write_config_file_atomically(path: &Path, raw: &str) -> Result<(), KagiError> {
735775
let parent = path.parent().unwrap_or_else(|| Path::new("."));
776+
if !parent.as_os_str().is_empty() {
777+
fs::create_dir_all(parent).map_err(|error| {
778+
KagiError::Config(format!(
779+
"failed to create config directory {}: {error}",
780+
parent.display()
781+
))
782+
})?;
783+
}
736784
let file_name = path
737785
.file_name()
738786
.map(|value| value.to_string_lossy().into_owned())
@@ -921,7 +969,7 @@ mod tests {
921969
api_token: None,
922970
session_token: None,
923971
search_preference: SearchAuthPreference::Session,
924-
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
972+
config_path: default_config_path(),
925973
profile: None,
926974
};
927975

@@ -943,7 +991,7 @@ mod tests {
943991
api_token: None,
944992
session_token: None,
945993
search_preference: SearchAuthPreference::Session,
946-
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
994+
config_path: default_config_path(),
947995
profile: None,
948996
};
949997

@@ -969,7 +1017,7 @@ mod tests {
9691017
value: "session".to_string(),
9701018
}),
9711019
search_preference: SearchAuthPreference::Session,
972-
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
1020+
config_path: default_config_path(),
9731021
profile: None,
9741022
};
9751023

@@ -995,7 +1043,7 @@ mod tests {
9951043
value: "session".to_string(),
9961044
}),
9971045
search_preference: SearchAuthPreference::Session,
998-
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
1046+
config_path: default_config_path(),
9991047
profile: None,
10001048
};
10011049

@@ -1020,7 +1068,7 @@ mod tests {
10201068
value: "session".to_string(),
10211069
}),
10221070
search_preference: SearchAuthPreference::Api,
1023-
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
1071+
config_path: default_config_path(),
10241072
profile: None,
10251073
};
10261074

@@ -1041,7 +1089,7 @@ mod tests {
10411089
}),
10421090
session_token: None,
10431091
search_preference: SearchAuthPreference::Api,
1044-
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
1092+
config_path: default_config_path(),
10451093
profile: None,
10461094
};
10471095

@@ -1085,7 +1133,7 @@ mod tests {
10851133
}),
10861134
session_token: None,
10871135
search_preference: SearchAuthPreference::Session,
1088-
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
1136+
config_path: default_config_path(),
10891137
profile: None,
10901138
};
10911139

@@ -1241,4 +1289,41 @@ mod tests {
12411289
assert_eq!(mode, 0o600);
12421290
}
12431291
}
1292+
1293+
#[test]
1294+
fn config_path_prefers_explicit_override() {
1295+
let _guard = lock_env();
1296+
let _override = set_env_var(CONFIG_PATH_ENV, "/tmp/custom/kagi-config.toml");
1297+
1298+
assert_eq!(
1299+
default_config_path(),
1300+
PathBuf::from("/tmp/custom/kagi-config.toml")
1301+
);
1302+
}
1303+
1304+
#[test]
1305+
fn config_path_uses_xdg_config_home_when_no_override() {
1306+
let _guard = lock_env();
1307+
let _override = set_env_var(CONFIG_PATH_ENV, "");
1308+
let _xdg = set_env_var("XDG_CONFIG_HOME", "/tmp/xdg-config");
1309+
1310+
let expected = PathBuf::from("/tmp/xdg-config")
1311+
.join("kagi-cli")
1312+
.join("config.toml");
1313+
assert_eq!(default_config_path(), expected);
1314+
}
1315+
1316+
#[test]
1317+
fn config_path_falls_back_to_home_config_dir() {
1318+
let _guard = lock_env();
1319+
let _override = set_env_var(CONFIG_PATH_ENV, "");
1320+
let _xdg = set_env_var("XDG_CONFIG_HOME", "");
1321+
let _home = set_env_var("HOME", "/home/tester");
1322+
1323+
let expected = PathBuf::from("/home/tester")
1324+
.join(".config")
1325+
.join("kagi-cli")
1326+
.join("config.toml");
1327+
assert_eq!(default_config_path(), expected);
1328+
}
12441329
}

src/auth_wizard.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ pub async fn run_auth_wizard() -> Result<(), KagiError> {
120120
"Current Auth",
121121
format_inventory_summary(&inventory),
122122
))?;
123-
wizard_io(log::info("Environment variables override .kagi.toml."))?;
123+
wizard_io(log::info(
124+
"Environment variables override the saved config file.",
125+
))?;
124126

125127
let Some(kind) = prompt_result(
126128
cliclack::select("Choose an auth method")

src/cli.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ pub struct Cli {
240240
#[arg(long, value_name = "SHELL", value_enum)]
241241
pub generate_completion: Option<CompletionShell>,
242242

243-
/// Use a named profile from .kagi.toml instead of the default auth block
243+
/// Use a named profile from the kagi config file instead of the default auth block
244244
#[arg(long, global = true, value_name = "NAME")]
245245
pub profile: Option<String>,
246246

@@ -630,15 +630,15 @@ pub enum AuthSubcommand {
630630
#[derive(Debug, Args)]
631631
/// Arguments for `auth set` (store a credential).
632632
pub struct AuthSetArgs {
633-
/// Kagi API key for current /api/v1 endpoints to save into .kagi.toml
633+
/// Kagi API key for current /api/v1 endpoints to save into the kagi config file
634634
#[arg(long, value_name = "KEY")]
635635
pub api_key: Option<String>,
636636

637-
/// Legacy Kagi API token for /api/v0 endpoints to save into .kagi.toml
637+
/// Legacy Kagi API token for /api/v0 endpoints to save into the kagi config file
638638
#[arg(long, value_name = "TOKEN")]
639639
pub api_token: Option<String>,
640640

641-
/// Kagi session token or full Session Link URL to save into .kagi.toml
641+
/// Kagi session token or full Session Link URL to save into the kagi config file
642642
#[arg(long, value_name = "TOKEN_OR_URL")]
643643
pub session_token: Option<String>,
644644
}

tests/integration-cli.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,20 @@ fn isolate_command_home(command: &mut Command, cwd: &Path) {
8686
.env("XDG_DATA_HOME", cwd.join(".local").join("share"));
8787
}
8888

89+
/// Path to the kagi config file for a sandboxed run, matching the isolated
90+
/// `XDG_CONFIG_HOME` set by `isolate_command_home`.
91+
fn config_path(cwd: &Path) -> std::path::PathBuf {
92+
cwd.join(".config").join("kagi-cli").join("config.toml")
93+
}
94+
95+
/// Seeds the kagi config file for a sandboxed run, creating parent directories.
96+
fn write_config(cwd: &Path, contents: &str) {
97+
let path = config_path(cwd);
98+
fs::create_dir_all(path.parent().expect("config path has a parent"))
99+
.expect("config directory should create");
100+
fs::write(path, contents).expect("config should write");
101+
}
102+
89103
fn assert_success(output: &Output) {
90104
assert!(
91105
output.status.success(),
@@ -691,11 +705,7 @@ fn search_command_falls_back_to_session_when_api_is_rate_limited() {
691705
});
692706

693707
let tempdir = TempDir::new().expect("tempdir");
694-
fs::write(
695-
tempdir.path().join(".kagi.toml"),
696-
"[auth]\npreferred_auth = \"api\"\n",
697-
)
698-
.expect("config should write");
708+
write_config(tempdir.path(), "[auth]\npreferred_auth = \"api\"\n");
699709
let env = vec![
700710
("KAGI_API_KEY", API_KEY.to_string()),
701711
("KAGI_SESSION_TOKEN", "test-session".to_string()),
@@ -929,7 +939,7 @@ fn auth_set_saves_api_key_and_legacy_api_token_separately() {
929939
);
930940

931941
assert_success(&output);
932-
let raw = fs::read_to_string(tempdir.path().join(".kagi.toml")).expect("config should exist");
942+
let raw = fs::read_to_string(config_path(tempdir.path())).expect("config should exist");
933943
assert!(raw.contains("api_key = \"current-key\""));
934944
assert!(raw.contains("api_token = \"legacy-token\""));
935945
}

0 commit comments

Comments
 (0)