diff --git a/README.md b/README.md index 9b6eec0b..52fa7504 100644 --- a/README.md +++ b/README.md @@ -738,6 +738,7 @@ backup taken before the upgrade. | VS Code | Yes | User mode (Copilot Chat) | MCP and skills | — | | Grok Build | Yes | System mode | MCP and skills | — | | GitHub Copilot CLI | Yes | User mode | — | — | +| Pi | Yes | User mode | MCP and skills | — | > **Don't see your tool?** We're actively expanding this list and would love > your help. [Open an integration request](https://github.com/agentdesktop-dev/agentdesktop/issues/new) diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index cc04752d..b2c30535 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -88,6 +88,7 @@ struct ResolvedDaemonArgs { vscode_chat_models: Option, /// VS Code's user `settings.json`; `None` in system mode (user-only program). vscode_settings: Option, + pi: ResolvedPiStartupConfig, /// `daemon.reconcileInterval`; `None` means no periodic re-apply. reconcile_interval: Option, once: bool, @@ -108,6 +109,11 @@ struct ResolvedOpenCodeStartupConfig { plugin: PathBuf, } +struct ResolvedPiStartupConfig { + models: PathBuf, + settings: PathBuf, +} + impl DaemonArgs { fn resolve( self, @@ -211,6 +217,16 @@ impl DaemonArgs { }, vscode_chat_models: None, vscode_settings: None, + pi: ResolvedPiStartupConfig { + models: startup + .pi + .models + .unwrap_or_else(reconcile::default_pi_models_path), + settings: startup + .pi + .settings + .unwrap_or_else(reconcile::default_pi_settings_path), + }, reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, @@ -298,6 +314,14 @@ impl DaemonArgs { .settings .unwrap_or_else(|| reconcile::default_vscode_settings_path(&home)), ), + pi: ResolvedPiStartupConfig { + models: startup.pi.models.unwrap_or_else(|| { + crate::provider::pi::user_pi_agent_dir(&home).join("models.json") + }), + settings: startup.pi.settings.unwrap_or_else(|| { + crate::provider::pi::user_pi_agent_dir(&home).join("settings.json") + }), + }, reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, @@ -423,6 +447,8 @@ where args.copilot_providers.clone(), args.vscode_chat_models.clone(), args.vscode_settings.clone(), + args.pi.models.clone(), + args.pi.settings.clone(), agentdesktop_client_executable()?, socket.clone(), ) @@ -1009,6 +1035,11 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow .vscode .as_ref() .is_some_and(|program| program.use_llm_gateway), + config + .programs + .pi + .as_ref() + .is_some_and(|program| program.use_llm_gateway), ] .into_iter() .any(|used| used); @@ -1262,6 +1293,37 @@ mod tests { use agentdesktop_core::model::{Agent, Discovery, LlmProxyInfo}; use tokio::sync::{mpsc, watch}; + #[test] + fn pi_startup_paths_override_defaults_in_both_scopes() { + for user in [false, true] { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + format!( + "daemon:\n user: {user}\n pi:\n models: custom/pi-models.json\n settings: custom/pi-settings.json\n" + ), + ) + .unwrap(); + let config = agentdesktop_core::config::load_daemon(file.path()).unwrap(); + let args = super::DaemonArgs { + user: false, + once: false, + dry_run: false, + config: None, + }; + let resolved = args + .resolve( + config.daemon.unwrap(), + file.path().to_path_buf(), + agentdesktop_core::DEFAULT_SOCKET_PATH.into(), + ) + .unwrap(); + assert_eq!(resolved.user, user); + assert_eq!(resolved.pi.models, Path::new("custom/pi-models.json")); + assert_eq!(resolved.pi.settings, Path::new("custom/pi-settings.json")); + } + } + fn discovery(kinds: &[&str]) -> Discovery { Discovery { agents: kinds @@ -1717,6 +1779,32 @@ programs: ); } + #[test] + fn one_shot_rejects_authenticated_pi_gateway() { + for authentication in [ + " type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\n", + " type: controllerJwt\n audience: agentgateway\n allowedClientIds: [pi]\n", + ] { + let config = parse_daemon(&format!( + "llmGateway:\n url: https://gateway.example.com\n authentication:\n{authentication}programs:\n pi:\n model: selected\n" + )).unwrap(); + let error = + validate_one_shot(&config).expect_err("Pi credentials require a running daemon"); + assert!(error.to_string().contains("credential helpers")); + } + } + + #[test] + fn one_shot_accepts_pi_without_runtime_credentials() { + for yaml in [ + "programs:\n pi: {}\n", + "llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n", + "llmGateway:\n url: https://gateway.example.com\n authentication:\n type: oidc\n issuer: https://login.example.com\n clientId: agentdesktop\nprograms:\n pi:\n useLlmGateway: false\n", + ] { + validate_one_shot(&parse_daemon(yaml).unwrap()).unwrap(); + } + } + #[test] fn one_shot_accepts_static_settings_and_rejects_runtime_services() { let static_config = parse_daemon( diff --git a/crates/agent/src/provider/README.md b/crates/agent/src/provider/README.md index 7049dde6..d019ba93 100644 --- a/crates/agent/src/provider/README.md +++ b/crates/agent/src/provider/README.md @@ -1,6 +1,6 @@ # Provider support -Features currently implemented by Agentdesktop. +Features currently implemented by agentdesktop. VS Code managed configuration (Copilot Chat custom models in the user's `chatLanguageModels.json`, or the Copilot Chat API override in the user's @@ -14,24 +14,58 @@ Grok Build managed configuration requires system mode. Linux and macOS use Grok's own configuration sync can delete or replace the user-level managed file. +Pi stores configuration in `~/.pi/agent` (or `PI_CODING_AGENT_DIR`, with `~` +expanded to the user's home). Management requires `--user`; system mode +rejects `programs.pi` because Pi does not read `/etc/pi/agent`. Pi cannot be +combined with Claude Desktop or Grok Build, because those programs require +system mode. Authenticated +gateway configuration also requires a running daemon and rejects `--once`. +User mode merges `providers.agentdesktop` into `models.json` and sets the +default model in `settings.json`. Model map keys determine catalog IDs, and +gateway URLs override per-model endpoints, using each model's API dialect. +The gateway owns the entire `providers.agentdesktop.models` catalog while +enabled; stale entries are replaced, and the prior catalog is restored on +cleanup. Other providers and user additions are preserved. On Unix, +`models.json` is written with owner-only permissions (`0600`), and +reconciliation repairs a looser mode on the managed file; cleanup keeps the +file's current mode. Dry runs report the `models.json` action and path +without its contents, which may include the user's own API keys. +Commented `models.json` files are accepted and rewritten as standard JSON, +preserving user configuration values. `settings.json` requires standard JSON. +Discovery recognizes npm's Windows command shims as well as symlinked launchers, +verifying the Pi package manifest without executing the launcher. +MCP inventory comes from `pi-mcp-adapter` config files +(`~/.pi/agent/mcp.json`, the working directory's `.pi/mcp.json` and +`.mcp.json`, and shared `~/.config/mcp/mcp.json` / `~/.agents/mcp.json` / +`~/.agents/mcp/mcp.json`). Ancestor project files are included only when a +user-global config sets `settings.ancestorConfigRoots` to an existing +directory under the home that contains the working directory. Both `.agents` +paths are supported by the [adapter's configuration loader](https://github.com/nicobailon/pi-mcp-adapter/blob/33bdc38d8dd3802f2b51ba1fe37e30ed7ae6a29a/config.ts#L15-L19). +`PI_MCP_CONFIG_MODE=exclusive` inventories only the Pi agent `mcp.json`. +Skill inventory reads `~/.pi/agent/skills`, `~/.agents/skills`, the working +directory's `.pi/skills`, and `.agents/skills` from the working directory +through the git repository root. +Host-specific Cursor/Claude +files are not scanned until the adapter imports them into a Pi-owned file. + ✅ Implemented · ◯ Not implemented · — Not applicable -| Feature | [Claude Code](claude_code/) | [Claude Desktop](claude_desktop/) | [Codex](codex/) | [OpenCode](opencode/) | [VS Code](vscode/) | [Ollama](ollama/) | [Cursor](cursor/) | [Grok Build](grok/) | [GitHub Copilot CLI](copilot/) | -| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| Installation and version discovery | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | -| Local model discovery | — | — | — | — | — | ✅ | — | — | — | -| MCP server discovery | ✅ | ✅ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | -| Skill discovery | ✅ | ◯ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | -| Managed configuration | ✅ | ✅ | ✅ | ✅ | ✅ | ◯ | ◯ | ✅ | ✅ | -| LLM gateway routing | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | -| Gateway credentials | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | -| Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | ◯ | -| Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | -| Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | +| Feature | [Claude Code](claude_code/) | [Claude Desktop](claude_desktop/) | [Codex](codex/) | [OpenCode](opencode/) | [VS Code](vscode/) | [Ollama](ollama/) | [Cursor](cursor/) | [Grok Build](grok/) | [GitHub Copilot CLI](copilot/) | [Pi](pi/) | +| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| Installation and version discovery | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | +| Local model discovery | — | — | — | — | — | ✅ | — | — | — | — | +| MCP server discovery | ✅ | ✅ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | ✅ | +| Skill discovery | ✅ | ◯ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | ✅ | +| Managed configuration | ✅ | ✅ | ✅ | ✅ | ✅ | ◯ | ◯ | ✅ | ✅ | ✅ | +| LLM gateway routing | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | ✅ | +| Gateway credentials | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | ✅ | +| Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | +| Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | +| Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | ◯ | -- ◯ describes a gap in Agentdesktop; it does not mean the upstream provider +- ◯ describes a gap in agentdesktop; it does not mean the upstream provider cannot support the feature. -- Managed configuration includes dry runs and removal of Agentdesktop-owned +- Managed configuration includes dry runs and removal of agentdesktop-owned settings (no dry run for the GitHub Copilot CLI providers file and the VS Code chat language models and settings files: their loopback proxy cannot run in one-shot mode). Claude Code also supports merging into user settings; Claude Desktop diff --git a/crates/agent/src/provider/copilot/reconcile.rs b/crates/agent/src/provider/copilot/reconcile.rs index 63a108b3..33fa84d0 100644 --- a/crates/agent/src/provider/copilot/reconcile.rs +++ b/crates/agent/src/provider/copilot/reconcile.rs @@ -81,6 +81,7 @@ fn options() -> json_merge::MergeOptions { keyed_arrays: KEYED, // The file holds the pairing and possibly the user's own API keys. redact_diff: true, + ..json_merge::MergeOptions::default() } } diff --git a/crates/agent/src/provider/json_merge.rs b/crates/agent/src/provider/json_merge.rs index eecf7d27..01fa94a6 100644 --- a/crates/agent/src/provider/json_merge.rs +++ b/crates/agent/src/provider/json_merge.rs @@ -17,6 +17,29 @@ struct MergeState { after: Value, } +/// How an existing user file is parsed. The merged file is always written as +/// standard JSON. +#[derive(Clone, Copy)] +pub(super) enum JsonFormat { + Json, + /// JSON with comments and trailing commas; they are not kept when the + /// file is rewritten. + Jsonc, +} + +impl JsonFormat { + fn parse(&self, contents: &[u8]) -> anyhow::Result { + match self { + Self::Json => Ok(serde_json::from_slice(contents)?), + Self::Jsonc => { + let contents = std::str::from_utf8(contents)?; + // Normalize commented input to ordinary JSON when writing it. + Ok(json5::from_str(contents.trim_start_matches('\u{feff}'))?) + } + } + } +} + pub(super) fn state_path(path: &Path) -> PathBuf { let name = path .file_name() @@ -48,6 +71,11 @@ pub(super) struct MergeOptions { /// Record actions without file content, for a file that holds secrets: /// the dry-run report then shows the action and the path only. pub(super) redact_diff: bool, + /// How the existing file is parsed. + pub(super) format: JsonFormat, + /// JSON pointers to values managed as a whole, rather than merged + /// additively; removal restores the user's original value. + pub(super) replace_paths: &'static [&'static str], } impl Default for MergeOptions { @@ -56,6 +84,8 @@ impl Default for MergeOptions { mode: 0o644, keyed_arrays: &[], redact_diff: false, + format: JsonFormat::Json, + replace_paths: &[], } } } @@ -130,11 +160,12 @@ pub(super) fn plan_merge_with( } }; // An empty (or whitespace-only) file holds nothing to keep and is not a - // conflict; the merge fills it. Comments or trailing commas are: the file - // is rewritten as plain JSON, so they would be lost silently. + // conflict; the merge fills it. Comments or trailing commas are, unless + // `options.format` accepts them: the file is rewritten as plain JSON, so + // they would be lost silently. let mut combined = match existing.as_deref() { Some(contents) if contents.iter().all(u8::is_ascii_whitespace) => empty_root(), - Some(contents) => match serde_json::from_slice::(contents) { + Some(contents) => match options.format.parse(contents) { Ok(value) if value.is_object() == managed.is_object() && value.is_array() == managed.is_array() => @@ -165,6 +196,14 @@ pub(super) fn plan_merge_with( remove_keyed(&mut combined, keyed, &managed); } let before = combined.clone(); + // Snapshot the user's original values before replacing any owned subtree. + for pointer in options.replace_paths { + if managed.pointer(pointer).is_some() + && let Some(existing) = combined.pointer_mut(pointer) + { + *existing = Value::Null; + } + } merge_overlay(&mut combined, managed); let mut contents = serde_json::to_vec_pretty(&combined) @@ -260,7 +299,7 @@ pub(super) fn plan_remove_with( remove_file(state_path, display_name, plan)?; return Ok(true); } - let settings = match serde_json::from_slice::(&existing) { + let settings = match options.format.parse(&existing) { Ok(value) if value.is_object() == state.after.is_object() && value.is_array() == state.after.is_array() => @@ -451,6 +490,33 @@ fn rollback_value( let after = after .and_then(Value::as_array) .map_or(&[][..], Vec::as_slice); + // Additive merges keep the original array as a prefix. A replacement + // must restore its order and duplicate entries, then retain user edits. + if !after.starts_with(before) { + let mut result = before.to_vec(); + let mut restored = before.to_vec(); + let mut added = current_array.clone(); + for value in after { + if let Some(index) = restored.iter().position(|entry| entry == value) { + restored.remove(index); + } + if let Some(index) = added.iter().position(|entry| entry == value) { + added.remove(index); + } else if let Some(index) = result.iter().position(|entry| entry == value) { + // Respect a user's removal of a retained original entry. + result.remove(index); + } + } + for value in added { + if let Some(index) = restored.iter().position(|entry| entry == &value) { + // An original entry restored by the user is already present. + restored.remove(index); + } else { + result.push(value); + } + } + return Some(Value::Array(result)); + } let mut result = current_array.clone(); for added in after.iter().filter(|value| !before.contains(value)) { if let Some(index) = result.iter().position(|value| value == added) { @@ -718,6 +784,7 @@ mod tests { mode: 0o600, keyed_arrays: ROOT_KEYED, redact_diff: true, + ..MergeOptions::default() } } diff --git a/crates/agent/src/provider/mod.rs b/crates/agent/src/provider/mod.rs index b22310d3..881282e5 100644 --- a/crates/agent/src/provider/mod.rs +++ b/crates/agent/src/provider/mod.rs @@ -18,6 +18,7 @@ mod json_merge; mod metadata; pub mod ollama; pub mod opencode; +pub mod pi; pub(crate) mod shared; pub mod vscode; diff --git a/crates/agent/src/provider/pi/discovery.rs b/crates/agent/src/provider/pi/discovery.rs new file mode 100644 index 00000000..cf0ae54f --- /dev/null +++ b/crates/agent/src/provider/pi/discovery.rs @@ -0,0 +1,979 @@ +use std::{ + collections::BTreeSet, + env, fs, + io::Read, + path::{Path, PathBuf}, +}; + +use agentdesktop_core::model::{Agent, McpServer}; +use serde_json::Value; + +use crate::provider::{metadata, vscode::discovery as vscode}; + +use super::{Pi, user_pi_agent_dir}; + +pub(super) fn discover() -> Option { + let executable = metadata::find_all_in_path("pi") + .into_iter() + .chain( + executable_candidates() + .into_iter() + .filter(|candidate| candidate.is_file()), + ) + .find(|candidate| is_pi(candidate))?; + Some(Agent { + version: package_version(&executable), + executable, + kind: Pi::ID.to_owned(), + mcp_servers: discover_mcp_servers(), + skills: metadata::discover_skills(skill_roots()), + }) +} + +fn executable_candidates() -> Vec { + let mut candidates = BTreeSet::new(); + for home in metadata::user_home_dirs() { + candidates.insert(home.join(".local/bin/pi")); + candidates.insert(home.join(".npm-global/bin/pi")); + } + #[cfg(target_os = "macos")] + candidates.extend([ + PathBuf::from("/opt/homebrew/bin/pi"), + PathBuf::from("/usr/local/bin/pi"), + ]); + #[cfg(target_os = "linux")] + candidates.extend([ + PathBuf::from("/usr/bin/pi"), + PathBuf::from("/usr/local/bin/pi"), + ]); + #[cfg(windows)] + { + windows_executable_candidates(candidates) + } + #[cfg(not(windows))] + candidates.into_iter().collect() +} + +#[cfg(any(windows, test))] +fn windows_executable_candidates(mut candidates: BTreeSet) -> Vec { + for home in metadata::user_home_dirs() { + candidates.extend([ + home.join(".local/bin/pi.exe"), + home.join("AppData/Roaming/npm/pi.cmd"), + home.join("AppData/Roaming/npm/pi.exe"), + home.join("AppData/Roaming/npm/pi.ps1"), + home.join("AppData/Local/pnpm/pi.exe"), + ]); + } + // PowerShell shims may be absent from PATHEXT; keep PATH order before fallbacks. + metadata::find_all_in_path("pi.ps1") + .into_iter() + .chain(candidates) + .collect() +} + +fn is_pi(executable: &Path) -> bool { + package_manifest(executable).is_some() +} + +fn package_version(executable: &Path) -> Option { + let manifest = package_manifest(executable)?; + metadata::json_package_version(&manifest, Pi::PACKAGE_NAME) +} + +fn package_manifest(executable: &Path) -> Option { + let mut seen = BTreeSet::new(); + for start in [ + Some(executable.to_path_buf()), + executable.canonicalize().ok(), + ] + .into_iter() + .flatten() + { + if let Some(manifest) = npm_shim_manifest(&start) { + return Some(manifest); + } + let mut directory = start.parent()?.to_path_buf(); + for _ in 0..8 { + let manifest = directory.join("package.json"); + if seen.insert(manifest.clone()) + && metadata::json_package_version(&manifest, Pi::PACKAGE_NAME).is_some() + { + return Some(manifest); + } + if !directory.pop() { + break; + } + } + } + None +} + +fn npm_shim_manifest(executable: &Path) -> Option { + let manifest = executable + .parent()? + .join("node_modules") + .join(Pi::PACKAGE_NAME) + .join("package.json"); + metadata::json_package_version(&manifest, Pi::PACKAGE_NAME)?; + + // npm's Windows global launchers point to a sibling package. Verify that + // relationship from the small launcher script without executing it. + const MAX_SHIM_BYTES: usize = 16 * 1024; + let file = std::fs::File::open(executable).ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + let mut source = String::new(); + file.take((MAX_SHIM_BYTES + 1) as u64) + .read_to_string(&mut source) + .ok()?; + if source.len() > MAX_SHIM_BYTES { + return None; + } + + let target = format!("node_modules/{}/dist/bundle/cli.js\"", Pi::PACKAGE_NAME); + let basedir_target = format!("\"$basedir/{target}"); + let dp0_target = format!("\"%dp0%/{target}"); + source + .lines() + .any(|line| { + let line = line.trim_start().replace('\\', "/"); + ((line.starts_with("exec ") + || line.starts_with("& ") + || line.starts_with("$input | & ")) + && line.contains(&basedir_target)) + || (line.starts_with("endLocal & ") + && line.contains("\"%_prog%\"") + && line.contains(&dp0_target)) + }) + .then_some(manifest) +} + +fn discover_mcp_servers() -> Vec { + mcp_config_paths() + .into_iter() + .flat_map(|path| vscode::mcp_servers_from_json(&path)) + .collect() +} + +/// Files `pi-mcp-adapter` loads as normal MCP config. +/// +/// Global files are always included. Project files are only the working +/// directory's `.mcp.json` and `.pi/mcp.json`, plus ancestors when a +/// user-global config opts in through `settings.ancestorConfigRoots`. +/// `PI_MCP_CONFIG_MODE=exclusive` keeps only the Pi agent `mcp.json`. +/// Host-specific Cursor/Claude/Codex files are not included: the adapter only +/// imports those after `/mcp setup` writes them into a Pi-owned file. +fn mcp_config_paths() -> Vec { + let current = env::current_dir().ok(); + mcp_config_paths_with( + &metadata::user_home_dirs(), + current.as_deref(), + pi_mcp_exclusive_mode(), + user_pi_agent_dir, + ) +} + +fn mcp_config_paths_with( + homes: &[PathBuf], + cwd: Option<&Path>, + exclusive: bool, + agent_dir: impl Fn(&Path) -> PathBuf, +) -> Vec { + let mut paths = BTreeSet::new(); + for home in homes { + let agent_mcp = agent_dir(home).join("mcp.json"); + if exclusive { + paths.insert(agent_mcp); + continue; + } + paths.insert(home.join(".config/mcp/mcp.json")); + paths.insert(home.join(".agents/mcp.json")); + paths.insert(home.join(".agents/mcp/mcp.json")); + paths.insert(agent_mcp); + } + if exclusive { + return paths.into_iter().collect(); + } + if let Some(cwd) = cwd { + let cwd = canonicalize_dir(cwd).unwrap_or_else(|| cwd.to_path_buf()); + paths.insert(cwd.join(".mcp.json")); + paths.insert(cwd.join(".pi/mcp.json")); + for home in homes { + let Some(root) = configured_ancestor_root(home, &cwd, &agent_dir) else { + continue; + }; + for directory in ancestor_project_dirs(&cwd, &root) { + paths.insert(directory.join(".mcp.json")); + paths.insert(directory.join(".pi/mcp.json")); + } + } + } + paths.into_iter().collect() +} + +fn pi_mcp_exclusive_mode() -> bool { + env::var("PI_MCP_CONFIG_MODE").is_ok_and(|value| value.trim().eq_ignore_ascii_case("exclusive")) +} + +/// Last user-global `settings.ancestorConfigRoots` that pi-mcp-adapter would honor. +/// +/// Later global files replace earlier ones. An empty or invalid value disables +/// ancestor loading. Project files cannot enable it. +fn configured_ancestor_root( + home: &Path, + cwd: &Path, + agent_dir: &impl Fn(&Path) -> PathBuf, +) -> Option { + let mut configured = None; + for path in global_mcp_sources(home, agent_dir) { + match read_ancestor_setting(&path) { + AncestorSetting::Unset => {} + AncestorSetting::Clear => configured = Some(Vec::new()), + AncestorSetting::Roots(roots) => configured = Some(roots), + } + } + let roots = configured.filter(|roots| !roots.is_empty())?; + let home_id = canonicalize_dir(home)?; + let mut valid = Vec::new(); + for entry in roots { + let Some(expanded) = expand_ancestor_root(&entry, home) else { + continue; + }; + let Some(root) = canonicalize_dir(&expanded) else { + continue; + }; + // The deepest existing directory under the user's home that contains + // the working directory wins. A root equal to the working directory + // adds no parent files. + if root.starts_with(&home_id) && root_contains_cwd(&root, cwd) { + valid.push(root); + } + } + valid.sort_by_key(|path| std::cmp::Reverse(path.as_os_str().len())); + valid.into_iter().next() +} + +fn global_mcp_sources(home: &Path, agent_dir: &impl Fn(&Path) -> PathBuf) -> Vec { + let user = agent_dir(home).join("mcp.json"); + let generic = home.join(".config/mcp/mcp.json"); + let mut sources = Vec::new(); + if generic != user { + sources.push(generic.clone()); + } + for relative in [ + Path::new(".agents/mcp.json"), + Path::new(".agents/mcp/mcp.json"), + ] { + let path = home.join(relative); + if path != user && path != generic { + sources.push(path); + } + } + sources.push(user); + sources +} + +enum AncestorSetting { + Unset, + Clear, + Roots(Vec), +} + +fn read_ancestor_setting(path: &Path) -> AncestorSetting { + let Ok(bytes) = fs::read(path) else { + return AncestorSetting::Unset; + }; + let Ok(text) = str::from_utf8(&bytes) else { + return AncestorSetting::Unset; + }; + let Ok(value) = json5::from_str::(text.trim_start_matches('\u{feff}')) else { + return AncestorSetting::Unset; + }; + let Some(roots) = value.pointer("/settings/ancestorConfigRoots") else { + return AncestorSetting::Unset; + }; + let Some(entries) = roots.as_array() else { + return AncestorSetting::Clear; + }; + if entries.is_empty() { + return AncestorSetting::Clear; + } + AncestorSetting::Roots( + entries + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + ) +} + +fn expand_ancestor_root(entry: &str, home: &Path) -> Option { + entry + .strip_prefix("~/") + .map(|rest| home.join(rest)) + .or_else(|| { + let path = PathBuf::from(entry); + path.is_absolute().then_some(path) + }) +} + +fn ancestor_project_dirs(cwd: &Path, root: &Path) -> Vec { + let Some(cwd) = canonicalize_dir(cwd) else { + return Vec::new(); + }; + let Some(root) = canonicalize_dir(root) else { + return Vec::new(); + }; + let mut dirs = Vec::new(); + let Some(mut current) = cwd.parent().map(Path::to_path_buf) else { + return dirs; + }; + while current.starts_with(&root) { + dirs.insert(0, current.clone()); + if current == root { + break; + } + let Some(parent) = current.parent().map(Path::to_path_buf) else { + break; + }; + if parent == current { + break; + } + current = parent; + } + dirs +} + +fn root_contains_cwd(root: &Path, cwd: &Path) -> bool { + let Some(cwd) = canonicalize_dir(cwd) else { + return false; + }; + let Some(root) = canonicalize_dir(root) else { + return false; + }; + cwd.starts_with(root) +} + +fn canonicalize_dir(path: &Path) -> Option { + let canonical = fs::canonicalize(path).ok()?; + canonical.is_dir().then_some(canonical) +} + +/// Skill directories Pi loads. +/// +/// User skills come from the Pi agent directory and `~/.agents/skills`. +/// Project `.pi/skills` is only the working directory. Project `.agents/skills` +/// walks from the working directory through the git repository root, or the +/// filesystem root when the working directory is not in a repository. +fn skill_roots() -> Vec { + let current = env::current_dir().ok(); + skill_roots_in(&metadata::user_home_dirs(), current.as_deref()) +} + +fn skill_roots_in(homes: &[PathBuf], cwd: Option<&Path>) -> Vec { + let mut roots = BTreeSet::new(); + for home in homes { + roots.insert(user_pi_agent_dir(home).join("skills")); + roots.insert(home.join(".agents/skills")); + } + if let Some(cwd) = cwd { + let cwd = canonicalize_dir(cwd).unwrap_or_else(|| cwd.to_path_buf()); + roots.insert(cwd.join(".pi/skills")); + roots.extend(agents_skill_dirs(&cwd)); + } + roots.into_iter().collect() +} + +fn agents_skill_dirs(start: &Path) -> Vec { + let start = canonicalize_dir(start).unwrap_or_else(|| start.to_path_buf()); + let git_root = git_repo_root(&start); + let mut dir = start; + let mut dirs = Vec::new(); + loop { + dirs.push(dir.join(".agents/skills")); + if git_root.as_ref().is_some_and(|root| &dir == root) { + break; + } + let Some(parent) = dir.parent().map(Path::to_path_buf) else { + break; + }; + if parent == dir { + break; + } + dir = parent; + } + dirs +} + +fn git_repo_root(start: &Path) -> Option { + let mut dir = start.to_path_buf(); + loop { + if dir.join(".git").exists() { + return Some(dir); + } + let parent = dir.parent()?.to_path_buf(); + if parent == dir { + return None; + } + dir = parent; + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + }; + + use serde_json::json; + + use super::{ + is_pi, mcp_config_paths, mcp_config_paths_with, package_version, skill_roots, + skill_roots_in, + }; + use crate::provider::pi::user_pi_agent_dir; + use crate::provider::vscode::discovery::mcp_servers_from_value; + + #[test] + fn accepts_earendil_pi_package_and_reads_version() { + let root = temporary("pi-package"); + let package = root.join("node_modules/@earendil-works/pi-coding-agent"); + let bundle = package.join("dist/bundle"); + fs::create_dir_all(&bundle).unwrap(); + fs::write( + package.join("package.json"), + r#"{"name":"@earendil-works/pi-coding-agent","version":"0.85.1"}"#, + ) + .unwrap(); + let executable = bundle.join("cli.js"); + fs::write(&executable, "#!/usr/bin/env node\n").unwrap(); + + assert!(is_pi(&executable)); + assert_eq!(package_version(&executable).as_deref(), Some("0.85.1")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rejects_an_unrelated_pi_binary() { + let root = temporary("pi-unrelated"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("package.json"), + r#"{"name":"raspberry-pi-tools","version":"1.0.0"}"#, + ) + .unwrap(); + let executable = root.join("pi"); + fs::write(&executable, "#!/bin/sh\n").unwrap(); + + assert!(!is_pi(&executable)); + assert_eq!(package_version(&executable), None); + + let _ = fs::remove_dir_all(root); + } + + // npm-generated Windows global shims: package.json is beside these launchers + // under node_modules, rather than in an ancestor of the launcher itself. + const NPM_CMD_SHIM: &str = r#"@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\@earendil-works\pi-coding-agent\dist\bundle\cli.js" %* +"#; + + const NPM_POWERSHELL_SHIM: &str = r#"#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" $args + } else { + & "$basedir/node$exe" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" $args + } else { + & "node$exe" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret +"#; + + const NPM_SHELL_SHIM: &str = r#"#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" "$@" +else + exec node "$basedir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" "$@" +fi +"#; + + fn npm_shim_fixture(name: &str, source: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let shim = write_npm_shim(root.path(), name, source); + (root, shim) + } + + fn write_npm_shim(prefix: &Path, name: &str, source: &str) -> PathBuf { + let package = prefix.join("node_modules/@earendil-works/pi-coding-agent"); + fs::create_dir_all(package.join("dist/bundle")).unwrap(); + fs::write( + package.join("package.json"), + r#"{"name":"@earendil-works/pi-coding-agent","version":"0.85.1"}"#, + ) + .unwrap(); + fs::write(package.join("dist/bundle/cli.js"), "#!/usr/bin/env node\n").unwrap(); + let shim = prefix.join(name); + fs::write(&shim, source).unwrap(); + shim + } + + #[test] + fn discovers_powershell_only_npm_installs_without_pathext() { + const EXPECTED_SHIM: &str = "AGENTDESKTOP_PI_PS1_TEST_SHIM"; + if let Some(expected) = std::env::var_os(EXPECTED_SHIM) { + #[cfg(windows)] + let executable = super::discover() + .expect("Pi should be discovered") + .executable; + // Exercise Windows candidate discovery on other platforms too. + #[cfg(not(windows))] + let executable = super::windows_executable_candidates(Default::default()) + .into_iter() + .filter(|path| path.is_file()) + .find(|path| is_pi(path)) + .expect("PowerShell-only Pi should be discovered"); + assert_eq!(executable, PathBuf::from(expected)); + return; + } + + for use_path in [false, true] { + let home = tempfile::tempdir().unwrap(); + let prefix = home.path().join(if use_path { + "z-custom-npm-prefix" + } else { + "AppData/Roaming/npm" + }); + let shim = write_npm_shim(&prefix, "pi.ps1", NPM_POWERSHELL_SHIM); + let path = if use_path { + // The first PATH match wins over later entries and fallback locations. + let later_prefix = home.path().join("a-older-npm-prefix"); + write_npm_shim(&later_prefix, "pi.ps1", NPM_POWERSHELL_SHIM); + write_npm_shim( + &home.path().join("AppData/Roaming/npm"), + "pi.ps1", + NPM_POWERSHELL_SHIM, + ); + std::env::join_paths([&prefix, &later_prefix]).unwrap() + } else { + Default::default() + }; + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "provider::pi::discovery::tests::discovers_powershell_only_npm_installs_without_pathext", + "--nocapture", + ]) + .env(EXPECTED_SHIM, &shim) + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("PATH", path) + .env("PATHEXT", ".COM;.EXE;.BAT;.CMD") + .output() + .unwrap(); + assert!( + output.status.success(), + "Pi with only pi.ps1 (on PATH: {use_path}):\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + } + + #[test] + fn accepts_npm_cmd_shim_and_reads_version() { + let (_root, shim) = npm_shim_fixture("pi.cmd", NPM_CMD_SHIM); + assert!(is_pi(&shim)); + assert_eq!(package_version(&shim).as_deref(), Some("0.85.1")); + } + + #[test] + fn accepts_npm_powershell_shim_and_reads_version() { + let (_root, shim) = npm_shim_fixture("pi.ps1", NPM_POWERSHELL_SHIM); + assert!(is_pi(&shim)); + assert_eq!(package_version(&shim).as_deref(), Some("0.85.1")); + } + + #[test] + fn accepts_npm_shell_shim_and_reads_version() { + let (_root, shim) = npm_shim_fixture("pi", NPM_SHELL_SHIM); + assert!(is_pi(&shim)); + assert_eq!(package_version(&shim).as_deref(), Some("0.85.1")); + } + + #[test] + fn rejects_unrelated_launchers_beside_pi_package() { + for (name, source) in [ + ("pi.cmd", NPM_CMD_SHIM), + ("pi.ps1", NPM_POWERSHELL_SHIM), + ("pi", NPM_SHELL_SHIM), + ] { + let unrelated = source + .replace("@earendil-works/pi-coding-agent", "unrelated-pi") + .replace(r"@earendil-works\pi-coding-agent", "unrelated-pi"); + let (_root, shim) = npm_shim_fixture(name, &unrelated); + assert!(!is_pi(&shim)); + assert_eq!(package_version(&shim), None); + } + } + + #[test] + fn rejects_npm_shim_without_matching_package_metadata() { + let (root, shim) = npm_shim_fixture("pi.cmd", NPM_CMD_SHIM); + let manifest = root + .path() + .join("node_modules/@earendil-works/pi-coding-agent/package.json"); + for contents in [ + r#"{"name":"unrelated-pi","version":"0.85.1"}"#, + r#"{"name":"@earendil-works/pi-coding-agent"}"#, + ] { + fs::write(&manifest, contents).unwrap(); + assert!(!is_pi(&shim)); + assert_eq!(package_version(&shim), None); + } + } + + #[test] + fn rejects_commented_npm_shim_references() { + let source = format!( + "{}\nexec unrelated-pi\n", + NPM_SHELL_SHIM + .lines() + .map(|line| format!("# {line}\n")) + .collect::() + ); + let (_root, shim) = npm_shim_fixture("pi", &source); + assert!(!is_pi(&shim)); + } + + #[test] + fn rejects_oversized_npm_shims() { + let source = format!("{NPM_CMD_SHIM}{}", " ".repeat(16 * 1024)); + let (_root, shim) = npm_shim_fixture("pi.cmd", &source); + assert!(!is_pi(&shim)); + } + + #[test] + fn reads_adapter_servers_without_secrets_or_arguments() { + let servers = mcp_servers_from_value( + &json!({ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp", + "headers": { "Authorization": "secret" } + }, + "chrome": { + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@1.6.0"], + "env": { "TOKEN": "secret" } + }, + "skipped": { + "command": "npx", + "disabled": true + } + } + }), + Path::new("/home/tester/.pi/agent/mcp.json"), + ); + + assert_eq!(servers.len(), 3); + assert_eq!(servers[0].name, "chrome"); + assert_eq!(servers[0].transport, "stdio"); + assert_eq!(servers[0].command.as_deref(), Some("npx")); + assert!(servers[0].enabled); + assert_eq!(servers[1].name, "github"); + assert_eq!(servers[1].transport, "http"); + assert_eq!( + servers[1].url.as_deref(), + Some("https://api.githubcopilot.com/mcp") + ); + assert!(!servers[2].enabled); + assert!( + servers + .iter() + .all(|server| server.source.ends_with("mcp.json")) + ); + } + + #[test] + fn project_mcp_stays_in_the_working_directory_unless_opted_in() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let repo = home.join("repo"); + let project = repo.join("child"); + let outside = home.join("outside"); + fs::create_dir_all(project.join(".pi")).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::create_dir_all(repo.join(".pi")).unwrap(); + let project_id = fs::canonicalize(&project).unwrap(); + let repo_id = fs::canonicalize(&repo).unwrap(); + let outside_id = fs::canonicalize(&outside).unwrap(); + let home_id = fs::canonicalize(&home).unwrap(); + let agent = |home: &Path| home.join(".pi/agent"); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, agent); + assert!(paths.contains(&project_id.join(".mcp.json"))); + assert!(paths.contains(&project_id.join(".pi/mcp.json"))); + assert!(!paths.contains(&repo_id.join(".mcp.json"))); + assert!(!paths.contains(&repo_id.join(".pi/mcp.json"))); + assert!(!paths.contains(&outside_id.join(".mcp.json"))); + assert!(paths.contains(&home.join(".config/mcp/mcp.json"))); + assert!(paths.contains(&home.join(".agents/mcp.json"))); + assert!(paths.contains(&home.join(".agents/mcp/mcp.json"))); + assert!(paths.contains(&agent(&home).join("mcp.json"))); + + // A project file cannot widen discovery. + fs::write( + project.join(".mcp.json"), + r#"{"settings":{"ancestorConfigRoots":["~/"]}}"#, + ) + .unwrap(); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, agent); + assert!(!paths.contains(&repo_id.join(".mcp.json"))); + + fs::create_dir_all(home.join(".config/mcp")).unwrap(); + fs::write( + home.join(".config/mcp/mcp.json"), + r#"{"settings":{"ancestorConfigRoots":["~/repo"]}}"#, + ) + .unwrap(); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, agent); + assert!(paths.contains(&repo_id.join(".mcp.json"))); + assert!(paths.contains(&repo_id.join(".pi/mcp.json"))); + assert!(!paths.contains(&outside_id.join(".mcp.json"))); + assert!(!paths.contains(&home_id.join(".mcp.json"))); + + // The Pi agent file is the last global source, so it can turn the opt-in off. + fs::create_dir_all(home.join(".pi/agent")).unwrap(); + fs::write( + agent(&home).join("mcp.json"), + r#"{"settings":{"ancestorConfigRoots":[]}}"#, + ) + .unwrap(); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, agent); + assert!(!paths.contains(&repo_id.join(".mcp.json"))); + + let paths = mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), true, agent); + assert_eq!(paths, vec![agent(&home).join("mcp.json")]); + } + + #[test] + fn ancestor_mcp_ignores_roots_outside_the_home_or_working_directory() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = home.join("work/child"); + let elsewhere = home.join("elsewhere"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&elsewhere).unwrap(); + fs::create_dir_all(home.join(".pi/agent")).unwrap(); + fs::write( + home.join(".pi/agent/mcp.json"), + format!( + r#"{{"settings":{{"ancestorConfigRoots":["{}", "~/elsewhere"]}}}}"#, + root.path().display() + ), + ) + .unwrap(); + let paths = + mcp_config_paths_with(std::slice::from_ref(&home), Some(&project), false, |home| { + home.join(".pi/agent") + }); + let root_id = fs::canonicalize(root.path()).unwrap(); + let elsewhere_id = fs::canonicalize(&elsewhere).unwrap(); + let work_id = fs::canonicalize(home.join("work")).unwrap(); + assert!(!paths.contains(&root_id.join(".mcp.json"))); + assert!(!paths.contains(&elsewhere_id.join(".mcp.json"))); + assert!(!paths.contains(&work_id.join(".mcp.json"))); + } + + #[test] + fn skill_roots_follow_pi_project_scopes() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let repo = root.path().join("repo"); + let project = repo.join("child"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&project).unwrap(); + fs::write(repo.join(".git"), "gitdir: /unused").unwrap(); + + let project = fs::canonicalize(&project).unwrap(); + let repo = fs::canonicalize(&repo).unwrap(); + let paths = skill_roots_in(std::slice::from_ref(&home), Some(&project)); + assert!(paths.contains(&project.join(".pi/skills"))); + assert!(paths.contains(&project.join(".agents/skills"))); + assert!(paths.contains(&repo.join(".agents/skills"))); + assert!(!paths.contains(&repo.join(".pi/skills"))); + assert!( + !paths.contains( + &fs::canonicalize(root.path()) + .unwrap() + .join(".agents/skills") + ) + ); + assert!(!paths.contains(&fs::canonicalize(root.path()).unwrap().join(".pi/skills"))); + assert!(paths.contains(&home.join(".agents/skills"))); + assert!(paths.contains(&user_pi_agent_dir(&home).join("skills"))); + + let loose = root.path().join("loose/child"); + fs::create_dir_all(&loose).unwrap(); + let loose = fs::canonicalize(&loose).unwrap(); + let paths = skill_roots_in(&[home], Some(&loose)); + assert!(paths.contains(&loose.join(".pi/skills"))); + assert!(!paths.contains(&loose.parent().unwrap().join(".pi/skills"))); + assert!(paths.contains(&loose.parent().unwrap().join(".agents/skills"))); + assert!( + paths.contains( + &loose + .parent() + .unwrap() + .parent() + .unwrap() + .join(".agents/skills") + ) + ); + } + + #[test] + fn scans_pi_owned_and_shared_mcp_files() { + let paths = mcp_config_paths(); + for home in crate::provider::metadata::user_home_dirs() { + assert!(paths.contains(&user_pi_agent_dir(&home).join("mcp.json"))); + } + assert!( + paths + .iter() + .any(|path| path.ends_with(std::path::Path::new(".config/mcp/mcp.json"))) + ); + assert!(paths.iter().any(|path| path.ends_with(".mcp.json"))); + assert!( + paths + .iter() + .any(|path| path.ends_with(std::path::Path::new(".pi/mcp.json"))) + ); + } + + #[test] + fn pi_agent_dir_overrides_match_all_consumers() { + const CHILD_HOME: &str = "AGENTDESKTOP_PI_PATH_TEST_HOME"; + const CHILD_EXPECTED: &str = "AGENTDESKTOP_PI_PATH_TEST_EXPECTED"; + if let Some(home) = std::env::var_os(CHILD_HOME) { + let home = PathBuf::from(home); + let expected = PathBuf::from(std::env::var_os(CHILD_EXPECTED).unwrap()); + assert_eq!(user_pi_agent_dir(&home), expected); + assert!(mcp_config_paths().contains(&expected.join("mcp.json"))); + assert!(skill_roots().contains(&expected.join("skills"))); + return; + } + + // Isolate environment overrides in a subprocess so parallel tests never + // observe changes to HOME, USERPROFILE, or PI_CODING_AGENT_DIR. + let home = tempfile::tempdir().unwrap(); + let absolute = home.path().join("absolute-agent"); + let cases = [ + ( + Some(Path::new("~/.pi/custom-agent")), + home.path().join(".pi/custom-agent"), + ), + (Some(Path::new("~")), home.path().to_owned()), + ( + Some(Path::new("relative-agent")), + PathBuf::from("relative-agent"), + ), + (Some(absolute.as_path()), absolute.clone()), + (Some(Path::new("")), home.path().join(".pi/agent")), + (None, home.path().join(".pi/agent")), + ( + Some(Path::new("~another-user/agent")), + PathBuf::from("~another-user/agent"), + ), + #[cfg(windows)] + ( + Some(Path::new(r"~\.pi\custom-agent")), + home.path().join(".pi/custom-agent"), + ), + #[cfg(unix)] + ( + Some(Path::new(r"~\.pi\custom-agent")), + PathBuf::from(r"~\.pi\custom-agent"), + ), + ]; + for (configured, expected) in cases { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "provider::pi::discovery::tests::pi_agent_dir_overrides_match_all_consumers", + "--nocapture", + ]) + .env(CHILD_HOME, home.path()) + .env(CHILD_EXPECTED, expected) + .env("HOME", home.path()) + .env("USERPROFILE", home.path()); + if let Some(configured) = configured { + command.env("PI_CODING_AGENT_DIR", configured); + } else { + command.env_remove("PI_CODING_AGENT_DIR"); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "PI_CODING_AGENT_DIR={configured:?}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + } + + fn temporary(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("agentdesktop-{name}-{}", std::process::id())) + } +} diff --git a/crates/agent/src/provider/pi/mod.rs b/crates/agent/src/provider/pi/mod.rs new file mode 100644 index 00000000..17ea8860 --- /dev/null +++ b/crates/agent/src/provider/pi/mod.rs @@ -0,0 +1,199 @@ +use std::path::PathBuf; + +use agentdesktop_core::config::DaemonConfig; +use agentdesktop_core::model::Discovery; + +use super::{Provider, ReconcileContext}; +use crate::reconcile::ReconcilePlan; + +pub(super) mod discovery; +mod reconcile; + +#[derive(Clone)] +pub struct Pi { + pub models_path: PathBuf, + pub settings_path: PathBuf, +} + +impl Default for Pi { + fn default() -> Self { + Self { + models_path: default_pi_models_path(), + settings_path: default_pi_settings_path(), + } + } +} + +impl Pi { + pub const ID: &'static str = "pi"; + pub const DISPLAY_NAME: &'static str = "Pi"; + pub const PACKAGE_NAME: &'static str = "@earendil-works/pi-coding-agent"; +} + +#[async_trait::async_trait] +impl Provider for Pi { + fn id(&self) -> &'static str { + Self::ID + } + + async fn discover(&self) -> Discovery { + Discovery { + agents: discovery::discover().into_iter().collect(), + model_runtimes: Vec::new(), + } + } + + fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { + if !ctx.merge_user_settings && config.programs.pi.is_some() { + anyhow::bail!( + "Pi only reads user configuration; run agentdesktop with --user to manage programs.pi, or remove programs.pi from the system configuration" + ); + } + let configured = config.programs.pi.as_ref().map(|provider| { + let gateway = config + .llm_gateway + .as_ref() + .filter(|_| provider.use_llm_gateway); + (provider, gateway) + }); + let plan = ReconcilePlan::default(); + reconcile::plan( + &self.models_path, + &self.settings_path, + &ctx.credential_helper, + &ctx.socket, + configured, + &plan, + )?; + Ok(plan) + } +} + +/// Returns the system-wide Pi models.json path. +pub fn default_pi_models_path() -> PathBuf { + pi_agent_dir(None).join("models.json") +} + +/// Returns the system-wide Pi settings.json path. +pub fn default_pi_settings_path() -> PathBuf { + pi_agent_dir(None).join("settings.json") +} + +pub fn user_pi_agent_dir(home: &std::path::Path) -> PathBuf { + std::env::var_os("PI_CODING_AGENT_DIR") + .filter(|value| !value.is_empty()) + .map(|value| { + let path = PathBuf::from(value); + // Path components handle ~/ on every platform and ~\ on Windows, + // matching Pi's platform-specific tilde expansion. + match path.strip_prefix("~") { + Ok(relative) => home.join(relative), + Err(_) => path, + } + }) + .unwrap_or_else(|| home.join(".pi/agent")) +} + +fn pi_agent_dir(home: Option<&std::path::Path>) -> PathBuf { + if let Some(home) = home { + return user_pi_agent_dir(home); + } + + #[cfg(windows)] + return windows_system_pi_agent_dir( + std::env::var_os("SystemDrive").or_else(|| std::env::var_os("SYSTEMDRIVE")), + ); + + #[cfg(not(windows))] + PathBuf::from("/etc/pi/agent") +} + +#[cfg(any(windows, test))] +fn windows_system_pi_agent_dir(system_drive: Option) -> PathBuf { + let drive = system_drive + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "C:".into()); + let mut drive = drive + .to_string_lossy() + .trim_end_matches(['\\', '/']) + .to_owned(); + if drive.len() == 1 && drive.as_bytes()[0].is_ascii_alphabetic() { + drive.push(':'); + } + if drive.is_empty() || !drive.ends_with(':') { + drive = "C:".to_owned(); + } + PathBuf::from(format!(r"{drive}\etc\pi\agent")) +} + +#[cfg(test)] +mod tests { + use agentdesktop_core::config::{DaemonConfig, parse_daemon}; + + use super::{Pi, Provider, ReconcileContext, windows_system_pi_agent_dir}; + + #[test] + fn rejects_configured_pi_in_system_mode() { + let root = tempfile::tempdir().unwrap(); + let pi = Pi { + models_path: root.path().join("models.json"), + settings_path: root.path().join("settings.json"), + }; + let ctx = ReconcileContext { + llm_proxy: None, + merge_user_settings: false, + credential_helper: root.path().join("agentdesktop"), + socket: root.path().join("agentdesktop.sock"), + }; + let config = + parse_daemon("programs:\n pi:\n managedConfig:\n providers: {}\n").unwrap(); + + let error = pi.plan(&ctx, &config).err().expect("Pi needs user mode"); + assert!(error.to_string().contains("--user")); + assert!(error.to_string().contains("programs.pi")); + assert!(!pi.models_path.exists()); + assert!(!pi.settings_path.exists()); + } + + #[test] + fn allows_absent_pi_cleanup_in_system_mode() { + let root = tempfile::tempdir().unwrap(); + let pi = Pi { + models_path: root.path().join("models.json"), + settings_path: root.path().join("settings.json"), + }; + let mut ctx = ReconcileContext { + llm_proxy: None, + merge_user_settings: true, + credential_helper: root.path().join("agentdesktop"), + socket: root.path().join("agentdesktop.sock"), + }; + let config = + parse_daemon("programs:\n pi:\n managedConfig:\n providers: {}\n").unwrap(); + pi.plan(&ctx, &config).unwrap().apply().unwrap(); + assert!(pi.models_path.exists()); + + ctx.merge_user_settings = false; + pi.plan(&ctx, &DaemonConfig::default()) + .unwrap() + .apply() + .unwrap(); + assert!(!pi.models_path.exists()); + } + + #[test] + fn windows_system_pi_agent_dir_uses_system_drive_root() { + assert_eq!( + windows_system_pi_agent_dir(Some("D:".into())), + std::path::PathBuf::from(r"D:\etc\pi\agent") + ); + assert_eq!( + windows_system_pi_agent_dir(Some(r"E:\".into())), + std::path::PathBuf::from(r"E:\etc\pi\agent") + ); + assert_eq!( + windows_system_pi_agent_dir(None), + std::path::PathBuf::from(r"C:\etc\pi\agent") + ); + } +} diff --git a/crates/agent/src/provider/pi/reconcile.rs b/crates/agent/src/provider/pi/reconcile.rs new file mode 100644 index 00000000..d04ee275 --- /dev/null +++ b/crates/agent/src/provider/pi/reconcile.rs @@ -0,0 +1,776 @@ +use super::Pi; + +use std::path::Path; + +use agentdesktop_core::config::{LlmGatewayAuthentication, LlmGatewayConfig, PiConfig}; +use anyhow::Context; +use serde_json::{Map, Value, json}; + +use crate::provider::{ + json_merge, + shared::{CommandSpec, deep_merge, render_command, responses_base_url}, +}; +use crate::reconcile::ReconcilePlan; + +const PROVIDER_NAME: &str = "agentdesktop"; +const DEFAULT_API: &str = "anthropic-messages"; +/// `models.json` may hold the user's own provider API keys: a file we write +/// is owner-only, and the dry-run report shows the action and path only. +const MODELS_OPTIONS: json_merge::MergeOptions = json_merge::MergeOptions { + mode: 0o600, + keyed_arrays: &[], + redact_diff: true, + // Pi accepts comments and trailing commas in models.json. + format: json_merge::JsonFormat::Jsonc, + replace_paths: &[], +}; + +pub(super) fn plan( + models_path: &Path, + settings_path: &Path, + credential_helper: &Path, + socket: &Path, + config: Option<(&PiConfig, Option<&LlmGatewayConfig>)>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + let models_state = json_merge::state_path(models_path); + let settings_state = json_merge::state_path(settings_path); + let Some((config, gateway)) = config else { + json_merge::plan_remove_with( + models_path, + &models_state, + "models", + Pi::DISPLAY_NAME, + MODELS_OPTIONS, + plan, + )?; + json_merge::plan_remove( + settings_path, + &settings_state, + "settings", + Pi::DISPLAY_NAME, + plan, + )?; + return Ok(()); + }; + + json_merge::plan_merge_with( + models_path, + &models_state, + managed_models(config, gateway, credential_helper, socket)?, + false, + "models", + Pi::DISPLAY_NAME, + json_merge::MergeOptions { + replace_paths: if gateway.is_some() { + &["/providers/agentdesktop/models"] + } else { + &[] + }, + ..MODELS_OPTIONS + }, + plan, + )?; + json_merge::plan_merge( + settings_path, + &settings_state, + managed_settings(config, gateway)?, + false, + "settings", + Pi::DISPLAY_NAME, + plan, + )?; + Ok(()) +} + +fn managed_models( + config: &PiConfig, + gateway: Option<&LlmGatewayConfig>, + credential_helper: &Path, + socket: &Path, +) -> anyhow::Result { + let mut settings = serde_json::to_value(&config.managed_config) + .context("serialize Pi pass-through managed configuration")?; + let Some(gateway) = gateway else { + return Ok(settings); + }; + + let default_model = config + .model + .as_deref() + .filter(|model| !model.trim().is_empty()) + .context("Pi gateway configuration has no model")?; + let api = config + .api + .as_deref() + .map(str::trim) + .filter(|api| !api.is_empty()) + .unwrap_or(DEFAULT_API); + let uses_credential_helper = gateway + .authentication + .as_ref() + .is_some_and(LlmGatewayAuthentication::uses_credential_helper); + let api_key = if uses_credential_helper { + format!( + "!{}", + render_command(&CommandSpec::new( + credential_helper, + [ + "--socket", + &socket.to_string_lossy(), + "credential", + "--client-id", + Pi::ID, + ], + )) + ) + } else { + "agentdesktop-managed".to_owned() + }; + + let mut models = Vec::new(); + for (id, mut entry) in catalog_entries(config, default_model) { + entry.insert("id".to_owned(), json!(id)); + let model_api = entry.get("api").and_then(Value::as_str).unwrap_or(api); + let base_url = gateway_base_url(gateway, model_api); + entry.insert("baseUrl".to_owned(), json!(base_url)); + models.push(Value::Object(entry)); + } + + let generated = json!({ + "providers": { + (PROVIDER_NAME): { + "baseUrl": gateway_base_url(gateway, api), + "api": api, + "apiKey": api_key, + "authHeader": true, + "models": models, + } + } + }); + deep_merge(&mut settings, generated); + Ok(settings) +} + +fn managed_settings( + config: &PiConfig, + gateway: Option<&LlmGatewayConfig>, +) -> anyhow::Result { + if gateway.is_none() { + return Ok(json!({})); + } + let model = config + .model + .as_deref() + .filter(|model| !model.trim().is_empty()) + .context("Pi gateway configuration has no model")?; + Ok(json!({ + "defaultProvider": PROVIDER_NAME, + "defaultModel": model, + })) +} + +fn catalog_entries(config: &PiConfig, default_model: &str) -> Vec<(String, Map)> { + if config.models.is_empty() { + return vec![(default_model.to_owned(), Map::new())]; + } + config + .models + .iter() + .map(|(id, value)| { + let object = match value { + Value::Object(object) => object.clone(), + _ => Map::new(), + }; + (id.clone(), object) + }) + .collect() +} + +fn gateway_base_url(gateway: &LlmGatewayConfig, api: &str) -> String { + match api { + "openai-completions" | "openai-responses" => responses_base_url(gateway), + _ => gateway.url.as_str().trim_end_matches('/').to_owned(), + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use agentdesktop_core::config::parse_daemon; + use serde_json::{Value, json}; + + use super::{managed_models, managed_settings, plan}; + use crate::reconcile::ReconcilePlan; + + fn reconcile_fixture(models: &Path, config: Option<&str>) -> ReconcilePlan { + let config = config.map(|yaml| parse_daemon(yaml).unwrap()); + let changes = ReconcilePlan::default(); + plan( + models, + &models.with_file_name("settings.json"), + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + config.as_ref().map(|config| { + ( + config.programs.pi.as_ref().unwrap(), + config.llm_gateway.as_ref(), + ) + }), + &changes, + ) + .unwrap(); + changes + } + + #[cfg(unix)] + #[test] + fn models_create_and_merge_use_private_permissions() { + use std::os::unix::fs::PermissionsExt; + + for existing_mode in [None, Some(0o600), Some(0o644)] { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + if let Some(mode) = existing_mode { + fs::write( + &models, + r#"{"providers":{"personal":{"apiKey":"test-secret"}}}"#, + ) + .unwrap(); + fs::set_permissions(&models, fs::Permissions::from_mode(mode)).unwrap(); + } + let changes = reconcile_fixture(&models, Some("programs:\n pi: {}\n")); + if let Some(mode) = existing_mode { + assert_eq!( + fs::metadata(&models).unwrap().permissions().mode() & 0o777, + mode + ); + } else { + assert!(!models.exists(), "planning must not write files"); + } + changes.apply().unwrap(); + assert_eq!( + fs::metadata(&models).unwrap().permissions().mode() & 0o077, + 0 + ); + if existing_mode.is_some() { + let merged: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!(merged["providers"]["personal"]["apiKey"], "test-secret"); + } + } + } + + #[cfg(unix)] + #[test] + fn models_cleanup_keeps_personal_credentials_private() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let original = json!({"providers":{"personal":{"apiKey":"test-secret"}}}); + fs::write(&models, serde_json::to_vec(&original).unwrap()).unwrap(); + reconcile_fixture(&models, Some("llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n")) + .apply().unwrap(); + fs::set_permissions(&models, fs::Permissions::from_mode(0o600)).unwrap(); + + reconcile_fixture(&models, None).apply().unwrap(); + assert_eq!( + fs::metadata(&models).unwrap().permissions().mode() & 0o077, + 0 + ); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!(restored, original); + } + + #[cfg(unix)] + #[test] + fn models_reconcile_repairs_permissions_without_content_changes() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + fs::write( + &models, + r#"{"providers":{"personal":{"apiKey":"test-secret"}}}"#, + ) + .unwrap(); + let config = Some("programs:\n pi: {}\n"); + reconcile_fixture(&models, config).apply().unwrap(); + let before = fs::read(&models).unwrap(); + // Loosened after the merge, for example by another program. + fs::set_permissions(&models, fs::Permissions::from_mode(0o644)).unwrap(); + let changes = reconcile_fixture(&models, config); + assert!( + changes.render().contains("UPDATE Pi models"), + "{}", + changes.render() + ); + changes.apply().unwrap(); + assert_eq!(fs::read(&models).unwrap(), before); + assert_eq!( + fs::metadata(&models).unwrap().permissions().mode() & 0o077, + 0 + ); + let repeated = reconcile_fixture(&models, config); + assert!(repeated.render().contains("Summary: 0 changes")); + repeated.apply().unwrap(); + } + + #[test] + fn models_dry_run_omits_file_contents() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + fs::write( + &models, + r#"{"providers":{"personal":{"apiKey":"test-secret"}}}"#, + ) + .unwrap(); + let config = "llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n"; + let merge = reconcile_fixture(&models, Some(config)); + assert!(merge.render().contains("UPDATE Pi models")); + assert!( + !merge.render().contains("test-secret"), + "{}", + merge.render() + ); + merge.apply().unwrap(); + let removal = reconcile_fixture(&models, None); + assert!( + !removal.render().contains("test-secret"), + "{}", + removal.render() + ); + } + + #[test] + fn gateway_catalog_replaces_existing_models_and_restores_on_removal() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let original = json!({"providers":{ + "agentdesktop": { + "baseUrl":"https://old.example.com", + "apiKey":"test-secret", + "models":[ + {"id":"selected","baseUrl":"https://gateway.example.com"}, + {"id":"selected","baseUrl":"https://outside.example.com"}, + {"id":"stale","baseUrl":"https://outside.example.com"} + ] + }, + "personal":{"models":[{"id":"personal-model"}],"apiKey":"personal-secret"} + }}); + fs::write(&models, serde_json::to_vec(&original).unwrap()).unwrap(); + + for selected in ["selected", "next"] { + let config = format!( + "llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: {selected}\n" + ); + reconcile_fixture(&models, Some(&config)).apply().unwrap(); + let merged: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + merged["providers"]["agentdesktop"]["models"], + json!([ + {"id":selected,"baseUrl":"https://gateway.example.com"} + ]) + ); + assert_eq!( + merged["providers"]["personal"], + original["providers"]["personal"] + ); + let repeated = reconcile_fixture(&models, Some(&config)); + assert!(repeated.render().contains("Summary: 0 changes")); + repeated.apply().unwrap(); + } + + // Unrelated user edits made while managed must survive cleanup. + let mut edited: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + edited["providers"]["personal"]["apiKey"] = json!("updated-personal-secret"); + fs::write(&models, serde_json::to_vec(&edited).unwrap()).unwrap(); + reconcile_fixture(&models, None).apply().unwrap(); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + restored["providers"]["agentdesktop"], + original["providers"]["agentdesktop"] + ); + assert_eq!( + restored["providers"]["personal"]["apiKey"], + "updated-personal-secret" + ); + assert!(!super::json_merge::state_path(&models).exists()); + } + + #[test] + fn cleanup_restores_catalog_order_after_user_additions() { + for restore_original in [false, true] { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + // Pi resolves duplicate IDs using the last entry. The original catalog + // must keep that ordering even if one entry matches the managed model. + let original_catalog = json!([ + {"id":"selected","baseUrl":"https://outside.example.com"}, + {"id":"selected","baseUrl":"https://gateway.example.com"}, + {"id":"selected","baseUrl":"https://gateway.example.com"} + ]); + fs::write( + &models, + serde_json::to_vec(&json!({"providers":{"agentdesktop":{ + "models":original_catalog + }}})) + .unwrap(), + ) + .unwrap(); + reconcile_fixture(&models, Some("llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n")) + .apply().unwrap(); + let mut edited: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + let addition = json!({"id":"added","baseUrl":"https://added.example.com"}); + if restore_original { + // A user may restore an original entry before disabling management. + edited["providers"]["agentdesktop"]["models"] + .as_array_mut() + .unwrap() + .push(original_catalog[0].clone()); + } + edited["providers"]["agentdesktop"]["models"] + .as_array_mut() + .unwrap() + .push(addition.clone()); + fs::write(&models, serde_json::to_vec(&edited).unwrap()).unwrap(); + + reconcile_fixture(&models, None).apply().unwrap(); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + let mut expected = original_catalog; + expected.as_array_mut().unwrap().push(addition); + assert_eq!(restored["providers"]["agentdesktop"]["models"], expected); + } + } + + #[test] + fn disabling_gateway_restores_catalog_and_preserves_user_additions() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let original_model = json!({"id":"original","baseUrl":"https://original.example.com"}); + fs::write( + &models, + serde_json::to_vec(&json!({"providers":{"agentdesktop":{ + "models":[original_model.clone()] + }}})) + .unwrap(), + ) + .unwrap(); + reconcile_fixture(&models, Some("llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n")) + .apply().unwrap(); + let mut edited: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + edited["providers"]["agentdesktop"]["models"] + .as_array() + .unwrap() + .len(), + 1 + ); + let added_model = json!({"id":"added","baseUrl":"https://added.example.com"}); + edited["providers"]["agentdesktop"]["models"] + .as_array_mut() + .unwrap() + .push(added_model.clone()); + fs::write(&models, serde_json::to_vec(&edited).unwrap()).unwrap(); + + reconcile_fixture( + &models, + Some("programs:\n pi:\n useLlmGateway: false\n"), + ) + .apply() + .unwrap(); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + let catalog = restored["providers"]["agentdesktop"]["models"] + .as_array() + .unwrap(); + assert_eq!(catalog.len(), 2); + assert!(catalog.contains(&original_model)); + assert!(catalog.contains(&added_model)); + reconcile_fixture(&models, None).apply().unwrap(); + assert_eq!( + serde_json::from_slice::(&fs::read(&models).unwrap()).unwrap(), + restored + ); + } + + #[test] + fn gateway_overrides_per_model_endpoints() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com/proxy +programs: + pi: + model: claude + models: + claude: + baseUrl: https://outside.example.com + chat: + api: openai-completions + baseUrl: https://outside.example.com/v1 + responses: + api: openai-responses +"#, + ) + .unwrap(); + let managed = managed_models( + config.programs.pi.as_ref().unwrap(), + config.llm_gateway.as_ref(), + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + ) + .unwrap(); + for model in managed["providers"]["agentdesktop"]["models"] + .as_array() + .unwrap() + { + let expected = if model["id"] == "claude" { + "https://gateway.example.com/proxy" + } else { + "https://gateway.example.com/proxy/v1" + }; + assert_eq!(model["baseUrl"], expected, "model {}", model["id"]); + } + } + + #[test] + fn model_map_keys_define_catalog_ids() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: selected + models: + selected: + id: different + name: Selected Model +"#, + ) + .unwrap(); + let pi = config.programs.pi.as_ref().unwrap(); + let gateway = config.llm_gateway.as_ref(); + let managed = managed_models( + pi, + gateway, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + ) + .unwrap(); + let settings = managed_settings(pi, gateway).unwrap(); + let model = &managed["providers"]["agentdesktop"]["models"][0]; + assert_eq!(model["id"], settings["defaultModel"]); + assert_eq!(model["name"], "Selected Model"); + } + + #[test] + fn commented_models_survive_merge_repeat_and_removal() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let settings = root.path().join("settings.json"); + fs::write( + &models, + "\u{feff}{\n // Personal provider\n \"providers\": {\"local\": {\"baseUrl\": \"http://localhost:11434/v1\",},},\n}\n", + ) + .unwrap(); + fs::write(&settings, r#"{"theme":"dark"}"#).unwrap(); + let config = parse_daemon( + "llmGateway:\n url: https://gateway.example.com\nprograms:\n pi:\n model: selected\n", + ) + .unwrap(); + let configured = Some(( + config.programs.pi.as_ref().unwrap(), + config.llm_gateway.as_ref(), + )); + let helper = root.path().join("agentdesktop"); + let socket = root.path().join("agentdesktop.sock"); + let changes = ReconcilePlan::default(); + plan(&models, &settings, &helper, &socket, configured, &changes).unwrap(); + assert!(!changes.has_conflicts(), "Pi accepts commented models.json"); + changes.apply().unwrap(); + let merged: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + merged["providers"]["local"]["baseUrl"], + "http://localhost:11434/v1" + ); + assert_eq!( + merged["providers"]["agentdesktop"]["models"][0]["id"], + "selected" + ); + + let before = fs::read(&models).unwrap(); + let repeated = ReconcilePlan::default(); + plan(&models, &settings, &helper, &socket, configured, &repeated).unwrap(); + assert!(repeated.render().contains("Summary: 0 changes")); + repeated.apply().unwrap(); + assert_eq!(fs::read(&models).unwrap(), before); + + // A user can add comments while agentdesktop manages the file. + fs::write( + &models, + format!("// Updated comment\n{}", String::from_utf8(before).unwrap()), + ) + .unwrap(); + let removal = ReconcilePlan::default(); + plan(&models, &settings, &helper, &socket, None, &removal).unwrap(); + removal.apply().unwrap(); + let restored: Value = serde_json::from_slice(&fs::read(&models).unwrap()).unwrap(); + assert_eq!( + restored, + json!({"providers":{"local":{"baseUrl":"http://localhost:11434/v1"}}}) + ); + let restored_settings: Value = + serde_json::from_slice(&fs::read(&settings).unwrap()).unwrap(); + assert_eq!(restored_settings, json!({"theme":"dark"})); + assert!(!super::json_merge::state_path(&models).exists()); + assert!(!super::json_merge::state_path(&settings).exists()); + } + + #[test] + fn commented_settings_remain_a_conflict() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let settings = root.path().join("settings.json"); + let existing = b"// Pi settings require strict JSON\n{\"theme\":\"dark\"}"; + fs::write(&settings, existing).unwrap(); + let config = parse_daemon("programs:\n pi: {}\n").unwrap(); + let changes = ReconcilePlan::default(); + plan( + &models, + &settings, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.pi.as_ref().unwrap(), None)), + &changes, + ) + .unwrap(); + assert!(changes.has_conflicts()); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&settings).unwrap(), existing); + assert!(!models.exists()); + } + + #[test] + fn malformed_models_prevent_settings_writes() { + let root = tempfile::tempdir().unwrap(); + let models = root.path().join("models.json"); + let settings = root.path().join("settings.json"); + let invalid = b"{\"providers\":"; + fs::write(&models, invalid).unwrap(); + let config = parse_daemon("programs:\n pi: {}\n").unwrap(); + let changes = ReconcilePlan::default(); + plan( + &models, + &settings, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.pi.as_ref().unwrap(), None)), + &changes, + ) + .unwrap(); + assert!(changes.has_conflicts()); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&models).unwrap(), invalid); + assert!(!settings.exists()); + } + + #[test] + fn pass_through_settings_are_merged_with_managed_gateway_values() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com/proxy + authentication: + type: controllerJwt + audience: agentgateway + allowedClientIds: [pi] +programs: + pi: + model: claude-sonnet-4-5 + models: + claude-sonnet-4-5: + name: Company Claude + contextWindow: 200000 + claude-haiku-4-5: + name: Haiku + managedConfig: + providers: + ollama: + baseUrl: http://127.0.0.1:11434/v1 +"#, + ) + .expect("valid daemon configuration"); + let pi = config.programs.pi.as_ref().unwrap(); + let gateway = config.llm_gateway.as_ref().unwrap(); + + let settings = managed_models( + pi, + Some(gateway), + Path::new("/usr/local/bin/agentdesktop"), + Path::new("/run/agentdesktop/agentdesktop.sock"), + ) + .expect("merged settings"); + + assert_eq!( + settings["providers"]["ollama"]["baseUrl"], + "http://127.0.0.1:11434/v1" + ); + let provider = &settings["providers"]["agentdesktop"]; + assert_eq!(provider["baseUrl"], "https://gateway.example.com/proxy"); + assert_eq!(provider["api"], "anthropic-messages"); + assert_eq!(provider["authHeader"], true); + let api_key = provider["apiKey"].as_str().unwrap(); + assert!(api_key.starts_with('!'), "{api_key}"); + #[cfg(unix)] + { + assert!(api_key.contains("credential"), "{api_key}"); + assert!(api_key.contains("--client-id"), "{api_key}"); + assert!(api_key.contains("/usr/local/bin/agentdesktop"), "{api_key}"); + } + let models = provider["models"].as_array().unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0]["id"], "claude-haiku-4-5"); + assert_eq!(models[1]["id"], "claude-sonnet-4-5"); + assert_eq!(models[1]["name"], "Company Claude"); + assert_eq!(models[1]["contextWindow"], 200000); + + let startup = managed_settings(pi, Some(gateway)).expect("startup settings"); + assert_eq!(startup["defaultProvider"], "agentdesktop"); + assert_eq!(startup["defaultModel"], "claude-sonnet-4-5"); + } + + #[test] + fn openai_compatible_gateways_append_v1() { + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: local-model + api: openai-completions +"#, + ) + .expect("valid daemon configuration"); + let pi = config.programs.pi.as_ref().unwrap(); + let gateway = config.llm_gateway.as_ref().unwrap(); + let settings = managed_models( + pi, + Some(gateway), + Path::new("agentdesktop"), + Path::new("/tmp/agentdesktop.sock"), + ) + .expect("merged settings"); + assert_eq!( + settings["providers"]["agentdesktop"]["baseUrl"], + "https://gateway.example.com/v1" + ); + assert_eq!( + settings["providers"]["agentdesktop"]["api"], + "openai-completions" + ); + } +} diff --git a/crates/agent/src/provider/vscode/reconcile.rs b/crates/agent/src/provider/vscode/reconcile.rs index acd9e16a..af29b7fb 100644 --- a/crates/agent/src/provider/vscode/reconcile.rs +++ b/crates/agent/src/provider/vscode/reconcile.rs @@ -77,6 +77,7 @@ fn options() -> json_merge::MergeOptions { keyed_arrays: KEYED, // The file holds the pairing. redact_diff: true, + ..json_merge::MergeOptions::default() } } diff --git a/crates/agent/src/reconcile/mod.rs b/crates/agent/src/reconcile/mod.rs index dee29d4d..6a1c0105 100644 --- a/crates/agent/src/reconcile/mod.rs +++ b/crates/agent/src/reconcile/mod.rs @@ -6,7 +6,7 @@ pub(crate) use plan::grants_beyond; use crate::provider::{ Provider, ReconcileContext, claude_code::ClaudeCode, claude_desktop::ClaudeDesktop, codex::Codex, copilot::Copilot, cursor::Cursor, grok::Grok, ollama::Ollama, opencode::OpenCode, - vscode::VsCode, + pi::Pi, vscode::VsCode, }; use agentdesktop_core::{ config::{DaemonConfig, ProgramsConfig}, @@ -31,6 +31,7 @@ pub use crate::provider::{ copilot::default_copilot_providers_path, grok::default_grok_managed_config_path, opencode::{default_open_code_managed_config_path, default_open_code_plugin_path}, + pi::{default_pi_models_path, default_pi_settings_path}, vscode::{default_vscode_chat_models_path, default_vscode_settings_path}, }; @@ -60,6 +61,8 @@ impl Reconciler { copilot_providers_path: Option, vscode_chat_models_path: Option, vscode_settings_path: Option, + pi_models_path: PathBuf, + pi_settings_path: PathBuf, credential_helper: PathBuf, socket: PathBuf, ) -> Self { @@ -96,6 +99,10 @@ impl Reconciler { Box::new(Copilot { providers_path: copilot_providers_path, }), + Box::new(Pi { + models_path: pi_models_path, + settings_path: pi_settings_path, + }), Box::new(Ollama), ]), last_apply: Arc::new(std::sync::Mutex::new(None)), @@ -595,6 +602,7 @@ pub fn configured_programs(programs: &ProgramsConfig) -> Vec<&'static str> { grok, copilot, vscode, + pi, } = programs; [ (claude_code.is_some(), ClaudeCode::ID), @@ -604,6 +612,7 @@ pub fn configured_programs(programs: &ProgramsConfig) -> Vec<&'static str> { (grok.is_some(), Grok::ID), (copilot.is_some(), Copilot::ID), (vscode.is_some(), VsCode::ID), + (pi.is_some(), Pi::ID), ] .into_iter() .filter_map(|(configured, id)| configured.then_some(id)) @@ -776,6 +785,8 @@ programs: Some(root.join("copilot/providers.json")), None, None, + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -817,6 +828,8 @@ programs: Some(root.join("copilot/providers.json")), None, None, + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -826,6 +839,91 @@ programs: assert!(!root.exists(), "preflight failure must not write any files"); } + #[test] + fn system_mode_rejects_pi_before_writing_other_settings() { + let fixture = Fixture::new(); + let config = parse_daemon("programs:\n claudeCode: {}\n pi: {}\n").unwrap(); + let error = fixture + .reconciler + .apply(&config) + .expect_err("Pi requires user mode"); + assert!(error.to_string().contains("--user")); + assert!( + !fixture.root.exists(), + "preflight failure must not write any files" + ); + } + + #[test] + fn user_mode_reconciles_pi_models_and_settings() { + let root = std::env::temp_dir().join(format!( + "agentdesktop-reconcile-user-pi-{}-{}", + std::process::id(), + rand::random::() + )); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: claude-sonnet-4-5 +"#, + ) + .unwrap(); + let reconciler = Reconciler::new( + true, + root.join("claude/settings.json"), + root.join("claude-desktop/settings.json"), + root.join("claude-desktop/helper"), + root.join("codex/config.toml"), + root.join("opencode/config.json"), + root.join("opencode/plugin.js"), + root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), + None, + None, + root.join("pi/models.json"), + root.join("pi/settings.json"), + root.join("bin/agentdesktop"), + root.join("agentdesktop.sock"), + ); + + reconciler + .apply(&config) + .expect("Pi is supported in user mode"); + let models = String::from_utf8(fs::read(root.join("pi/models.json")).unwrap()).unwrap(); + assert!(models.contains("agentdesktop")); + assert!(models.contains("claude-sonnet-4-5")); + let settings = String::from_utf8(fs::read(root.join("pi/settings.json")).unwrap()).unwrap(); + assert!(settings.contains("defaultProvider")); + + let repeated = reconciler.plan(&config).unwrap(); + assert!(repeated.render().contains("Summary: 0 changes")); + repeated.apply().unwrap(); + assert_eq!( + fs::read_to_string(root.join("pi/models.json")).unwrap(), + models + ); + assert_eq!( + fs::read_to_string(root.join("pi/settings.json")).unwrap(), + settings + ); + + reconciler + .apply(&parse_daemon("programs: {}").unwrap()) + .unwrap(); + for path in [ + "models.json", + ".models.json.agentdesktop", + "settings.json", + ".settings.json.agentdesktop", + ] { + assert!(!root.join("pi").join(path).exists()); + } + fs::remove_dir_all(root).unwrap(); + } + #[test] fn copilot_directory_is_created_owner_only_through_the_reconciler() { // The private-directory request must survive `ReconcilePlan::append`, @@ -859,6 +957,8 @@ programs: Some(providers.clone()), None, None, + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ) @@ -914,6 +1014,8 @@ programs: Some(root.join("copilot/providers.json")), Some(chat_models.clone()), Some(chat_models.with_file_name("settings.json")), + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ) @@ -966,6 +1068,8 @@ programs: None, None, None, + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -1027,6 +1131,8 @@ programs: Some(root.join("copilot/providers.json")), None, None, + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -1071,6 +1177,8 @@ programs: Some(root.join("copilot/providers.json")), None, None, + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -1094,7 +1202,7 @@ llmGateway: authentication: type: controllerJwt audience: agentgateway - allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok] + allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok, pi] programs: claudeCode: {} claudeDesktop: {} @@ -1247,6 +1355,8 @@ programs: Some(root.join("copilot/providers.json")), Some(root.join("vscode/User/chatLanguageModels.json")), Some(root.join("vscode/User/settings.json")), + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ) @@ -1372,6 +1482,39 @@ programs: let _ = fs::remove_dir_all(&root); } + #[test] + fn pi_reports_applied_unchanged_removed_then_no_row() { + let root = new_root("pi"); + let configured = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: claude-sonnet-4-5 +"#, + ) + .unwrap(); + let disabled = parse_daemon("programs: {}").unwrap(); + let reconciler = full_reconciler(&root); + let mut states = Vec::new(); + for config in [&configured, &configured, &disabled, &disabled] { + let (report, result) = reconciler.apply_with_report(config); + result.expect("apply succeeds"); + states.push(program_outcome(&report, "pi").map(|outcome| outcome.state)); + } + assert_eq!( + states, + [ + Some(ProgramState::Applied), + Some(ProgramState::Unchanged), + Some(ProgramState::Removed), + None, + ] + ); + let _ = fs::remove_dir_all(&root); + } + #[test] fn use_llm_gateway_false_reports_applied_or_unchanged() { let root = new_root("use-llm-gateway-false"); @@ -1699,6 +1842,7 @@ programs: "copilot", "vscode", "opencode", + "pi", ] { assert!( program_outcome(&report, absent).is_none(), diff --git a/crates/agent/src/remote.rs b/crates/agent/src/remote.rs index 4b41a456..78648e6a 100644 --- a/crates/agent/src/remote.rs +++ b/crates/agent/src/remote.rs @@ -1543,6 +1543,8 @@ mod tests { Some(root.join("copilot/providers.json")), None, None, + root.join("pi/models.json"), + root.join("pi/settings.json"), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ) diff --git a/crates/agentdesktop/gen/schemas/macOS-schema.json b/crates/agentdesktop/gen/schemas/macOS-schema.json index 32866459..1ef12251 100644 --- a/crates/agentdesktop/gen/schemas/macOS-schema.json +++ b/crates/agentdesktop/gen/schemas/macOS-schema.json @@ -212,6 +212,12 @@ "const": "core:app:allow-default-window-icon", "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." }, + { + "description": "Enables the exit command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-exit", + "markdownDescription": "Enables the exit command without any pre-configured scope." + }, { "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", @@ -302,6 +308,12 @@ "const": "core:app:deny-default-window-icon", "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." }, + { + "description": "Denies the exit command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-exit", + "markdownDescription": "Denies the exit command without any pre-configured scope." + }, { "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", "type": "string", @@ -1562,6 +1574,12 @@ "const": "core:window:allow-set-fullscreen", "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." }, + { + "description": "Enables the set_fullscreen_on_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen-on-monitor", + "markdownDescription": "Enables the set_fullscreen_on_monitor command without any pre-configured scope." + }, { "description": "Enables the set_icon command without any pre-configured scope.", "type": "string", @@ -2030,6 +2048,12 @@ "const": "core:window:deny-set-fullscreen", "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." }, + { + "description": "Denies the set_fullscreen_on_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen-on-monitor", + "markdownDescription": "Denies the set_fullscreen_on_monitor command without any pre-configured scope." + }, { "description": "Denies the set_icon command without any pre-configured scope.", "type": "string", diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 5d9ca3f1..393bc9d1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -88,6 +88,9 @@ pub struct DaemonStartupConfig { /// directory). #[serde(default)] pub vscode: VsCodeStartupConfig, + /// Pi paths. + #[serde(default)] + pub pi: PiStartupConfig, /// Local loopback LLM proxy. #[serde(default)] pub llm_proxy: LlmProxyStartupConfig, @@ -172,6 +175,19 @@ pub struct OpenCodeStartupConfig { pub plugin: Option, } +/// Local Pi paths. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PiStartupConfig { + /// Model catalog file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub models: Option, + /// Settings file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, +} + impl Default for DaemonConfig { fn default() -> Self { Self { @@ -643,6 +659,12 @@ pub struct ProgramsConfig { /// loopback proxy, or GitHub's models through the gateway). #[serde(default, skip_serializing_if = "Option::is_none")] pub vscode: Option, + /// Pi coding-agent harness managed configuration. + /// + /// Requires `--user`. Cannot be combined with Claude Desktop or Grok Build, + /// which require system mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi: Option, } impl ProgramsConfig { @@ -654,6 +676,7 @@ impl ProgramsConfig { && self.grok.is_none() && self.copilot.is_none() && self.vscode.is_none() + && self.pi.is_none() } } @@ -1095,6 +1118,41 @@ impl VsCodeConfig { } } +/// Settings reconciled into Pi's user `models.json` and `settings.json`. +/// +/// Generated `providers.agentdesktop` values take precedence over overlapping +/// `managedConfig` keys. Pi stores configuration under `~/.pi/agent` (or +/// `PI_CODING_AGENT_DIR`), so management requires `--user`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PiConfig { + /// Whether this program uses the top-level LLM gateway. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub use_llm_gateway: bool, + /// Model ID selected when pointing Pi at the LLM gateway. + /// + /// This is required when a top-level `llmGateway` is configured. If `models` + /// is empty, agentdesktop creates a catalog entry with this ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Extra Pi model objects, keyed by model ID. + /// + /// Each value is an arbitrary Pi `models.json` model object. The map key + /// sets its `id`. Generated gateway `baseUrl` and `apiKey` values take + /// precedence, including per-model URLs. When this map is non-empty, + /// `model` must name one of its keys. Every key must contain a + /// non-whitespace character. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub models: BTreeMap, + /// Pi API dialect for the managed provider. Defaults to `anthropic-messages`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api: Option, + /// Arbitrary values merged into Pi's `models.json`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub managed_config: BTreeMap, +} + /// Upstream authentication selected by a managed agent. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -1256,6 +1314,9 @@ fn validate_daemon( if programs.vscode.is_some() { anyhow::bail!("sandbox is not supported for VS Code"); } + if programs.pi.is_some() { + anyhow::bail!("sandbox is not supported for Pi"); + } } if let Some(gateway) = llm_gateway { if !matches!(gateway.url.scheme(), "http" | "https") { @@ -1480,6 +1541,28 @@ fn validate_daemon( } } } + if programs.pi.is_some() && programs.claude_desktop.is_some() { + anyhow::bail!("programs.pi requires --user, which cannot apply programs.claudeDesktop"); + } + if programs.pi.is_some() && programs.grok.is_some() { + anyhow::bail!("programs.pi requires --user, which cannot apply programs.grok"); + } + if let Some(pi) = &programs.pi + && llm_gateway.is_some() + && pi.use_llm_gateway + { + let model = pi + .model + .as_deref() + .filter(|model| !model.trim().is_empty()) + .context("Pi requires model when llmGateway is configured")?; + if pi.models.keys().any(|model| model.trim().is_empty()) { + anyhow::bail!("Pi model IDs cannot be empty"); + } + if !pi.models.is_empty() && !pi.models.contains_key(model) { + anyhow::bail!("Pi model {model} is not declared in models"); + } + } Ok(()) } @@ -1531,6 +1614,22 @@ mod tests { assert!(super::parse_local_daemon("daemon: { stateDr: /tmp/device }").is_err()); } + #[test] + fn pi_startup_paths_are_local_only() { + let yaml = "daemon:\n pi:\n models: custom/pi-models.json\n settings: custom/pi-settings.json\n"; + let config = super::parse_local_daemon(yaml).expect("local Pi paths are supported"); + let value = serde_json::to_value(config).unwrap(); + assert_eq!(value["daemon"]["pi"]["models"], "custom/pi-models.json"); + assert_eq!(value["daemon"]["pi"]["settings"], "custom/pi-settings.json"); + assert!( + parse_daemon(yaml) + .unwrap_err() + .to_string() + .contains("only allowed in the local") + ); + assert!(super::parse_local_daemon("daemon: { pi: { model: wrong } }").is_err()); + } + #[test] fn daemon_configuration_supports_local_and_managed_options() { let document = r#" @@ -1995,4 +2094,108 @@ programs: .contains("Grok Build model missing is not declared in models") ); } + + #[test] + fn pi_parses_gateway_catalog_settings() { + let daemon = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: claude-sonnet-4-5 + api: anthropic-messages + models: + claude-sonnet-4-5: + name: Claude Sonnet +"#, + ) + .expect("valid Pi configuration"); + let pi = daemon.programs.pi.expect("Pi program"); + assert!(pi.use_llm_gateway); + assert_eq!(pi.model.as_deref(), Some("claude-sonnet-4-5")); + assert_eq!(pi.api.as_deref(), Some("anthropic-messages")); + assert!(pi.models.contains_key("claude-sonnet-4-5")); + } + + #[test] + fn pi_requires_a_model_when_using_the_gateway() { + let error = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: {} +"#, + ) + .expect_err("Pi without a model should fail"); + + assert!( + error + .to_string() + .contains("Pi requires model when llmGateway is configured") + ); + } + + #[test] + fn pi_requires_a_declared_gateway_model_when_models_are_listed() { + let error = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: missing + models: + available: {} +"#, + ) + .expect_err("undeclared Pi model should fail"); + + assert!( + error + .to_string() + .contains("Pi model missing is not declared in models") + ); + } + + #[test] + fn pi_rejects_empty_model_ids() { + let error = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + pi: + model: claude-sonnet-4-5 + models: + claude-sonnet-4-5: {} + "": {} +"#, + ) + .expect_err("empty Pi model id should fail"); + + assert!(error.to_string().contains("Pi model IDs cannot be empty")); + } + + #[test] + fn pi_rejects_system_only_program_combinations() { + for (name, message) in [ + ( + "claudeDesktop", + "programs.pi requires --user, which cannot apply programs.claudeDesktop", + ), + ( + "grok", + "programs.pi requires --user, which cannot apply programs.grok", + ), + ] { + let error = parse_daemon(&format!("programs:\n pi: {{}}\n {name}: {{}}\n")) + .expect_err("Pi combined with a system-only program should fail"); + assert!( + error.to_string().contains(message), + "missing {message} in {error}" + ); + } + } } diff --git a/examples/claude/README.md b/examples/claude/README.md index cbc03bfb..97dcdc9b 100644 --- a/examples/claude/README.md +++ b/examples/claude/README.md @@ -70,6 +70,19 @@ agentdesktop Now that agentdesktop is running, Claude Code can be run. It will show the configured `Managed by Agentdesktop` and direct traffic through the gateway. +## Pi + +Pi requires user-mode configuration. To include Pi, remove `programs.claudeDesktop` +from `claude-code.yaml` and uncomment `programs.pi`. Run the daemon as the user +who runs Pi: + +```console +agentdesktop daemon --user --config examples/claude/agentdesktop.yaml +``` + +After enrollment, run `pi`; the managed default model uses the gateway. Keep the +daemon running so Pi can request gateway credentials. + ## Stop the scenario Stop the foreground daemon and controller with Ctrl-C, then stop Dex and diff --git a/examples/claude/claude-code.yaml b/examples/claude/claude-code.yaml index b43aa366..d5e9a37c 100644 --- a/examples/claude/claude-code.yaml +++ b/examples/claude/claude-code.yaml @@ -4,7 +4,7 @@ llmGateway: authentication: type: controllerJwt audience: "agentgateway" - allowedClientIds: [claude-code, claude-desktop, codex, opencode] + allowedClientIds: [claude-code, claude-desktop, codex, opencode, pi] telemetry: events: @@ -16,3 +16,7 @@ programs: companyAnnouncements: ["Managed by Agentdesktop"] claudeDesktop: isLocalDevMcpEnabled: true + # Pi requires --user. Remove claudeDesktop before enabling Pi in user mode. + # pi: + # useLlmGateway: true + # model: claude-sonnet-4-5 diff --git a/examples/standalone/README.md b/examples/standalone/README.md index 6198fdcd..120162e4 100644 --- a/examples/standalone/README.md +++ b/examples/standalone/README.md @@ -62,13 +62,20 @@ $ agentdesktop daemon --config examples/standalone/config.yaml --user When we run `claude`, we can now see the message we programmed (`Using the local Agentgateway through Agentdesktop`), and messages we send will traverse Agentgateway. +## Pi + +The user-mode configuration also enables Pi with `claude-sonnet-4-5`. Run `pi` +while the daemon is running to use that model through the gateway. Pi requires +`--user`; authenticated gateway configuration cannot be applied with `--once`. + ## Claude Desktop In the Claude Code example, we ran with `--user`. This runs the daemon as an unprivileged user. Claude Desktop, however, requires root configuration. -Uncomment the Claude Desktop configuration in `config.yaml` and save the file. +Remove `programs.pi` and uncomment the Claude Desktop configuration in +`config.yaml`, then save the file. Then run the daemon as root: ```sh diff --git a/examples/standalone/config.yaml b/examples/standalone/config.yaml index dcbad869..2a9316b6 100644 --- a/examples/standalone/config.yaml +++ b/examples/standalone/config.yaml @@ -12,6 +12,9 @@ programs: claudeCode: useLlmGateway: true companyAnnouncements: ["Using the local Agentgateway through Agentdesktop"] -# Claude desktop cannot run with --user. Uncomment to enable + pi: + useLlmGateway: true + model: claude-sonnet-4-5 +# Claude Desktop requires system mode. Remove pi above before enabling it. # claudeDesktop: # useLlmGateway: true diff --git a/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index 6a152abe..731cc0f1 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -94,7 +94,8 @@ export type AgentKind = | "openCode" | "grok" | "copilot" - | "vscode"; + | "vscode" + | "pi"; export type AgentDraft = { kind: AgentKind; diff --git a/frontend/controller/src/views/ConfigurationView.tsx b/frontend/controller/src/views/ConfigurationView.tsx index 0722225e..e910fa40 100644 --- a/frontend/controller/src/views/ConfigurationView.tsx +++ b/frontend/controller/src/views/ConfigurationView.tsx @@ -585,6 +585,13 @@ const configurableAgents: Array<{ "copilotChat: githubModels\n# or own models through the gateway:\n# models:\n# gpt-4.1-mini:\n# maxInputTokens: 128000", initialSettings: "models:\n gpt-4.1-mini: {}", }, + { + kind: "pi", + label: "Pi", + iconKind: "pi", + placeholder: "model: claude-sonnet-4-5", + initialSettings: "model: claude-sonnet-4-5", + }, ]; const sandboxUnsupportedAgents = new Set([ @@ -593,6 +600,7 @@ const sandboxUnsupportedAgents = new Set([ "grok", "copilot", "vscode", + "pi", ]); function daemonConfigYaml(options: { @@ -625,7 +633,7 @@ function daemonConfigYaml(options: { " authentication:", " type: controllerJwt", ` audience: ${yamlString(options.audience)}`, - " allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok, copilot-cli, vscode-copilot]", + " allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok, copilot-cli, vscode-copilot, pi]", ); } lines.push(""); diff --git a/frontend/desktop/src/views/ToolsView.tsx b/frontend/desktop/src/views/ToolsView.tsx index 0f1c702c..48f2586c 100644 --- a/frontend/desktop/src/views/ToolsView.tsx +++ b/frontend/desktop/src/views/ToolsView.tsx @@ -96,8 +96,8 @@ export function ToolsView({ discovery, unavailable }: ToolsViewProps) {
No supported tools found - Agent Desktop can inventory VS Code, Claude Code, Claude - Desktop, Codex, OpenCode, and Grok Build. + agentdesktop can inventory VS Code, Claude Code, Claude Desktop, + Codex, OpenCode, Grok Build, and Pi.
diff --git a/frontend/ui/src/assets/pi.svg b/frontend/ui/src/assets/pi.svg new file mode 100644 index 00000000..046fd463 --- /dev/null +++ b/frontend/ui/src/assets/pi.svg @@ -0,0 +1 @@ +Pi diff --git a/frontend/ui/src/tools.tsx b/frontend/ui/src/tools.tsx index 11f59a7d..ed561d4f 100644 --- a/frontend/ui/src/tools.tsx +++ b/frontend/ui/src/tools.tsx @@ -15,6 +15,7 @@ import cursorIcon from "./assets/cursor.svg"; import grokIcon from "./assets/grok.svg"; import ollamaIcon from "./assets/ollama.svg"; import openCodeIcon from "./assets/opencode.svg"; +import piIcon from "./assets/pi.svg"; export interface ToolMcpServer { name: string; @@ -53,6 +54,7 @@ const toolIcons: Record = { "grok-build": grokIcon, cursor: cursorIcon, opencode: openCodeIcon, + pi: piIcon, vscode: copilotIcon, copilot: copilotIcon, }; @@ -67,6 +69,7 @@ export function friendlyTool(kind: string) { grok: "Grok Build", "grok-build": "Grok Build", opencode: "OpenCode", + pi: "Pi", vscode: "VS Code", cursor: "Cursor", copilot: "GitHub Copilot CLI", diff --git a/schema/daemon-config.json b/schema/daemon-config.json index 51dc8036..b6f7e018 100644 --- a/schema/daemon-config.json +++ b/schema/daemon-config.json @@ -274,6 +274,11 @@ "$ref": "#/$defs/OpenCodeStartupConfig", "default": {} }, + "pi": { + "description": "Pi paths.", + "$ref": "#/$defs/PiStartupConfig", + "default": {} + }, "reconcileInterval": { "description": "Opt-in interval between periodic re-applies of the current\nconfiguration, which repair drift (a managed file deleted or edited by\nhand, a fixed conflict, a loosened mode) without rewriting anything\nunchanged. Unset means no periodic re-apply; a controller-managed\ndevice still re-applies the controller's configuration on every\nreconnect. Must be greater than zero and at most 30 days; read at\nstartup only (restart the daemon after changing it).", "type": [ @@ -572,6 +577,62 @@ }, "additionalProperties": false }, + "PiConfig": { + "description": "Settings reconciled into Pi's user `models.json` and `settings.json`.\n\nGenerated `providers.agentdesktop` values take precedence over overlapping\n`managedConfig` keys. Pi stores configuration under `~/.pi/agent` (or\n`PI_CODING_AGENT_DIR`), so management requires `--user`.", + "type": "object", + "properties": { + "api": { + "description": "Pi API dialect for the managed provider. Defaults to `anthropic-messages`.", + "type": [ + "string", + "null" + ] + }, + "managedConfig": { + "description": "Arbitrary values merged into Pi's `models.json`.", + "type": "object", + "additionalProperties": true + }, + "model": { + "description": "Model ID selected when pointing Pi at the LLM gateway.\n\nThis is required when a top-level `llmGateway` is configured. If `models`\nis empty, agentdesktop creates a catalog entry with this ID.", + "type": [ + "string", + "null" + ] + }, + "models": { + "description": "Extra Pi model objects, keyed by model ID.\n\nEach value is an arbitrary Pi `models.json` model object. The map key\nsets its `id`. Generated gateway `baseUrl` and `apiKey` values take\nprecedence, including per-model URLs. When this map is non-empty,\n`model` must name one of its keys. Every key must contain a\nnon-whitespace character.", + "type": "object", + "additionalProperties": true + }, + "useLlmGateway": { + "description": "Whether this program uses the top-level LLM gateway.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "PiStartupConfig": { + "description": "Local Pi paths.", + "type": "object", + "properties": { + "models": { + "description": "Model catalog file.", + "type": [ + "string", + "null" + ] + }, + "settings": { + "description": "Settings file.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "ProgramAuthentication": { "description": "Upstream authentication selected by a managed agent.", "oneOf": [ @@ -652,6 +713,17 @@ } ] }, + "pi": { + "description": "Pi coding-agent harness managed configuration.\n\nRequires `--user`. Cannot be combined with Claude Desktop or Grok Build,\nwhich require system mode.", + "anyOf": [ + { + "$ref": "#/$defs/PiConfig" + }, + { + "type": "null" + } + ] + }, "vscode": { "description": "VS Code Copilot Chat managed configuration (own models through the\nloopback proxy, or GitHub's models through the gateway).", "anyOf": [ diff --git a/schema/daemon-config.md b/schema/daemon-config.md index 25376840..c1c300dc 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -26,6 +26,9 @@ |`daemon.openCode`|object|OpenCode paths.| |`daemon.openCode.config`|string|Configuration file.| |`daemon.openCode.plugin`|string|Credential plugin path.| +|`daemon.pi`|object|Pi paths.| +|`daemon.pi.models`|string|Model catalog file.| +|`daemon.pi.settings`|string|Settings file.| |`daemon.reconcileInterval`|string|Opt-in interval between periodic re-applies of the current
configuration, which repair drift (a managed file deleted or edited by
hand, a fixed conflict, a loosened mode) without rewriting anything
unchanged. Unset means no periodic re-apply; a controller-managed
device still re-applies the controller's configuration on every
reconnect. Must be greater than zero and at most 30 days; read at
startup only (restart the daemon after changing it).| |`daemon.socket`|string|Local API Unix socket or Windows named pipe.| |`daemon.stateDir`|string|Persistent daemon state directory.| @@ -77,6 +80,12 @@ |`programs.openCode.model`|string|Model ID selected from `models` when using the LLM gateway.

This is required when a top-level `llmGateway` is configured.| |`programs.openCode.models`|object|Models exposed by the managed LLM gateway provider, keyed by model ID.

Each value is an arbitrary OpenCode model configuration object. At least
one model is required when a top-level `llmGateway` is configured.| |`programs.openCode.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| +|`programs.pi`|object|Pi coding-agent harness managed configuration.

Requires `--user`. Cannot be combined with Claude Desktop or Grok Build,
which require system mode.| +|`programs.pi.api`|string|Pi API dialect for the managed provider. Defaults to `anthropic-messages`.| +|`programs.pi.managedConfig`|object|Arbitrary values merged into Pi's `models.json`.| +|`programs.pi.model`|string|Model ID selected when pointing Pi at the LLM gateway.

This is required when a top-level `llmGateway` is configured. If `models`
is empty, agentdesktop creates a catalog entry with this ID.| +|`programs.pi.models`|object|Extra Pi model objects, keyed by model ID.

Each value is an arbitrary Pi `models.json` model object. The map key
sets its `id`. Generated gateway `baseUrl` and `apiKey` values take
precedence, including per-model URLs. When this map is non-empty,
`model` must name one of its keys. Every key must contain a
non-whitespace character.| +|`programs.pi.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| |`programs.vscode`|object|VS Code Copilot Chat managed configuration (own models through the
loopback proxy, or GitHub's models through the gateway).| |`programs.vscode.copilotChat`|enum|Which Copilot Chat model source VS Code is pointed at: agentdesktop's
own custom models (`ownModels`), or GitHub's own models reached
through the gateway (`githubModels`).
Possible values: `ownModels`, `githubModels`.| |`programs.vscode.models`|object|Custom model entries exposed to VS Code's Copilot Chat model picker,
keyed by the model ID VS Code sends as `model`. Only meaningful under
`copilotChat: ownModels`; `githubModels` rejects a non-empty map.

At least one is required when a top-level `llmGateway` is configured
and `copilotChat` is `ownModels`.|