diff --git a/macos/DISTRIBUTION.md b/macos/DISTRIBUTION.md index 790a2aa6..51eceef1 100644 --- a/macos/DISTRIBUTION.md +++ b/macos/DISTRIBUTION.md @@ -173,3 +173,6 @@ both must be safe to have at once: file that redirects the store moves the app with it. Otherwise the app would read the default database while the CLI read the user's, and the lock above would guard a file neither shares. +- **Claude configuration** — `CLAUDE_CONFIG_DIR` is imported by that probe too, + so account metadata, installed skills, and spawned Claude processes all use + the same profile as the user's terminal. diff --git a/src/local/harness/claude.rs b/src/local/harness/claude.rs index 0d54b19a..0f20e33c 100644 --- a/src/local/harness/claude.rs +++ b/src/local/harness/claude.rs @@ -45,7 +45,7 @@ use crate::local::chat::{ }; use crate::local::claude::{SpawnConfig, SpawnSpec, TurnEvent}; use crate::local::opencode::ensure_playbook; -use crate::local::shell_env::find_on_path; +use crate::local::shell_env::{self, find_on_path}; /// FALLBACK model list, used only when the `list_models` control request fails /// (a CLI too old to answer it, or a spawn/timeout failure). The primary source @@ -423,6 +423,36 @@ pub(crate) fn find_claude() -> Option { }) } +#[derive(Debug, PartialEq, Eq)] +struct ClaudeConfigPaths { + root: PathBuf, + metadata: PathBuf, +} + +fn resolve_config_paths( + config_dir: Option, + home: Option, +) -> Option { + if let Some(root) = config_dir.filter(|path| !path.as_os_str().is_empty()) { + return Some(ClaudeConfigPaths { + metadata: root.join(".claude.json"), + root, + }); + } + let home = home?; + Some(ClaudeConfigPaths { + root: home.join(".claude"), + metadata: home.join(".claude.json"), + }) +} + +fn config_paths() -> Option { + resolve_config_paths( + shell_env::var("CLAUDE_CONFIG_DIR").map(PathBuf::from), + dirs::home_dir(), + ) +} + #[async_trait] impl Harness for ClaudeCode { fn id(&self) -> &'static str { @@ -468,8 +498,8 @@ impl Harness for ClaudeCode { if info.auth_state == HarnessAuthState::Ready { info.authenticated = true; if probe.method == Some("oauth") { - if let Some(acct) = dirs::home_dir() - .and_then(|h| read_json(h.join(".claude.json"))) + if let Some(acct) = config_paths() + .and_then(|paths| read_json(paths.metadata)) .and_then(|cfg| cfg.get("oauthAccount").cloned()) { info.account = nonempty_str(&acct, "emailAddress"); @@ -708,7 +738,7 @@ impl Harness for ClaudeCode { } fn config_home(&self) -> Option { - Some(dirs::home_dir()?.join(".claude")) + config_paths().map(|paths| paths.root) } fn skill_target(&self) -> Option { @@ -2018,6 +2048,25 @@ mod tests { use super::super::options::REASONING_DEFAULT_ID; use super::*; + #[test] + fn config_override_moves_the_root_and_metadata_together() { + let custom = PathBuf::from("/custom/claude"); + assert_eq!( + resolve_config_paths(Some(custom.clone()), Some(PathBuf::from("/home/user"))), + Some(ClaudeConfigPaths { + root: custom.clone(), + metadata: custom.join(".claude.json"), + }) + ); + assert_eq!( + resolve_config_paths(None, Some(PathBuf::from("/home/user"))), + Some(ClaudeConfigPaths { + root: PathBuf::from("/home/user/.claude"), + metadata: PathBuf::from("/home/user/.claude.json"), + }) + ); + } + /// A `list_models` response in the live 2.1.212 shape (fields we don't /// read trimmed). Covers the four things the parser decides: the `default` /// entry is skipped, `value` (the alias the CLI's own picker submits) is diff --git a/src/local/shell_env.rs b/src/local/shell_env.rs index 681abebb..8702d74d 100644 --- a/src/local/shell_env.rs +++ b/src/local/shell_env.rs @@ -22,9 +22,15 @@ use std::path::PathBuf; use std::sync::OnceLock; /// Deliberately short. These are the variables whose divergence makes the app -/// and the CLI behave like different installs; credentials reach harness -/// children through `chat::prepare_env` instead. -pub const IMPORTED: [&str; 4] = ["PATH", "ORX_DATA_DIR", "XDG_DATA_HOME", "XDG_CONFIG_HOME"]; +/// and the CLI behave like different installs; values adopted here reach +/// harness children through `chat::prepare_env`. +pub const IMPORTED: [&str; 5] = [ + "PATH", + "ORX_DATA_DIR", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "CLAUDE_CONFIG_DIR", +]; static OVERRIDE: OnceLock> = OnceLock::new(); @@ -148,7 +154,7 @@ mod tests { #[test] fn reads_every_imported_variable() { let vars = parse_probe( - &fenced("/opt/homebrew/bin:/usr/bin\0/data\0/share\0/config\0"), + &fenced("/opt/homebrew/bin:/usr/bin\0/data\0/share\0/config\0/claude-config\0"), M, ) .unwrap(); @@ -156,11 +162,12 @@ mod tests { assert_eq!(vars["ORX_DATA_DIR"], OsString::from("/data")); assert_eq!(vars["XDG_DATA_HOME"], OsString::from("/share")); assert_eq!(vars["XDG_CONFIG_HOME"], OsString::from("/config")); + assert_eq!(vars["CLAUDE_CONFIG_DIR"], OsString::from("/claude-config")); } #[test] fn unset_variables_are_dropped_so_lookups_fall_through() { - let vars = parse_probe(&fenced("/usr/bin\0\0\0\0"), M).unwrap(); + let vars = parse_probe(&fenced("/usr/bin\0\0\0\0\0"), M).unwrap(); assert_eq!(vars["PATH"], OsString::from("/usr/bin")); assert!(!vars.contains_key("ORX_DATA_DIR")); assert_eq!(vars.len(), 1); @@ -170,7 +177,7 @@ mod tests { fn rejects_truncated_empty_or_pathless_output() { assert!(parse_probe("", M).is_none()); assert!(parse_probe(&format!("{M}/usr/bin\0"), M).is_none()); - assert!(parse_probe(&fenced("\0/data\0\0\0"), M).is_none()); + assert!(parse_probe(&fenced("\0/data\0\0\0\0"), M).is_none()); } #[test]