diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 6a52a2d6fd..4e0bb27930 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -360,7 +360,6 @@ jobs: tests/test_service.py tests/test_service_bootstrap.py tests/test_service_environment.py - tests/test_cli_workbuddy.py::test_setup_workbuddy_remote_checkout_refreshes_the_requested_ref dify-tools: needs: changes diff --git a/desktop/README.md b/desktop/README.md index 2e069efc27..eb3ba4e8f1 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -95,7 +95,7 @@ Register an explicitly trusted local installation in `%APPDATA%/com.powercontext } ``` -The native adapter verifies the absolute executable path and pinned digest before running either `service status --json` or `doctor integrations --json`. Registration does not require a version; legacy `version` fields are ignored. Compatibility is determined by the diagnostic response matching the adapter's supported output schema. The observed CLI version is collected for display after a valid diagnostic response; an unavailable version is shown as unknown and does not reject the response. This is a local installation pin, not publisher-signature verification; the Python environment and dependencies must also be trusted. Update the registered digest if the executable changes. No PATH-first CLI selection or renderer-provided commands are accepted. +The native adapter verifies the absolute executable path and pinned digest before running either `service status --json` or `doctor codex claude-code dsh openclaw opencode pi hermes workbuddy kimi-code zcode --json`. Registration does not require a version; legacy `version` fields are ignored. Compatibility is determined by the diagnostic response matching the adapter's supported output schema. The observed CLI version is collected for display after a valid diagnostic response; an unavailable version is shown as unknown and does not reject the response. This is a local installation pin, not publisher-signature verification; the Python environment and dependencies must also be trusted. Update the registered digest if the executable changes. No PATH-first CLI selection or renderer-provided commands are accepted. The optional version query has a 15-second deadline, service status 20 seconds, and integration diagnostics 60 seconds. Each invocation limits combined stdout/stderr to 256 KiB. Helpers run hidden in an owned Windows Job; completion, timeout and cancellation clean up their descendants. Only allowlisted status fields reach the UI. Valid unhealthy JSON remains useful even with exit code 1. Isolated real-CLI checks pass; installed-application qualification remains open in [validation guide](VALIDATION.md). diff --git a/desktop/README.zh.md b/desktop/README.zh.md index 5818ddd2bf..a1db8b2e0d 100644 --- a/desktop/README.zh.md +++ b/desktop/README.zh.md @@ -69,7 +69,7 @@ pnpm --dir desktop desktop:dev } ``` -Desktop 在每次诊断前检查固定路径和摘要,然后只允许 `service status --json` 或 `doctor integrations --json`。收到符合格式的诊断响应后,尝试读取实际 CLI 版本用于显示;版本查询失败时显示“未知”,不阻止有效诊断结果。版本查询最长 15 秒,服务检查最长 20 秒,集成检查最长 60 秒;每次进程调用的 stdout/stderr 合计最多 256 KiB。窗口隐藏,进程及其后代归属于本次专属 Windows Job,结束或超时后清理。界面只显示允许的状态字段,不显示任意 detail、路径或原始输出。合法 JSON 配合退出码 1 仍可显示不健康结果。 +Desktop 在每次诊断前检查固定路径和摘要,然后只允许 `service status --json` 或 `doctor codex claude-code dsh openclaw opencode pi hermes workbuddy kimi-code zcode --json`。收到符合格式的诊断响应后,尝试读取实际 CLI 版本用于显示;版本查询失败时显示“未知”,不阻止有效诊断结果。版本查询最长 15 秒,服务检查最长 20 秒,集成检查最长 60 秒;每次进程调用的 stdout/stderr 合计最多 256 KiB。窗口隐藏,进程及其后代归属于本次专属 Windows Job,结束或超时后清理。界面只显示允许的状态字段,不显示任意 detail、路径或原始输出。合法 JSON 配合退出码 1 仍可显示不健康结果。 未配置或未通过核验时,本机诊断不可用,独立远程业务仍可使用。进程树清理和隔离真实 CLI 测试已通过;安装后交互与完整 S2 资格仍待验收。 diff --git a/desktop/src-tauri/examples/diagnostic_cli_probe.rs b/desktop/src-tauri/examples/diagnostic_cli_probe.rs index 6811534336..77e818496e 100644 --- a/desktop/src-tauri/examples/diagnostic_cli_probe.rs +++ b/desktop/src-tauri/examples/diagnostic_cli_probe.rs @@ -77,7 +77,7 @@ async fn main() { .iter() .any(|v| v.field == "registration" && v.status == "not_installed") ); - assert_eq!(integrations.hosts.len(), 8); + assert_eq!(integrations.hosts.len(), 10); let mut legacy = registration.clone(); legacy["version"] = "0.0.0".into(); let legacy_path = invalid_directory.path().join("legacy.json"); diff --git a/desktop/src-tauri/src/diagnostics.rs b/desktop/src-tauri/src/diagnostics.rs index 95740d75fb..2a998ec883 100644 --- a/desktop/src-tauri/src/diagnostics.rs +++ b/desktop/src-tauri/src/diagnostics.rs @@ -28,10 +28,23 @@ pub enum DiagnosticKind { Integrations, } impl DiagnosticKind { - pub fn arguments(self) -> [&'static str; 3] { + pub fn arguments(self) -> &'static [&'static str] { match self { - Self::Service => ["service", "status", "--json"], - Self::Integrations => ["doctor", "integrations", "--json"], + Self::Service => &["service", "status", "--json"], + Self::Integrations => &[ + "doctor", + "codex", + "claude-code", + "dsh", + "openclaw", + "opencode", + "pi", + "hermes", + "workbuddy", + "kimi-code", + "zcode", + "--json", + ], } } } @@ -46,7 +59,7 @@ pub struct DiagnosticItem { #[serde(rename_all = "camelCase")] pub struct HostDiagnostic { pub host: String, - pub presence: String, + pub status: String, pub checks: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, TS)] @@ -116,10 +129,8 @@ pub fn project( } } DiagnosticKind::Integrations => { - let status = selected(&value["status"], &["ok", "failed"])?; - if value["ok"].as_bool() != Some(status == "ok") { - return Err(SafeError::InvalidResponse); - } + let ok = value["ok"].as_bool().ok_or(SafeError::InvalidResponse)?; + let status = if ok { "ok" } else { "failed" }.to_owned(); report.items.push(DiagnosticItem { field: "integrations".into(), status, @@ -127,7 +138,7 @@ pub fn project( let hosts = value["hosts"] .as_object() .ok_or(SafeError::InvalidResponse)?; - if hosts.len() > 8 { + if hosts.len() > 10 { return Err(SafeError::InvalidResponse); } for (name, host) in hosts { @@ -140,6 +151,8 @@ pub fn project( "pi", "hermes", "workbuddy", + "kimi-code", + "zcode", ] .contains(&name.as_str()) { @@ -147,14 +160,21 @@ pub fn project( } let mut row = HostDiagnostic { host: name.clone(), - presence: selected(&host["presence"], &["present", "missing"])?, + status: selected(&host["status"], &["ok", "degraded", "failed"])?, checks: vec![], }; - let fields = host.as_object().ok_or(SafeError::InvalidResponse)?; - for (field, check) in fields { - if field == "presence" { + if host["ok"].as_bool() != Some(row.status == "ok") { + return Err(SafeError::InvalidResponse); + } + let fields = match host.get("checks") { + Some(checks) => checks.as_object().ok_or(SafeError::InvalidResponse)?, + None if row.status == "failed" => { + report.hosts.push(row); continue; } + None => return Err(SafeError::InvalidResponse), + }; + for (field, check) in fields { if ![ "codex", "claude_code", @@ -170,6 +190,34 @@ pub fn project( "settings", "mcp", "transport", + "authorization", + "mcp_configuration", + "mcp_tools", + "mcp_full_profile", + "kimi_code", + "node", + "hook", + "configuration", + "probe", + "credentials", + "scope", + "server", + "runtime", + "verification", + "mcp_session", + "protected_api", + "scope_probe", + "prepare_probe", + "zcode", + "provider", + "command_enabled", + "command_plugin", + "service_support", + "service_registration", + "service_definition", + "service_manager", + "server_liveness", + "server_readiness", ] .contains(&field.as_str()) { @@ -315,7 +363,7 @@ fn execute( }; let result = crate::diagnostic_process::run( &config.executable, - &kind.arguments(), + kind.arguments(), Duration::from_secs(timeout), cancelled.clone(), )?; diff --git a/desktop/src-tauri/tests/diagnostics.rs b/desktop/src-tauri/tests/diagnostics.rs index f41524e355..0dc700057e 100644 --- a/desktop/src-tauri/tests/diagnostics.rs +++ b/desktop/src-tauri/tests/diagnostics.rs @@ -38,9 +38,11 @@ fn unhealthy_service_json_survives_exit_one_without_leaking_private_details() { } #[test] fn integration_status_is_projected_without_details_or_checks_text() { - let result = project(DiagnosticKind::Integrations, br#"{"ok":false,"status":"failed","hosts":{"codex":{"presence":"present","codex":{"ok":true,"status":"ok","detail":"private-path"},"mcp":{"ok":false,"status":"failed","detail":"secret","checks":{"token":"secret"}}}}}"#, 1).unwrap(); + let result = project(DiagnosticKind::Integrations, br#"{"ok":false,"hosts":{"codex":{"status":"failed","ok":false,"checks":{"codex":{"ok":true,"status":"ok","detail":"private-path"},"mcp":{"ok":false,"status":"failed","detail":"secret","checks":{"token":"secret"}}}},"kimi-code":{"status":"failed","ok":false,"error":"private-path secret-token"}}}"#, 1).unwrap(); assert_eq!(result.hosts[0].host, "codex"); assert_eq!(result.hosts[0].checks.len(), 2); + assert_eq!(result.hosts[1].status, "failed"); + assert!(result.hosts[1].checks.is_empty()); let output = serde_json::to_string(&result).unwrap(); assert!(!output.contains("private")); assert!(!output.contains("secret")); @@ -50,8 +52,8 @@ fn malformed_unknown_and_oversized_diagnostics_are_rejected() { for output in [ b"not json".as_slice(), br#"{}"#, - br#"{"ok":true,"status":"secret","hosts":{}}"#, - br#"{"ok":false,"status":"ok","hosts":{}}"#, + br#"{"ok":true,"hosts":{"codex":{"status":"secret","ok":true,"checks":{}}}}"#, + br#"{"ok":false,"hosts":{"codex":{"status":"ok","ok":false,"checks":{}}}}"#, ] { assert_eq!( project(DiagnosticKind::Integrations, output, 1).unwrap_err(), diff --git a/desktop/ui/src/app/Diagnostics.tsx b/desktop/ui/src/app/Diagnostics.tsx index 338c8ef3d6..00ce5727e6 100644 --- a/desktop/ui/src/app/Diagnostics.tsx +++ b/desktop/ui/src/app/Diagnostics.tsx @@ -47,8 +47,6 @@ const labels: Record = { ok: ["通过", "Passed"], degraded: ["部分可用", "Degraded"], skipped: ["未检查", "Skipped"], - present: ["已发现", "Present"], - missing: ["未发现", "Missing"], plugin: ["插件", "Plugin"], package: ["扩展包", "Package"], skill: ["技能", "Skill"], @@ -149,7 +147,7 @@ export function Diagnostics({ language }: { language: Language }) { {report.hosts.map((host) => (

- {host.host} · {label(host.presence)} + {host.host} · {label(host.status)}

{host.checks.map((item) => ( diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index d0385a8356..b42a1582a2 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -22,7 +22,7 @@ export type CredentialWriteRequest = { secret: string, storage: StorageChoice, } export type CredentialWriteReceipt = { storage: StorageChoice, }; export type DiagnosticKind = "service" | "integrations"; export type DiagnosticItem = { field: string, status: string, }; -export type HostDiagnostic = { host: string, presence: string, checks: Array, }; +export type HostDiagnostic = { host: string, status: string, checks: Array, }; export type DiagnosticReport = { checkedAt: number, exitCode: number, cliVersion?: string, items: Array, hosts: Array, }; export type Authentication = "unauthenticated_loopback" | "bearer"; export type ProfileView = { id: string, revision: number, name: string, endpoint: string, authentication: Authentication, caPem: string | null, compatibility: string | null, credentialState: CredentialState, }; diff --git a/docs/en/docs/develop/interfaces.md b/docs/en/docs/develop/interfaces.md index d55efd02ac..cd04b68738 100644 --- a/docs/en/docs/develop/interfaces.md +++ b/docs/en/docs/develop/interfaces.md @@ -131,14 +131,14 @@ boundary flushing fail open; explicit durable writes require interactive confirm Set `POWERCONTEXT_SCOPE_ID` to an existing ID returned by `create_scope` before running scoped content commands. ```text -powercontext setup -powercontext setup select --host codex --host dsh +powercontext setup [agents...] +powercontext setup codex dsh +powercontext config agent codex dsh --server-url https://memory.example powercontext config init --output .env powercontext config show --env-file .env powercontext config validate --env-file .env powercontext doctor -powercontext doctor -powercontext doctor integrations +powercontext doctor codex dsh powercontext server run powercontext server run --env-file .env powercontext ready @@ -165,15 +165,14 @@ The `config` group creates, redacts, and validates an explicit environment file. current directory; pass `--env-file` to select another file or `--no-env-file` to disable discovery. See [Configuration](../operate/configuration.md) for precedence and credential-handling rules. -Use `codex`, `claude-code`, `dsh`, `hermes`, `openclaw`, `opencode`, `pi`, or `workbuddy` for ``. -The first-class catalog used by `setup select` and `doctor integrations` contains every listed host except WorkBuddy. -WorkBuddy remains available through the explicit `setup workbuddy` and `doctor workbuddy` commands. +Use `codex`, `claude-code`, `dsh`, `hermes`, `kimi-code`, `openclaw`, `opencode`, `pi`, `workbuddy`, or `zcode` for agents. +`powercontext setup` offers interactive selection; explicit agent names support non-interactive and multi-agent setup. +Setup installs, configures, activates where supported, and runs doctor. Failures remain isolated per agent; completed installation is reported even when diagnostics fail. -`powercontext doctor` checks the package and Server without requiring an integration. `powercontext doctor integrations` -prints a read-only matrix for every first-class host; a missing CLI is `missing` and does not fail the command. -Each `powercontext doctor ` command still fails when that host CLI is missing. The matrix preserves every -host-specific integration check, including OpenCode's separate `plugin` and `skill` results. DSH checks that -`dump-config` lists `powercontext-dsh`; Pi checks that the CLI lists the PowerContext package. +`powercontext config agent [agents...]` updates installed connections without reinstalling or enabling plugins. +`powercontext doctor [agents...]` checks the selected agents, or saved connections when none are selected. +With no saved connections, doctor checks the package and Server. Selected agents with a missing required CLI fail their own checks. +Doctor preserves host-specific checks, including OpenCode's separate `plugin` and `skill` results. DSH checks its native registration; Pi checks its registered package. The unified HTTP review routes are `/v1/candidates/list`, `get`, `history`, `revise`, `approve`, and `reject` (all POST). `candidate_kind=artifact|tag` distinguishes proposals. Old Artifact Candidate routes are removed; callers must upgrade together. diff --git a/docs/en/docs/integrations/claude-code.md b/docs/en/docs/integrations/claude-code.md index fa4400a7a2..d29f584e9a 100644 --- a/docs/en/docs/integrations/claude-code.md +++ b/docs/en/docs/integrations/claude-code.md @@ -33,14 +33,13 @@ set +a powercontext setup claude-code --server-url "$POWERCONTEXT_CLAUDE_SERVER_URL" ``` -Before changing Claude Code settings, setup reports the settings entry, plugin cache, persistent data location, -required permissions, and exact rollback commands. It then registers or refreshes the Marketplace, updates or installs -the plugin at user scope, and verifies the enabled plugin through Claude Code's JSON output. - -Claude Code owns the Marketplace registry, versioned plugin cache, and plugin data directory. Claude 2.1.133 does not -accept configuration flags on `plugin install`, so setup atomically merges `server_url` and `capture_prompts` into -the user-level `pluginConfigs` after installation while preserving unrelated settings; on failure it restores the -pre-install snapshot. PowerContext resolves these locations from `CLAUDE_CONFIG_DIR` or Claude Code's default +Setup registers or refreshes the Marketplace, then installs or updates the plugin at user scope. Updates preserve +explicit plugin disablement. The result reports the installed version, plugin cache, and persistent data location. + +Claude Code owns the Marketplace registry, versioned plugin cache, and plugin data directory. After installation, +setup merges `server_url` and `capture_prompts` into the user-level `pluginConfigs`, preserving unrelated settings +and omitted capture preferences. If connection configuration fails, its local changes are restored; the completed +native installation remains. PowerContext resolves these locations from `CLAUDE_CONFIG_DIR` or Claude Code's default configuration directory. For a local checkout, pass its directory: diff --git a/docs/en/docs/integrations/codex.md b/docs/en/docs/integrations/codex.md index 79ac72d761..44e788f392 100644 --- a/docs/en/docs/integrations/codex.md +++ b/docs/en/docs/integrations/codex.md @@ -52,8 +52,8 @@ Prompt capture is enabled by default; see [Control prompt capture](#control-prom `mcp_full_profile`. Full coverage requires every capability-bearing tool in the Codex integration's `server-mcp` manifest, including Memory maintenance, Scope management and Handoff operations. Missing tools produce a degraded result and are listed by name; discovery does not prove model readiness, Hook execution or write authorization. -Both `setup codex` and `setup select --host codex` can succeed with basic Memory support while `doctor` reports -incomplete full coverage. Authentication and basic connection failures still fail installation. +Setup reports installation completion separately from diagnostics. Basic Memory support can remain usable while `doctor` reports +incomplete full coverage. Authentication and basic connection failures still produce a failed result. ## Understand automatic recall, Memory, and Handoff diff --git a/docs/en/docs/integrations/hermes.md b/docs/en/docs/integrations/hermes.md index 00ac8c7339..e5a5d087b9 100644 --- a/docs/en/docs/integrations/hermes.md +++ b/docs/en/docs/integrations/hermes.md @@ -33,11 +33,13 @@ powercontext setup hermes powercontext doctor hermes ``` -The setup command copies the provider to `$HERMES_HOME/plugins/powercontext`, installs -`$HERMES_HOME/plugins/powercontext-command`, and enables the companion without granting built-in tool override -permissions. It does not start the Server or select the provider in Hermes. +The setup command copies the provider to `$HERMES_HOME/plugins/powercontext` and installs +`$HERMES_HOME/plugins/powercontext-command`. It enables the companion unless explicitly disabled, without granting +built-in tool override permissions. If `memory.provider` is absent, setup selects PowerContext automatically; +an existing provider choice, including an explicitly empty value, is preserved. Setup does not start the Server. -Run the Hermes memory setup wizard and select `PowerContext`: +To switch from an existing provider or change provider settings, run the Hermes memory setup wizard and select +`PowerContext`: ```bash hermes memory setup diff --git a/docs/en/docs/integrations/index.md b/docs/en/docs/integrations/index.md index 5e9c65d6f0..e529c6444f 100644 --- a/docs/en/docs/integrations/index.md +++ b/docs/en/docs/integrations/index.md @@ -40,10 +40,10 @@ allow loopback HTTP by default and require explicit consent for non-loopback HTT To select multiple hosts interactively, run: ```bash -powercontext setup select +powercontext setup ``` -Use the same ref as the Server. ZCode uses its dedicated `powercontext setup zcode` command and is not in the selector; consult each Agent guide for its supported installation path. +Use the same ref as the Server. `powercontext setup codex dsh` installs, configures, and diagnoses both agents. Use `powercontext config agent codex dsh` to update installed connections without reinstalling or enabling them; consult each Agent guide for native requirements. ## Python Agent frameworks diff --git a/docs/en/docs/integrations/kimi-code.md b/docs/en/docs/integrations/kimi-code.md index dfd9b1ca26..e7e26a1a8d 100644 --- a/docs/en/docs/integrations/kimi-code.md +++ b/docs/en/docs/integrations/kimi-code.md @@ -29,7 +29,7 @@ kimi The source must be a local checkout or the unconfigured plugin directory from it; the installed managed copy is not an update source. Kimi and Node must be on PATH; KIMI_CLI_BIN can point to a real binary or built main.mjs for diagnostics and acceptance. KIMI_CODE_HOME selects the Kimi home, defaulting to ~/.kimi-code. -Setup installs a managed copy at /plugins/managed/powercontext and registers it in plugins/installed.json. It leaves model configuration and other plugins intact, preserves plugin/MCP disablement, and restores previous registration and plugin contents if publication fails. If rollback itself is denied, setup retains the previous plugin at plugins/managed/.powercontext-kimi-backup and reports the recovery path; restore it before retrying. Repeat setup to update; omitted endpoint, Scope and capture options preserve installed settings. Reinstall from the source to change files in the managed copy. +Setup installs a managed copy at /plugins/managed/powercontext and registers it in plugins/installed.json. It leaves model configuration and other plugins intact, preserves plugin/MCP disablement, and restores previous registration and plugin contents if publication fails. If rollback itself is denied, setup reports where the previous contents remain; restore the reported target from that path before retrying. Repeat setup to update; omitted endpoint, Scope and capture options preserve installed settings. Reinstall from the source to change files in the managed copy. Open a **new session** after installation or reconfiguration; an existing session may retain its old Skill or MCP configuration. The plugin is user-wide, not repository-local. Setup does not create a Scope or modify bindings. HTTPS and loopback HTTP are supported; non-loopback plaintext HTTP is rejected. diff --git a/docs/en/docs/integrations/opencode.md b/docs/en/docs/integrations/opencode.md index 96b927c3f5..c2ece3b005 100644 --- a/docs/en/docs/integrations/opencode.md +++ b/docs/en/docs/integrations/opencode.md @@ -65,18 +65,14 @@ Set `POWERCONTEXT_OPENCODE_CAPTURE_PROMPTS=false` when prompts must not be persi ### Recover a failed refresh -If setup reports `recovery failed`, keep the reported backups. Setup refuses further installation writes while a -`.powercontext-install-*` path or a `.powercontext-project-context.*.bak` path remains; a missing active Skill is -not treated as a fresh installation in this state. - -Restore the failed file destinations named in the error from the installation backup directory; file backups use their -original filenames. If `skills/powercontext-project-context` is absent, restore its retained Skill directory to that -location. If an active Skill or multiple backups exist, compare and reconcile their contents before choosing a version; -setup does not choose or merge them automatically. Preserve personal files and notes outside managed regions. - -Only after restoring or reconciling the contents, move remaining recovery paths out of the OpenCode config directory -and rerun the setup command, then run the checks below. Removing backups just to bypass the check can discard user notes. -These safeguards cover caught filesystem failures; they do not guarantee recovery from process crashes or concurrent setups. +If setup reports that it could not restore a target, keep the backup path named in the error. Restore that backup +to the reported target before retrying. Setup refuses to treat a missing target with a retained backup as a fresh +installation. + +If an active Skill or multiple backups exist, compare and reconcile their contents before choosing a version. +Preserve personal files and notes outside managed regions; deleting backups just to bypass the check can discard +them. After recovery, rerun setup and the checks below. Recovery covers caught filesystem failures; it does not +guarantee recovery from process crashes or concurrent setups. ### Check the plugin diff --git a/docs/en/docs/integrations/pi.md b/docs/en/docs/integrations/pi.md index 2730d36767..eae7dc496a 100644 --- a/docs/en/docs/integrations/pi.md +++ b/docs/en/docs/integrations/pi.md @@ -30,6 +30,10 @@ powercontext server run pi ``` +If an older PowerContext package at another path has Pi resource filters, setup stops before changing the host. +Move that package entry to the managed path reported in the error, keeping its filters, then rerun setup. +Refreshes at the managed path preserve existing filters. + ## Understand what the package does Before Pi starts an agent turn, the package calls `POST /v1/context/prepare` once with an 8000-byte default budget. diff --git a/docs/en/docs/integrations/workbuddy.md b/docs/en/docs/integrations/workbuddy.md index 159d14e5fe..125d5ec338 100644 --- a/docs/en/docs/integrations/workbuddy.md +++ b/docs/en/docs/integrations/workbuddy.md @@ -22,7 +22,7 @@ PowerContext Server over HTTP. ## Install with the PowerContext CLI -WorkBuddy is not listed by `setup select` or `doctor integrations`; use the dedicated setup and diagnostic commands on this page. +WorkBuddy supports `setup workbuddy`, `config agent workbuddy`, and `doctor workbuddy`, including multi-agent commands. The CLI installs the hooks, MCP server, and Skill from a local checkout or a GitHub source in one step: diff --git a/docs/en/docs/operate/connect-remote-server.md b/docs/en/docs/operate/connect-remote-server.md index f777b1f678..0d36ab6e90 100644 --- a/docs/en/docs/operate/connect-remote-server.md +++ b/docs/en/docs/operate/connect-remote-server.md @@ -29,7 +29,7 @@ powercontext setup claude-code --server-url http://192.0.2.10:8000 --allow-insec ``` Replace `claude-code` with `codex`, `dsh`, `openclaw`, `opencode`, `pi`, `hermes`, or `workbuddy`. -The same options work with `setup select --host ...` for its catalog hosts; WorkBuddy uses its dedicated command. +The same options work with `setup codex dsh` for multiple agents and `config agent codex dsh` for installed connections. A declined or missing consent fails before installation starts. A warning is written to stderr, leaving JSON stdout parseable. Successful installation saves the endpoint and consent for new sessions; it does not save credentials. diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 9daa0aa8aa..bfa6cbb009 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -11,12 +11,12 @@ Start with: powercontext doctor ``` -The command checks the package, Server liveness, and Server readiness. It exits with status 1 unless every check is +The command checks saved agents and their Servers, or the package and Server when no agents are saved. It exits 1 unless every check is `ok`; a `degraded` readiness result is usable but is not a complete diagnostic success. Add `--json` for automation; -the top-level result and every check include `ok` and `status`. Check optional host integrations separately: +JSON includes the overall `ok` result and individual diagnostic statuses. Select host integrations explicitly: ```bash -powercontext doctor integrations +powercontext doctor codex dsh powercontext doctor codex powercontext doctor claude-code powercontext doctor dsh @@ -26,9 +26,9 @@ powercontext doctor pi powercontext doctor hermes ``` -`doctor integrations` prints every first-class host. A host whose CLI is not on PATH is `missing` and does not fail -the command. A present host that is broken still exits 1. Single-host commands such as `doctor codex` stay fail-closed -when that CLI is missing. +`doctor` checks saved agent connections by default, falling back to package and Server checks when none are saved. +Explicit names select the agents to check. A missing required CLI or unhealthy agent makes the command exit 1; +other selected agents still receive their own results. ## Installation cannot read the Git URL @@ -60,7 +60,7 @@ command -v hermes Add the uv tool bin directory to `PATH` if needed. `powercontext setup codex`, `powercontext setup claude-code`, `powercontext setup dsh`, `powercontext setup openclaw`, `powercontext setup opencode`, `powercontext setup pi`, and `powercontext setup hermes` report an error rather than attempting installation when the host CLI is unavailable. -`powercontext setup select` installs only the hosts you choose. A selected host that is missing still fails that row +`powercontext setup codex dsh` installs only the hosts you choose. A selected host that is missing still fails that row and does not block the other selected hosts. An unselected host is skipped even if its CLI is on `PATH`. ## The plugin is missing or stale diff --git a/docs/zh/docs/develop/interfaces.md b/docs/zh/docs/develop/interfaces.md index 939f9f6f76..df1c5a737f 100644 --- a/docs/zh/docs/develop/interfaces.md +++ b/docs/zh/docs/develop/interfaces.md @@ -116,14 +116,14 @@ Pi transcript。召回、采集和边界 flush 都会正常降级;显式持久 运行带 Scope 的内容命令前,将 `POWERCONTEXT_SCOPE_ID` 设置为 `create_scope` 返回的已有 ID。 ```text -powercontext setup -powercontext setup select --host codex --host dsh +powercontext setup [agents...] +powercontext setup codex dsh +powercontext config agent codex dsh --server-url https://memory.example powercontext config init --output .env powercontext config show --env-file .env powercontext config validate --env-file .env powercontext doctor -powercontext doctor -powercontext doctor integrations +powercontext doctor codex dsh powercontext server run powercontext server run --env-file .env powercontext ready @@ -149,14 +149,14 @@ powercontext external-skill import --scope-id "$POWERCONTEXT_SCOPE_ID" --fingerp `config` 命令组用于生成、脱敏显示和校验显式环境文件。CLI 不会隐式搜索该文件;使用 `config show`、 `config validate` 或 `server run` 时需要通过 `--env-file` 传入。配置优先级和凭据处理规则见[配置](../operate/configuration.md)。 -`` 可以是 `codex`、`claude-code`、`dsh`、`hermes`、`openclaw`、`opencode`、`pi` 或 -`workbuddy`。`setup select` 和 `doctor integrations` 使用的一级宿主目录包含上述除 WorkBuddy 外的所有宿主; -WorkBuddy 仍可通过显式的 `setup workbuddy` 和 `doctor workbuddy` 命令使用。 +Agent 可以是 `codex`、`claude-code`、`dsh`、`hermes`、`kimi-code`、`openclaw`、`opencode`、`pi`、`workbuddy` 或 `zcode`。 +`powercontext setup` 提供交互选择;显式 Agent 名称支持非交互和多 Agent 安装。 +Setup 会安装、配置、按宿主能力启用插件并运行 doctor。各 Agent 的失败互相隔离;诊断失败时仍会报告已完成的安装。 -`powercontext doctor` 检查安装包和 Server,不要求任何集成。`powercontext doctor integrations` 打印全部一级宿主的只读矩阵; -CLI 不在 PATH 上时该行是 `missing`,不会让整条命令失败。各个 `powercontext doctor ` 命令在对应 CLI -缺失时仍会失败。矩阵保留每个宿主专有的全部集成检查,包括 OpenCode 独立的 `plugin` 与 `skill` 结果。 -DSH 检查 `dump-config` 是否列出 `powercontext-dsh`;Pi 检查 CLI 是否列出 PowerContext package。 +`powercontext config agent [agents...]` 更新已安装连接,不会重新安装或启用插件。 +`powercontext doctor [agents...]` 检查选中的 Agent;未指定时检查已保存的连接。 +没有已保存连接时,doctor 检查安装包和 Server。选中的 Agent 缺少必需 CLI 时,其检查失败。 +Doctor 保留宿主专有检查,包括 OpenCode 独立的 `plugin` 与 `skill` 结果。DSH 检查原生注册;Pi 检查已注册的 package。 Candidate 的统一 HTTP 路径为 `/v1/candidates/list`、`get`、`history`、`revise`、`approve`、`reject`(均为 POST)。 通过 `candidate_kind=artifact|tag` 区分类型;旧 Artifact Candidate 路径直接移除,调用方必须同步升级。 diff --git a/docs/zh/docs/integrations/claude-code.md b/docs/zh/docs/integrations/claude-code.md index 085605ec73..141c52e529 100644 --- a/docs/zh/docs/integrations/claude-code.md +++ b/docs/zh/docs/integrations/claude-code.md @@ -31,13 +31,13 @@ set +a powercontext setup claude-code --server-url "$POWERCONTEXT_CLAUDE_SERVER_URL" ``` -修改 Claude Code 设置前,setup 会报告设置项、插件缓存、持久化数据位置、所需权限和准确的回滚命令。 -之后命令会注册或刷新 Marketplace、以 user scope 更新或安装插件,并通过 Claude Code 的 JSON 输出确认插件已启用。 +setup 会注册或刷新 Marketplace,再以 user scope 安装或更新插件。更新保留显式禁用状态,结果报告已安装版本、 +插件缓存和持久化数据位置。 -Marketplace registry、按版本保存的插件缓存和插件数据目录由 Claude Code 管理。Claude 2.1.133 的 -`plugin install` 不支持配置参数,因此 setup 会在安装成功后,把 `server_url` 和 `capture_prompts` -原子合并到用户级 `pluginConfigs`,并保留其他设置;失败时恢复安装前快照。PowerContext 从 -`CLAUDE_CONFIG_DIR` 或 Claude Code 默认配置目录解析这些位置。 +Marketplace registry、按版本保存的插件缓存和插件数据目录由 Claude Code 管理。安装成功后,setup 将 +`server_url` 和 `capture_prompts` 合并到用户级 `pluginConfigs`,保留其他设置及省略的采集偏好。 +连接配置失败时恢复该阶段的本地变更,已完成的原生安装保留。PowerContext 从 `CLAUDE_CONFIG_DIR` +或 Claude Code 默认配置目录解析这些位置。 使用本地 checkout 时,传入目录: diff --git a/docs/zh/docs/integrations/codex.md b/docs/zh/docs/integrations/codex.md index da03a9365d..7387426392 100644 --- a/docs/zh/docs/integrations/codex.md +++ b/docs/zh/docs/integrations/codex.md @@ -47,7 +47,7 @@ powercontext doctor codex `powercontext doctor codex --json` 通过 `mcp_tools` 报告基础 MCP 连接,通过 `mcp_full_profile` 报告完整工具覆盖。 完整覆盖要求 Codex 集成 manifest 的 `server-mcp` 中声明的全部能力工具,包括 Memory 维护、Scope 管理和 Handoff 操作。缺失工具会按名称列出,并使结果降级;工具发现不代表模型就绪、Hook 已执行或具有写入权限。 -具备基础 Memory 支持时,`setup codex` 和 `setup select --host codex` 均可成功,`doctor` 单独报告 full 覆盖不完整。 +Setup 会分别报告安装完成和诊断结果;基础 Memory 支持仍可使用时,`doctor` 可能单独报告 full 覆盖不完整。 鉴权或基础连接失败仍会使安装失败。 ## 理解自动恢复、Memory 和 Handoff diff --git a/docs/zh/docs/integrations/hermes.md b/docs/zh/docs/integrations/hermes.md index 7987c1ed15..e836226d62 100644 --- a/docs/zh/docs/integrations/hermes.md +++ b/docs/zh/docs/integrations/hermes.md @@ -33,10 +33,11 @@ powercontext doctor hermes ``` Setup 命令会把 provider 复制到 `$HERMES_HOME/plugins/powercontext`,安装 -`$HERMES_HOME/plugins/powercontext-command`,并在不授予内置工具覆盖权限的情况下启用 slash-command 插件。它不会启动 -Server,也不会在 Hermes 中选择 provider。 +`$HERMES_HOME/plugins/powercontext-command`。除非用户已显式禁用,否则会启用 slash-command 插件,且不授予内置工具覆盖权限。 +未配置 `memory.provider` 时,setup 自动选择 PowerContext;已有 provider 选择(包括显式空值)会保留。 +Setup 不会启动 Server。 -运行 Hermes Memory 设置向导,并选择 `PowerContext`: +需要从现有 provider 切换到 PowerContext,或调整 provider 配置时,运行 Hermes Memory 设置向导并选择 `PowerContext`: ```bash hermes memory setup diff --git a/docs/zh/docs/integrations/index.md b/docs/zh/docs/integrations/index.md index ca7ff39a7a..a8275cef8c 100644 --- a/docs/zh/docs/integrations/index.md +++ b/docs/zh/docs/integrations/index.md @@ -37,10 +37,10 @@ description: 选择插件、MCP、Skills、Agent Host 或 Python 框架接入。 需要交互选择多个宿主时,可运行: ```bash -powercontext setup select +powercontext setup ``` -使用与 Server 相同的 ref。ZCode 使用独立的 `powercontext setup zcode` 命令,不在该选择器中;完整支持情况见各 Agent 文档。 +使用与 Server 相同的 ref。`powercontext setup codex dsh` 会安装、配置并诊断两个 Agent。更新已安装连接时使用 `powercontext config agent codex dsh`,不会重新安装或启用插件;原生要求见各 Agent 文档。 ## Python Agent 框架 diff --git a/docs/zh/docs/integrations/kimi-code.md b/docs/zh/docs/integrations/kimi-code.md index ad28f568ac..9fe49ba9e9 100644 --- a/docs/zh/docs/integrations/kimi-code.md +++ b/docs/zh/docs/integrations/kimi-code.md @@ -29,7 +29,7 @@ kimi source 仅支持本地 checkout 或其中未配置的插件目录,不能将已配置的受管理副本当作更新源。Kimi 和 Node 需在 PATH;KIMI_CLI_BIN 可指向真实二进制或构建后的 main.mjs,用于诊断和验收。KIMI_CODE_HOME 指定宿主目录,默认 ~/.kimi-code。 -安装器将副本放到 /plugins/managed/powercontext,注册到 plugins/installed.json;保留模型配置、其他插件和已有插件/MCP 禁用状态,发布失败时恢复原插件与注册记录。回滚本身被拒绝时,会将原插件保留在 plugins/managed/.powercontext-kimi-backup 并报告恢复路径,需先恢复再重试。重复 setup 更新插件,省略的 endpoint、Scope 和采集参数保留已安装设置。修改源文件后需要重新安装,宿主运行的是受管理副本。 +安装器将副本放到 /plugins/managed/powercontext,注册到 plugins/installed.json;保留模型配置、其他插件和已有插件/MCP 禁用状态,发布失败时恢复原插件与注册记录。回滚本身被拒绝时,会报告原内容的保留路径,需按报错恢复对应目标后再重试。重复 setup 更新插件,省略的 endpoint、Scope 和采集参数保留已安装设置。修改源文件后需要重新安装,宿主运行的是受管理副本。 安装或改配置后开启**新会话**;现有会话可能保留旧 Skill/MCP 配置。插件对用户全局生效,不限当前仓库。setup 不创建 Scope、不修改绑定。支持 HTTPS 和环回 HTTP,拒绝非环回明文 HTTP。 diff --git a/docs/zh/docs/integrations/opencode.md b/docs/zh/docs/integrations/opencode.md index 902834c01c..b2fb2e45a4 100644 --- a/docs/zh/docs/integrations/opencode.md +++ b/docs/zh/docs/integrations/opencode.md @@ -62,16 +62,12 @@ HTTPS 证书校验仍然启用。安装与持久化同意见[连接远程 Server ### 恢复失败的更新 -如果 setup 报告 `recovery failed`,请保留报错中列出的备份。只要配置目录中仍有 `.powercontext-install-*` -或 `.powercontext-project-context.*.bak` 恢复路径,后续 setup 就会在安装写入前停止;此时不会把缺失的 Skill -目录当成首次安装。 +如果 setup 报告无法恢复某个目标,请保留报错中列出的备份,按报错路径还原对应目标后再重试。 +目标缺失且仍有备份时,setup 不会将其当成首次安装。 -按照报错列出的失败目标,从安装备份目录还原对应文件,文件备份使用原文件名。如果 `skills/powercontext-project-context` -不存在,将保留的 Skill 备份目录恢复到该位置。如果已有当前 Skill 或多个备份,先比较并整理其中的内容;setup 不会自动 -选择或合并版本。请保留个人文件及管理区域外的笔记。 - -恢复或核对完内容后,再将剩余恢复路径移出 OpenCode 配置目录,重新执行 setup,并运行下面的检查。仅为绕过检查而删除 -备份,可能丢弃用户笔记。以上保护针对可捕获的文件操作失败,不保证进程崩溃或并发安装时的恢复。 +如果已有当前 Skill 或多个备份,先比较并整理内容,再选择要保留的版本。请保留个人文件及管理区域外的笔记; +仅为绕过检查而删除备份可能丢失这些内容。恢复后重新执行 setup,并运行下面的检查。 +恢复覆盖可捕获的文件操作失败,不保证进程崩溃或并发安装时的恢复。 ### 检查插件 diff --git a/docs/zh/docs/integrations/pi.md b/docs/zh/docs/integrations/pi.md index 17cdd3655d..543d554794 100644 --- a/docs/zh/docs/integrations/pi.md +++ b/docs/zh/docs/integrations/pi.md @@ -30,6 +30,10 @@ powercontext server run pi ``` +如果其他路径下的旧 PowerContext 包带有 Pi 资源筛选,setup 会在修改宿主前停止。 +请将该包的注册路径改为报错中给出的受管理路径,保留筛选设置后重新执行 setup。 +已位于受管理路径的包在更新时保留现有筛选。 + ## 了解 package 的行为 Pi 开始 agent turn 前,package 会以默认 8000-byte 预算调用一次 `POST /v1/context/prepare`。它只严格接受 diff --git a/docs/zh/docs/integrations/workbuddy.md b/docs/zh/docs/integrations/workbuddy.md index e9d204f080..95e6f3a60c 100644 --- a/docs/zh/docs/integrations/workbuddy.md +++ b/docs/zh/docs/integrations/workbuddy.md @@ -21,7 +21,7 @@ description: 安装 PowerContext WorkBuddy hooks 并控制其本地行为。 ## 使用 PowerContext CLI 安装 -WorkBuddy 不在 `setup select` 或 `doctor integrations` 的宿主目录中,使用本页的独立安装和诊断命令。 +WorkBuddy 支持 `setup workbuddy`、`config agent workbuddy` 和 `doctor workbuddy`,也可与其他 Agent 一起操作。 CLI 可以从本地 checkout 或 GitHub 源一键安装 hooks、MCP Server 和 Skill: diff --git a/docs/zh/docs/operate/connect-remote-server.md b/docs/zh/docs/operate/connect-remote-server.md index e9b34116b1..97bdc08c5c 100644 --- a/docs/zh/docs/operate/connect-remote-server.md +++ b/docs/zh/docs/operate/connect-remote-server.md @@ -27,7 +27,7 @@ powercontext setup claude-code --server-url http://192.0.2.10:8000 --allow-insec ``` `claude-code` 可替换为 `codex`、`dsh`、`openclaw`、`opencode`、`pi`、`hermes` 或 `workbuddy`。 -`setup select --host ...` 也支持这两个选项,覆盖其目录中的 Agent;WorkBuddy 使用独立 setup 命令。 +`setup codex dsh` 和 `config agent codex dsh` 也支持这两个选项,分别用于多 Agent 安装和已安装连接配置。 拒绝或未提供同意时,在安装操作前退出。风险警告写入 stderr,不污染 stdout 的 JSON。 安装成功后保存地址及同意状态,新会话无需重复手贴这两项配置;鉴权凭据仍需单独配置。 diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index ceb1c280da..51232b08f2 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -11,12 +11,12 @@ description: 诊断 PowerContext 安装、Server、数据库和宿主集成问 powercontext doctor ``` -该命令检查安装包、Server liveness 和 Server readiness;只有所有检查均为 `ok` 时才以状态码 0 退出。 -`degraded` 表示仍可使用,但不算完整诊断成功。自动化场景可添加 `--json`,顶层结果和每个检查都会包含 -`ok` 与 `status`。可单独检查可选的宿主集成: +该命令检查已保存的 Agent 及其 Server;未保存连接时检查安装包和 Server。只有所有检查均为 `ok` 时才以状态码 0 退出。 +`degraded` 表示仍可使用,但不算完整诊断成功。自动化场景可添加 `--json`,查看整体 `ok` 结果 +和各项诊断状态。可显式选择要检查的宿主集成: ```bash -powercontext doctor integrations +powercontext doctor codex dsh powercontext doctor codex powercontext doctor claude-code powercontext doctor dsh @@ -26,8 +26,8 @@ powercontext doctor pi powercontext doctor hermes ``` -`doctor integrations` 会打印全部一级宿主。CLI 不在 PATH 上时该行是 `missing`,不会让整条命令失败;已安装但异常的 -宿主仍会以状态码 1 退出。单宿主命令(如 `doctor codex`)在该 CLI 缺失时仍然失败。 +`doctor` 默认检查已保存的 Agent 连接,没有已保存连接时检查安装包和 Server。显式名称用于选择检查对象。 +选中的 Agent 缺少必需 CLI 或检查异常时,命令以状态码 1 退出,其余选中 Agent 仍会得到各自结果。 ## 安装时无法读取 Git 地址 @@ -59,12 +59,12 @@ command -v hermes 必要时把 uv tool bin 目录加入 `PATH`。宿主 CLI 不可用时,`powercontext setup codex`、 `powercontext setup claude-code`、`powercontext setup dsh`、`powercontext setup openclaw`、 `powercontext setup opencode`、`powercontext setup pi` 和 `powercontext setup hermes` 都会报告错误,而不会尝试安装。 -`powercontext setup select` 只安装你选中的宿主。某个选中宿主未安装时,该行失败,但不会阻塞其余选中项; +`powercontext setup codex dsh` 只安装你选中的宿主。某个选中宿主未安装时,该行失败,但不会阻塞其余选中项; 未选中的宿主即使已在 `PATH` 上也不会安装。 ## 插件缺失或版本不一致 -先在不涉及 Server 的情况下确认集成故障: +检查选中的集成及其 Server 连接: ```bash powercontext doctor codex diff --git a/e2e/bub/tests/test_harbor_job_config.py b/e2e/bub/tests/test_harbor_job_config.py index b11847792e..9e2ba37ebe 100644 --- a/e2e/bub/tests/test_harbor_job_config.py +++ b/e2e/bub/tests/test_harbor_job_config.py @@ -865,16 +865,18 @@ def test_kimi_code_on_arm_installs_the_plugin_as_setup_does(monkeypatch, tmp_pat # Kimi Code installs plugins only from its interactive interface, so the ON arm writes what `powercontext setup # kimi-code` writes, from the files the container mounts. The harness itself depends on PowerContext's client only. from powercontext.cli import kimi_code as kimi_setup + from powercontext.cli.installation import setup as install_plugin plugin = _mounted_kimi_plugin(monkeypatch, tmp_path) plugin_url = f"http://127.0.0.1:{harbor_kimi_code.RELAY_PORT}" harness = _install_kimi_plugin(tmp_path, plugin_url, token=token) monkeypatch.setenv("KIMI_CODE_HOME", str(tmp_path / "setup-home")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) if token is not None: monkeypatch.setenv(harbor_kimi_code.KIMI_TOKEN, token) monkeypatch.setattr(kimi_setup, "kimi_executable", lambda: _NODE) - kimi_setup.install_kimi_plugin(source=plugin, server_url=plugin_url, scope_id="scope-1") + install_plugin("kimi-code", source=str(plugin), server_url=plugin_url, scope_id="scope-1") assert _kimi_installation(harness) == _kimi_installation(tmp_path / "setup-home") diff --git a/evaluation/skills/skill-up/skill-lock.json b/evaluation/skills/skill-up/skill-lock.json index 26d3f40e57..9376f053a5 100644 --- a/evaluation/skills/skill-up/skill-lock.json +++ b/evaluation/skills/skill-up/skill-lock.json @@ -1,13 +1,13 @@ { "schema_version": 1, "source_path": "integrations/claude-code/plugins/powercontext/skills/powercontext-project-context", - "source_commit": "afadbb472bb29d2b4ac84e28249dc61dcb654f8d", + "source_commit": "1ff19cc761969b9f3be68865f629c8523684a36a", "files": { - ".powercontext-guidance.json": "e4ee4acbeccb65a95ed8d6b54f518d19d77d1aa5f6b629666ff9ae62abf8b0d2", + ".powercontext-guidance.json": "c9872b03159d41f8880b91b679d77b5216970e865187e2c22cdc3e9feddc20fa", "SKILL.md": "c85d5d4ae80354278982ca31d5910b5c4ef27fd1c8ed2bf3683f12722cb8c769", "references/review-publication.md": "e796ffcb2ffb7fef1d17c2e356104a44c3797603e74ae7f27a9445f556529621", "references/scope-memory.md": "f84c8dc933cdfe9934bc50fd23cb53f4d6a3631506700bfdfff4731fe43e6818", "references/work-handoff.md": "248069bc7f69268ba90de38b2537fe9a385d4a220a35c54603d53e16df10988f" }, - "content_sha256": "005192ab90dcefc16fba1a94b24313eb803fe587a13f5ef03871257f53142390" + "content_sha256": "f4303bc04f7f578bd705ef6515eaa66ede71e91368cd138e6fcfcbe2bd57e072" } diff --git a/evaluation/skills/skill-up/vendor/powercontext-project-context/.powercontext-guidance.json b/evaluation/skills/skill-up/vendor/powercontext-project-context/.powercontext-guidance.json index fb38ebc68f..70c3f5a2cb 100644 --- a/evaluation/skills/skill-up/vendor/powercontext-project-context/.powercontext-guidance.json +++ b/evaluation/skills/skill-up/vendor/powercontext-project-context/.powercontext-guidance.json @@ -1,10 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "fd6aec8d2ee4e4aa522f8ecf75138b640c3d197c02401c5d3185089d7b345ac3", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "6858a67a67fe83164234c2cdce42e094def4e1821034816739be31635c8a2943", - "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" - }, "resources": [ "SKILL.md", "references/review-publication.md", diff --git a/evaluation/zcode_guidance/run.py b/evaluation/zcode_guidance/run.py index 41951e6823..1506e9d48f 100644 --- a/evaluation/zcode_guidance/run.py +++ b/evaluation/zcode_guidance/run.py @@ -177,8 +177,8 @@ def install(self, url: str, *, with_skill: bool) -> dict: [ sys.executable, "-c", - "from powercontext.cli.zcode import install_zcode_plugin; import sys; " - 'install_zcode_plugin(source=sys.argv[1], ref="master", server_url=sys.argv[2])', + "from powercontext.cli.installation import setup; import sys; " + 'setup("zcode", source=sys.argv[1], ref="master", server_url=sys.argv[2])', str(REPOSITORY), url, ] diff --git a/evaluation/zcode_guidance/skill-lock.json b/evaluation/zcode_guidance/skill-lock.json index 3b0afa8813..fc1c45c836 100644 --- a/evaluation/zcode_guidance/skill-lock.json +++ b/evaluation/zcode_guidance/skill-lock.json @@ -1,9 +1,9 @@ { "schema_version": 1, "source_path": "integrations/zcode/plugins/powercontext/skills/powercontext-project-context", - "source_commit": "b6e6b65b22f46f79341f33f58aeadbd48d471934", + "source_commit": "1ff19cc761969b9f3be68865f629c8523684a36a", "files": { - ".powercontext-guidance.json": "655cbbb72bd53c08f9e846e315ed64d3a855eb54767e97580b00b88b237f5358", + ".powercontext-guidance.json": "c9872b03159d41f8880b91b679d77b5216970e865187e2c22cdc3e9feddc20fa", "SKILL.md": "9bd2e6c47aa8b7baeb9ee46897e795283bf5f0366a67d7daf2bfcbea76839ed1", "references/review-publication.md": "e796ffcb2ffb7fef1d17c2e356104a44c3797603e74ae7f27a9445f556529621", "references/scope-memory.md": "00b0a24e9e5dfed9d92acc5b4def70b6aec3a78b2978d3e8287d6f0f09bf5d90", diff --git a/integrations/agent-plugin/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/agent-plugin/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index 2469e004cc..70c3f5a2cb 100644 --- a/integrations/agent-plugin/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/agent-plugin/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,10 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "fd6aec8d2ee4e4aa522f8ecf75138b640c3d197c02401c5d3185089d7b345ac3", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "1e6310ddd4e34028eb2ad0a4e393a6ff77b552e93f568251cb7fd82f82f9bfb9", - "references/work-handoff.md": "cf3ef4386ead7653431c60faa53c2ac045acd8db92cea6a214332f5c3aaeff8b" - }, "resources": [ "SKILL.md", "references/review-publication.md", diff --git a/integrations/claude-code/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/claude-code/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index fb38ebc68f..70c3f5a2cb 100644 --- a/integrations/claude-code/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/claude-code/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,10 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "fd6aec8d2ee4e4aa522f8ecf75138b640c3d197c02401c5d3185089d7b345ac3", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "6858a67a67fe83164234c2cdce42e094def4e1821034816739be31635c8a2943", - "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" - }, "resources": [ "SKILL.md", "references/review-publication.md", diff --git a/integrations/codex/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/codex/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index 45a2054266..d35edfcc5f 100644 --- a/integrations/codex/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/codex/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,11 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "bdb0e330d9eb38cbe4575ee94b08b3d0cb8b53c28a6443ac5b86ab3de9bfe479", - "references/experience-skills.md": "fea6f15789eec3133b1c1f61694fa5a33153392ee44050e656cecba60171418e", - "references/review-publication.md": "7fae5a77d776103f3f5fa2a406c71e4eca852941be1108da49a6f3a8985e1375", - "references/scope-memory.md": "6fac5c903ec27d4d0efbe5e18c5fc9053b940f07c57b345a6c1bb862899a9ebc", - "references/work-handoff.md": "e501146386f2f6a936d8c43d67bc172c66a46a82e1358cafb079ea6a4366156c" - }, "resources": [ "SKILL.md", "agents/openai.yaml", diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs b/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs index 28a1a0a46b..6daaef60f3 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs +++ b/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs @@ -29,8 +29,8 @@ import { MCP_OPERATIONS } from '../../src/mcp-operations.generated.ts' test('documented setup installs the matched plugin, diagnoses the running host and recalls processed Source', { timeout: 240000 }, async () => { const env = await environment({ fullCatalog: true }) try { - const installation = await installIntoHome(env.home) - assert.ok(installation.setup.includes('powercontext-dsh')) + const installation = await installIntoHome(env.home, { serverUrl: env.baseUrl }) + assert.equal(installation.setup.setup, 'complete') assert.equal(installation.doctor.checks.plugin.checks.registration, 'present') assert.equal(installation.doctor.checks.plugin.checks.running_host_configuration, 'not_observed') const { instance, dshHome, doctor, status, workspace } = env.harness({ baseUrl: 'http://127.0.0.1:1' }, { diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/setup-fixture.mjs b/integrations/dsh/plugins/powercontext/tests/runtime/setup-fixture.mjs index 8b5faa179f..1fe96df20e 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/setup-fixture.mjs +++ b/integrations/dsh/plugins/powercontext/tests/runtime/setup-fixture.mjs @@ -50,18 +50,19 @@ export function setupFixture(home, { dshHome = join(home, 'installed-dsh') } = { homePatch: join(env.DSH_HOME, 'cordis.patch.yml'), clients: env.POWERCONTEXT_CLIENT_CONFIG_FILE } } -export async function installIntoHome(home, { customized = false } = {}) { +export async function installIntoHome(home, { customized = false, serverUrl } = {}) { const { root, cli, native, profile, patch } = setupFixture(home) const customizations = '- id: agent-default-model\n name: "@deepseek-ai/dsh-agent-default-model"\n' if (customized) { await native(['--profile', 'web', '--dump-default-config']) writeFileSync(patch, customizations) } - const setup = await cli(['setup', 'dsh', '--source', root]) + const args = ['setup', 'dsh', '--source', root, '--server-url', serverUrl, '--json'] + const setup = JSON.parse(await cli(args)).hosts.dsh if (customized) { - await cli(['setup', 'dsh', '--source', root]) + await cli(args) assert.equal(readFileSync(patch, 'utf8'), customizations) } const doctor = await cli(['doctor', 'dsh', '--json']) - return { setup, doctor: JSON.parse(doctor), plugin: join(profile, 'node_modules/powercontext-dsh') } + return { setup, doctor: JSON.parse(doctor).hosts.dsh, plugin: join(profile, 'node_modules/powercontext-dsh') } } diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/setup.test.mjs b/integrations/dsh/plugins/powercontext/tests/runtime/setup.test.mjs index 0fef4298e2..f900e05ecd 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/setup.test.mjs +++ b/integrations/dsh/plugins/powercontext/tests/runtime/setup.test.mjs @@ -18,9 +18,14 @@ import assert from 'node:assert/strict' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' -import { test } from 'node:test' +import { after, before, test } from 'node:test' import { pathToFileURL } from 'node:url' import { installIntoHome, setupFixture } from './setup-fixture.mjs' +import { environment } from './fixture.mjs' + +let runtime +before(async () => { runtime = await environment() }) +after(async () => { await runtime?.close() }) test('setup validates native include trees observed by a real DSH startup', { timeout: 240000 }, async () => { const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-include-')) @@ -37,7 +42,7 @@ test('setup validates native include trees observed by a real DSH startup', { ti "import { appendFileSync } from 'node:fs'; export const name = 'powercontext-dsh'; " + "export function apply(ctx, config) { appendFileSync(process.env.DSH_INCLUDE_RECORDING, JSON.stringify(config) + '\\n'); }\n") writeFileSync(join(source, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ - id: 'powercontext-dsh', name: 'powercontext-dsh', config: { baseUrl: 'https://selected.example' }, + id: 'powercontext-dsh', name: 'powercontext-dsh', config: { baseUrl: runtime.baseUrl }, }] }])) mkdirSync(fixture.profile, { recursive: true }) writeFileSync(join(fixture.profile, 'package.json'), JSON.stringify({ @@ -63,7 +68,7 @@ test('setup validates native include trees observed by a real DSH startup', { ti await fixture.native(['--profile', 'web']) const received = readFileSync(recording, 'utf8').trim().split('\n').map(line => JSON.parse(line)) assert.deepEqual(received.map(config => config.baseUrl).sort(), [ - 'https://selected.example', 'https://unexpected.example', + runtime.baseUrl, 'https://unexpected.example', ]) writeFileSync(fixture.clients, JSON.stringify({ version: 1, hosts: { dsh: { server_url: 'https://saved.example', allow_insecure_http: false }, @@ -75,11 +80,7 @@ test('setup validates native include trees observed by a real DSH startup', { ti const paths = [join(fixture.profile, 'package.json'), fixture.patch, outer, included, fixture.clients, credentials] const before = paths.map(path => readFileSync(path)) await assert.rejects(fixture.cli(['setup', 'dsh', '--source', source, - '--server-url', 'https://selected.example', '--json']), error => { - assert.equal(error.code, 1) - assert.match(error.stderr, /Multiple PowerContext entries/) - return true - }) + '--server-url', runtime.baseUrl, '--json']), { code: 1 }) for (const [index, path] of paths.entries()) assert.deepEqual(readFileSync(path), before[index]) const uiPlugin = join(home, 'ui-fixture.mjs') @@ -99,16 +100,16 @@ test('setup validates native include trees observed by a real DSH startup', { ti const includePaths = [fixture.patch, outer, included] const includeBefore = includePaths.map(path => readFileSync(path)) const result = JSON.parse(await fixture.cli(['setup', 'dsh', '--source', source, - '--server-url', 'https://selected.example', '--json'])) - assert.equal(result.plugin, 'powercontext-dsh') - assert.equal(JSON.parse(readFileSync(fixture.clients, 'utf8')).hosts.dsh.server_url, 'https://selected.example') + '--server-url', runtime.baseUrl, '--json'])) + assert.equal(result.hosts.dsh.setup, 'complete') + assert.equal(JSON.parse(readFileSync(fixture.clients, 'utf8')).hosts.dsh.server_url, runtime.baseUrl) assert.equal(JSON.parse(readFileSync(credentials, 'utf8')).authorization, 'Bearer new-token') for (const [index, path] of includePaths.entries()) assert.deepEqual(readFileSync(path), includeBefore[index]) writeFileSync(recording, '') fixture.env.DSH_UI_RECORDING = uiRecording await fixture.native(['--profile', 'web']) assert.deepEqual(readFileSync(recording, 'utf8').trim().split('\n').map(line => JSON.parse(line)), [ - { baseUrl: 'https://selected.example' }, + { baseUrl: runtime.baseUrl }, ]) assert.deepEqual(JSON.parse(readFileSync(uiRecording, 'utf8')), { theme: 'dark', model: 'configured-model' }) } finally { @@ -139,11 +140,7 @@ test('setup rejects duplicate PowerContext entries in a native named group', { t const paths = [join(fixture.profile, 'package.json'), fixture.patch, fixture.homePatch, fixture.clients] const before = paths.map(path => readFileSync(path)) await assert.rejects(fixture.cli(['setup', 'dsh', '--source', fixture.root, - '--server-url', 'https://selected.example', '--json']), error => { - assert.equal(error.code, 1) - assert.match(error.stderr, /Multiple PowerContext entries/) - return true - }) + '--server-url', 'https://selected.example', '--json']), { code: 1 }) for (const [index, path] of paths.entries()) assert.deepEqual(readFileSync(path), before[index]) } finally { assert.equal(dirname(resolve(home)), resolve(tmpdir())) @@ -163,14 +160,12 @@ test('setup preserves a trailing-space DSH home and URL-bound credentials', { const patch = readFileSync(fixture.patch) for (let attempt = 0; attempt < 2; attempt += 1) { const result = JSON.parse(await fixture.cli(['setup', 'dsh', '--source', fixture.root, - '--server-url', 'https://selected.example', '--json'])) - assert.equal(result.plugin, 'powercontext-dsh') - assert.deepEqual(JSON.parse(readFileSync(fixture.clients, 'utf8')).hosts.dsh, { - server_url: 'https://selected.example', allow_insecure_http: false, - }) - assert.deepEqual(JSON.parse(readFileSync(join(fixture.env.DSH_HOME, 'powercontext/credentials.json'), 'utf8')), { - version: 1, server_url: 'https://selected.example', authorization: 'Bearer test-token', - }) + '--server-url', runtime.baseUrl, '--json'])) + assert.equal(result.hosts.dsh.setup, 'complete') + assert.equal(JSON.parse(readFileSync(fixture.clients, 'utf8')).hosts.dsh.server_url, runtime.baseUrl) + const credentials = JSON.parse(readFileSync(join(fixture.env.DSH_HOME, 'powercontext/credentials.json'), 'utf8')) + assert.equal(credentials.server_url, runtime.baseUrl) + assert.equal(credentials.authorization, 'Bearer test-token') const dump = await fixture.native(['--profile', 'web', '--dump-config']) assert.match(dump, /id: powercontext-dsh/) assert.deepEqual(readFileSync(fixture.patch), patch) @@ -184,8 +179,8 @@ test('setup preserves a trailing-space DSH home and URL-bound credentials', { test('setup and repeated setup preserve an existing customized DSH profile', { timeout: 240000 }, async () => { const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-')) try { - const installation = await installIntoHome(home, { customized: true }) - assert.match(installation.setup, /setup complete/) + const installation = await installIntoHome(home, { customized: true, serverUrl: runtime.baseUrl }) + assert.equal(installation.setup.setup, 'complete') assert.equal(installation.doctor.ok, true) assert.equal(installation.doctor.checks.plugin.checks.registration, 'present') } finally { @@ -217,32 +212,28 @@ async function inactiveTransportBundle(home, config, { installed = true } = {}) dsh: { server_url: 'https://saved.example', allow_insecure_http: false }, pi: { server_url: 'https://unrelated.example', allow_insecure_http: false }, } }, undefined, 2) + '\n') - return { ...fixture, manifest } + return fixture } for (const scenario of [ { name: 'endpoint override during reinstall', installed: true, config: { baseUrl: 'https://unexpected.example' }, endpoint: 'https://selected.example', - consent: '--no-allow-insecure-http', error: /baseUrl conflicts/ }, + consent: '--no-allow-insecure-http' }, { name: 'endpoint override during first installation', installed: false, config: { baseUrl: 'https://unexpected.example' }, endpoint: 'https://selected.example', - consent: '--no-allow-insecure-http', error: /baseUrl conflicts/ }, + consent: '--no-allow-insecure-http' }, { name: 'endpoint-bound HTTP refusal', installed: true, config: { baseUrl: 'http://selected.example', allowInsecureHttp: false }, endpoint: 'http://selected.example', - consent: '--allow-insecure-http', error: /HTTP consent/ }, + consent: '--allow-insecure-http' }, ]) { - test(`setup rejects an inactive bundle's ${scenario.name} without changing saved state`, { timeout: 240000 }, async () => { + test(`setup rejects an inactive bundle's ${scenario.name} without changing saved connections or user patches`, { timeout: 240000 }, async () => { const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-conflict-')) try { const fixture = await inactiveTransportBundle(home, scenario.config, { installed: scenario.installed }) - const paths = [fixture.manifest, fixture.patch, fixture.homePatch, fixture.clients] + const paths = [fixture.patch, fixture.homePatch, fixture.clients] const before = paths.map(path => readFileSync(path)) await assert.rejects(fixture.cli(['setup', 'dsh', '--source', fixture.root, - '--server-url', scenario.endpoint, scenario.consent, '--json']), error => { - assert.equal(error.code, 1) - assert.match(error.stderr, scenario.error) - return true - }) + '--server-url', scenario.endpoint, scenario.consent, '--json']), { code: 1 }) for (const [index, path] of paths.entries()) assert.deepEqual(readFileSync(path), before[index]) } finally { assert.equal(dirname(resolve(home)), resolve(tmpdir())) @@ -255,29 +246,23 @@ test('matching inactive bundle settings survive setup and repeated setup in nati const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-matching-')) try { const fixture = await inactiveTransportBundle(home, { - baseUrl: 'http://selected.example', allowInsecureHttp: true, + baseUrl: runtime.baseUrl, allowInsecureHttp: true, }) const patches = [fixture.patch, fixture.homePatch] const original = patches.map(path => readFileSync(path)) - let manifest for (let attempt = 0; attempt < 2; attempt += 1) { const result = JSON.parse(await fixture.cli(['setup', 'dsh', '--source', fixture.root, - '--server-url', 'http://selected.example', '--allow-insecure-http', '--json'])) - assert.equal(result.plugin, 'powercontext-dsh') + '--server-url', runtime.baseUrl, '--allow-insecure-http', '--json'])) + assert.equal(result.hosts.dsh.setup, 'complete') const settings = JSON.parse(readFileSync(fixture.clients, 'utf8')).hosts - assert.deepEqual(settings.dsh, { server_url: 'http://selected.example', allow_insecure_http: true }) + assert.equal(settings.dsh.server_url, runtime.baseUrl) + assert.equal(settings.dsh.allow_insecure_http, true) assert.deepEqual(settings.pi, { server_url: 'https://unrelated.example', allow_insecure_http: false }) const dump = await fixture.native(['--profile', 'web', '--dump-config']) const entry = dump.match(/- id: powercontext-dsh\r?\n(?:(?!- id:)[\s\S])*/)?.[0] assert.ok(entry, 'native configuration must contain the installed PowerContext entry') - assert.match(entry, /baseUrl: http:\/\/selected\.example/) + assert.ok(entry.includes(runtime.baseUrl)) assert.match(entry, /allowInsecureHttp: true/) - const current = readFileSync(fixture.manifest) - const bundles = JSON.parse(current).dsh.profile.bundles - assert.equal(bundles.filter(name => name === 'powercontext-dsh').length, 1) - assert.equal(bundles.filter(name => name === 'transport-override').length, 1) - if (manifest) assert.deepEqual(current, manifest) - manifest = current for (const [index, path] of patches.entries()) assert.deepEqual(readFileSync(path), original[index]) } } finally { diff --git a/integrations/hermes/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/hermes/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index d246008a83..70c3f5a2cb 100644 --- a/integrations/hermes/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/hermes/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,10 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "839e3793e43f90bb3df011acf618891ffc0c826039149c8bb7ce7d882d844eb3", - "references/review-publication.md": "32907cfbc4ec8641cfce719a2b49632fbf2194ed94af59845084c6b742faa5e1", - "references/scope-memory.md": "bece231f60b6d4655a7ee018be16afa66e8a63855fe48c5149a3fe9a820e49c4", - "references/work-handoff.md": "251fcee928765caa888fb7fe58a3a45780ea9caa391031c5d2a9a927fee3a783" - }, "resources": [ "SKILL.md", "references/review-publication.md", diff --git a/integrations/kimi-code/plugins/powercontext/scripts/doctor.mjs b/integrations/kimi-code/plugins/powercontext/scripts/doctor.mjs index 466c86b8a2..98cf21fd11 100644 --- a/integrations/kimi-code/plugins/powercontext/scripts/doctor.mjs +++ b/integrations/kimi-code/plugins/powercontext/scripts/doctor.mjs @@ -32,8 +32,11 @@ catch (error) { checks[['authentication_failed', 'invalid_credentials'].includes if (settings) { checks.settings = { status: 'ok', detail: 'Installed endpoint, Scope and runtime credentials are valid.' } try { - const { value } = await request(settings, 'GET', '/health/ready', undefined, AbortSignal.timeout(1500)) - checks.server = ['ready', 'degraded'].includes(value.status) ? { status: 'ok', detail: value.status === 'ready' ? 'Server is ready.' : 'Server is available; optional capabilities are degraded.' } : + const { status, value } = await request(settings, 'GET', '/health/ready', undefined, AbortSignal.timeout(1500)) + const valid = status === 200 && value.checks && typeof value.checks === 'object' && !Array.isArray(value.checks) && + Object.values(value.checks).every(item => typeof item === 'string') && Object.keys(value).every(key => ['status', 'checks'].includes(key)) + checks.server = valid && ['ready', 'degraded'].includes(value.status) ? { status: value.status === 'ready' ? 'ok' : 'degraded', + detail: value.status === 'ready' ? 'Server is ready.' : 'Server is available; optional capabilities are degraded.', checks: value.checks } : { status: 'failed', detail: 'Server is not ready.' } } catch (error) { checks.server = result(error) } try { @@ -51,4 +54,4 @@ if (values.runtime) { .includes(error.code ?? error.message) ? error.code ?? error.message : 'runtime_unavailable' } } } -process.stdout.write(JSON.stringify({ schema: 'powercontext.kimi-code.diagnostics.v1', checks, ...(runtime ? { runtime } : {}) }) + '\n') +process.stdout.write(JSON.stringify({ schema: 'powercontext.kimi-code.diagnostics.v2', checks, ...(runtime ? { runtime } : {}) }) + '\n') diff --git a/integrations/kimi-code/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/kimi-code/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index 7277818425..f2fd32cffc 100644 --- a/integrations/kimi-code/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/kimi-code/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,7 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "45c7a0702ab3d958bd55c221db3c46e24041d2cdc0af50b76c398f68e8d5bba7" - }, "resources": [ "SKILL.md", "references/scope-memory.md" diff --git a/integrations/kimi-code/plugins/powercontext/tests/plugin.test.mjs b/integrations/kimi-code/plugins/powercontext/tests/plugin.test.mjs index bf71c02da4..e2c54ab598 100644 --- a/integrations/kimi-code/plugins/powercontext/tests/plugin.test.mjs +++ b/integrations/kimi-code/plugins/powercontext/tests/plugin.test.mjs @@ -44,7 +44,7 @@ async function fixture(t, overrides = {}, settings = {}, listenHost = '127.0.0.1 const answer = req.url === '/v1/scope-bindings/resolve' ? { scope_id: 'scope-test' } : req.url === '/v1/context/prepare' ? { schema: 'powercontext.prepared-context.v1', status: 'ready', content: 'Historical context: use uv.', content_bytes: Buffer.byteLength('Historical context: use uv.') } : - req.url === '/health/ready' ? { status: 'ready' } : undefined + req.url === '/health/ready' ? { status: 'ready', checks: {} } : undefined if (answer) { res.writeHead(200); res.end(JSON.stringify(answer)); return } if (req.url === '/v1/sources/content') { captures.push(body) diff --git a/integrations/minimax/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/minimax/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index 33d45a6b0f..ad15eceb4a 100644 --- a/integrations/minimax/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/minimax/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,11 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "7e3aafba9cdd269a7cf0421c1f235c6119353ba3edc979f8129c445d7ad941f6", - "references/http-boundaries.md": "8c0298c997591cc5cde29bddec4a5fd0a37cf53cabb1bf77660d65b6b89c1834", - "references/review-publication.md": "b75f536d185ee502d79af4a12ea11591c74a4819dd42e1550dcc1c6067c9bb98", - "references/scope-memory.md": "4bfb120e2b7b9ba1d8cc7ebe9b0338d32ad4078f13145f8506dbd3846d823689", - "references/work-handoff.md": "ef2bce44a6bf2be5a5c8961c4129d1ae0a0194a7dae9ca2249386033b8df526c" - }, "resources": [ "SKILL.md", "references/examples.json", diff --git a/integrations/openclaw/plugins/memory-powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/openclaw/plugins/memory-powercontext/skills/powercontext-project-context/.powercontext-guidance.json index 09343095e1..1812873da5 100644 --- a/integrations/openclaw/plugins/memory-powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/openclaw/plugins/memory-powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,9 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "b1cd97c975a13053b8b5a5cac7e297484bbc63ca3c6360e7270e5a5333d65703", - "references/scope-memory.md": "dc3286c614d32d60a87e5659a4e7fb205c8dbcddab3f4acc9fe65cebfb8f36f0", - "references/work-handoff.md": "48a5d8f4c7093f2d4a17236a5457c0b993ff57413797c91ad6889b775f715e78" - }, "resources": [ "SKILL.md", "references/scope-memory.md", diff --git a/integrations/opencode/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/opencode/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index 3ba38bf8cf..70c3f5a2cb 100644 --- a/integrations/opencode/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/opencode/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,10 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "01f42a4f0a71f3cfddeb54c5a5b0c39337c3b4670144cb46f630ae3a56b7ea7a", - "references/review-publication.md": "ba507d9eec550541876cc6cd37a6c06a72792e18813d039db0236f97f9b4ff1f", - "references/scope-memory.md": "3b48026f37925246bdb100a5f4e5a40ea1b6b281219e4d69b6524503d7c96789", - "references/work-handoff.md": "bfdfe8d08b2db2bc844b3a819205b367e1c3f5e56cc4942aa3641db0e2547599" - }, "resources": [ "SKILL.md", "references/review-publication.md", diff --git a/integrations/pi/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/pi/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index 06babfaff8..e3a432c68e 100644 --- a/integrations/pi/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/pi/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,10 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "191b66dcc2aa1c533677b58f6ddca5b0018150c4fcbb37cb52e45f3948d2f9de", - "references/review-publication.md": "f3c483787bc709857b6d86067fc28be3094d8880589c93f07a1ff0dc81d94a05", - "references/scope-memory.md": "08839279b8a2aedd945274cb925891cee13f185e406b0416eb1681babd2c42d0", - "references/work-handoff.md": "dfe9bf07e8fac81c042d07d976e0bbfd66beca04853526e9a6320ced34550f47" - }, "resources": [ "SKILL.md", "agents/openai.yaml", diff --git a/integrations/workbuddy/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/workbuddy/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index b4cd91c263..70c3f5a2cb 100644 --- a/integrations/workbuddy/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/workbuddy/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,10 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "fd6aec8d2ee4e4aa522f8ecf75138b640c3d197c02401c5d3185089d7b345ac3", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "6e5aab92f4a2bd2bf732470201d513c2c7b60a152ddd7980a6f8d4a6338c880d", - "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" - }, "resources": [ "SKILL.md", "references/review-publication.md", diff --git a/integrations/zcode/plugins/powercontext/scripts/doctor.mjs b/integrations/zcode/plugins/powercontext/scripts/doctor.mjs index 99ff7eddf9..47f77d2f75 100644 --- a/integrations/zcode/plugins/powercontext/scripts/doctor.mjs +++ b/integrations/zcode/plugins/powercontext/scripts/doctor.mjs @@ -29,7 +29,15 @@ try { for (const [name, path] of [['server', '/health/ready'], ['protected_api', '/v1/capabilities']]) { try { const response = await request(settings, 'GET', path, undefined, signal) - checks[name] = { status: response.status === 200 ? 'ok' : 'failed', code: response.status === 200 ? name === 'server' ? 'ready' : 'protected_api_reachable' : 'invalid_response' } + if (name === 'server') { + const state = response.body?.status + const details = response.body?.checks + const valid = response.status === 200 && details && typeof details === 'object' && !Array.isArray(details) && + Object.values(details).every(item => typeof item === 'string') && Object.keys(response.body).every(key => ['status', 'checks'].includes(key)) + checks[name] = valid && (state === 'ready' || state === 'degraded') + ? { status: state === 'ready' ? 'ok' : 'degraded', code: state, checks: details } + : { status: 'failed', code: 'server_unavailable' } + } else checks[name] = { status: response.status === 200 ? 'ok' : 'failed', code: response.status === 200 ? 'protected_api_reachable' : 'invalid_response' } } catch (error) { checks[name] = { status: 'failed', code: failureCode(error) } } } let scope @@ -67,5 +75,5 @@ try { } else checks.runtime = { status: 'skipped', code: 'not_observed' } checks.mcp_session = { status: 'skipped', code: 'not_observed' } } catch { checks.probe = { status: 'failed', code: 'invalid_arguments' } } -console.log(JSON.stringify({ schema: 'powercontext.zcode.diagnostics.v1', checks })) +console.log(JSON.stringify({ schema: 'powercontext.zcode.diagnostics.v2', checks })) if (Object.values(checks).some(check => check.status === 'failed')) process.exitCode = 1 diff --git a/integrations/zcode/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json b/integrations/zcode/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json index 84d9fd13b6..70c3f5a2cb 100644 --- a/integrations/zcode/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json +++ b/integrations/zcode/plugins/powercontext/skills/powercontext-project-context/.powercontext-guidance.json @@ -1,10 +1,4 @@ { - "legacy_sha256": { - "SKILL.md": "88b6eed1a865b395dedc9508ab6cfcb15f2fc800f13bd9d5df02b963234906a1", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "0e6441569d116d76e79fc84089ce646aea7b07257eb39748068c4c835c2f68be", - "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" - }, "resources": [ "SKILL.md", "references/review-publication.md", diff --git a/integrations/zcode/plugins/powercontext/tests/observations.test.mjs b/integrations/zcode/plugins/powercontext/tests/observations.test.mjs index c6c117c479..c13b310e40 100644 --- a/integrations/zcode/plugins/powercontext/tests/observations.test.mjs +++ b/integrations/zcode/plugins/powercontext/tests/observations.test.mjs @@ -51,7 +51,7 @@ test('Hook observations preserve latest-attempt order, unknown writes and conten for await (const chunk of req) raw += chunk const body = raw ? JSON.parse(raw) : {} res.setHeader('Content-Type', 'application/json') - if (req.url === '/health/ready') res.end('{}') + if (req.url === '/health/ready') res.end('{"status":"ready","checks":{}}') else if (req.url === '/v1/capabilities') { res.statusCode = protectedStatus; res.end('{}') } else if (req.url === '/v1/scope-bindings/resolve') res.end('{"scope_id":"scope-runtime"}') else if (req.url === '/v1/context/prepare') { diff --git a/scripts/guidance_templates/README.md b/scripts/guidance_templates/README.md index b6355f4a9e..7c75be23e3 100644 --- a/scripts/guidance_templates/README.md +++ b/scripts/guidance_templates/README.md @@ -4,9 +4,7 @@ Packaged `powercontext-project-context` resources are generated from this direct Change `base/` for common instructions and `hosts//` for genuine host differences. Host templates inherit named blocks; do not edit generated copies under `integrations/`. `resources.json` lists the complete packaged resource set, including linked references. -`legacy.json` is a frozen migration record of pristine pre-marker resources, not a second editable guidance source. -Generated `.powercontext-guidance.json` files separate the current `resources` inventory from -`legacy_sha256` migration hashes using `schema_version: 1`. Initial flat hash-map manifests remain readable. +Generated `.powercontext-guidance.json` files contain the current `resources` inventory and `schema_version: 1`. All declared resources (including unlinked auxiliary files) and local Markdown references must exist before installation writes. Managed content without its manifest, invalid manifests, and incomplete legacy reference trees are rejected; existing files never repair an incomplete incoming package. @@ -16,7 +14,7 @@ then `uv run python scripts/integration_guidance_skills.py --check-generated`. The latter runs in `make check`, reports drift without writing, and never runs model evaluation. Running the generator again with unchanged inputs preserves file bytes and mtimes. -`src/powercontext/cli/guidance.py` owns distribution paths, installer entry points and the permitted +`src/powercontext/cli/guidance.py` owns distribution paths, guidance refresh and the permitted path variables. Supported capabilities come from `integrations/capabilities.toml`; capability removal, missing resolver scripts and unknown template variables fail generation. The portable agent-plugin and MiniMax MCP packages use the Server MCP toolset as a content contract, not a native-host qualification. @@ -29,16 +27,14 @@ and the discovery description is updated with the body. Text outside the range i Duplicate, incomplete or reversed markers fail before writing any generated file. The installation helpers for WorkBuddy/OpenCode and the staged Hermes/ZCode upgrades preserve these -ranges and additional local Skill files. An unchanged legacy document can be migrated automatically. -An edited document without markers is rejected rather than overwritten: identify the package-owned +ranges and additional local Skill files. +An existing document without markers is rejected rather than overwritten: identify the package-owned range and add the matching markers, leaving personal notes outside. Host-managed package installation remains subject to that host's behavior; generation tests do not certify native discovery or execution. Reinstalling an older, unmarked package is allowed only where its existing Markdown is identical. An incompatible downgrade fails before replacing installed guidance; staged upgrades retain additional -user files even when the incoming package predates managed-refresh metadata. WorkBuddy uses the previous -owned hook's interpreter substitution to recognize untouched legacy guidance across environment changes; -the complete normalized document must still match its frozen migration hash. +user files even when the incoming package predates managed-refresh metadata. An upgrade that drops a previously owned resource requires an explicit migration and currently fails before writing. This preserves edits and notes in retired resources and avoids silently reviving them as user files. diff --git a/scripts/guidance_templates/legacy.json b/scripts/guidance_templates/legacy.json deleted file mode 100644 index 553a6ec2ce..0000000000 --- a/scripts/guidance_templates/legacy.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "agent-plugin": { - "SKILL.md": "fd6aec8d2ee4e4aa522f8ecf75138b640c3d197c02401c5d3185089d7b345ac3", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "1e6310ddd4e34028eb2ad0a4e393a6ff77b552e93f568251cb7fd82f82f9bfb9", - "references/work-handoff.md": "cf3ef4386ead7653431c60faa53c2ac045acd8db92cea6a214332f5c3aaeff8b" - }, - "claude-code": { - "SKILL.md": "fd6aec8d2ee4e4aa522f8ecf75138b640c3d197c02401c5d3185089d7b345ac3", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "6858a67a67fe83164234c2cdce42e094def4e1821034816739be31635c8a2943", - "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" - }, - "codex": { - "SKILL.md": "bdb0e330d9eb38cbe4575ee94b08b3d0cb8b53c28a6443ac5b86ab3de9bfe479", - "references/experience-skills.md": "fea6f15789eec3133b1c1f61694fa5a33153392ee44050e656cecba60171418e", - "references/review-publication.md": "7fae5a77d776103f3f5fa2a406c71e4eca852941be1108da49a6f3a8985e1375", - "references/scope-memory.md": "6fac5c903ec27d4d0efbe5e18c5fc9053b940f07c57b345a6c1bb862899a9ebc", - "references/work-handoff.md": "e501146386f2f6a936d8c43d67bc172c66a46a82e1358cafb079ea6a4366156c" - }, - "hermes": { - "SKILL.md": "839e3793e43f90bb3df011acf618891ffc0c826039149c8bb7ce7d882d844eb3", - "references/review-publication.md": "32907cfbc4ec8641cfce719a2b49632fbf2194ed94af59845084c6b742faa5e1", - "references/scope-memory.md": "bece231f60b6d4655a7ee018be16afa66e8a63855fe48c5149a3fe9a820e49c4", - "references/work-handoff.md": "251fcee928765caa888fb7fe58a3a45780ea9caa391031c5d2a9a927fee3a783" - }, - "minimax": { - "SKILL.md": "7e3aafba9cdd269a7cf0421c1f235c6119353ba3edc979f8129c445d7ad941f6", - "references/http-boundaries.md": "8c0298c997591cc5cde29bddec4a5fd0a37cf53cabb1bf77660d65b6b89c1834", - "references/review-publication.md": "b75f536d185ee502d79af4a12ea11591c74a4819dd42e1550dcc1c6067c9bb98", - "references/scope-memory.md": "4bfb120e2b7b9ba1d8cc7ebe9b0338d32ad4078f13145f8506dbd3846d823689", - "references/work-handoff.md": "ef2bce44a6bf2be5a5c8961c4129d1ae0a0194a7dae9ca2249386033b8df526c" - }, - "openclaw": { - "SKILL.md": "b1cd97c975a13053b8b5a5cac7e297484bbc63ca3c6360e7270e5a5333d65703", - "references/scope-memory.md": "dc3286c614d32d60a87e5659a4e7fb205c8dbcddab3f4acc9fe65cebfb8f36f0", - "references/work-handoff.md": "48a5d8f4c7093f2d4a17236a5457c0b993ff57413797c91ad6889b775f715e78" - }, - "opencode": { - "SKILL.md": "01f42a4f0a71f3cfddeb54c5a5b0c39337c3b4670144cb46f630ae3a56b7ea7a", - "references/review-publication.md": "ba507d9eec550541876cc6cd37a6c06a72792e18813d039db0236f97f9b4ff1f", - "references/scope-memory.md": "3b48026f37925246bdb100a5f4e5a40ea1b6b281219e4d69b6524503d7c96789", - "references/work-handoff.md": "bfdfe8d08b2db2bc844b3a819205b367e1c3f5e56cc4942aa3641db0e2547599" - }, - "pi": { - "SKILL.md": "191b66dcc2aa1c533677b58f6ddca5b0018150c4fcbb37cb52e45f3948d2f9de", - "references/review-publication.md": "f3c483787bc709857b6d86067fc28be3094d8880589c93f07a1ff0dc81d94a05", - "references/scope-memory.md": "08839279b8a2aedd945274cb925891cee13f185e406b0416eb1681babd2c42d0", - "references/work-handoff.md": "dfe9bf07e8fac81c042d07d976e0bbfd66beca04853526e9a6320ced34550f47" - }, - "workbuddy": { - "SKILL.md": "fd6aec8d2ee4e4aa522f8ecf75138b640c3d197c02401c5d3185089d7b345ac3", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "6e5aab92f4a2bd2bf732470201d513c2c7b60a152ddd7980a6f8d4a6338c880d", - "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" - }, - "zcode": { - "SKILL.md": "88b6eed1a865b395dedc9508ab6cfcb15f2fc800f13bd9d5df02b963234906a1", - "references/review-publication.md": "83f3fa674d83785d06945669cbbe08b6d679665fed7e165d778dbfbcd9d10841", - "references/scope-memory.md": "0e6441569d116d76e79fc84089ce646aea7b07257eb39748068c4c835c2f68be", - "references/work-handoff.md": "76dea45b708f2155e395029afe57b31ac112d8b13d614a6ab604aa436e3d2b2e" - }, - "kimi-code": { - "SKILL.md": "45c7a0702ab3d958bd55c221db3c46e24041d2cdc0af50b76c398f68e8d5bba7" - } -} diff --git a/scripts/integration_guidance_skills.py b/scripts/integration_guidance_skills.py index 2f6fbf88fd..1a0b0d6c1e 100644 --- a/scripts/integration_guidance_skills.py +++ b/scripts/integration_guidance_skills.py @@ -87,7 +87,6 @@ def generated_guidance(root: Path = ROOT) -> dict[Path, bytes]: autoescape=select_autoescape(enabled_extensions=("html", "xml"), default_for_string=False, default=False), ) resources = json.loads((templates / "resources.json").read_text(encoding="utf-8")) - legacy = json.loads((templates / "legacy.json").read_text(encoding="utf-8")) manifest = tomllib.loads((root / "integrations/capabilities.toml").read_text(encoding="utf-8")) discovered = { path.parent.relative_to(root).as_posix() for path in (root / "integrations").glob(f"**/{ENTRY_NAME}/SKILL.md") @@ -121,12 +120,11 @@ def generated_guidance(root: Path = ROOT) -> dict[Path, bytes]: content = merge_guidance( destination.read_bytes().decode("utf-8"), content, - legacy_sha256=legacy[host].get(relative), ) output[root / descriptor.skill / relative] = content.encode("utf-8") output[root / descriptor.skill / RESOURCE_MANIFEST] = ( json.dumps( - {"schema_version": 1, "resources": sorted(paths), "legacy_sha256": legacy[host]}, + {"schema_version": 1, "resources": sorted(paths)}, sort_keys=True, indent=2, ) diff --git a/src/powercontext/cli/app.py b/src/powercontext/cli/app.py index abc8d05e6c..23df43e7b8 100644 --- a/src/powercontext/cli/app.py +++ b/src/powercontext/cli/app.py @@ -109,7 +109,11 @@ def main( raise ValueError("CLI command applications must define a name") # noqa: TRY003 if name in registered_names: raise ValueError(f"duplicate CLI command provider: {name}") # noqa: TRY003 - cli.add_typer(command, name=name) + callback = command.registered_callback.callback if command.registered_callback else None + if not command.registered_commands and not command.registered_groups and callback is not None: + cli.command(name=name, help=command.info.help, context_settings=command.info.context_settings)(callback) + else: + cli.add_typer(command, name=name) registered_names.add(name) return cli diff --git a/src/powercontext/cli/authorization.py b/src/powercontext/cli/authorization.py index ef79a064c5..9943db4453 100644 --- a/src/powercontext/cli/authorization.py +++ b/src/powercontext/cli/authorization.py @@ -92,14 +92,22 @@ def credential_path(host: str) -> Path: return root / "powercontext" / "credentials.json" +def _authorization_bytes(path: Path, *, server_url: str, value: str) -> bytes: + if path.is_symlink() or (path.exists() and not path.is_file()): + raise ValueError("credential path must be a regular file") # noqa: TRY003 + payload = { + "version": 1, + "server_url": normalize_client_url(server_url), + "authorization": normalize_authorization(value), + } + return (json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n").encode() + + def write_stored_authorization(path: Path, *, server_url: str, value: str) -> None: """Atomically persist one URL-bound authorization record.""" path = Path(path).expanduser() - if path.exists() and (path.is_symlink() or not path.is_file()): - raise ValueError("credential path must be a regular file") # noqa: TRY003 - normalized_url = normalize_client_url(server_url) - authorization = normalize_authorization(value) + content = _authorization_bytes(path, server_url=server_url, value=value) parent = path.parent parent.mkdir(parents=True, exist_ok=True) if os.name != "nt": @@ -109,14 +117,8 @@ def write_stored_authorization(path: Path, *, server_url: str, value: str) -> No try: if os.name != "nt": os.fchmod(fd, 0o600) - payload = { - "version": 1, - "server_url": normalized_url, - "authorization": authorization, - } - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(payload, handle, ensure_ascii=True, separators=(",", ":")) - handle.write("\n") + with os.fdopen(fd, "wb") as handle: + handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) @@ -160,17 +162,18 @@ def clear_stored_authorization(path: Path) -> Literal["cleared", "not_configured return "cleared" -def configure_stored_authorization(host: str, *, server_url: str, value: str | None = None) -> SetupAuthorizationStatus: - """Persist a setup-only token, or report the existing URL-bound credential state.""" - - path = credential_path(host) +def authorization_updates(connection) -> tuple[dict[Path, bytes], SetupAuthorizationStatus]: + """Validate credentials before publishing the connection and its URL binding together.""" + path = credential_path(connection.host) + value = setup_authorization_value(connection.host) if value is not None and value.strip(): - write_stored_authorization(path, server_url=server_url, value=value) - return "configured" - resolution = read_stored_authorization(path, server_url=server_url) - if resolution.status == "configured": - return "preserved" - return resolution.status + content = _authorization_bytes(path, server_url=connection.server_url, value=value) + path.parent.mkdir(parents=True, exist_ok=True) + if os.name != "nt": + path.parent.chmod(0o700) + return {path: content}, "configured" + resolution = read_stored_authorization(path, server_url=connection.server_url) + return {}, "preserved" if resolution.status == "configured" else resolution.status def setup_authorization_value(host: str) -> str | None: @@ -182,11 +185,18 @@ def setup_authorization_value(host: str) -> str | None: return values.get(_AUTHORIZATION_ENVIRONMENTS[host]) or values.get("POWERCONTEXT_CLIENT_API_TOKEN") -def setup_server_url(host: str, default: str) -> str: - """Compatibility entry point; installation resolves endpoints in setup orchestration.""" - from powercontext.cli.transport import resolve_setup_endpoint +def validate_setup_authorization(host: str) -> None: + """Reject invalid explicit credentials before changing host configuration.""" + if host in _AUTHORIZATION_ENVIRONMENTS: + value = setup_authorization_value(host) + if value is not None and value.strip(): + normalize_authorization(value) + elif host == "zcode": + from powercontext.cli.transport import setup_environment - return resolve_setup_endpoint(host, default=default) + value = (setup_environment() | dict(os.environ)).get("POWERCONTEXT_ZCODE_AUTHORIZATION") + if value and normalize_authorization(value) != value: + raise ValueError("POWERCONTEXT_ZCODE_AUTHORIZATION must be a complete Bearer header") # noqa: TRY003 def configure_codex_desktop_authorization(value: str) -> bool: @@ -258,12 +268,11 @@ def _read_windows_user_environment(name: str) -> str | None: "AuthorizationStatus", "clear_stored_authorization", "configure_codex_desktop_authorization", - "configure_stored_authorization", "credential_path", "normalize_authorization", "read_codex_desktop_authorization", "read_stored_authorization", "setup_authorization_value", - "setup_server_url", + "validate_setup_authorization", "write_stored_authorization", ] diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index bb62c720da..f18386607b 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -274,6 +274,76 @@ class WizardLanguage(StrEnum): CHINESE = "zh" +@app.command("agent") +def configure_agents( + context: typer.Context, + agents: Annotated[list[str] | None, typer.Argument(help="Installed agents to configure.")] = None, + server_url: Annotated[str | None, typer.Option(help="Server URL for the selected agents.")] = None, + allow_insecure_http: Annotated[bool | None, typer.Option("--allow-insecure-http/--no-allow-insecure-http")] = None, + capture_prompts: Annotated[ + bool | None, + typer.Option( + "--capture-prompts/--no-capture-prompts", + help="Claude Code, Kimi Code and ZCode only; omission preserves the capture preference.", + ), + ] = None, + scope_id: Annotated[str | None, typer.Option(help="Existing Scope ID for Kimi Code.")] = None, + workspace_scope: Annotated[ + bool, + typer.Option("--workspace-scope", help="Kimi Code: resolve workspace bindings, then the Server default Scope."), + ] = False, + boundary_flush: Annotated[bool | None, typer.Option("--boundary-flush/--no-boundary-flush")] = None, + profile: Annotated[str | None, typer.Option(help="DSH profile; defaults to the saved profile or web.")] = None, + dsh_command: Annotated[Path | None, typer.Option(help="DSH launcher path.")] = None, + env_file: Annotated[Path | None, typer.Option(help="Setup environment file; defaults to .env.")] = None, + json_output: Annotated[bool, typer.Option("--json")] = False, +) -> None: + """Update existing agent connections without installing or activating plugins.""" + from powercontext.cli.authorization import validate_setup_authorization + from powercontext.cli.dsh_runtime import DshProfile, resolve_dsh_target + from powercontext.cli.hosts import native_options, report_agents, select_hosts, validate_native_options + from powercontext.cli.installation import installers + from powercontext.cli.transport import prepare_setup_transport, setup_environment_file + + token = setup_environment_file.set(env_file) + context.call_on_close(lambda: setup_environment_file.reset(token)) + selected = select_hosts(agents, json_output=json_output) + validate_native_options( + selected, + capture_prompts=capture_prompts, + scope_id=scope_id, + workspace_scope=workspace_scope or None, + boundary_flush=boundary_flush, + profile=profile, + dsh_command=dsh_command, + ) + + def configure(host): + validate_setup_authorization(host) + options = native_options( + host, + capture_prompts=capture_prompts, + scope_id=scope_id, + workspace_scope=workspace_scope or None, + boundary_flush=boundary_flush, + ) + if host == "dsh": + options["dsh_target"] = resolve_dsh_target( + DshProfile(profile) if profile is not None else None, dsh_command + ) + connection = prepare_setup_transport( + host, + server_url=server_url, + allow_insecure_http=allow_insecure_http, + json_output=json_output, + dsh_target=options.get("dsh_target"), + ) + result = installers()[host](**options).configure(connection) + return {"status": "configured", "result": result} + + report_agents(selected, configure, json_output=json_output) + + @app.command("init") def init_command( output: Annotated[Path, typer.Option("--output", "-o", help="Environment file to create.")] = Path(".env"), diff --git a/src/powercontext/cli/config_wizard_agents.py b/src/powercontext/cli/config_wizard_agents.py index 1dea544311..5e79d14c8e 100644 --- a/src/powercontext/cli/config_wizard_agents.py +++ b/src/powercontext/cli/config_wizard_agents.py @@ -20,8 +20,6 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass -from powercontext.cli.hosts import FIRST_CLASS_HOSTS - @dataclass(frozen=True, slots=True) class AgentSpec: @@ -146,7 +144,7 @@ def environment_name(self, setting: str | None) -> str | None: ), } -AGENT_SPECS: tuple[AgentSpec, ...] = tuple(_HOST_METADATA[host.name] for host in FIRST_CLASS_HOSTS) +AGENT_SPECS: tuple[AgentSpec, ...] = tuple(_HOST_METADATA.values()) AGENT_SPEC_BY_ID = {spec.identifier: spec for spec in AGENT_SPECS} diff --git a/src/powercontext/cli/dsh.py b/src/powercontext/cli/dsh.py index 6039440327..0477bb0b0c 100644 --- a/src/powercontext/cli/dsh.py +++ b/src/powercontext/cli/dsh.py @@ -16,26 +16,15 @@ from __future__ import annotations -import json import os -import shutil import subprocess from dataclasses import dataclass from pathlib import Path from shutil import which from powercontext.cli.dsh_runtime import DshProfile, DshTarget, dsh_home -from powercontext.cli.git_source import ( - InvalidGitHubSourceError, - clone_github_source, -) -from powercontext.cli.git_source import ( - github_clone_url as _github_clone_url, -) -from powercontext.cli.git_source import ( - is_local_source as _is_local_source, -) from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError +from powercontext.cli.transport import configure_connection from powercontext.paths import powercontext_data_dir DSH_PLUGIN_NAME = "powercontext-dsh" @@ -44,161 +33,11 @@ DSH_BUNDLE = Path("lib") / "index.js" -@dataclass(frozen=True, slots=True) -class DshSetupResult: - plugin: str - plugin_path: str - data_dir: str - authorization_state: str = "not_attempted" - profile: str = "web" - profile_dir: str = "" - - -def install_dsh_plugin( - *, - source: str, - ref: str, - server_url: str = "http://127.0.0.1:8000", - allow_insecure_http: bool = False, - target: DshTarget | None = None, -) -> DshSetupResult: - """Install the plugin from a PowerContext checkout or Git source.""" - - profile = target.profile if target else DshProfile.WEB - executable = target.command if target and target.command else dsh_executable() - data_dir = powercontext_data_dir() - try: - data_dir.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise SetupError.data_directory(data_dir, error) from error - plugin_dir = resolve_dsh_plugin_dir(source=source, ref=ref) - require_built_plugin(plugin_dir) - from powercontext.cli.dsh_transport import read_dsh_settings, validate_dsh_setup_transport - - try: - settings = read_dsh_settings(profile=profile, candidate=plugin_dir, prospective=True, executable=executable) - validate_dsh_setup_transport(settings, server_url, allow_insecure_http) - except ValueError as error: - raise SetupError(str(error)) from error - _run_dsh("plugin", "--profile", profile, "add", str(plugin_dir), **({"executable": executable} if target else {})) - try: - settings = read_dsh_settings(profile=profile, executable=executable, require_installed=True) - validate_dsh_setup_transport(settings, server_url, allow_insecure_http) - except ValueError as error: - raise SetupError( # noqa: TRY003 - actionable host-specific readback failure. - f"DSH installation completed but its resulting configuration could not be validated: {error}. " - "Connection settings and credentials were not saved. Check the profile before restarting DSH." - ) from error - from powercontext.cli.authorization import ( - configure_stored_authorization, - setup_authorization_value, - ) - - return DshSetupResult( - plugin=DSH_PLUGIN_NAME, - plugin_path=str(plugin_dir), - data_dir=str(data_dir), - profile=profile, - profile_dir=str(dsh_home() / "profiles" / profile), - authorization_state=configure_stored_authorization( - "dsh", - server_url=server_url, - value=setup_authorization_value("dsh"), - ), - ) - - -def resolve_dsh_plugin_dir(*, source: str, ref: str) -> Path: - """Return the plugin directory for a local checkout or a materialized Git ref.""" - - if _is_local_source(source): - return plugin_dir_from_checkout(Path(source).expanduser().resolve()) - return plugin_dir_from_checkout(_materialize_remote_checkout(source, ref)) - - -def plugin_dir_from_checkout(root: Path) -> Path: - """Accept either the plugin directory or a PowerContext repository root.""" - - if _is_dsh_plugin(root): - return root - plugin = root / DSH_PLUGIN_RELATIVE - if _is_dsh_plugin(plugin): - return plugin - raise SetupError.missing_dsh_plugin(root) - - def require_built_plugin(path: Path) -> None: """Reject a plugin directory that cannot be loaded by DeepSeek Harness.""" if not (path / DSH_BUNDLE).is_file(): - raise SetupError.unbuilt_dsh_plugin(path) - - -def run_dsh_diagnostics(*, target: DshTarget | None = None) -> dict[str, Diagnostic]: - """Collect diagnostics for the optional DeepSeek Harness integration.""" - - try: - executable = target.command if target and target.command else dsh_executable() - except SetupError: - return { - "dsh": Diagnostic( - status=DiagnosticStatus.FAILED, - detail="DeepSeek Harness CLI is not installed or is not on PATH", - ), - "plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because DeepSeek Harness CLI is unavailable", - ), - } - if target and target.profile == DshProfile.DESKTOP: - return _desktop_diagnostics(target) - try: - output = _run_dsh("--profile", DSH_PROFILE, "--dump-config", **({"executable": executable} if target else {})) - except SetupError as error: - cause = error.__cause__ - checks = {"dump_config": "failed"} - detail = "dsh --profile web --dump-config failed" - if isinstance(cause, subprocess.TimeoutExpired): - checks["dump_config"] = "timeout" - detail = "dsh --profile web --dump-config exceeded the 120-second deadline" - elif isinstance(cause, subprocess.CalledProcessError): - checks["exit_code"] = str(cause.returncode) - detail = f"dsh --profile web --dump-config exited with code {cause.returncode}" - elif isinstance(cause, OSError): - checks["dump_config"] = "launch_failed" - detail = "could not launch dsh --profile web --dump-config; verify the executable and its permissions" - return { - "dsh": Diagnostic( - status=DiagnosticStatus.FAILED, - detail=( - f"{detail}; inspect the Web profile locally for configuration " - "or plugin-load errors. Raw host output is withheld because it can contain credentials." - ), - checks=checks, - ), - "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="plugin list is unavailable"), - } - installed = plugin_id_installed(output) - return { - "dsh": Diagnostic(status=DiagnosticStatus.OK, detail=executable), - "plugin": Diagnostic( - status=DiagnosticStatus.OK if installed else DiagnosticStatus.FAILED, - detail=( - f"{DSH_PLUGIN_NAME} is registered in the Web profile. This standalone check cannot observe " - "the running DSH process environment or overrides; run /pc doctor inside that session." - if installed - else "PowerContext DSH plugin is not registered in the Web profile; run powercontext setup dsh." - ), - checks={ - "registration": "present" if installed else "missing", - "running_host_configuration": "not_observed", - "server_liveness": "not_checked", - "server_readiness": "not_checked", - "route_compatibility": "not_checked", - "session_scope": "not_checked", - }, - ), - } + raise SetupError(f"PowerContext DSH plugin at {path} is missing lib/index.js. Build the plugin before setup.") # noqa: TRY003 def _desktop_diagnostics(target: DshTarget) -> dict[str, Diagnostic]: @@ -238,7 +77,7 @@ def dsh_executable() -> str: return cmd executable = which("dsh") if executable is None: - raise SetupError.dsh_unavailable() + raise SetupError("DeepSeek Harness CLI is not installed or is not on PATH.") # noqa: TRY003 return executable @@ -253,64 +92,6 @@ def plugin_id_installed(output: str) -> bool: return any(raw.strip().lstrip("-").strip() in expected for raw in output.splitlines()) -def github_clone_url(source: str) -> str: - """Accept a GitHub slug or repository URL and return a clone URL.""" - - try: - return _github_clone_url(source) - except InvalidGitHubSourceError: - raise SetupError.invalid_dsh_source() from None - - -def checkout_target(ref: str) -> Path: - """Resolve a Git ref to a directory that stays under the DSH checkout root.""" - - root = (powercontext_data_dir() / "checkouts" / "dsh").resolve() - if not ref or ref in {".", ".."} or "\x00" in ref: - raise SetupError.invalid_dsh_ref(ref) - target = (root / ref).resolve() - try: - target.relative_to(root) - except ValueError as error: - raise SetupError.invalid_dsh_ref(ref) from error - if target == root: - raise SetupError.invalid_dsh_ref(ref) - return target - - -def _is_dsh_plugin(path: Path) -> bool: - manifest = path / "package.json" - if not manifest.is_file(): - return False - try: - payload = json.loads(manifest.read_text(encoding="utf-8")) - except (OSError, ValueError): - return False - return payload.get("name") == DSH_PLUGIN_NAME - - -def _usable_checkout(target: Path) -> bool: - return _is_dsh_plugin(target) or _is_dsh_plugin(target / DSH_PLUGIN_RELATIVE) - - -def _materialize_remote_checkout(source: str, ref: str) -> Path: - target = checkout_target(ref) - if _usable_checkout(target): - return target - if target.exists(): - shutil.rmtree(target) - target.parent.mkdir(parents=True, exist_ok=True) - _clone_github_source(source, ref, target) - return target - - -def _clone_github_source(source: str, ref: str, target: Path) -> None: - try: - clone_github_source(source, ref, target) - except InvalidGitHubSourceError: - raise SetupError.invalid_dsh_source() from None - - def _run_dsh(*arguments: str, executable: str | None = None) -> str: command = [executable or dsh_executable(), *arguments] try: @@ -324,27 +105,119 @@ def _run_dsh(*arguments: str, executable: str | None = None) -> str: timeout=120, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 if completed.returncode != 0: detail = ( (completed.stderr or "").strip() or (completed.stdout or "").strip() or f"exit code {completed.returncode}" ) - raise SetupError.command_failed(command, detail) from subprocess.CalledProcessError( + raise SetupError(f"`{' '.join(command)}` failed: {detail}") from subprocess.CalledProcessError( # noqa: TRY003 completed.returncode, command ) return completed.stdout or "" -__all__ = [ - "DSH_PLUGIN_NAME", - "DshSetupResult", - "checkout_target", - "dsh_executable", - "github_clone_url", - "install_dsh_plugin", - "plugin_dir_from_checkout", - "plugin_id_installed", - "require_built_plugin", - "resolve_dsh_plugin_dir", - "run_dsh_diagnostics", -] +__all__ = ["DSH_PLUGIN_NAME", "dsh_executable", "plugin_id_installed", "require_built_plugin"] + + +@dataclass +class DshInstaller: + dsh_target: DshTarget | None = None + + def install(self, source): + target = self.dsh_target + profile = target.profile if target else DshProfile.WEB + executable = target.command if target and target.command else dsh_executable() + with source.package("dsh", linked=True) as package: + require_built_plugin(package) + _run_dsh("plugin", "--profile", profile, "add", str(package), executable=executable) + return { + "plugin": DSH_PLUGIN_NAME, + "plugin_path": str(package), + "data_dir": str(powercontext_data_dir()), + "profile": profile, + "profile_dir": str(dsh_home() / "profiles" / profile), + } + + def configure(self, connection): + from powercontext.cli.dsh_transport import read_dsh_settings, validate_dsh_setup_transport + + settings = read_dsh_settings( + profile=self.dsh_target.profile if self.dsh_target else DshProfile.WEB, + executable=self.dsh_target.command if self.dsh_target else None, + require_installed=True, + ) + validate_dsh_setup_transport(settings, connection.server_url, connection.allow_insecure_http) + return { + "authorization_state": configure_connection( + connection, dsh_target=self.dsh_target or DshTarget(DshProfile.WEB) + ) + } + + def verify(self) -> dict[str, Diagnostic]: + """Collect diagnostics for the optional DeepSeek Harness integration.""" + target = self.dsh_target + + try: + executable = target.command if target and target.command else dsh_executable() + except SetupError: + return { + "dsh": Diagnostic( + status=DiagnosticStatus.FAILED, + detail="DeepSeek Harness CLI is not installed or is not on PATH", + ), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because DeepSeek Harness CLI is unavailable", + ), + } + if target and target.profile == DshProfile.DESKTOP: + return _desktop_diagnostics(target) + try: + output = _run_dsh( + "--profile", DSH_PROFILE, "--dump-config", **({"executable": executable} if target else {}) + ) + except SetupError as error: + cause = error.__cause__ + checks = {"dump_config": "failed"} + detail = "dsh --profile web --dump-config failed" + if isinstance(cause, subprocess.TimeoutExpired): + checks["dump_config"] = "timeout" + detail = "dsh --profile web --dump-config exceeded the 120-second deadline" + elif isinstance(cause, subprocess.CalledProcessError): + checks["exit_code"] = str(cause.returncode) + detail = f"dsh --profile web --dump-config exited with code {cause.returncode}" + elif isinstance(cause, OSError): + checks["dump_config"] = "launch_failed" + detail = "could not launch dsh --profile web --dump-config; verify the executable and its permissions" + return { + "dsh": Diagnostic( + status=DiagnosticStatus.FAILED, + detail=( + f"{detail}; inspect the Web profile locally for configuration " + "or plugin-load errors. Raw host output is withheld because it can contain credentials." + ), + checks=checks, + ), + "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="plugin list is unavailable"), + } + installed = plugin_id_installed(output) + return { + "dsh": Diagnostic(status=DiagnosticStatus.OK, detail=executable), + "plugin": Diagnostic( + status=DiagnosticStatus.OK if installed else DiagnosticStatus.FAILED, + detail=( + f"{DSH_PLUGIN_NAME} is registered in the Web profile. This standalone check cannot observe " + "the running DSH process environment or overrides; run /pc doctor inside that session." + if installed + else "PowerContext DSH plugin is not registered in the Web profile; run powercontext setup dsh." + ), + checks={ + "registration": "present" if installed else "missing", + "running_host_configuration": "not_observed", + "server_liveness": "not_checked", + "server_readiness": "not_checked", + "route_compatibility": "not_checked", + "session_scope": "not_checked", + }, + ), + } diff --git a/src/powercontext/cli/dsh_config.mjs b/src/powercontext/cli/dsh_config.mjs index a39de3976d..ea0d1b5fe5 100644 --- a/src/powercontext/cli/dsh_config.mjs +++ b/src/powercontext/cli/dsh_config.mjs @@ -64,7 +64,7 @@ function installation(executable) { fail('Cannot locate the installed DSH package from its CLI; use a supported npm/pnpm DSH installation') } -async function inspect(executable, home, profile, candidate, prospective, desktopAnchor, requireInstalled) { +async function inspect(executable, home, profile, prospective, desktopAnchor, requireInstalled) { const anchor = desktopAnchor || installation(executable) if (desktopAnchor) { const manifest = read(anchor, () => JSON.parse(readFileSync(anchor, 'utf8'))) @@ -88,60 +88,11 @@ async function inspect(executable, home, profile, candidate, prospective, deskto ? read(manifestPath, () => boot.readProfileManifest('powercontext', dir)) : undefined const template = boot.PROFILE_TEMPLATES?.[profile] - let bundles = manifest?.dsh?.profile?.bundles ?? (manifest ? [] : template?.bundles ?? template) + const bundles = manifest?.dsh?.profile?.bundles ?? (manifest ? [] : template?.bundles ?? template) if (!Array.isArray(bundles) || bundles.some(name => typeof name !== 'string')) { fail('Cannot determine the DSH profile bundle list', manifestPath) } if (requireInstalled && !bundles.includes(plugin)) fail('PowerContext is not enabled in this profile', manifestPath) - let candidateBundle = candidate - if (candidate) { - // Both native reconciliation and composition prefer installation-owned - // packages. The native resolver also handles packages hiding package.json. - try { candidateBundle = boot.resolveBundleDir('powercontext', plugin, anchor, dirname(anchor)) } - catch { /* The candidate supplies the profile-owned package. */ } - } - // With a materialized candidate, project the same direct-dependency - // reconciliation performed by the selected CLI. Doctor reads current bundles. - if (candidate) { - const dependencies = manifest?.dependencies ?? {} - if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) { - fail('Cannot determine the DSH profile dependencies', manifestPath) - } - const before = new Set(Object.keys(dependencies)) - const after = [...new Set([...before, plugin])] - // pnpm sorts dependency keys when saving a changed manifest. A no-op add - // leaves its original bytes and ordering intact. - const spec = 'link:' + resolve(candidate).replaceAll('\\', '/') - if (dependencies[plugin] !== spec) after.sort() - const cli = read(anchor, () => JSON.parse(readFileSync(anchor, 'utf8'))) - const preservesInactive = !!cli.dependencies?.['@deepseek-ai/dsh-plugin-manager'] - const declarations = new Map() - function declaresBundle(name) { - if (declarations.has(name)) return declarations.get(name) - let packageDir - if (name === plugin) packageDir = candidateBundle - else { - try { packageDir = boot.resolveBundleDir('powercontext', name, anchor, dir) } - catch { declarations.set(name, false); return false } - } - const file = join(packageDir, 'package.json') - const metadata = read(file, () => boot.readProfileManifest('powercontext', packageDir)) - const declared = metadata.dsh?.bundle?.patch !== undefined - declarations.set(name, declared) - return declared - } - const managed = new Set(after) - bundles = bundles.filter(name => !managed.has(name) || declaresBundle(name)) - for (const name of after) { - // Newer CLI operations preserve an already-installed inactive dependency; - // legacy CLI reconciliation enables every dependency declaring a patch. - if (preservesInactive && before.has(name)) continue - if (!bundles.includes(name) && declaresBundle(name)) bundles.push(name) - } - if (!bundles.includes(plugin)) { - fail('DSH installation would leave PowerContext disabled; enable its bundle in this profile before setup', manifestPath) - } - } const exemptions = typeof boot.readProfileVersionExemptions === 'function' ? read(dir, () => boot.readProfileVersionExemptions(dir)) : {} const skippedBundles = [] @@ -170,16 +121,11 @@ async function inspect(executable, home, profile, candidate, prospective, deskto return files.flatMap(path => read(path, () => boot.loadOverlayPatches('powercontext', path))) } const layers = bundles.map(name => { - if (name === plugin && candidate) return bundle(name, candidateBundle) const packageDir = read(manifestPath, () => boot.resolveBundleDir('powercontext', name, anchor, dir)) return bundle(name, packageDir) }) - if (!bundles.includes(plugin)) { - if (candidate) layers.push(bundle(plugin, candidate)) - // Before materializing the source, expose the future plugin id to user - // patches. The installer rechecks with the actual candidate before add. - else if (prospective) layers.push([{ insert: [{ id: plugin, name: plugin, config: {} }] }]) - } + // Expose the future plugin id to user patches before native installation. + if (!bundles.includes(plugin) && prospective) layers.push([{ insert: [{ id: plugin, name: plugin, config: {} }] }]) const userFiles = [join(dir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')] for (const file of userFiles) { if (existsSync(file)) layers.push(read(file, () => boot.loadOverlayPatches('powercontext', file))) @@ -273,7 +219,7 @@ async function inspect(executable, home, profile, candidate, prospective, deskto } await visit(rows) if (matches.length > 1) fail('Multiple PowerContext entries make the DSH transport ambiguous', dir) - if (!matches.length && (requireInstalled || candidate || prospective || bundles.includes(plugin))) { + if (!matches.length && (requireInstalled || prospective || bundles.includes(plugin))) { fail('The composed DSH profile does not contain PowerContext', dir) } const config = matches[0]?.config ?? {} @@ -292,8 +238,8 @@ async function inspect(executable, home, profile, candidate, prospective, deskto } try { - const [executable, home, profile, candidate, prospective, desktopAnchor, requireInstalled] = process.argv.slice(2) - const result = await inspect(resolve(executable), resolve(home), profile, candidate || undefined, prospective === 'true', desktopAnchor, requireInstalled === 'true') + const [executable, home, profile, prospective, desktopAnchor, requireInstalled] = process.argv.slice(2) + const result = await inspect(resolve(executable), resolve(home), profile, prospective === 'true', desktopAnchor, requireInstalled === 'true') process.stdout.write(JSON.stringify(result)) } catch (error) { // Native parser messages and stacks can include credentials or whole YAML rows. diff --git a/src/powercontext/cli/dsh_runtime.py b/src/powercontext/cli/dsh_runtime.py index a467025a94..92f9bd6104 100644 --- a/src/powercontext/cli/dsh_runtime.py +++ b/src/powercontext/cli/dsh_runtime.py @@ -75,8 +75,14 @@ def directory(self) -> Path: return dsh_home() / "profiles" / self.profile -def resolve_dsh_target(profile: DshProfile, command: Path | None = None) -> DshTarget: - """Resolve an explicit command or find the selected carrier on PATH.""" +def resolve_dsh_target(profile: DshProfile | None = None, command: Path | None = None) -> DshTarget: + """Resolve the requested or saved profile and its native carrier.""" + from powercontext.client.transport_policy import load_client_settings + + saved = load_client_settings("dsh") + profile = profile or DshProfile(saved.get("profile", "web")) + if command is None and saved.get("profile") == profile and saved.get("dsh_command"): + command = Path(saved["dsh_command"]) from powercontext.cli.system import SetupError if command is not None: diff --git a/src/powercontext/cli/dsh_transport.py b/src/powercontext/cli/dsh_transport.py index 9571d8d630..d2e8d204e3 100644 --- a/src/powercontext/cli/dsh_transport.py +++ b/src/powercontext/cli/dsh_transport.py @@ -32,30 +32,24 @@ def read_dsh_settings( *, profile: str = "web", - candidate: Path | None = None, prospective: bool = False, executable: str | None = None, require_installed: bool = False, ) -> dict[str, Any]: - """Compose the installed host's layers; optionally substitute the installation candidate.""" + """Compose the installed host's current or prospective configuration.""" from powercontext.cli.dsh import dsh_executable from powercontext.cli.system import SetupError home = dsh_home() if Path(profile).name != profile or profile in {".", ".."} or "\\" in profile: raise ValueError("Cannot inspect an invalid DSH profile name") # noqa: TRY003 - # An absent profile with no patches has no native transport override. The - # candidate check always composes the host's default bundles before install. - if ( - candidate is None - and not require_installed - and not any( - path.exists() - for path in ( - home / "profiles" / profile / "package.json", - home / "profiles" / profile / "cordis.patch.yml", - home / "cordis.patch.yml", - ) + # An absent profile with no patches has no native transport override. + if not require_installed and not any( + path.exists() + for path in ( + home / "profiles" / profile / "package.json", + home / "profiles" / profile / "cordis.patch.yml", + home / "cordis.patch.yml", ) ): return {} @@ -74,7 +68,6 @@ def read_dsh_settings( executable, str(home), profile, - str(candidate) if candidate else "", str(prospective).lower(), anchor, str(require_installed).lower(), diff --git a/src/powercontext/cli/git_source.py b/src/powercontext/cli/git_source.py index 86db5db2fa..fee364faa2 100644 --- a/src/powercontext/cli/git_source.py +++ b/src/powercontext/cli/git_source.py @@ -16,11 +16,14 @@ from __future__ import annotations +import shutil import subprocess from pathlib import Path from urllib.parse import urlsplit +from uuid import uuid4 -from powercontext.cli.system import SetupError +from powercontext.cli.installation import SetupError +from powercontext.paths import powercontext_data_dir _GITHUB_HOST = "github.com" @@ -67,9 +70,9 @@ def clone_github_source(source: str, ref: str, target: Path) -> None: timeout=120, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.git_clone_failed() from error + raise SetupError("failed to clone the GitHub source") from error # noqa: TRY003 if completed.returncode != 0: - raise SetupError.git_clone_failed() + raise SetupError("failed to clone the GitHub source") # noqa: TRY003 def _repository_path(value: str) -> str: @@ -80,3 +83,34 @@ def _repository_path(value: str) -> str: if len(parts) != 2 or any(not part or any(character.isspace() for character in part) for part in parts): raise InvalidGitHubSourceError return f"{parts[0]}/{parts[1]}.git" + + +def source_checkout(source: str, ref: str) -> Path: + """Keep a separate checkout for each installation, including linked packages.""" + if is_local_source(source): + return Path(source).expanduser().resolve() + try: + github_clone_url(source) + except InvalidGitHubSourceError: + raise SetupError("invalid GitHub source; use a local path or an HTTPS/SSH GitHub repository") from None # noqa: TRY003 + parent = powercontext_data_dir() / "checkouts" + parent.mkdir(parents=True, exist_ok=True) + target = parent / uuid4().hex + try: + clone_github_source(source, ref, target) + except BaseException: + shutil.rmtree(target, ignore_errors=True) + raise + return target + + +def resolve_plugin_source(source: str, ref: str, host: str) -> Path: + """Locate a native package in a checkout, leaving installation to its host.""" + from powercontext.cli.guidance import HOST_GUIDANCE + + root = source_checkout(source, ref) + descriptor = HOST_GUIDANCE[host] + for candidate in (root, root / descriptor.plugin): + if descriptor.recognizes(candidate): + return candidate + raise SetupError(f"PowerContext {host} plugin was not found in source") # noqa: TRY003 diff --git a/src/powercontext/cli/guidance.py b/src/powercontext/cli/guidance.py index a5cb2d6e09..57606c8461 100644 --- a/src/powercontext/cli/guidance.py +++ b/src/powercontext/cli/guidance.py @@ -16,7 +16,6 @@ from __future__ import annotations -import hashlib import json import re import shutil @@ -38,16 +37,23 @@ class HostGuidance: """Distribution facts; supported capabilities are read from capabilities.toml.""" plugin: str - installer: str | None = None - server_default: str | None = None - server_option: bool = True - empty_server_uses_default: bool = True - capture_prompts: bool = False - allow_insecure_http: bool = False - target_option: bool = False + source_files: tuple[str, ...] = ("package.json",) + package_name: str | None = None variables: tuple[str, ...] = () script_evidence: tuple[str, ...] = () + def recognizes(self, path: Path) -> bool: + """Identify a host package without requiring optional build output.""" + if not all((path / name).is_file() for name in self.source_files): + return False + if self.package_name is None: + return True + try: + manifest = json.loads((path / self.source_files[0]).read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + return isinstance(manifest, dict) and manifest.get("name") == self.package_name + @property def skill(self) -> Path: return Path(self.plugin) / SKILL_DIRECTORY @@ -56,66 +62,52 @@ def skill(self) -> Path: HOST_GUIDANCE = { "codex": HostGuidance( "integrations/codex/plugins/powercontext", - "powercontext.cli.system:install_codex_plugin", - empty_server_uses_default=False, variables=("PLUGIN_ROOT",), script_evidence=("scripts/scope_binding.py",), ), "claude-code": HostGuidance( "integrations/claude-code/plugins/powercontext", - "powercontext.cli.system:install_claude_code_plugin", - server_default="http://127.0.0.1:8000", - empty_server_uses_default=False, - capture_prompts=True, - allow_insecure_http=True, variables=("PLUGIN_ROOT",), script_evidence=("scripts/workspace_scope.py",), ), "dsh": HostGuidance( "integrations/dsh/plugins/powercontext", - "powercontext.cli.dsh:install_dsh_plugin", - server_default="http://127.0.0.1:8000", - allow_insecure_http=True, - target_option=True, + package_name="powercontext-dsh", ), "openclaw": HostGuidance( "integrations/openclaw/plugins/memory-powercontext", - "powercontext.cli.openclaw:install_openclaw_plugin", - server_default="http://127.0.0.1:8000", - empty_server_uses_default=False, - allow_insecure_http=True, + package_name="@oceanbase/openclaw-memory-powercontext", ), "opencode": HostGuidance( "integrations/opencode/plugins/powercontext", - "powercontext.cli.opencode:install_opencode_plugin", - server_default="http://127.0.0.1:8000", + package_name="powercontext-opencode", ), "pi": HostGuidance( "integrations/pi/plugins/powercontext", - "powercontext.cli.pi:install_pi_plugin", - server_default="http://127.0.0.1:8000", + package_name="powercontext-pi", ), "hermes": HostGuidance( "integrations/hermes/plugins/powercontext", - "powercontext.cli.hermes:install_hermes_plugin", - server_option=False, + source_files=("plugin.yaml", "__init__.py"), ), "workbuddy": HostGuidance( "integrations/workbuddy/plugins/powercontext", - "powercontext.cli.workbuddy:install_workbuddy_plugin", - empty_server_uses_default=False, variables=("POWERCONTEXT_PYTHON", "POWERCONTEXT_SCOPE_BINDING_SCRIPT"), script_evidence=("scripts/workspace_scope.py",), + source_files=("hooks/workbuddy_powercontext_hook.py", "scripts/workspace_scope.py"), ), "zcode": HostGuidance( "integrations/zcode/plugins/powercontext", - "powercontext.cli.zcode:install_zcode_plugin", - server_default="http://127.0.0.1:8000", - capture_prompts=True, - allow_insecure_http=True, script_evidence=("scripts/scope.mjs",), + source_files=(".zcode-plugin/plugin.json", "hooks/user_prompt_submit.mjs"), + package_name="powercontext", + ), + "kimi-code": HostGuidance( + "integrations/kimi-code/plugins/powercontext", + script_evidence=("scripts/scope.mjs",), + source_files=("kimi.plugin.json",), + package_name="powercontext", ), - "kimi-code": HostGuidance("integrations/kimi-code/plugins/powercontext", script_evidence=("scripts/scope.mjs",)), "agent-plugin": HostGuidance("integrations/agent-plugin/powercontext"), "minimax": HostGuidance("integrations/minimax/plugins/powercontext"), } @@ -140,7 +132,7 @@ def _resource_name(value: object) -> str: return value -def _resource_inventory(directory: Path) -> tuple[set[str], dict[str, str]] | None: +def _resource_inventory(directory: Path) -> set[str] | None: manifest = directory / RESOURCE_MANIFEST if not manifest.exists(): return None @@ -152,23 +144,13 @@ def _resource_inventory(directory: Path) -> tuple[set[str], dict[str, str]] | No raise GuidanceError("invalid guidance manifest") from error # noqa: TRY003 if not isinstance(payload, dict): raise GuidanceError("invalid guidance manifest") # noqa: TRY003 - if "schema_version" in payload: - resources, legacy = payload.get("resources"), payload.get("legacy_sha256") - if payload["schema_version"] != 1 or not isinstance(resources, list) or not isinstance(legacy, dict): - raise GuidanceError("unsupported guidance manifest schema") # noqa: TRY003 - else: - # Initial generated packages used the frozen migration map as their inventory. - resources, legacy = list(payload), payload + resources = payload.get("resources") + if payload.get("schema_version") != 1 or not isinstance(resources, list): + raise GuidanceError("unsupported guidance manifest schema") # noqa: TRY003 names = {_resource_name(name) for name in resources} if "SKILL.md" not in names or len(names) != len(resources): raise GuidanceError("guidance manifest needs a unique resource inventory including SKILL.md") # noqa: TRY003 - hashes = {} - for name, digest in legacy.items(): - key = _resource_name(name) - if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None: - raise GuidanceError("invalid legacy hash in guidance manifest") # noqa: TRY003 - hashes[key] = digest - return names, hashes + return names def _linked_resources(directory: Path, *, require_files: bool) -> set[str]: @@ -200,15 +182,15 @@ def _linked_resources(directory: Path, *, require_files: bool) -> set[str]: return seen -def _package_resources(directory: Path) -> tuple[set[str], dict[str, str]]: +def _package_resources(directory: Path) -> set[str]: inventory = _resource_inventory(directory) linked = _linked_resources(directory, require_files=True) if inventory is None: for path in directory.rglob("*.md"): if guidance_region(path.read_text(encoding="utf-8")) is not None: raise GuidanceError("managed guidance is missing its resource manifest") # noqa: TRY003 - return linked, {} - names, legacy = inventory + return linked + names = inventory packaged = { path.relative_to(directory).as_posix() for path in directory.rglob("*") @@ -222,7 +204,7 @@ def _package_resources(directory: Path) -> tuple[set[str], dict[str, str]]: path = directory / name if not path.is_file() or not path.resolve().is_relative_to(directory.resolve()): raise GuidanceError(f"missing or out-of-package guidance resource: {name}") # noqa: TRY003 - return names, legacy + return names def guidance_region(content: str) -> tuple[int, int] | None: @@ -237,16 +219,15 @@ def guidance_region(content: str) -> tuple[int, int] | None: return content.index(marker), content.index(END) + len(END) -def merge_guidance(existing: str, generated: str, *, legacy_sha256: str | None = None) -> str: - """Replace only managed bytes; recognize pristine pre-marker packages on upgrade.""" +def merge_guidance(existing: str, generated: str) -> str: + """Replace only managed bytes; preserve content outside ownership markers.""" desired = guidance_region(generated) if desired is None: message = "generated guidance has no managed region" raise GuidanceError(message) current = guidance_region(existing) if current is None: - digest = hashlib.sha256(existing.replace("\r\n", "\n").encode("utf-8")).hexdigest() - if not existing or digest == legacy_sha256: + if not existing: return generated message = "unmarked guidance contains unrecognized content; add ownership markers before refreshing" raise GuidanceError(message) @@ -266,10 +247,8 @@ def _render_installed_markdown( content: bytes, current: Path, replacements: dict[str, str], - legacy_hash: str | None, - legacy_replacements: dict[str, str], ) -> bytes: - """Expand installation paths and safely migrate or refresh one Markdown resource.""" + """Expand installation paths and refresh one Markdown resource.""" generated = content.decode("utf-8").replace("\r\n", "\n") for placeholder, replacement in replacements.items(): generated = generated.replace(placeholder, replacement) @@ -280,26 +259,15 @@ def _render_installed_markdown( return existing.encode("utf-8") message = "incoming unmarked guidance cannot safely replace existing content" raise GuidanceError(message) - for substitutions in (legacy_replacements, replacements): - unexpanded = existing - for placeholder, replacement in substitutions.items(): - unexpanded = unexpanded.replace(replacement, placeholder) - if hashlib.sha256(unexpanded.replace("\r\n", "\n").encode("utf-8")).hexdigest() == legacy_hash: - legacy_hash = hashlib.sha256(existing.replace("\r\n", "\n").encode("utf-8")).hexdigest() - break - generated = merge_guidance( - existing, - generated, - legacy_sha256=legacy_hash, - ) + generated = merge_guidance(existing, generated) return generated.encode("utf-8") -def _validate_resource_transition(source: Path, previous: Path, resources: set[str], legacy: dict[str, str]) -> None: +def _validate_resource_transition(source: Path, previous: Path, resources: set[str]) -> None: """Do not promote retired or ambiguously owned package files into user content.""" if previous.is_dir(): prior = _resource_inventory(previous) - owned = prior[0] if prior is not None else _linked_resources(previous, require_files=False) | set(legacy) + owned = prior if prior is not None else _linked_resources(previous, require_files=False) removed = {name for name in owned - resources if (previous / name).is_file()} if removed: message = f"removing owned guidance requires an explicit migration: {sorted(removed)}" @@ -319,17 +287,16 @@ def refresh_skill( *, replacements: dict[str, str] | None = None, previous: Path | None = None, - legacy_replacements: dict[str, str] | None = None, ) -> None: """Refresh packaged resources without deleting user files or changing unchanged mtimes. - Plan every merge first, so malformed markers or an edited legacy file cannot + Plan every merge first, so malformed markers or unrecognized user content cannot leave a partially refreshed Skill. Native host installation remains separate. """ - resources, legacy = _package_resources(source) + resources = _package_resources(source) writes: dict[Path, bytes] = {} previous = previous or target - _validate_resource_transition(source, previous, resources, legacy) + _validate_resource_transition(source, previous, resources) for path in source.rglob("*"): if not path.is_file(): continue @@ -345,9 +312,7 @@ def refresh_skill( raise GuidanceError(message) content = path.read_bytes() if path.suffix == ".md": - content = _render_installed_markdown( - content, current, replacements or {}, legacy.get(relative.as_posix()), legacy_replacements or {} - ) + content = _render_installed_markdown(content, current, replacements or {}) writes[destination] = content for destination, content in writes.items(): old = previous / destination.relative_to(target) diff --git a/src/powercontext/cli/hermes.py b/src/powercontext/cli/hermes.py index 58add14d63..8dff7df88f 100644 --- a/src/powercontext/cli/hermes.py +++ b/src/powercontext/cli/hermes.py @@ -16,19 +16,22 @@ from __future__ import annotations -import hashlib +import json import os import re import shutil import subprocess import tempfile -import uuid -from dataclasses import dataclass from pathlib import Path from shutil import which +from typing import Any -from powercontext.cli.guidance import HOST_GUIDANCE, GuidanceError, preserve_installed_guidance +import yaml + +from powercontext.cli.guidance import HOST_GUIDANCE, preserve_installed_guidance +from powercontext.cli.installation import publish from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError +from powercontext.cli.transport import setup_transport_updates from powercontext.paths import powercontext_data_dir HERMES_HOME_ENV = "HERMES_HOME" @@ -40,226 +43,6 @@ _HERMES_VERSION_PATTERN = re.compile(r"Hermes Agent v?(\d+)\.(\d+)\.(\d+)") -@dataclass(frozen=True, slots=True) -class HermesSetupResult: - plugin: str - plugin_path: str - hermes_home: str - data_dir: str - command_plugin_path: str - - -def install_hermes_plugin(*, source: str, ref: str) -> HermesSetupResult: - """Install the provider and its early slash-command companion.""" - - executable = hermes_executable() - get_hermes_version(executable) - data_dir = powercontext_data_dir() - try: - data_dir.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise SetupError.data_directory(data_dir, error) from error - - plugin_dir, command_plugin_dir = resolve_hermes_plugin_dirs(source=source, ref=ref) - home = hermes_home() - plugins_root = home / "plugins" - target = plugins_root / HERMES_PLUGIN_NAME - command_target = plugins_root / HERMES_COMMAND_PLUGIN_NAME - try: - _install_hermes_plugin_pair( - executable=executable, - plugins_root=plugins_root, - source_dirs=((plugin_dir, target), (command_plugin_dir, command_target)), - ) - except OSError as error: - raise SetupError.hermes_plugin_write(target, error) from error - except GuidanceError as error: - raise SetupError(str(error)) from error - - return HermesSetupResult( - plugin=HERMES_PLUGIN_NAME, - plugin_path=str(target), - hermes_home=str(home), - data_dir=str(data_dir), - command_plugin_path=str(command_target), - ) - - -def _install_hermes_plugin_pair( - *, - executable: str, - plugins_root: Path, - source_dirs: tuple[tuple[Path, Path], ...], -) -> None: - """Stage, validate, and transactionally install the Hermes plugin pair.""" - - plugins_root.mkdir(parents=True, exist_ok=True) - staged = _stage_hermes_plugins(executable, source_dirs) - try: - _commit_hermes_plugins(executable, staged) - finally: - for staging, _target in staged: - _remove_path(staging) - - -def _stage_hermes_plugins( - executable: str, - source_dirs: tuple[tuple[Path, Path], ...], -) -> list[tuple[Path, Path]]: - staged: list[tuple[Path, Path]] = [] - try: - for source_dir, target in source_dirs: - staging = _new_staging_directory(target) - staged.append((staging, target)) - _remove_path(staging) - shutil.copytree(source_dir, staging) - preserve_installed_guidance(target, staging) - _run_plugin_doctor(executable, staging) - except BaseException: - for staging, _target in staged: - _remove_path(staging) - raise - return staged - - -def _commit_hermes_plugins(executable: str, staged: list[tuple[Path, Path]]) -> None: - backups: list[tuple[Path, Path]] = [] - installed: list[Path] = [] - try: - for staging, target in staged: - backup = _backup_directory(target) - if backup is not None: - backups.append((target, backup)) - os.replace(staging, target) - installed.append(target) - _enable_hermes_plugin(executable, HERMES_COMMAND_PLUGIN_NAME) - except BaseException: - for target in reversed(installed): - _remove_path(target) - for target, backup in reversed(backups): - if _path_exists(backup): - os.replace(backup, target) - raise - else: - for _target, backup in backups: - _remove_path(backup) - - -def resolve_hermes_plugin_dir(*, source: str, ref: str) -> Path: - """Return the Hermes provider directory from a local or remote checkout.""" - - if _is_local_source(source): - return plugin_dir_from_checkout(Path(source).expanduser().resolve()) - return plugin_dir_from_checkout(_materialize_remote_checkout(source, ref)) - - -def resolve_hermes_plugin_dirs(*, source: str, ref: str) -> tuple[Path, Path]: - """Return the provider and standalone command plugin directories.""" - - if _is_local_source(source): - root = Path(source).expanduser().resolve() - else: - root = _materialize_remote_checkout(source, ref) - return plugin_dirs_from_checkout(root) - - -def plugin_dir_from_checkout(root: Path) -> Path: - """Accept either the provider directory or a PowerContext repository root.""" - - if _is_hermes_plugin(root): - return root - plugin = root / HERMES_PLUGIN_RELATIVE - if _is_hermes_plugin(plugin): - return plugin - raise SetupError.missing_hermes_plugin(root) - - -def plugin_dirs_from_checkout(root: Path) -> tuple[Path, Path]: - """Resolve both Hermes plugin directories from a repository checkout.""" - - provider = plugin_dir_from_checkout(root) - command = provider.parent / HERMES_COMMAND_PLUGIN_NAME - if not _is_hermes_plugin(command): - raise SetupError.missing_hermes_plugin(root) - return provider, command - - -def run_hermes_diagnostics() -> dict[str, Diagnostic]: - """Collect diagnostics for the optional Hermes integration.""" - - try: - executable = hermes_executable() - except SetupError: - return { - "hermes": Diagnostic( - status=DiagnosticStatus.FAILED, - detail="Hermes CLI is not installed or is not on PATH", - ), - "plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because Hermes CLI is unavailable", - ), - } - - try: - hermes_version = get_hermes_version(executable) - except SetupError as error: - return { - "hermes": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), - "plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because Hermes version validation failed", - ), - } - - plugin = hermes_home() / "plugins" / HERMES_PLUGIN_NAME - command_plugin = hermes_home() / "plugins" / HERMES_COMMAND_PLUGIN_NAME - if not _is_hermes_plugin(plugin): - return { - "hermes": Diagnostic(status=DiagnosticStatus.OK, detail=f"{executable} (Hermes Agent v{hermes_version})"), - "plugin": Diagnostic( - status=DiagnosticStatus.FAILED, - detail="PowerContext Hermes plugin is not installed", - ), - "command_plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because the PowerContext memory provider is not installed", - ), - } - - try: - _run_plugin_doctor(executable, plugin) - except SetupError as error: - plugin_diagnostic = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) - else: - plugin_diagnostic = Diagnostic( - status=DiagnosticStatus.OK, - detail="powercontext passed Hermes plugin doctor", - ) - - if not _is_hermes_plugin(command_plugin): - command_diagnostic = Diagnostic( - status=DiagnosticStatus.FAILED, - detail="PowerContext Hermes command companion is not installed", - ) - else: - try: - _run_plugin_doctor(executable, command_plugin) - except SetupError as error: - command_diagnostic = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) - else: - command_diagnostic = Diagnostic( - status=DiagnosticStatus.OK, - detail="powercontext-command passed Hermes plugin doctor", - ) - - return { - "hermes": Diagnostic(status=DiagnosticStatus.OK, detail=f"{executable} (Hermes Agent v{hermes_version})"), - "plugin": plugin_diagnostic, - "command_plugin": command_diagnostic, - } - - def hermes_home() -> Path: """Return Hermes' user home, honoring the host's environment override.""" @@ -272,80 +55,10 @@ def hermes_executable() -> str: executable = which("hermes") if executable is None: - raise SetupError.hermes_unavailable() + raise SetupError("Hermes CLI is not installed or is not on PATH.") # noqa: TRY003 return executable -def checkout_target(source: str, ref: str) -> Path: - """Resolve a source and Git ref to a directory under the Hermes cache.""" - - root = (powercontext_data_dir() / "checkouts" / "hermes").resolve() - if not ref or ref in {".", ".."} or "\x00" in ref: - raise SetupError.invalid_hermes_ref(ref) - source_key = _source_cache_key(source) - target = (root / source_key / ref).resolve() - try: - target.relative_to(root) - except ValueError as error: - raise SetupError.invalid_hermes_ref(ref) from error - if target == root: - raise SetupError.invalid_hermes_ref(ref) - return target - - -def github_clone_url(source: str) -> str: - """Accept a GitHub slug or repository URL and return a clone URL.""" - - text = source.strip() - if text.startswith(("https://github.com/", "http://github.com/", "git@github.com:")): - return text if text.endswith(".git") else f"{text}.git" - if "://" in text or text.startswith("git@"): - raise SetupError.invalid_hermes_source(source) - if "/" in text and not text.startswith("."): - return f"https://github.com/{text}.git" - raise SetupError.invalid_hermes_source(source) - - -def _is_local_source(source: str) -> bool: - candidate = Path(source).expanduser() - return source.startswith((".", "/", "~")) or (len(source) >= 2 and source[1] == ":") or candidate.exists() - - -def _is_hermes_plugin(path: Path) -> bool: - return (path / "__init__.py").is_file() and (path / "plugin.yaml").is_file() - - -def _materialize_remote_checkout(source: str, ref: str) -> Path: - target = checkout_target(source, ref) - target.parent.mkdir(parents=True, exist_ok=True) - staging = _new_staging_directory(target) - shutil.rmtree(staging) - try: - _clone_github_source(source, ref, staging) - _replace_directory(staging, target) - except BaseException: - _remove_path(staging) - raise - return target - - -def _clone_github_source(source: str, ref: str, target: Path) -> None: - command = ["git", "clone", "--depth", "1", "--branch", ref, github_clone_url(source), str(target)] - try: - completed = subprocess.run( # noqa: S603 - arguments are passed directly to git. - command, - check=False, - capture_output=True, - text=True, - timeout=120, - ) - except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error - if completed.returncode != 0: - detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" - raise SetupError.command_failed(command, detail) - - def get_hermes_version(executable: str | None = None) -> str: """Return the Hermes version and reject versions below the supported minimum.""" @@ -354,15 +67,17 @@ def get_hermes_version(executable: str | None = None) -> str: completed = _run_hermes_command(command) if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" - raise SetupError.command_failed(command, detail) + raise SetupError(f"`{' '.join(command)}` failed: {detail}") # noqa: TRY003 match = _HERMES_VERSION_PATTERN.search(f"{completed.stdout}\n{completed.stderr}") if match is None: - raise SetupError.invalid_command_output(command, "an unrecognized Hermes version") + raise SetupError(f"`{' '.join(command)}` returned an unrecognized Hermes version") # noqa: TRY003 parsed = tuple(int(part) for part in match.groups()) if parsed < HERMES_MIN_VERSION: actual = ".".join(str(part) for part in parsed) minimum = ".".join(str(part) for part in HERMES_MIN_VERSION) - raise SetupError.unsupported_hermes_version(actual, minimum) + raise SetupError( # noqa: TRY003 + f"Hermes Agent v{actual} is unsupported; PowerContext requires Hermes Agent v{minimum} or newer." + ) return ".".join(str(part) for part in parsed) @@ -371,17 +86,7 @@ def _run_plugin_doctor(executable: str, plugin: Path) -> None: completed = _run_hermes_command(command) if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" - raise SetupError.command_failed(command, detail) - - -def _enable_hermes_plugin(executable: str, name: str) -> None: - """Enable an installed standalone plugin without granting tool overrides.""" - - command = [executable, "plugins", "enable", name, "--no-allow-tool-override"] - completed = _run_hermes_command(command) - if completed.returncode != 0: - detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" - raise SetupError.command_failed(command, detail) + raise SetupError(f"`{' '.join(command)}` failed: {detail}") # noqa: TRY003 def _run_hermes_command(command: list[str]) -> subprocess.CompletedProcess[str]: @@ -396,68 +101,181 @@ def _run_hermes_command(command: list[str]) -> subprocess.CompletedProcess[str]: timeout=30, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error - + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 -def _source_cache_key(source: str) -> str: - normalized = github_clone_url(source) - return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] - -def _new_staging_directory(target: Path) -> Path: - return Path(tempfile.mkdtemp(prefix=f".{target.name}-", dir=target.parent)) +__all__ = [ + "HERMES_COMMAND_PLUGIN_NAME", + "HERMES_COMMAND_PLUGIN_RELATIVE", + "HERMES_PLUGIN_NAME", + "get_hermes_version", + "hermes_executable", + "hermes_home", +] -def _backup_directory(target: Path) -> Path | None: - if not _path_exists(target): - return None - backup = target.with_name(f".{target.name}.backup-{uuid.uuid4().hex}") - os.replace(target, backup) - return backup +def hermes_transport_updates(settings) -> dict[Path, bytes]: + configured = os.environ.get("POWERCONTEXT_HERMES_CONFIG") + path = Path(configured) if configured else hermes_home() / "powercontext/config.json" + config = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + if not isinstance(config, dict): + raise ValueError("Hermes client configuration must be an object") # noqa: TRY003, TRY004 + config.update(base_url=settings.server_url, allow_insecure_http=settings.allow_insecure_http) + return {path: (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode()} -def _replace_directory(staging: Path, target: Path) -> None: - backup = target.with_name(f".{target.name}.backup-{uuid.uuid4().hex}") - moved_old = False +def _hermes_config() -> dict[str, Any]: + path = hermes_home() / "config.yaml" try: - if _path_exists(target): - os.replace(target, backup) - moved_old = True - os.replace(staging, target) - except BaseException: - if moved_old and not _path_exists(target) and _path_exists(backup): - os.replace(backup, target) - raise - finally: - _remove_path(staging) - _remove_path(backup) + value = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else {} + if not isinstance(value, dict): + raise ValueError # noqa: TRY004, TRY301 - normalize invalid native configuration. + except (OSError, ValueError, yaml.YAMLError) as error: + raise SetupError("Cannot read Hermes native configuration") from error # noqa: TRY003 + return value -def _path_exists(path: Path) -> bool: - return path.exists() or path.is_symlink() +class HermesInstaller: + def install(self, source): + executable = hermes_executable() + get_hermes_version(executable) + with source.package("hermes") as package: + command = package.parent / HERMES_COMMAND_PLUGIN_NAME + if not (command / "plugin.yaml").is_file(): + raise SetupError(f"PowerContext Hermes plugin was not found under {command}.") # noqa: TRY003 + root = hermes_home() / "plugins" + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".powercontext-", dir=root) as temporary: + replacements = [] + for path, name in ((package, HERMES_PLUGIN_NAME), (command, HERMES_COMMAND_PLUGIN_NAME)): + target, candidate = root / name, Path(temporary) / name + shutil.copytree(path, candidate) + preserve_installed_guidance(target, candidate) + replacements.append((candidate, target)) + publish(replacements) + return { + "plugin": HERMES_PLUGIN_NAME, + "plugin_path": str(root / HERMES_PLUGIN_NAME), + "hermes_home": str(hermes_home()), + "data_dir": str(powercontext_data_dir()), + "command_plugin_path": str(root / HERMES_COMMAND_PLUGIN_NAME), + } + + def configure(self, connection): + if not (hermes_home() / "plugins/powercontext/__init__.py").is_file(): + raise SetupError("PowerContext is not installed in hermes; run powercontext setup hermes") # noqa: TRY003 + publish(writes={**setup_transport_updates(connection), **hermes_transport_updates(connection)}) + return {} + + def enable(self) -> None: + config = _hermes_config() + plugins = config.get("plugins", {}) + memory = config.get("memory", {}) + commands = [] + if HERMES_COMMAND_PLUGIN_NAME not in plugins.get( + "disabled", [] + ) and HERMES_COMMAND_PLUGIN_NAME not in plugins.get("enabled", []): + commands.append(["plugins", "enable", HERMES_COMMAND_PLUGIN_NAME, "--no-allow-tool-override"]) + if "provider" not in memory: + commands.append(["memory", "setup", HERMES_PLUGIN_NAME]) + for arguments in commands: + command = [hermes_executable(), *arguments] + completed = _run_hermes_command(command) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" + raise SetupError(f"`{' '.join(command)}` failed: {detail}") # noqa: TRY003 + if "provider" not in memory and _hermes_config().get("memory", {}).get("provider") != HERMES_PLUGIN_NAME: + raise SetupError("Hermes did not select PowerContext; inspect hermes memory status") # noqa: TRY003 + + def verify(self) -> dict[str, Diagnostic]: + """Collect diagnostics for the optional Hermes integration.""" + try: + executable = hermes_executable() + except SetupError: + return { + "hermes": Diagnostic( + status=DiagnosticStatus.FAILED, + detail="Hermes CLI is not installed or is not on PATH", + ), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because Hermes CLI is unavailable", + ), + } -def _remove_path(path: Path) -> None: - if path.is_symlink() or path.is_file(): - path.unlink(missing_ok=True) - elif path.is_dir(): - shutil.rmtree(path) + try: + hermes_version = get_hermes_version(executable) + except SetupError as error: + return { + "hermes": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because Hermes version validation failed", + ), + } + + config = _hermes_config() + provider = config.get("memory", {}).get("provider") + plugins = config.get("plugins", {}) + plugin = hermes_home() / "plugins" / HERMES_PLUGIN_NAME + command_plugin = hermes_home() / "plugins" / HERMES_COMMAND_PLUGIN_NAME + if not HOST_GUIDANCE["hermes"].recognizes(plugin): + return { + "hermes": Diagnostic( + status=DiagnosticStatus.OK, detail=f"{executable} (Hermes Agent v{hermes_version})" + ), + "plugin": Diagnostic( + status=DiagnosticStatus.FAILED, + detail="PowerContext Hermes plugin is not installed", + ), + "command_plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because the PowerContext memory provider is not installed", + ), + } + try: + _run_plugin_doctor(executable, plugin) + except SetupError as error: + plugin_diagnostic = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) + else: + plugin_diagnostic = Diagnostic( + status=DiagnosticStatus.OK, + detail="powercontext passed Hermes plugin doctor", + ) -__all__ = [ - "HERMES_COMMAND_PLUGIN_NAME", - "HERMES_COMMAND_PLUGIN_RELATIVE", - "HERMES_PLUGIN_NAME", - "HermesSetupResult", - "checkout_target", - "get_hermes_version", - "github_clone_url", - "hermes_executable", - "hermes_home", - "install_hermes_plugin", - "plugin_dir_from_checkout", - "plugin_dirs_from_checkout", - "resolve_hermes_plugin_dir", - "resolve_hermes_plugin_dirs", - "run_hermes_diagnostics", -] + if not HOST_GUIDANCE["hermes"].recognizes(command_plugin): + command_diagnostic = Diagnostic( + status=DiagnosticStatus.FAILED, + detail="PowerContext Hermes command companion is not installed", + ) + else: + try: + _run_plugin_doctor(executable, command_plugin) + except SetupError as error: + command_diagnostic = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) + else: + command_diagnostic = Diagnostic( + status=DiagnosticStatus.OK, + detail="powercontext-command passed Hermes plugin doctor", + ) + + return { + "hermes": Diagnostic(status=DiagnosticStatus.OK, detail=f"{executable} (Hermes Agent v{hermes_version})"), + "plugin": plugin_diagnostic, + "command_plugin": command_diagnostic, + "provider": Diagnostic( + DiagnosticStatus.OK if provider == HERMES_PLUGIN_NAME else DiagnosticStatus.FAILED, + "PowerContext selected" + if provider == HERMES_PLUGIN_NAME + else "PowerContext is not the selected memory provider; use hermes memory setup", + ), + "command_enabled": Diagnostic( + DiagnosticStatus.OK + if HERMES_COMMAND_PLUGIN_NAME in plugins.get("enabled", []) + and HERMES_COMMAND_PLUGIN_NAME not in plugins.get("disabled", []) + else DiagnosticStatus.FAILED, + "Inspect hermes plugins list for the PowerContext command companion", + ), + } diff --git a/src/powercontext/cli/hosts.py b/src/powercontext/cli/hosts.py index 57e97e2702..71be575107 100644 --- a/src/powercontext/cli/hosts.py +++ b/src/powercontext/cli/hosts.py @@ -12,514 +12,144 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""First-class host catalog, opt-in multi-host setup, and read-only integration diagnostics.""" +"""Select agent targets and report independent setup and diagnostic results.""" from __future__ import annotations -import importlib import json import sys -from collections.abc import Sequence -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from collections.abc import Callable, Sequence +from typing import Any import typer -from powercontext.cli.guidance import HOST_GUIDANCE - -if TYPE_CHECKING: - from powercontext.cli.dsh_runtime import DshTarget - from powercontext.cli.system import Diagnostic - from powercontext.cli.transport import SetupTransport - - -@dataclass(frozen=True, slots=True) -class HostSpec: - """One first-class setup target. PATH is not used to build this catalog.""" - - name: str - label: str - - -FIRST_CLASS_HOSTS: tuple[HostSpec, ...] = ( - HostSpec("codex", "Codex"), - HostSpec("claude-code", "Claude Code"), - HostSpec("dsh", "DeepSeek Harness"), - HostSpec("openclaw", "OpenClaw"), - HostSpec("opencode", "OpenCode"), - HostSpec("pi", "Pi"), - HostSpec("hermes", "Hermes"), - HostSpec("workbuddy", "WorkBuddy"), -) -HOST_NAMES: tuple[str, ...] = tuple(host.name for host in FIRST_CLASS_HOSTS) -_HOST_INDEX: dict[str, str] = {str(index): host.name for index, host in enumerate(FIRST_CLASS_HOSTS, start=1)} -_INTEGRATION_KEYS = frozenset({"plugin", "package", "skill", "settings", "mcp"}) -_PATH_MISSING = "is not installed or is not on PATH" - - -class SetupSelectError(RuntimeError): - """Invalid setup select usage or host selection.""" - - @classmethod - def json_requires_host(cls) -> SetupSelectError: - return cls("setup select --json requires --host.") - - @classmethod - def tty_requires_host(cls) -> SetupSelectError: - return cls("setup select requires --host when stdin is not a TTY.") - - @classmethod - def unknown_host(cls, token: str) -> SetupSelectError: - return cls(f"unknown host: {token}. Choose from: {', '.join(HOST_NAMES)}.") - - -@dataclass(frozen=True, slots=True) -class HostSetupRow: - """One catalog row in a setup select report.""" - - host: str - status: str - error: str | None = None - - def as_json(self) -> dict[str, str]: - payload = {"host": self.host, "status": self.status} - if self.error is not None: - payload["error"] = self.error - return payload - - -@dataclass(frozen=True, slots=True) -class SetupSelectReport: - """Per-host matrix for one setup select run.""" - - hosts: tuple[HostSetupRow, ...] - - @property - def has_failure(self) -> bool: - return any(row.status == "failed" for row in self.hosts) - - @property - def has_installed(self) -> bool: - return any(row.status == "installed" for row in self.hosts) - - def is_installed(self, host: str) -> bool: - """Return whether one host completed installation and verification.""" - - return any(row.host == host and row.status == "installed" for row in self.hosts) - - -@dataclass(frozen=True, slots=True) -class IntegrationRow: - """One first-class host in a doctor integrations report.""" - - host: str - presence: str - cli_key: str - cli: Diagnostic - integrations: tuple[tuple[str, Diagnostic], ...] - - @property - def failed(self) -> bool: - return self.presence == "present" and not ( - self.cli.ok and all(diagnostic.ok for _, diagnostic in self.integrations) - ) - - def as_json(self) -> dict[str, object]: - payload: dict[str, object] = { - "presence": self.presence, - self.cli_key: self.cli.as_json(), - } - payload.update({key: diagnostic.as_json() for key, diagnostic in self.integrations}) - return payload - - -@dataclass(frozen=True, slots=True) -class IntegrationReport: - """Read-only matrix of first-class host CLI and integration status.""" - - hosts: tuple[IntegrationRow, ...] - - @property - def has_present_failure(self) -> bool: - return any(row.failed for row in self.hosts) - - @property - def ok(self) -> bool: - return not self.has_present_failure - - @property - def status(self) -> str: - return "failed" if self.has_present_failure else "ok" - - def as_json(self) -> dict[str, object]: - return { - "ok": self.ok, - "status": self.status, - "hosts": {row.host: row.as_json() for row in self.hosts}, - } - def stdin_is_tty() -> bool: - """Return True when the current stdin can prompt for a host selection.""" - return sys.stdin.isatty() -def parse_host_selection(text: str) -> tuple[str, ...] | None: - """Parse a comma-separated catalog selection. Empty input cancels.""" - - stripped = text.strip() - if not stripped: - return None - selected: set[str] = set() - for token in (part.strip() for part in stripped.split(",")): - if not token: - continue - selected.add(_resolve_host_token(token)) - return tuple(host.name for host in FIRST_CLASS_HOSTS if host.name in selected) - - -def normalize_requested_hosts(hosts: Sequence[str]) -> tuple[str, ...]: - """Validate --host values, drop duplicates, and keep catalog order.""" - - selected = {_resolve_host_token(name) for name in hosts} - return tuple(host.name for host in FIRST_CLASS_HOSTS if host.name in selected) - - -def run_setup_select( - *, - hosts: Sequence[str] | None, - source: str, - ref: str, - server_url: str | None, - capture_prompts: bool, - json_output: bool, - allow_insecure_http: bool | None = None, -) -> None: - """Resolve a selection, install those hosts, and print the matrix.""" +def select_hosts(requested: Sequence[str] | None, *, json_output: bool = False) -> tuple[str, ...]: + """Validate all targets before writes; prompt once when no targets were supplied.""" + from powercontext.cli.installation import installers + + names = tuple(installers()) + if not requested: + if json_output or not stdin_is_tty(): + raise typer.BadParameter("Specify one or more agents when running non-interactively") # noqa: TRY003 - CLI input validation. + for index, name in enumerate(names, 1): + typer.echo(f" {index}) {name}") + typer.echo("Select agents by name or number, separated by commas; Enter cancels:") + tokens = sys.stdin.readline().strip().split(",") + requested = [ + names[int(token.strip()) - 1] + if token.strip().isdigit() and 1 <= int(token.strip()) <= len(names) + else token.strip() + for token in tokens + if token.strip() + ] + selected = tuple(dict.fromkeys(requested)) + unknown = set(selected) - set(names) + if unknown: + raise typer.BadParameter(f"Unknown agent: {', '.join(sorted(unknown))}. Choose from: {', '.join(names)}") # noqa: TRY003 - CLI input validation. + return selected + + +def validate_native_options(selected: Sequence[str], **options: Any) -> None: + """Reject explicit flags that none of the selected agents can use.""" + owners = { + "capture_prompts": ("claude-code", "kimi-code", "zcode"), + "scope_id": ("kimi-code",), + "workspace_scope": ("kimi-code",), + "boundary_flush": ("zcode",), + "profile": ("dsh",), + "dsh_command": ("dsh",), + "runtime": ("kimi-code",), + "cwd": ("kimi-code",), + "session_id": ("kimi-code",), + "runtime_data_dir": ("zcode",), + "prepare": ("zcode",), + } + for key, value in options.items(): + if value is not None and not set(selected).intersection(owners[key]): + flag = "--" + key.replace("_", "-") + raise typer.BadParameter(f"{flag} requires selecting {', '.join(owners[key])}") # noqa: TRY003 + + +def native_options(host: str, **options: Any) -> dict[str, Any]: + """Pass only options owned by the selected native adapter.""" + fields = { + "claude-code": ("capture_prompts",), + "dsh": ("dsh_target",), + "kimi-code": ("scope_id", "capture_prompts", "workspace_scope"), + "zcode": ("capture_prompts", "boundary_flush"), + } + return {key: options[key] for key in fields.get(host, ()) if options.get(key) is not None} + + +def doctor_checks(host: str, **options) -> dict[str, Any]: + """Run native verification and Server checks through the doctor boundary.""" + from powercontext.cli.installation import installers + from powercontext.cli.native_transport import resolve_host_transport + from powercontext.cli.system import Diagnostic, DiagnosticStatus, server_diagnostics try: - selected = resolve_selected_hosts(requested=hosts, json_output=json_output) - except SetupSelectError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - if selected is None: - return - report = setup_selected_hosts( - selected=selected, - source=source, - ref=ref, - server_url=server_url, - capture_prompts=capture_prompts, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - ) - write_setup_select_report(report, json_output=json_output) - if report.has_failure: - raise typer.Exit(code=1) - - -def resolve_selected_hosts(*, requested: Sequence[str] | None, json_output: bool) -> tuple[str, ...] | None: - """Return catalog names to install, or None when the user cancels.""" - - if requested: - return normalize_requested_hosts(requested) - if json_output: - raise SetupSelectError.json_requires_host() - if not stdin_is_tty(): - raise SetupSelectError.tty_requires_host() - _write_host_catalog() - return parse_host_selection(sys.stdin.readline()) - - -@dataclass(frozen=True, slots=True) -class HostInstallation: - """Return an adapter's native result alongside its persisted connection policy.""" - - result: Any - transport: SetupTransport - - -def setup_host( - name: str, - *, - source: str, - ref: str, - server_url: str | None = None, - capture_prompts: bool = True, - allow_insecure_http: bool | None = None, - json_output: bool = False, - dsh_target: DshTarget | None = None, -) -> HostInstallation: - """Resolve, install, and persist one Agent using the common connection policy. - - Resolution fails before installation side effects. Persistence happens only after - the adapter succeeds and before callers run their host-specific diagnostics. - """ - from powercontext.cli.transport import prepare_setup_transport, save_setup_transport + if host == "dsh" and "dsh_target" not in options: + from powercontext.cli.dsh_runtime import resolve_dsh_target + + options["dsh_target"] = resolve_dsh_target(options.get("profile"), options.get("dsh_command")) + installer = installers()[host](**native_options(host, **options)) + fields = {"kimi-code": ("runtime", "cwd", "session_id"), "zcode": ("runtime_data_dir", "prepare")} + checks = installer.verify(**{key: options[key] for key in fields.get(host, ()) if options.get(key) is not None}) + except Exception as error: # Each target must produce a result even when native configuration is malformed. + return {"verification": Diagnostic(DiagnosticStatus.FAILED, str(error))} + try: + target = {"dsh_target": installer.dsh_target} if host == "dsh" else {} + endpoint, allowed = resolve_host_transport(host, **target) + checks.update( + server_diagnostics(server_url=endpoint, allow_insecure_http=allowed, readiness=checks.get("server")) + ) + except Exception as error: # A failed Server probe must not hide completed installation. + checks["transport"] = Diagnostic(DiagnosticStatus.FAILED, str(error)) + return checks - transport = prepare_setup_transport( - name, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - **({"dsh_target": dsh_target} if dsh_target else {}), - ) - result = install_host( - name, - source=source, - ref=ref, - server_url=transport.server_url, - capture_prompts=capture_prompts, - allow_insecure_http=transport.allow_insecure_http, - **({"dsh_target": dsh_target} if dsh_target else {}), - ) - save_setup_transport(transport) - return HostInstallation(result, transport) +def diagnostic_result(checks) -> dict[str, Any]: + from powercontext.cli.system import DiagnosticStatus, _diagnostics_status -def setup_selected_hosts( - *, - selected: Sequence[str], - source: str, - ref: str, - server_url: str | None, - capture_prompts: bool, - allow_insecure_http: bool | None = None, - json_output: bool = False, -) -> SetupSelectReport: - """Install selected hosts and isolate failures from sibling hosts.""" + # Optional session history stays visible without pretending that it was observed. + required = { + key: item + for key, item in checks.items() + if item.status is not DiagnosticStatus.SKIPPED or key not in {"runtime", "mcp_session", "mcp_full_profile"} + } + status = _diagnostics_status(required) + return {"status": status.value, "checks": {key: item.as_json() for key, item in checks.items()}} - from powercontext.cli.system import SetupError - selected_names = set(selected) - rows: list[HostSetupRow] = [] - for host in FIRST_CLASS_HOSTS: - if host.name not in selected_names: - rows.append(HostSetupRow(host=host.name, status="skipped")) - continue +def report_agents(selected: Sequence[str], action: Callable[[str], dict[str, Any]], *, json_output: bool) -> None: # noqa: C901 - render one complete batch result. + """Finish every selected target, preserving partial success and all diagnostics.""" + rows: dict[str, dict[str, Any]] = {} + for name in selected: try: - setup_host( - host.name, - source=source, - ref=ref, - server_url=server_url, - capture_prompts=capture_prompts, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - ) - verify_host(host.name) - except SetupError as error: - rows.append(HostSetupRow(host=host.name, status="failed", error=str(error))) - continue - rows.append(HostSetupRow(host=host.name, status="installed")) - return SetupSelectReport(hosts=tuple(rows)) - - -def install_host( - name: str, - *, - source: str, - ref: str, - server_url: str | None, - capture_prompts: bool, - allow_insecure_http: bool = False, - dsh_target: DshTarget | None = None, -) -> object: - """Call the existing installer for one first-class host.""" - - descriptor = HOST_GUIDANCE.get(name) - if descriptor is None or descriptor.installer is None: - raise SetupSelectError.unknown_host(name) - module_name, function_name = descriptor.installer.split(":") - installer = getattr(importlib.import_module(module_name), function_name) - options: dict[str, Any] = {"source": source, "ref": ref} - if descriptor.server_option: - use_default = server_url is None or (descriptor.empty_server_uses_default and not server_url) - options["server_url"] = descriptor.server_default if use_default else server_url - if descriptor.capture_prompts: - options["capture_prompts"] = capture_prompts - if descriptor.allow_insecure_http: - options["allow_insecure_http"] = allow_insecure_http - if descriptor.target_option and dsh_target: - options["target"] = dsh_target - return installer(**options) - - -def verify_host(name: str) -> None: - """Run the post-install diagnostics used by the matching single-host setup command.""" - - from powercontext.cli.system import SetupError - - if name == "codex": - from powercontext.cli.system import codex_setup_checks, run_codex_diagnostics - - diagnostics = codex_setup_checks(run_codex_diagnostics()) - elif name == "dsh": - from powercontext.cli.dsh import run_dsh_diagnostics - - diagnostics = run_dsh_diagnostics() - elif name == "pi": - from powercontext.cli.pi import run_pi_diagnostics - - diagnostics = run_pi_diagnostics() - elif name == "hermes": - from powercontext.cli.hermes import run_hermes_diagnostics - - diagnostics = run_hermes_diagnostics() - elif name == "workbuddy": - from powercontext.cli.workbuddy import run_workbuddy_diagnostics - - diagnostics = run_workbuddy_diagnostics() - elif name == "opencode": - from powercontext.cli.opencode import run_opencode_diagnostics - - diagnostics = run_opencode_diagnostics() - elif name in {"claude-code", "openclaw"}: - return - else: - raise SetupSelectError.unknown_host(name) - - failures = [f"{check}: {diagnostic.detail}" for check, diagnostic in diagnostics.items() if not diagnostic.ok] - if failures: - raise SetupError.post_install_verification(failures) - - -def write_setup_select_report(report: SetupSelectReport, *, json_output: bool) -> None: - """Write the per-host matrix as JSON or human text.""" - - if json_output: - typer.echo(json.dumps({"hosts": [row.as_json() for row in report.hosts]}, indent=2)) - return - for row in report.hosts: - if row.status == "failed" and row.error is not None: - typer.echo(f"{row.host}: failed - {row.error}") - continue - typer.echo(f"{row.host}: {row.status}") - if report.has_installed: - typer.echo("Next: run `powercontext server run`, then start a new host session.") - if report.is_installed("hermes"): - typer.echo("Hermes: run `hermes memory setup` and select PowerContext before starting Hermes.") - - -def _resolve_host_token(token: str) -> str: - name = _HOST_INDEX.get(token, token) - if name not in HOST_NAMES: - raise SetupSelectError.unknown_host(token) - return name - - -def _write_host_catalog() -> None: - typer.echo("Official first-class integrations:") - for index, host in enumerate(FIRST_CLASS_HOSTS, start=1): - typer.echo(f" {index}) {host.label} ({host.name})") - typer.echo("Select hosts by number or name (comma-separated), or press Enter to cancel:") - - -def diagnose_host(name: str) -> dict[str, Diagnostic]: - """Lazily collect diagnostics for one first-class host.""" - - if name == "codex": - from powercontext.cli.system import run_codex_diagnostics - - return run_codex_diagnostics() - if name == "claude-code": - from powercontext.cli.system import run_claude_code_diagnostics - - return run_claude_code_diagnostics() - if name == "dsh": - from powercontext.cli.dsh import run_dsh_diagnostics - - return run_dsh_diagnostics() - if name == "openclaw": - from powercontext.cli.openclaw import run_openclaw_diagnostics - - return run_openclaw_diagnostics() - if name == "opencode": - from powercontext.cli.opencode import run_opencode_diagnostics - - return run_opencode_diagnostics() - if name == "pi": - from powercontext.cli.pi import run_pi_diagnostics - - return run_pi_diagnostics() - if name == "hermes": - from powercontext.cli.hermes import run_hermes_diagnostics - - return run_hermes_diagnostics() - if name == "workbuddy": - from powercontext.cli.workbuddy import run_workbuddy_diagnostics - - return run_workbuddy_diagnostics() - raise SetupSelectError.unknown_host(name) - - -def split_host_diagnostics( - diagnostics: dict[str, Diagnostic], -) -> tuple[str, Diagnostic, tuple[tuple[str, Diagnostic], ...]]: - """Split one host probe into its CLI check and integration checks.""" - - cli_key = next(key for key in diagnostics if key not in _INTEGRATION_KEYS) - integrations = tuple((key, diagnostic) for key, diagnostic in diagnostics.items() if key in _INTEGRATION_KEYS) - return cli_key, diagnostics[cli_key], integrations - - -def classify_host_presence(cli: Diagnostic, integrations: tuple[tuple[str, Diagnostic], ...]) -> str: - """Mark a host missing only when PATH lookup failed and the integration was skipped.""" - - missing_cli = _PATH_MISSING in cli.detail or "WorkBuddy hooks are not installed" in cli.detail - if missing_cli and all(diagnostic.status.value == "skipped" for _, diagnostic in integrations): - return "missing" - return "present" - - -def build_integration_row(name: str, diagnostics: dict[str, Diagnostic]) -> IntegrationRow: - """Classify one host diagnostic pair without deciding the command exit code.""" - - cli_key, cli, integrations = split_host_diagnostics(diagnostics) - presence = classify_host_presence(cli, integrations) - if presence == "present": - from powercontext.cli.transport import transport_diagnostic - - transport = transport_diagnostic(name) - if not transport.ok: - integrations = (*integrations, ("transport", transport)) - return IntegrationRow( - host=name, - presence=presence, - cli_key=cli_key, - cli=cli, - integrations=integrations, - ) - - -def collect_integration_diagnostics() -> IntegrationReport: - """Walk the shared catalog and collect a row for every first-class host.""" - - return IntegrationReport( - hosts=tuple(build_integration_row(host.name, diagnose_host(host.name)) for host in FIRST_CLASS_HOSTS) - ) - - -def format_integration_row(row: IntegrationRow) -> str: - integrations = " ".join(f"{key}={diagnostic.status.value}" for key, diagnostic in row.integrations) - return f"{row.host}: {row.presence} - cli={row.cli.status.value} {integrations}" - - -def write_integration_report(report: IntegrationReport, *, json_output: bool) -> None: - """Write the host matrix as JSON or one human line per host.""" - + rows[name] = action(name) + except Exception as error: # Isolate agents; cancellation and process exits still propagate. + rows[name] = {"status": "failed", "error": "\n".join([str(error), *getattr(error, "__notes__", ())])} + for row in rows.values(): + row["ok"] = row["status"] in {"ok", "configured"} + failed = any(row["status"] not in {"ok", "configured"} for row in rows.values()) if json_output: - typer.echo(json.dumps(report.as_json(), indent=2)) - return - for row in report.hosts: - typer.echo(format_integration_row(row)) - - -def run_doctor_integrations(*, json_output: bool) -> None: - """Print the first-class host matrix and fail only when a present host is broken.""" - - report = collect_integration_diagnostics() - write_integration_report(report, json_output=json_output) - if report.has_present_failure: + typer.echo(json.dumps({"ok": not failed, "hosts": rows}, ensure_ascii=False, indent=2)) + else: + for name, row in rows.items(): + typer.echo(f"{name}: {row['status']}" + (" (setup complete)" if row.get("setup") == "complete" else "")) + for key in ("plugin_path", "package_path", "cache_dir", "profile", "profile_dir", "authorization_state"): + if key in row.get("result", {}): + typer.echo(f" {key}: {row['result'][key]}") + if "error" in row: + typer.echo(row["error"]) + for key, item in row.get("checks", {}).items(): + typer.echo(f" {key}: {item['status']} - {item['detail']}") + for check, status in item.get("checks", {}).items(): + typer.echo(f" {check}: {status}") + if failed: raise typer.Exit(code=1) diff --git a/src/powercontext/cli/installation.py b/src/powercontext/cli/installation.py new file mode 100644 index 0000000000..71272a8fb7 --- /dev/null +++ b/src/powercontext/cli/installation.py @@ -0,0 +1,185 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Compose native installation and connection configuration.""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +from collections.abc import Callable +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from uuid import uuid4 + + +class SetupError(RuntimeError): + """Report a failed installation or external setup command.""" + + +def _remove(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink(missing_ok=True) + elif path.is_dir(): + shutil.rmtree(path) + + +def write_json(path: Path, value: object) -> None: + write_bytes(path, (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()) + + +def write_bytes(path: Path, value: bytes) -> None: + """Replace one complete configuration file.""" + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(name) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(value) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def publish(replacements=(), writes=None, *, action: Callable[[], object] | None = None) -> None: # noqa: C901 + """Publish local changes, restoring previous contents if writing or the action fails.""" + previous: list[tuple[Path, Path | None]] = [] + changes = [(target, source) for source, target in replacements] + list((writes or {}).items()) + for target, value in changes: + if value is not None and not target.exists() and not target.is_symlink(): + backup = next(target.parent.glob(f".{target.name}.*.bak"), None) + if backup is not None: + message = f"Restore {target} from {backup} before retrying setup" + raise SetupError(message) + try: + for target, value in changes: + target.parent.mkdir(parents=True, exist_ok=True) + backup = None + if target.exists() or target.is_symlink(): + backup = target.with_name(f".{target.name}.{uuid4().hex}.bak") + if value is None or (target.is_dir() and not target.is_symlink()): + os.replace(target, backup) + else: + shutil.copy2(target, backup, follow_symlinks=False) + previous.append((target, backup)) + if isinstance(value, Path): + os.replace(value, target) + elif value is not None: + write_bytes(target, value) + if action is not None: + action() + except BaseException as error: + for target, backup in reversed(previous): + try: + if backup is None or (target.is_dir() and not target.is_symlink()): + _remove(target) + if backup is not None: + os.replace(backup, target) + except OSError: + error.add_note(f"Could not restore {target}; previous contents remain at {backup}") + raise + for _, backup in previous: + if backup is not None: + _remove(backup) + + +@dataclass(frozen=True) +class PluginSource: + location: str + ref: str = "master" + + @contextmanager + def package(self, host: str, *, linked: bool = False): + from powercontext.cli.git_source import clone_github_source, is_local_source, resolve_plugin_source + + if linked or is_local_source(self.location): + yield resolve_plugin_source(self.location, self.ref, host) + else: + with tempfile.TemporaryDirectory(prefix="powercontext-source-") as temporary: + checkout = Path(temporary) / "checkout" + clone_github_source(self.location, self.ref, checkout) + yield resolve_plugin_source(str(checkout), self.ref, host) + + +def installers(): + from powercontext.cli.dsh import DshInstaller + from powercontext.cli.hermes import HermesInstaller + from powercontext.cli.kimi_code import KimiInstaller + from powercontext.cli.openclaw import OpenClawInstaller + from powercontext.cli.opencode import OpenCodeInstaller + from powercontext.cli.pi import PiInstaller + from powercontext.cli.system import ClaudeInstaller, CodexInstaller + from powercontext.cli.workbuddy import WorkBuddyInstaller + from powercontext.cli.zcode import ZCodeInstaller + + return { + "codex": CodexInstaller, + "claude-code": ClaudeInstaller, + "dsh": DshInstaller, + "hermes": HermesInstaller, + "kimi-code": KimiInstaller, + "openclaw": OpenClawInstaller, + "opencode": OpenCodeInstaller, + "pi": PiInstaller, + "workbuddy": WorkBuddyInstaller, + "zcode": ZCodeInstaller, + } + + +def setup( + host: str, + source: str, + ref: str = "master", + *, + server_url: str | None = None, + allow_insecure_http: bool | None = None, + json_output: bool = False, + **options: Any, +) -> dict[str, Any]: + from powercontext.cli.authorization import validate_setup_authorization + from powercontext.cli.transport import prepare_setup_transport + + try: + validate_setup_authorization(host) + connection = prepare_setup_transport( + host, + server_url=server_url, + allow_insecure_http=allow_insecure_http, + json_output=json_output, + dsh_target=options.get("dsh_target"), + ) + installer = installers()[host](**options) + report = installer.install(PluginSource(source, ref)) + try: + report.update(installer.configure(connection)) + except Exception as error: + error.add_note("Plugin installation completed; connection configuration failed.") + raise + try: + operation = getattr(installer, "enable", None) + if operation is not None: + operation() + except Exception as error: + error.add_note("Plugin installation and connection configuration completed; native activation failed.") + raise + return report # noqa: TRY300 + except (OSError, ValueError) as error: + failure = SetupError(f"Could not set up {host}: {error}") + for note in getattr(error, "__notes__", ()): + failure.add_note(note) + raise failure from error diff --git a/src/powercontext/cli/kimi_code.py b/src/powercontext/cli/kimi_code.py index 71ec88bb0a..d499585cad 100644 --- a/src/powercontext/cli/kimi_code.py +++ b/src/powercontext/cli/kimi_code.py @@ -23,13 +23,16 @@ import tempfile from dataclasses import dataclass from datetime import UTC, datetime +from functools import cached_property from pathlib import Path from shutil import which from typing import Any from urllib.parse import urlsplit -from powercontext.cli.guidance import preserve_installed_guidance +from powercontext.cli.guidance import guidance_region, preserve_installed_guidance +from powercontext.cli.installation import publish, write_json from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError +from powercontext.cli.transport import setup_transport_updates from powercontext.client.transport_policy import normalize_client_url from powercontext.transport import is_loopback_host @@ -49,14 +52,6 @@ ) -@dataclass(frozen=True, slots=True) -class KimiSetupResult: - plugin_path: str - config_file: str - server_url: str - scope_id: str | None - - def kimi_home() -> Path: value = os.environ.get("KIMI_CODE_HOME", "") return Path(value if value.strip() else Path.home() / ".kimi-code").expanduser().resolve() @@ -95,21 +90,6 @@ def _registry(path: Path) -> dict[str, Any]: return value -def _write_json(path: Path, value: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - temporary = Path(name) - try: - with os.fdopen(fd, "w", encoding="utf-8") as stream: - json.dump(value, stream, ensure_ascii=False, indent=2) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) - - def _endpoint(value: str) -> str: endpoint = normalize_client_url(value).removesuffix("/mcp").rstrip("/") parsed = urlsplit(endpoint) @@ -126,224 +106,249 @@ def _endpoint(value: str) -> str: return endpoint -def install_kimi_plugin( # noqa: C901 - validates and atomically replaces two user-owned destinations. - *, - source: Path, - server_url: str | None = None, - scope_id: str | None = None, - capture_prompts: bool | None = None, - workspace_scope: bool = False, -) -> KimiSetupResult: - """Install a local plugin and preserve other records and existing enablement choices.""" - if kimi_executable() is None or which("node") is None: - raise SetupError("Kimi CLI and Node must be available; KIMI_CLI_BIN may point to a built CLI") # noqa: TRY003 - source = source.expanduser().resolve() - plugin = source if (source / "kimi.plugin.json").is_file() else source / PLUGIN_RELATIVE - if any(not (plugin / name).is_file() for name in REQUIRED_FILES): - raise SetupError("Local source does not contain a complete PowerContext Kimi plugin") # noqa: TRY003 - skill_template = (plugin / "skills/powercontext-project-context/SKILL.md").read_text(encoding="utf-8") - if any( - marker not in skill_template for marker in ("__POWERCONTEXT_SCOPE_JSON__", "__POWERCONTEXT_SCOPE_SCRIPT_JSON__") - ): - raise SetupError("Source must be an unconfigured plugin; use the original PowerContext checkout") # noqa: TRY003 - manifest = _read_json(plugin / "kimi.plugin.json") - if ( - manifest.get("name") != "powercontext" - or manifest.get("hooks") != [HOOK] - or manifest.get("skills") != "./skills/" - or manifest.get("sessionStart") != {"skill": "powercontext-project-context"} - ): - raise SetupError("Invalid PowerContext Kimi plugin manifest") # noqa: TRY003 - home = kimi_home() - registry_path = home / "plugins/installed.json" - destination = home / "plugins/managed/powercontext" - if registry_path.is_symlink() or destination.is_symlink(): - raise SetupError("Kimi installation paths must not be symbolic links") # noqa: TRY003 - registry = _registry(registry_path) - previous_record = next((item for item in registry["plugins"] if item["id"] == "powercontext"), {}) - if previous_record and Path(previous_record.get("root", "")).resolve() != destination.resolve(): - raise SetupError("A PowerContext plugin is already registered at another path") # noqa: TRY003 - if destination.exists() and not (destination / OWNER_MARKER).is_file(): - raise SetupError("Existing Kimi PowerContext directory is not owned by this installer") # noqa: TRY003 - previous = _read_json(destination / "powercontext.json") if destination.exists() else {} - endpoint = _endpoint(server_url if server_url is not None else previous.get("server_url", "http://127.0.0.1:8000")) - if workspace_scope and scope_id is not None: - raise SetupError("--workspace-scope and --scope-id are mutually exclusive") # noqa: TRY003 - selected_scope = None if workspace_scope else scope_id if scope_id is not None else previous.get("scope_id") - if (selected_scope is None and not workspace_scope and "scope_id" not in previous) or ( - selected_scope is not None - and (not isinstance(selected_scope, str) or not selected_scope.strip() or len(selected_scope) > 256) - ): - raise SetupError("Specify --scope-id or --workspace-scope; setup never creates a Scope") # noqa: TRY003 - token = os.environ.get("POWERCONTEXT_KIMI_TOKEN") - if token is not None and ( - not token or not token.isascii() or any(ord(c) < 33 or ord(c) > 126 for c in token) or len(token) > 8192 - ): - raise SetupError("POWERCONTEXT_KIMI_TOKEN must contain one raw token") # noqa: TRY003 - use_token = token is not None or (endpoint == previous.get("server_url") and previous.get("use_token") is True) - capture = capture_prompts if capture_prompts is not None else previous.get("capture_prompts", True) - if not isinstance(capture, bool): - raise SetupError("Invalid saved capture preference") # noqa: TRY003 - destination.parent.mkdir(parents=True, exist_ok=True) - backup = destination.with_name(".powercontext-kimi-backup") - if backup.exists(): - raise SetupError(f"Restore the retained Kimi plugin backup at {backup} before setup") # noqa: TRY003 - original_registry = registry_path.read_bytes() if registry_path.exists() else None - with tempfile.TemporaryDirectory(prefix=".powercontext-kimi-", dir=destination.parent) as temporary: - staging = Path(temporary) / "plugin" - shutil.copytree(plugin, staging, ignore=shutil.ignore_patterns("tests", "node_modules")) - preserve_installed_guidance(destination, staging) - (staging / OWNER_MARKER).write_text("powercontext-kimi-code-v1\n", encoding="utf-8") - _write_json( - staging / "powercontext.json", - { - "server_url": endpoint, - "scope_id": selected_scope, - "capture_prompts": capture, - "use_token": use_token, - }, - ) - manifest["mcpServers"] = { - "powercontext": { - "url": endpoint + "/mcp", - **({"bearerTokenEnvVar": "POWERCONTEXT_KIMI_TOKEN"} if use_token else {}), - "enabledTools": MEMORY_TOOLS, - "startupTimeoutMs": 10000, - "toolTimeoutMs": 10000, - } - } - skill = staging / "skills/powercontext-project-context/SKILL.md" - skill.write_text( - skill - .read_text(encoding="utf-8") - .replace("__POWERCONTEXT_SCOPE_JSON__", json.dumps(selected_scope, ensure_ascii=False)) - .replace( - "__POWERCONTEXT_SCOPE_SCRIPT_JSON__", - json.dumps(str(destination / "scripts/scope.mjs"), ensure_ascii=False), - ), - encoding="utf-8", - ) - _write_json(staging / "kimi.plugin.json", manifest) - now = datetime.now(UTC).isoformat() - record = { - **previous_record, - "id": "powercontext", - "root": str(destination), - "source": "local-path", - "enabled": previous_record.get("enabled", True), - "installedAt": previous_record.get("installedAt", now), - "updatedAt": now, - "originalSource": str(plugin), - } - registry["plugins"] = [item for item in registry["plugins"] if item["id"] != "powercontext"] + [record] - moved = False - installed = False - try: - if destination.exists(): - destination.rename(backup) - moved = True - staging.rename(destination) - installed = True - _write_json(registry_path, registry) - except BaseException: - try: - if installed: - shutil.rmtree(destination) - if moved: - backup.rename(destination) - if original_registry is None: - registry_path.unlink(missing_ok=True) - else: - registry_path.write_bytes(original_registry) - except OSError as error: - message = f"Kimi setup rollback failed; previous plugin backup is retained at {backup}" - raise SetupError(message) from error - raise - if moved: - shutil.rmtree(backup) - return KimiSetupResult(str(destination), str(registry_path), endpoint, selected_scope) +def _configured_skill(skill: Path, *, scope: str | None, previous_scope: str | None, scope_script: Path) -> bytes: + existing = skill.read_bytes().decode("utf-8") + region = guidance_region(existing) + if region is None: + raise SetupError("Add managed guidance markers to the installed Skill before reconfiguring") # noqa: TRY003 + start, end = region + content = existing[start:end] + old_scope = ( + "__POWERCONTEXT_SCOPE_JSON__" + if "__POWERCONTEXT_SCOPE_JSON__" in content + else json.dumps(previous_scope, ensure_ascii=False) + ) + for label in ("The configured Scope is", "The configured explicit Scope is"): + content = content.replace(f"{label} {old_scope}.", f"{label} {json.dumps(scope, ensure_ascii=False)}.") + content = content.replace("__POWERCONTEXT_SCOPE_SCRIPT_JSON__", json.dumps(str(scope_script), ensure_ascii=False)) + return (existing[:start] + content + existing[end:]).encode("utf-8") -def run_kimi_diagnostics( - *, runtime: bool = False, cwd: Path | None = None, session_id: str | None = None -) -> dict[str, Diagnostic]: - """Check installed declarations and perform readonly Server and Scope probes.""" - ok, failed, skipped = DiagnosticStatus.OK, DiagnosticStatus.FAILED, DiagnosticStatus.SKIPPED - node = which("node") - checks = { - "kimi_code": Diagnostic( - ok if kimi_executable() else failed, "available" if kimi_executable() else "Kimi not found" - ), - "node": Diagnostic(ok if node else failed, "available" if node else "Node not found"), - } - destination = kimi_home() / "plugins/managed/powercontext" - try: - records = _registry(kimi_home() / "plugins/installed.json")["plugins"] - record = next((item for item in records if item["id"] == "powercontext"), None) - registered = record is not None and Path(record.get("root", "")).resolve() == destination.resolve() - enabled = registered and record.get("enabled") is True - checks["plugin"] = Diagnostic( - ok if enabled else failed, - "registered and enabled" if enabled else "disabled" if registered else "not installed", +@dataclass +class KimiInstaller: + scope_id: str | None = None + capture_prompts: bool | None = None + workspace_scope: bool = False + + @cached_property + def _preferences(self): + path = kimi_home() / "plugins/managed/powercontext/powercontext.json" + previous = _read_json(path) if path.exists() else {} + if self.workspace_scope and self.scope_id is not None: + raise SetupError("--workspace-scope and --scope-id are mutually exclusive") # noqa: TRY003 + scope = ( + None if self.workspace_scope else self.scope_id if self.scope_id is not None else previous.get("scope_id") ) - settings = _read_json(destination / "powercontext.json") + if (scope is None and not self.workspace_scope and "scope_id" not in previous) or ( + scope is not None and (not isinstance(scope, str) or not scope.strip() or len(scope) > 256) + ): + raise SetupError("Specify --scope-id or --workspace-scope; setup never creates a Scope") # noqa: TRY003 + capture = self.capture_prompts if self.capture_prompts is not None else previous.get("capture_prompts", True) + if not isinstance(capture, bool): + raise SetupError("Invalid saved capture preference") # noqa: TRY003 + token = os.environ.get("POWERCONTEXT_KIMI_TOKEN") + if token is not None and ( + not token or not token.isascii() or any(ord(c) < 33 or ord(c) > 126 for c in token) or len(token) > 8192 + ): + raise SetupError("POWERCONTEXT_KIMI_TOKEN must contain one raw token") # noqa: TRY003 + return previous, scope, capture, token is not None + + def install(self, source): # noqa: C901 + _ = self._preferences + if kimi_executable() is None or which("node") is None: + raise SetupError("Kimi CLI and Node must be available; KIMI_CLI_BIN may point to a built CLI") # noqa: TRY003 + with source.package("kimi-code") as plugin: + skill_template = (plugin / "skills/powercontext-project-context/SKILL.md").read_text(encoding="utf-8") + if any( + marker not in skill_template + for marker in ("__POWERCONTEXT_SCOPE_JSON__", "__POWERCONTEXT_SCOPE_SCRIPT_JSON__") + ): + raise SetupError("Source must be an unconfigured plugin; use the original PowerContext checkout") # noqa: TRY003 + if (plugin / OWNER_MARKER).exists(): + raise SetupError("Use the original PowerContext checkout or an unconfigured release package") # noqa: TRY003 + if any(not (plugin / name).is_file() for name in REQUIRED_FILES): + raise SetupError("Local source does not contain a complete PowerContext Kimi plugin") # noqa: TRY003 + manifest = _read_json(plugin / "kimi.plugin.json") + if ( + manifest.get("name") != "powercontext" + or manifest.get("hooks") != [HOOK] + or manifest.get("skills") != "./skills/" + or manifest.get("sessionStart") != {"skill": "powercontext-project-context"} + ): + raise SetupError("Invalid PowerContext Kimi plugin manifest") # noqa: TRY003 + home = kimi_home() + registry_path, destination = home / "plugins/installed.json", home / "plugins/managed/powercontext" + if registry_path.is_symlink() or destination.is_symlink(): + raise SetupError("Kimi installation paths must not be symbolic links") # noqa: TRY003 + registry = _registry(registry_path) + previous_record = next((item for item in registry["plugins"] if item["id"] == "powercontext"), {}) + if previous_record and Path(previous_record.get("root", "")).resolve() != destination.resolve(): + raise SetupError("A PowerContext plugin is already registered at another path") # noqa: TRY003 + if destination.exists() and not (destination / OWNER_MARKER).is_file(): + raise SetupError("Existing Kimi PowerContext directory is not owned by this installer") # noqa: TRY003 + previous = ( + _read_json(destination / "powercontext.json") if (destination / "powercontext.json").exists() else {} + ) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".powercontext-", dir=destination.parent) as temporary: + staged = Path(temporary) / "plugin" + shutil.copytree(plugin, staged, ignore=shutil.ignore_patterns("tests", "node_modules")) + preserve_installed_guidance(destination, staged) + if previous: + shutil.copy2(destination / "powercontext.json", staged / "powercontext.json") + native = _read_json(destination / "kimi.plugin.json") + if "mcpServers" in native: + manifest["mcpServers"] = native["mcpServers"] + (staged / OWNER_MARKER).write_text("powercontext-kimi-code-v1\n", encoding="utf-8") + if previous: + skill = staged / "skills/powercontext-project-context/SKILL.md" + skill.write_bytes( + _configured_skill( + skill, + scope=previous["scope_id"], + previous_scope=previous["scope_id"], + scope_script=destination / "scripts/scope.mjs", + ) + ) + write_json(staged / "kimi.plugin.json", manifest) + now = datetime.now(UTC).isoformat() + record = { + **previous_record, + "id": "powercontext", + "root": str(destination), + "source": "local-path", + "enabled": previous_record.get("enabled", True), + "installedAt": previous_record.get("installedAt", now), + "updatedAt": now, + "originalSource": source.location, + } + registry["plugins"] = [item for item in registry["plugins"] if item["id"] != "powercontext"] + [record] + publish( + [(staged, destination)], + {registry_path: (json.dumps(registry, ensure_ascii=False, indent=2) + "\n").encode()}, + ) + return { + "plugin_path": str(destination), + "config_file": str(registry_path), + "server_url": previous.get("server_url", "http://127.0.0.1:8000"), + "scope_id": previous.get("scope_id"), + } + + def configure(self, connection): + destination = kimi_home() / "plugins/managed/powercontext" + previous, scope, capture, supplied_token = self._preferences + endpoint = connection.server_url + use_token = supplied_token or (endpoint == previous.get("server_url") and previous.get("use_token") is True) + settings = {"server_url": endpoint, "scope_id": scope, "capture_prompts": capture, "use_token": use_token} manifest = _read_json(destination / "kimi.plugin.json") server = manifest["mcpServers"]["powercontext"] - capabilities = (record or {}).get("capabilities", {}).get("mcpServers", {}).get("powercontext", {}) - configured = ( - server.get("url") == _endpoint(settings["server_url"]) + "/mcp" - and server.get("bearerTokenEnvVar") == ("POWERCONTEXT_KIMI_TOKEN" if settings.get("use_token") else None) - and not server.get("headers") - and server.get("enabledTools") == MEMORY_TOOLS - and server.get("enabled", True) is True - and capabilities.get("enabled", True) is True + server["url"] = endpoint + "/mcp" + if use_token: + server["bearerTokenEnvVar"] = "POWERCONTEXT_KIMI_TOKEN" + elif server.get("bearerTokenEnvVar") == "POWERCONTEXT_KIMI_TOKEN": + del server["bearerTokenEnvVar"] + skill = destination / "skills/powercontext-project-context/SKILL.md" + content = _configured_skill( + skill, scope=scope, previous_scope=previous.get("scope_id"), scope_script=destination / "scripts/scope.mjs" ) - checks["mcp"] = Diagnostic( - ok if configured else failed, - "configured (not a live MCP check)" if configured else "disabled or configuration mismatch", + publish( + writes={ + **setup_transport_updates(connection), + destination / "powercontext.json": (json.dumps(settings, ensure_ascii=False, indent=2) + "\n").encode(), + destination / "kimi.plugin.json": (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode(), + skill: content, + } ) - hooks_ok = manifest.get("hooks") == [HOOK] and all((destination / name).is_file() for name in REQUIRED_FILES) - skill_ok = manifest.get("skills") == "./skills/" and manifest.get("sessionStart") == { - "skill": "powercontext-project-context" + return {"server_url": endpoint, "scope_id": scope} + + def verify( + self, *, runtime: bool = False, cwd: Path | None = None, session_id: str | None = None + ) -> dict[str, Diagnostic]: + """Check installed declarations and perform readonly Server and Scope probes.""" + ok, failed, skipped = DiagnosticStatus.OK, DiagnosticStatus.FAILED, DiagnosticStatus.SKIPPED + node = which("node") + checks = { + "kimi_code": Diagnostic( + ok if kimi_executable() else failed, "available" if kimi_executable() else "Kimi not found" + ), + "node": Diagnostic(ok if node else failed, "available" if node else "Node not found"), } - checks["skill"] = Diagnostic( - ok if skill_ok else failed, "configured" if skill_ok else "Skill is not registered" - ) - checks["hook"] = Diagnostic( - ok if hooks_ok else failed, - "configured (injection not verified)" if hooks_ok else "missing or mismatched Hook files", - ) - except (SetupError, KeyError, TypeError, AttributeError, ValueError): - checks.setdefault("plugin", Diagnostic(failed, "invalid installation")) - checks["configuration"] = Diagnostic(failed, "Missing or invalid installed plugin configuration") - return checks - if node is None: - checks["server"] = Diagnostic(skipped, "Node is required for readonly probes") + destination = kimi_home() / "plugins/managed/powercontext" + try: + records = _registry(kimi_home() / "plugins/installed.json")["plugins"] + record = next((item for item in records if item["id"] == "powercontext"), None) + registered = record is not None and Path(record.get("root", "")).resolve() == destination.resolve() + enabled = registered and record.get("enabled") is True + checks["plugin"] = Diagnostic( + ok if enabled else failed, + "registered and enabled" if enabled else "disabled" if registered else "not installed", + ) + settings = _read_json(destination / "powercontext.json") + manifest = _read_json(destination / "kimi.plugin.json") + server = manifest["mcpServers"]["powercontext"] + capabilities = (record or {}).get("capabilities", {}).get("mcpServers", {}).get("powercontext", {}) + configured = ( + server.get("url") == _endpoint(settings["server_url"]) + "/mcp" + and server.get("bearerTokenEnvVar") + == ("POWERCONTEXT_KIMI_TOKEN" if settings.get("use_token") else None) + and not server.get("headers") + and server.get("enabledTools") == MEMORY_TOOLS + and server.get("enabled", True) is True + and capabilities.get("enabled", True) is True + ) + checks["mcp"] = Diagnostic( + ok if configured else failed, + "configured (not a live MCP check)" if configured else "disabled or configuration mismatch", + ) + hooks_ok = manifest.get("hooks") == [HOOK] and all( + (destination / name).is_file() for name in REQUIRED_FILES + ) + skill_ok = manifest.get("skills") == "./skills/" and manifest.get("sessionStart") == { + "skill": "powercontext-project-context" + } + checks["skill"] = Diagnostic( + ok if skill_ok else failed, "configured" if skill_ok else "Skill is not registered" + ) + checks["hook"] = Diagnostic( + ok if hooks_ok else failed, + "configured (injection not verified)" if hooks_ok else "missing or mismatched Hook files", + ) + except (SetupError, KeyError, TypeError, AttributeError, ValueError): + checks.setdefault("plugin", Diagnostic(failed, "invalid installation")) + checks["configuration"] = Diagnostic(failed, "Missing or invalid installed plugin configuration") + return checks + if node is None: + checks["server"] = Diagnostic(skipped, "Node is required for readonly probes") + return checks + try: + arguments = [node, str(destination / "scripts/doctor.mjs"), "--cwd", str((cwd or Path.cwd()).absolute())] + if runtime: + arguments.append("--runtime") + if session_id is not None: + arguments.extend(["--session-id", session_id]) + result = subprocess.run( # noqa: S603 - fixed Node script and no shell. + arguments, + capture_output=True, + text=True, + encoding="utf-8", + timeout=6, + check=False, + ) + report = json.loads(result.stdout) + if result.returncode or report.get("schema") != "powercontext.kimi-code.diagnostics.v2": + checks["probe"] = Diagnostic( + failed, "Unsupported installed diagnostics; run powercontext setup kimi-code" + ) + return checks + for name, value in report["checks"].items(): + checks[name] = Diagnostic(DiagnosticStatus(value["status"]), value["detail"], value.get("checks")) + if runtime: + checks["runtime"] = _runtime_diagnostic(report["runtime"]) + except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, TypeError): + checks["server"] = Diagnostic( + failed, "Readonly probes failed; run powercontext doctor kimi-code after repair" + ) return checks - try: - arguments = [node, str(destination / "scripts/doctor.mjs"), "--cwd", str((cwd or Path.cwd()).absolute())] - if runtime: - arguments.append("--runtime") - if session_id is not None: - arguments.extend(["--session-id", session_id]) - result = subprocess.run( # noqa: S603 - fixed Node script and no shell. - arguments, - capture_output=True, - text=True, - encoding="utf-8", - timeout=6, - check=False, - ) - report = json.loads(result.stdout) - if result.returncode or report.get("schema") != "powercontext.kimi-code.diagnostics.v1": - raise ValueError # noqa: TRY301 - invalid external probe output joins the failure path. - for name, value in report["checks"].items(): - checks[name] = Diagnostic(DiagnosticStatus(value["status"]), value["detail"], value.get("checks")) - if runtime: - checks["runtime"] = _runtime_diagnostic(report["runtime"]) - except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, TypeError): - checks["server"] = Diagnostic(failed, "Readonly probes failed; run powercontext doctor kimi-code after repair") - return checks def _runtime_diagnostic(history: dict[str, Any]) -> Diagnostic: diff --git a/src/powercontext/cli/native_transport.py b/src/powercontext/cli/native_transport.py index 1a7e9e4723..c9de87f064 100644 --- a/src/powercontext/cli/native_transport.py +++ b/src/powercontext/cli/native_transport.py @@ -73,21 +73,22 @@ def _environment_url(*names: str) -> str | None: def _codex_url() -> str | None: - cache = _home("CODEX_HOME", ".codex") / "plugins" / "cache" - try: - paths = list(cache.glob("*/powercontext/*/.mcp.json")) - except OSError: - raise ValueError(_UNKNOWN) from None - urls = set() - for path in paths: - entry = _object_at(_read(path), "mcpServers", "powercontext") - raw = entry.get("url") - if not isinstance(raw, str) or not raw.rstrip("/").endswith("/mcp"): - raise ValueError(_UNKNOWN) - urls.add(_url(raw)) - if len(urls) > 1: - raise ValueError("Cannot determine the active PowerContext endpoint from multiple Codex cache versions") # noqa: TRY003 - return next(iter(urls), None) + from powercontext.cli.system import _installed_codex_plugin + + if not (_home("CODEX_HOME", ".codex") / "plugins/cache").is_dir(): + return None + plugin = _installed_codex_plugin() + if plugin is None: + return None + path = ( + _home("CODEX_HOME", ".codex") + / "plugins/cache" + / plugin["marketplaceName"] + / "powercontext" + / plugin["version"] + / ".mcp.json" + ) + return _url(_object_at(_read(path), "mcpServers", "powercontext").get("url")) def _native_settings(host: str, dsh_target: DshTarget | None = None) -> tuple[dict[str, Any], str, str]: @@ -102,6 +103,14 @@ def _native_settings(host: str, dsh_target: DshTarget | None = None) -> tuple[di "baseUrl", "allowInsecureHttp", ) + if host == "kimi-code": + from powercontext.cli.kimi_code import kimi_home + + return ( + _read(kimi_home() / "plugins/managed/powercontext/powercontext.json"), + "server_url", + "allow_insecure_http", + ) if host == "claude-code": settings = _object_at( _read(_home("CLAUDE_CONFIG_DIR", ".claude") / "settings.json"), @@ -158,7 +167,7 @@ def _selected_url(host: str, prefix: str, native_url: str | None) -> str: return _environment_url(prefix + "_BASE_URL") or native_url or common_url or fallback if host == "workbuddy": return _environment_url(prefix + "_SERVER_URL") or common_url or fallback - if host == "zcode": + if host in {"zcode", "kimi-code"}: return native_url or _environment_url(prefix + "_SERVER_URL") or _DEFAULT_URL return environment_url or common_url or native_url or fallback diff --git a/src/powercontext/cli/openclaw.py b/src/powercontext/cli/openclaw.py index 39c44ddae5..6b04ef70dd 100644 --- a/src/powercontext/cli/openclaw.py +++ b/src/powercontext/cli/openclaw.py @@ -19,12 +19,16 @@ import json import os import re +import shutil import subprocess +import tempfile from pathlib import Path -from shutil import rmtree, which -from urllib.parse import urlsplit, urlunsplit +from shutil import which +from typing import Any, cast -from powercontext.cli.system import Diagnostic, DiagnosticStatus, OpenClawSetupResult, SetupError +from powercontext.cli.installation import publish +from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError +from powercontext.cli.transport import setup_transport_updates from powercontext.paths import powercontext_data_dir OPENCLAW_PLUGIN_RELATIVE = Path("integrations") / "openclaw" / "plugins" / "memory-powercontext" @@ -39,44 +43,9 @@ "powercontext_memory_revise", "powercontext_memory_retire", ) -_GITHUB_REPOSITORY = re.compile(r"^[^/\s]+/[^/\s]+$") _OPENCLAW_VERSION = re.compile(r"(?:OpenClaw\s+)?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?") -def install_openclaw_plugin( - *, - source: str, - ref: str, - server_url: str, - allow_insecure_http: bool = False, -) -> OpenClawSetupResult: - """Build, install, and configure the OpenClaw plugin from one source/ref.""" - - executable = openclaw_executable() - require_supported_openclaw(executable) - normalized_url = normalize_server_url(server_url) - data_dir = powercontext_data_dir() - try: - data_dir.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise SetupError.data_directory(data_dir, error) from error - - plugin_dir = resolve_openclaw_plugin_dir(source=source, ref=ref) - build_openclaw_plugin(plugin_dir) - run_openclaw(executable, "plugins", "install", "--link", "--force", str(plugin_dir)) - configure_openclaw( - executable=executable, - server_url=normalized_url, - allow_insecure_http=allow_insecure_http, - ) - return OpenClawSetupResult( - plugin=OPENCLAW_PLUGIN_NAME, - plugin_path=str(plugin_dir), - server_url=normalized_url, - data_dir=str(data_dir), - ) - - def openclaw_executable() -> str: """Return a subprocess-launchable OpenClaw executable path.""" @@ -85,7 +54,7 @@ def openclaw_executable() -> str: executable = which(candidate) if executable is not None: return executable - raise SetupError.openclaw_unavailable() + raise SetupError("OpenClaw CLI is not installed or is not on PATH.") # noqa: TRY003 def pnpm_executable() -> str: @@ -96,7 +65,7 @@ def pnpm_executable() -> str: executable = which(candidate) if executable is not None: return executable - raise SetupError.pnpm_unavailable() + raise SetupError("pnpm is not installed or is not on PATH; it is required to build the OpenClaw plugin.") # noqa: TRY003 def require_supported_openclaw(executable: str) -> str: @@ -106,105 +75,20 @@ def require_supported_openclaw(executable: str) -> str: version_text = (completed.stdout or "").strip() match = _OPENCLAW_VERSION.search(version_text) if match is None: - raise SetupError.unsupported_openclaw_version(version_text) + raise SetupError(f"OpenClaw {version_text or 'version unknown'} is unsupported; upgrade to >= 2026.8.1-beta.2") # noqa: TRY003 major = int(match.group(1)) minor = int(match.group(2)) patch = int(match.group(3)) beta = int(match.group(4)) if match.group(4) is not None else 10**9 actual = (major, minor, patch, beta) if actual < OPENCLAW_MIN_VERSION: - raise SetupError.unsupported_openclaw_version(version_text) + raise SetupError(f"OpenClaw {version_text or 'version unknown'} is unsupported; upgrade to >= 2026.8.1-beta.2") # noqa: TRY003 + help_text = run_process([executable, "plugins", "install", "--help"], timeout=30).stdout or "" + if "--no-enable" not in help_text: + raise SetupError("OpenClaw must support plugins install --no-enable; upgrade to a release such as 2026.9.7") # noqa: TRY003 return version_text -def resolve_openclaw_plugin_dir(*, source: str, ref: str) -> Path: - """Resolve a local checkout or materialize a remote Git ref.""" - - if is_local_source(source): - return plugin_dir_from_checkout(Path(source).expanduser().resolve()) - return plugin_dir_from_checkout(materialize_remote_checkout(source, ref)) - - -def plugin_dir_from_checkout(root: Path) -> Path: - """Accept either the plugin directory or a PowerContext repository root.""" - - if is_openclaw_plugin(root): - return root - plugin = root / OPENCLAW_PLUGIN_RELATIVE - if is_openclaw_plugin(plugin): - return plugin - raise SetupError.missing_openclaw_plugin(root) - - -def is_local_source(source: str) -> bool: - """Return whether a source names a local checkout.""" - - candidate = Path(source).expanduser() - return source.startswith((".", "/", "~")) or (len(source) >= 2 and source[1] == ":") or candidate.exists() - - -def is_openclaw_plugin(path: Path) -> bool: - """Return whether a directory contains the PowerContext OpenClaw plugin.""" - - package_file = path / "package.json" - if not package_file.is_file(): - return False - try: - package = json.loads(package_file.read_text(encoding="utf-8")) - except (OSError, ValueError): - return False - return isinstance(package, dict) and package.get("name") == OPENCLAW_PACKAGE_NAME - - -def materialize_remote_checkout(source: str, ref: str) -> Path: - """Clone a remote source into the user-owned PowerContext checkout cache.""" - - target = checkout_target(ref) - if is_openclaw_plugin(target) or is_openclaw_plugin(target / OPENCLAW_PLUGIN_RELATIVE): - return target - if target.exists(): - rmtree(target) - target.parent.mkdir(parents=True, exist_ok=True) - clone_github_source(source, ref, target) - return target - - -def checkout_target(ref: str) -> Path: - """Resolve a ref below the OpenClaw checkout cache without path traversal.""" - - if not ref or ref in {".", ".."} or "\x00" in ref: - raise SetupError.invalid_openclaw_ref(ref) - root = (powercontext_data_dir() / "checkouts" / OPENCLAW_CHECKOUT_ROOT).resolve() - target = (root / ref).resolve() - try: - target.relative_to(root) - except ValueError as error: - raise SetupError.invalid_openclaw_ref(ref) from error - if target == root: - raise SetupError.invalid_openclaw_ref(ref) - return target - - -def clone_github_source(source: str, ref: str, target: Path) -> None: - """Clone a GitHub slug or URL at a specific ref.""" - - command = ["git", "clone", "--depth", "1", "--branch", ref, github_clone_url(source), str(target)] - run_process(command, timeout=120) - - -def github_clone_url(source: str) -> str: - """Accept a GitHub slug or repository URL and return a clone URL.""" - - text = source.strip() - if text.startswith(("https://github.com/", "http://github.com/", "git@github.com:")): - return text if text.endswith(".git") else f"{text}.git" - if "://" in text or text.startswith("git@"): - raise SetupError.invalid_openclaw_source(source) - if _GITHUB_REPOSITORY.fullmatch(text): - return f"https://github.com/{text}.git" - raise SetupError.invalid_openclaw_source(source) - - def build_openclaw_plugin(plugin_dir: Path) -> None: """Install plugin dependencies and produce the runtime bundle.""" @@ -218,68 +102,16 @@ def build_openclaw_plugin(plugin_dir: Path) -> None: ) run_process([executable, "--dir", str(plugin_dir), "run", "build"], timeout=600, env=environment) if not (plugin_dir / "dist" / "index.js").is_file(): - raise SetupError.unbuilt_openclaw_plugin(plugin_dir) - - -def normalize_server_url(value: str) -> str: - """Normalize the configured HTTP endpoint without accepting embedded secrets.""" - - normalized = value.strip().rstrip("/") - parsed = urlsplit(normalized) - if parsed.scheme not in {"http", "https"} or parsed.hostname is None: - raise SetupError.openclaw_server_url_scheme() - if parsed.username is not None or parsed.password is not None: - raise SetupError.openclaw_server_url_credentials() - if parsed.query or parsed.fragment: - raise SetupError.openclaw_server_url_suffix() - return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) + raise SetupError(f"PowerContext OpenClaw plugin at {plugin_dir} is missing dist/index.js after build.") # noqa: TRY003 def configure_openclaw(*, executable: str, server_url: str, allow_insecure_http: bool = False) -> None: - """Write plugin, memory-slot, and coding-tool allowlist configuration.""" - + """Configure the plugin connection without changing host policy or restarting it.""" settings = [ - {"path": "plugins.entries.memory-powercontext.enabled", "value": True}, {"path": "plugins.entries.memory-powercontext.config.endpoint", "value": server_url}, {"path": "plugins.entries.memory-powercontext.config.allowInsecureHttp", "value": allow_insecure_http}, - {"path": "plugins.entries.memory-powercontext.config.autoRecall", "value": True}, - {"path": "plugins.entries.memory-powercontext.config.autoCapture", "value": True}, - {"path": "plugins.entries.memory-powercontext.hooks.allowConversationAccess", "value": True}, - {"path": "plugins.slots.memory", "value": OPENCLAW_PLUGIN_NAME}, ] - if read_config_value(executable, "gateway.mode") is None: - settings.insert(0, {"path": "gateway.mode", "value": "local"}) run_openclaw(executable, "config", "set", "--batch-json", json.dumps(settings, separators=(",", ":"))) - current = read_tools_allowlist(executable) - merged = list(current) - for tool in POWERCONTEXT_TOOLS: - if tool not in merged: - merged.append(tool) - run_openclaw( - executable, - "config", - "set", - "tools.alsoAllow", - json.dumps(merged, separators=(",", ":")), - "--strict-json", - ) - run_openclaw(executable, "gateway", "restart") - - -def read_tools_allowlist(executable: str) -> list[object]: - """Read the existing allowlist, treating an absent path as an empty list.""" - - command = [executable, "config", "get", "tools.alsoAllow", "--json"] - completed = run_process(command, timeout=60, check=False) - if completed.returncode != 0: - return [] - try: - value = json.loads(completed.stdout or "[]") - except json.JSONDecodeError as error: - raise SetupError.invalid_command_output(command, "invalid JSON") from error - if not isinstance(value, list): - raise SetupError.invalid_command_output(command, "tools.alsoAllow is not an array") - return value def read_config_value(executable: str, path: str) -> object | None: @@ -287,12 +119,19 @@ def read_config_value(executable: str, path: str) -> object | None: command = [executable, "config", "get", path, "--json"] completed = run_process(command, timeout=60, check=False) - if completed.returncode != 0: - return None try: - return json.loads(completed.stdout or "null") + value = json.loads(completed.stdout) except json.JSONDecodeError as error: - raise SetupError.invalid_command_output(command, "invalid JSON") from error + raise SetupError(f"Cannot read OpenClaw configuration: {path}") from error # noqa: TRY003 + if completed.returncode != 0: + if ( + isinstance(value, dict) + and value.get("ok") is False + and value.get("error", {}).get("message", "").startswith(f"Config path is valid but unset: {path}.") + ): + return None + raise SetupError(f"Cannot read OpenClaw configuration: {path}") # noqa: TRY003 + return value def run_openclaw(executable: str, *arguments: str) -> subprocess.CompletedProcess[str]: @@ -322,54 +161,13 @@ def run_process( env=env, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 if check and completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" - raise SetupError.command_failed(command, detail) + raise SetupError(f"`{' '.join(command)}` failed: {detail}") # noqa: TRY003 return completed -def run_openclaw_diagnostics() -> dict[str, Diagnostic]: - """Collect diagnostics for the optional OpenClaw integration.""" - - try: - executable = openclaw_executable() - except SetupError: - return { - "openclaw": Diagnostic( - status=DiagnosticStatus.FAILED, - detail="OpenClaw CLI is not installed or is not on PATH", - ), - "plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because OpenClaw CLI is unavailable", - ), - } - try: - output = run_openclaw(executable, "plugins", "list", "--enabled", "--json").stdout or "" - active_memory_plugin = read_config_value(executable, "plugins.slots.memory") - installed = openclaw_plugin_installed(output, active_memory_plugin=active_memory_plugin) - except SetupError as error: - return { - "openclaw": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), - "plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="plugin list is unavailable", - ), - } - return { - "openclaw": Diagnostic(status=DiagnosticStatus.OK, detail=executable), - "plugin": Diagnostic( - status=DiagnosticStatus.OK if installed else DiagnosticStatus.FAILED, - detail=( - f"{OPENCLAW_PLUGIN_NAME} is installed and active" - if installed - else "PowerContext OpenClaw plugin is not enabled, loaded, and selected as the memory plugin" - ), - ), - } - - def openclaw_plugin_installed(output: str, *, active_memory_plugin: object | None = None) -> bool: """Return whether OpenClaw reports the PowerContext plugin as the active memory plugin.""" @@ -377,13 +175,13 @@ def openclaw_plugin_installed(output: str, *, active_memory_plugin: object | Non try: payload = json.loads(output) except json.JSONDecodeError as error: - raise SetupError.invalid_command_output(command, "invalid JSON") from error + raise SetupError(f"`{' '.join(command)}` returned invalid JSON") from error # noqa: TRY003 if not isinstance(payload, dict) or not isinstance(payload.get("plugins"), list): - raise SetupError.invalid_command_output(command, "an invalid plugin list") + raise SetupError(f"`{' '.join(command)}` returned an invalid plugin list") # noqa: TRY003 for plugin in payload["plugins"]: if not isinstance(plugin, dict): - raise SetupError.invalid_command_output(command, "an invalid plugin entry") + raise SetupError(f"`{' '.join(command)}` returned an invalid plugin entry") # noqa: TRY003 if plugin.get("id") != OPENCLAW_PLUGIN_NAME: continue memory_slot_selected = plugin.get("memorySlotSelected") @@ -398,18 +196,146 @@ def openclaw_plugin_installed(output: str, *, active_memory_plugin: object | Non "OPENCLAW_PLUGIN_NAME", "POWERCONTEXT_TOOLS", "build_openclaw_plugin", - "checkout_target", "configure_openclaw", - "github_clone_url", - "install_openclaw_plugin", - "is_openclaw_plugin", - "materialize_remote_checkout", - "normalize_server_url", "openclaw_executable", "openclaw_plugin_installed", - "plugin_dir_from_checkout", - "read_tools_allowlist", - "resolve_openclaw_plugin_dir", "run_openclaw", - "run_openclaw_diagnostics", ] + + +class OpenClawInstaller: + def install(self, source): + executable = openclaw_executable() + require_supported_openclaw(executable) + with source.package("openclaw") as package: + with tempfile.TemporaryDirectory(prefix="powercontext-openclaw-") as temporary: + staging = Path(temporary) + candidate = staging / "plugin" + shutil.copytree(package, candidate, ignore=shutil.ignore_patterns("node_modules", ".git")) + build_openclaw_plugin(candidate) + # Archive installation delegates runtime dependencies and replacement to OpenClaw. + run_process( + [pnpm_executable(), "--dir", str(candidate), "pack", "--pack-destination", str(staging)], + timeout=120, + ) + (archive,) = staging.glob("*.tgz") + run_openclaw(executable, "plugins", "install", "--force", "--no-enable", str(archive)) + try: + record = json.loads( + run_openclaw(executable, "plugins", "inspect", OPENCLAW_PLUGIN_NAME, "--json").stdout + ) + path = record["install"]["installPath"] + if not isinstance(path, str) or not Path(path).is_dir(): + raise ValueError # noqa: TRY301 + except (ValueError, KeyError, TypeError) as error: + raise SetupError("OpenClaw managed installation path could not be read back") from error # noqa: TRY003 + return { + "plugin": OPENCLAW_PLUGIN_NAME, + "plugin_path": path, + "server_url": "", + "data_dir": str(powercontext_data_dir()), + } + + def configure(self, connection): + executable = openclaw_executable() + run_openclaw(executable, "plugins", "inspect", OPENCLAW_PLUGIN_NAME, "--json") + endpoint = connection.server_url + publish( + writes=setup_transport_updates(connection), + action=lambda: configure_openclaw( + executable=executable, server_url=endpoint, allow_insecure_http=connection.allow_insecure_http + ), + ) + return {"server_url": endpoint} + + def enable(self) -> None: # noqa: C901 - initialize only absent native activation settings. + executable = openclaw_executable() + plugins = read_config_value(executable, "plugins") + if plugins is None: + plugins = {} + if not isinstance(plugins, dict): + raise SetupError("OpenClaw plugins configuration must be an object") # noqa: TRY003 + plugins = cast(dict[str, Any], plugins) + entry = plugins.get("entries", {}).get(OPENCLAW_PLUGIN_NAME, {}) + slot = plugins.get("slots", {}).get("memory") + if ( + plugins.get("enabled") is False + or entry.get("enabled") is False + or OPENCLAW_PLUGIN_NAME in plugins.get("deny", []) + or slot not in {None, OPENCLAW_PLUGIN_NAME} + ): + return + settings = [] + defaults = { + "plugins.slots.memory": (slot, OPENCLAW_PLUGIN_NAME), + "plugins.entries.memory-powercontext.config.autoRecall": (entry.get("config", {}).get("autoRecall"), True), + "plugins.entries.memory-powercontext.config.autoCapture": ( + entry.get("config", {}).get("autoCapture"), + True, + ), + "plugins.entries.memory-powercontext.hooks.allowConversationAccess": ( + entry.get("hooks", {}).get("allowConversationAccess"), + True, + ), + } + for path, (current, default) in defaults.items(): + if current is None: + settings.append({"path": path, "value": default}) + if slot is None: + allowed = read_config_value(executable, "tools.alsoAllow") + if allowed is None: + allowed = [] + if not isinstance(allowed, list): + raise SetupError("OpenClaw tools.alsoAllow must be an array") # noqa: TRY003 + tools = allowed + [tool for tool in POWERCONTEXT_TOOLS if tool not in allowed] + if tools != allowed: + settings.append({"path": "tools.alsoAllow", "value": tools}) + activate = entry.get("enabled") is not True + if settings: + run_openclaw(executable, "config", "set", "--batch-json", json.dumps(settings, separators=(",", ":"))) + if activate: + run_openclaw(executable, "plugins", "enable", OPENCLAW_PLUGIN_NAME) + if activate or settings: + run_openclaw(executable, "gateway", "restart") + + def verify(self) -> dict[str, Diagnostic]: + """Collect diagnostics for the optional OpenClaw integration.""" + + try: + executable = openclaw_executable() + except SetupError: + return { + "openclaw": Diagnostic( + status=DiagnosticStatus.FAILED, + detail="OpenClaw CLI is not installed or is not on PATH", + ), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because OpenClaw CLI is unavailable", + ), + } + try: + output = run_openclaw(executable, "plugins", "list", "--enabled", "--json").stdout or "" + active_memory_plugin = read_config_value(executable, "plugins.slots.memory") + installed = openclaw_plugin_installed(output, active_memory_plugin=active_memory_plugin) + except SetupError as error: + return { + "openclaw": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="plugin list is unavailable", + ), + } + return { + "openclaw": Diagnostic(status=DiagnosticStatus.OK, detail=executable), + "plugin": Diagnostic( + status=DiagnosticStatus.OK if installed else DiagnosticStatus.FAILED, + detail=( + f"{OPENCLAW_PLUGIN_NAME} is installed, enabled and selected in native configuration; " + "running Gateway loading is not observed" + if installed + else "PowerContext OpenClaw plugin is not enabled and selected as the memory plugin" + ), + checks={"running_host_configuration": "not_observed"}, + ), + } diff --git a/src/powercontext/cli/opencode.py b/src/powercontext/cli/opencode.py index 8ff21f0674..81d73040dd 100644 --- a/src/powercontext/cli/opencode.py +++ b/src/powercontext/cli/opencode.py @@ -16,7 +16,6 @@ from __future__ import annotations -import hashlib import json import os import re @@ -25,8 +24,6 @@ import subprocess import tempfile import time -from contextlib import suppress -from dataclasses import dataclass from pathlib import Path from shutil import which from typing import cast @@ -35,10 +32,10 @@ from urllib.request import url2pathname, urlopen from uuid import uuid4 -from powercontext.cli.git_source import InvalidGitHubSourceError, clone_github_source, github_clone_url -from powercontext.cli.git_source import is_local_source as _is_local_source -from powercontext.cli.guidance import HOST_GUIDANCE, SKILL_FILE, GuidanceError, refresh_skill, write_if_changed +from powercontext.cli.guidance import HOST_GUIDANCE, SKILL_FILE, refresh_skill, write_if_changed +from powercontext.cli.installation import publish from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError +from powercontext.cli.transport import configure_connection from powercontext.paths import powercontext_data_dir OPENCODE_PLUGIN_NAME = "powercontext-opencode" @@ -50,27 +47,17 @@ PLUGIN_MANIFEST = ".powercontext-opencode.json" MINIMUM_VERSION = (1, 18, 21) _VERSION = re.compile(r"^(?P\d+)\.(?P\d+)\.(?P\d+)") -_COMMIT = re.compile(r"^[0-9a-f]{40,64}$") _ACTIVATION_PROBE_PATH = "POWERCONTEXT_OPENCODE_ACTIVATION_PROBE_PATH" _ACTIVATION_PROBE_NONCE = "POWERCONTEXT_OPENCODE_ACTIVATION_PROBE_NONCE" _ACTIVATION_PROBE_TIMEOUT = 15 -@dataclass(frozen=True, slots=True) -class OpenCodeSetupResult: - plugin: str - plugin_path: str - skill_path: str - data_dir: str - authorization_state: str = "not_attempted" - - def opencode_executable() -> str: """Return a subprocess-launchable OpenCode CLI path.""" executable = which("opencode") if executable is None: - raise SetupError.opencode_unavailable() + raise SetupError("OpenCode CLI is not installed or is not on PATH.") # noqa: TRY003 return executable @@ -78,160 +65,13 @@ def _version() -> str: value = _run_opencode("--version").strip() match = _VERSION.match(value) if match is None: - raise SetupError.invalid_command_output([opencode_executable(), "--version"], "an invalid version") + raise SetupError(f"`{' '.join([opencode_executable(), '--version'])}` returned an invalid version") # noqa: TRY003 current = tuple(int(match.group(name)) for name in ("major", "minor", "patch")) if current[0] != 1 or current < MINIMUM_VERSION: - raise SetupError.unsupported_opencode_version(value) - return value - - -def install_opencode_plugin(*, source: str, ref: str, server_url: str = "http://127.0.0.1:8000") -> OpenCodeSetupResult: - """Install the plugin and its owned global Skill from one checkout.""" - - opencode_executable() - _version() - data_dir = powercontext_data_dir() - try: - data_dir.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise SetupError.data_directory(data_dir, error) from error - plugin_dir = resolve_opencode_plugin_dir(source=source, ref=ref) - require_complete_plugin(plugin_dir) - config_dir = opencode_config_dir() - plugin_target = config_dir / "plugins" / f"{OPENCODE_PLUGIN_NAME}.js" - tui_source = plugin_dir / OPENCODE_TUI_BUNDLE - legacy_tui_target = config_dir / "plugins" / f"{OPENCODE_PLUGIN_NAME}-tui.js" - skill_target = config_dir / "skills" / "powercontext-project-context" - _require_no_pending_recovery(skill_target, config_dir=config_dir) - require_replaceable_plugin(plugin_target) - require_replaceable_plugin(legacy_tui_target) - require_replaceable_skill(skill_target) - tui_config = _prepare_tui_config(config_dir, tui_source) - _install_opencode_files(plugin_dir, plugin_target, legacy_tui_target, skill_target, tui_config) - from powercontext.cli.authorization import ( - configure_stored_authorization, - setup_authorization_value, - ) - - return OpenCodeSetupResult( - plugin=OPENCODE_PLUGIN_NAME, - plugin_path=str(plugin_dir), - skill_path=str(skill_target), - data_dir=str(data_dir), - authorization_state=configure_stored_authorization( - "opencode", - server_url=server_url, - value=setup_authorization_value("opencode"), - ), - ) - - -def _install_opencode_files( - plugin_dir: Path, - plugin_target: Path, - legacy_tui_target: Path, - skill_target: Path, - tui_config: tuple[Path, str | None], -) -> None: - """Prepare guidance before writes, and restore the installation on a caught commit failure.""" - config_dir = plugin_target.parent.parent - try: - config_dir.mkdir(parents=True, exist_ok=True) - transaction = Path(tempfile.mkdtemp(prefix=".powercontext-install-", dir=config_dir)) - except OSError as error: - raise SetupError.command_unavailable(["stage", "OpenCode", "installation"], error) from error - keep_backups = False - try: - staged_skill = transaction / "skill" - _stage_skill(plugin_dir / OPENCODE_SKILL.parent, skill_target, staged_skill) - backups: list[tuple[Path, Path | None]] = [] - for target in ( - plugin_target, - _plugin_manifest_path(plugin_target), - tui_config[0], - legacy_tui_target, - ): - backup = transaction / target.name if target.exists() else None - if backup is not None: - shutil.copy2(target, backup) - backups.append((target, backup)) - try: - _install_plugin(plugin_dir / OPENCODE_BUNDLE, plugin_target) - _commit_tui_config(tui_config) - legacy_tui_target.unlink(missing_ok=True) - # Skill replacement is last and restores its old directory if the swap fails. - _commit_skill(staged_skill, skill_target) - except BaseException as error: - failed = _restore_opencode_files(backups) - if failed: - keep_backups = True - message = ( - f"OpenCode installation recovery failed for {', '.join(failed)}; backups retained at {transaction}. " - f"Initial failure: {error}" - ) - raise SetupError(message) from error - raise - except (OSError, GuidanceError) as error: - raise SetupError.command_unavailable(["install", "OpenCode", "plugin and Skill"], error) from error - finally: - if not keep_backups: - with suppress(OSError): - shutil.rmtree(transaction) - - -def _restore_opencode_files(backups: list[tuple[Path, Path | None]]) -> list[str]: - """Attempt every file restoration even if one path is unavailable.""" - failed: list[str] = [] - for target, backup in reversed(backups): - try: - if backup is None: - target.unlink(missing_ok=True) - else: - os.replace(backup, target) - except OSError: - failed.append(str(target)) - return failed - - -def _require_no_pending_recovery(skill_target: Path, *, config_dir: Path | None = None) -> None: - """Do not mistake a failed restoration for a new installation.""" - locations = [(skill_target.parent, f".{skill_target.name}.", ".bak")] - if config_dir is not None: - locations.append((config_dir, ".powercontext-install-", "")) - retained: list[Path] = [] - for directory, prefix, suffix in locations: - try: - retained.extend( - path for path in directory.iterdir() if path.name.startswith(prefix) and path.name.endswith(suffix) - ) - except FileNotFoundError: - continue - except OSError as error: - raise SetupError.command_unavailable(["inspect", "OpenCode", "recovery", str(directory)], error) from error - if retained: - paths = ", ".join(str(path) for path in sorted(retained)) - message = ( - f"Pending OpenCode recovery at {paths}. " - "Restore or reconcile the retained contents before removing these paths and retrying installation." + raise SetupError( # noqa: TRY003 + f"OpenCode v{value} is unsupported; PowerContext requires OpenCode v1.18.21 or newer in the 1.x line." ) - raise SetupError(message) - - -def resolve_opencode_plugin_dir(*, source: str, ref: str) -> Path: - """Return the OpenCode package directory for a local checkout or Git ref.""" - - if _is_local_source(source): - return plugin_dir_from_checkout(Path(source).expanduser().resolve()) - return plugin_dir_from_checkout(_materialize_remote_checkout(source, ref)) - - -def plugin_dir_from_checkout(root: Path) -> Path: - if _is_opencode_plugin(root): - return root - plugin = root / OPENCODE_PLUGIN_RELATIVE - if _is_opencode_plugin(plugin): - return plugin - raise SetupError.missing_opencode_plugin(root) + return value def require_complete_plugin(path: Path) -> None: @@ -240,29 +80,9 @@ def require_complete_plugin(path: Path) -> None: or not (path / OPENCODE_TUI_BUNDLE).is_file() or not (path / OPENCODE_SKILL).is_file() ): - raise SetupError.incomplete_opencode_plugin(path) - - -def checkout_target(source: str, ref: str, resolved_commit: str) -> Path: - root = (powercontext_data_dir() / "checkouts" / "opencode").resolve() - if not ref or ref in {".", ".."} or "\x00" in ref: - raise SetupError.invalid_opencode_ref(ref) - try: - normalized_source = _normalized_source_identity(source) - except InvalidGitHubSourceError: - raise SetupError.invalid_opencode_source() from None - if _COMMIT.fullmatch(resolved_commit) is None: - raise SetupError.git_clone_failed() - source_key = hashlib.sha256(normalized_source.encode()).hexdigest()[:16] - ref_key = hashlib.sha256(ref.encode()).hexdigest()[:16] - target = (root / source_key / ref_key / resolved_commit).resolve() - try: - target.relative_to(root) - except ValueError as error: - raise SetupError.invalid_opencode_ref(ref) from error - if target == root: - raise SetupError.invalid_opencode_ref(ref) - return target + raise SetupError( # noqa: TRY003 + f"PowerContext OpenCode plugin at {path} is missing lib/index.js, lib/tui.js, or powercontext-project-context Skill." + ) def opencode_config_dir() -> Path: @@ -271,32 +91,17 @@ def opencode_config_dir() -> Path: key, separator, value = line.strip().partition(" ") if key == "config" and separator and value.strip(): return Path(value.strip()).expanduser().resolve() - raise SetupError.invalid_command_output([opencode_executable(), "debug", "paths"], "no config path") + raise SetupError(f"`{' '.join([opencode_executable(), 'debug', 'paths'])}` returned no config path") # noqa: TRY003 def require_replaceable_skill(target: Path) -> None: if target.exists() and not _owned_skill(target): - raise SetupError.opencode_skill_conflict(target) + raise SetupError(f"OpenCode Skill path {target} already exists and is not owned by PowerContext.") # noqa: TRY003 def require_replaceable_plugin(target: Path) -> None: if target.exists() and not _owned_plugin(target): - raise SetupError.opencode_plugin_conflict(target) - - -def _install_skill(source: Path, target: Path) -> None: - _require_no_pending_recovery(target) - require_replaceable_skill(target) - target.parent.mkdir(parents=True, exist_ok=True) - staging = target.parent / f".{target.name}.{uuid4().hex}.tmp" - try: - _stage_skill(source, target, staging) - _commit_skill(staging, target) - except (OSError, GuidanceError) as error: - raise SetupError.command_unavailable(["install", "OpenCode", "Skill"], error) from error - finally: - if staging.exists(): - shutil.rmtree(staging) + raise SetupError(f"OpenCode plugin path {target} already exists and is not owned by PowerContext.") # noqa: TRY003 def _stage_skill(source: Path, target: Path, staging: Path) -> None: @@ -311,28 +116,6 @@ def _stage_skill(source: Path, target: Path, staging: Path) -> None: ) -def _commit_skill(staging: Path, target: Path) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - backup: Path | None = None - try: - if target.exists(): - backup = target.parent / f".{target.name}.{uuid4().hex}.bak" - os.replace(target, backup) - os.replace(staging, target) - except BaseException: - if backup is not None and backup.exists() and not target.exists(): - try: - os.replace(backup, target) - except OSError as error: - message = f"OpenCode Skill recovery failed for {target}; backup retained at {backup}" - raise SetupError(message) from error - raise - else: - if backup is not None: - with suppress(OSError): - shutil.rmtree(backup) - - def _owned_skill(path: Path) -> bool: try: payload = json.loads((path / SKILL_MANIFEST).read_text(encoding="utf-8")) @@ -353,30 +136,6 @@ def _owned_plugin(path: Path) -> bool: return payload == {"schema": 1, "owner": "powercontext", "integration": "opencode-plugin"} -def _install_plugin(source: Path, target: Path) -> None: - require_replaceable_plugin(target) - target.parent.mkdir(parents=True, exist_ok=True) - staging = target.parent / f".{target.name}.tmp" - manifest = _plugin_manifest_path(target) - manifest_staging = target.parent / f".{manifest.name}.tmp" - try: - shutil.copy2(source, staging) - manifest_staging.write_text( - json.dumps({"schema": 1, "owner": "powercontext", "integration": "opencode-plugin"}, indent=2) + "\n", - encoding="utf-8", - ) - # Install the ownership manifest first: an interruption then leaves a manifest without a - # plugin, which the next setup run replaces instead of an unowned plugin conflict. - os.replace(manifest_staging, manifest) - os.replace(staging, target) - except OSError as error: - with suppress(OSError): - staging.unlink() - with suppress(OSError): - manifest_staging.unlink() - raise SetupError.command_unavailable(["install", "OpenCode", "plugin"], error) from error - - def _tui_config_path(config_dir: Path) -> Path: for name in ("tui.json", "tui.jsonc"): candidate = config_dir / name @@ -411,7 +170,7 @@ def _owns_tui_entry(entry: object, *, target: Path) -> bool: return True if path.name != OPENCODE_TUI_BUNDLE.name: return False - if _is_opencode_plugin(path.parent.parent): + if HOST_GUIDANCE["opencode"].recognizes(path.parent.parent): return True return _is_opencode_checkout_entry(path) @@ -605,7 +364,7 @@ def _prepare_tui_config(config_dir: Path, plugin_path: Path) -> tuple[Path, str try: payload, plugins = _read_tui_config(config_path) except (OSError, TypeError, ValueError) as error: - raise SetupError.command_unavailable(["read", "OpenCode", "TUI config"], error) from error + raise SetupError(f"Cannot run {' '.join(['read', 'OpenCode', 'TUI config'])}: {error}") from error # noqa: TRY003 target = plugin_path.resolve() resolved = [entry for entry in plugins if not _owns_tui_entry(entry, target=target)] if not any(_tui_entry_path(entry) == target for entry in resolved): @@ -621,93 +380,6 @@ def _prepare_tui_config(config_dir: Path, plugin_path: Path) -> tuple[Path, str return config_path, json.dumps(payload, indent=2) + "\n" -def _commit_tui_config(prepared: tuple[Path, str | None]) -> None: - config_path, serialized = prepared - if serialized is None: - return - staging = config_path.with_name(f".{config_path.name}.tmp") - try: - staging.write_text(serialized, encoding="utf-8") - os.replace(staging, config_path) - except OSError as error: - with suppress(OSError): - staging.unlink() - raise SetupError.command_unavailable(["install", "OpenCode", "TUI plugin"], error) from error - - -def _is_opencode_plugin(path: Path) -> bool: - try: - payload = json.loads((path / "package.json").read_text(encoding="utf-8")) - except (OSError, ValueError): - return False - return payload.get("name") == OPENCODE_PLUGIN_NAME - - -def _materialize_remote_checkout(source: str, ref: str) -> Path: - if not ref or ref in {".", ".."} or "\x00" in ref: - raise SetupError.invalid_opencode_ref(ref) - try: - normalized_source = _normalized_source_identity(source) - except InvalidGitHubSourceError: - raise SetupError.invalid_opencode_source() from None - root = (powercontext_data_dir() / "checkouts" / "opencode").resolve() - source_key = hashlib.sha256(normalized_source.encode()).hexdigest()[:16] - ref_key = hashlib.sha256(ref.encode()).hexdigest()[:16] - staging_parent = root / source_key / ref_key - staging_parent.mkdir(parents=True, exist_ok=True) - staging = Path(tempfile.mkdtemp(prefix=".checkout-", dir=staging_parent)) - try: - clone_github_source(source, ref, staging) - resolved_commit = _checkout_commit(staging) - target = checkout_target(source, ref, resolved_commit) - if _is_opencode_plugin(target) or _is_opencode_plugin(target / OPENCODE_PLUGIN_RELATIVE): - return target - _replace_checkout(staging, target) - finally: - if staging.exists(): - shutil.rmtree(staging) - return target - - -def _normalized_source_identity(source: str) -> str: - clone_url = github_clone_url(source) - if clone_url.startswith("git@github.com:"): - repository = clone_url.removeprefix("git@github.com:") - else: - repository = urlparse(clone_url).path.lstrip("/") - return f"github.com/{repository.removesuffix('.git').casefold()}" - - -def _checkout_commit(path: Path) -> str: - command = ["git", "-C", str(path), "rev-parse", "--verify", "HEAD"] - try: - completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=10) # noqa: S603 - except (OSError, subprocess.SubprocessError) as error: - raise SetupError.git_clone_failed() from error - commit = completed.stdout.strip().lower() - if completed.returncode != 0 or _COMMIT.fullmatch(commit) is None: - raise SetupError.git_clone_failed() - return commit - - -def _replace_checkout(staging: Path, target: Path) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - backup = target.with_name(f".{target.name}.{uuid4().hex}.bak") - moved_old = False - try: - if target.exists(): - os.replace(target, backup) - moved_old = True - os.replace(staging, target) - except BaseException: - if moved_old and not target.exists() and backup.exists(): - os.replace(backup, target) - raise - finally: - if backup.exists() and target.exists(): - shutil.rmtree(backup) - - def _configured_plugin(output: str) -> bool: payload = _debug_config_payload(output) if payload is None: @@ -725,7 +397,7 @@ def _configured_plugin(output: str) -> bool: path = Path(url2pathname(f"{authority}{parsed.path}")) else: path = Path(spec) - if _is_opencode_plugin(path) or _is_opencode_plugin(path.parent): + if HOST_GUIDANCE["opencode"].recognizes(path) or HOST_GUIDANCE["opencode"].recognizes(path.parent): return True return False @@ -746,51 +418,6 @@ def _debug_config_payload(output: str) -> dict[str, object] | None: return None -def run_opencode_diagnostics() -> dict[str, Diagnostic]: - """Collect model-free diagnostics for the optional OpenCode integration.""" - - try: - executable = opencode_executable() - actual = _version() - except SetupError as error: - return { - "opencode": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), - "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="not checked because OpenCode is unavailable"), - "skill": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="not checked because OpenCode is unavailable"), - } - try: - config_dir = opencode_config_dir() - output, activated = _probe_plugin_activation() - configured = _configured_plugin(output) or _owned_plugin(config_dir / "plugins" / f"{OPENCODE_PLUGIN_NAME}.js") - except SetupError as error: - return { - "opencode": Diagnostic(status=DiagnosticStatus.OK, detail=f"{executable} ({actual})"), - "plugin": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), - "skill": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="not checked because config is unavailable"), - } - skill = config_dir / "skills" / "powercontext-project-context" - skill_ok = _owned_skill(skill) and (skill / "SKILL.md").is_file() - return { - "opencode": Diagnostic(status=DiagnosticStatus.OK, detail=f"{executable} ({actual})"), - "plugin": Diagnostic( - status=DiagnosticStatus.OK if configured and activated else DiagnosticStatus.FAILED, - detail=( - f"{OPENCODE_PLUGIN_NAME} is configured and active" - if configured and activated - else ( - "PowerContext OpenCode plugin is configured but did not activate" - if configured - else "PowerContext OpenCode plugin is not configured" - ) - ), - ), - "skill": Diagnostic( - status=DiagnosticStatus.OK if skill_ok else DiagnosticStatus.FAILED, - detail=(str(skill) if skill_ok else "PowerContext OpenCode Skill is not installed"), - ), - } - - def _probe_plugin_activation() -> tuple[str, bool]: token = uuid4().hex with tempfile.TemporaryDirectory(prefix="powercontext-opencode-probe-") as directory: @@ -817,7 +444,7 @@ def _probe_plugin_activation() -> tuple[str, bool]: }, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 try: activated = path.read_text(encoding="utf-8") == token except OSError: @@ -856,7 +483,7 @@ def _run_opencode_probe(command: list[str], env: dict[str, str]) -> None: time.sleep(0.05) raise subprocess.TimeoutExpired(command, _ACTIVATION_PROBE_TIMEOUT) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 finally: if process is not None and process.poll() is None: process.terminate() @@ -887,24 +514,107 @@ def _run_opencode(*arguments: str, env: dict[str, str] | None = None) -> str: env=None if env is None else os.environ | env, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 if completed.returncode != 0: detail = ( (completed.stderr or "").strip() or (completed.stdout or "").strip() or f"exit code {completed.returncode}" ) - raise SetupError.command_failed(command, detail) + raise SetupError(f"`{' '.join(command)}` failed: {detail}") # noqa: TRY003 return completed.stdout or "" -__all__ = [ - "OPENCODE_PLUGIN_NAME", - "OpenCodeSetupResult", - "checkout_target", - "install_opencode_plugin", - "opencode_config_dir", - "opencode_executable", - "plugin_dir_from_checkout", - "require_complete_plugin", - "resolve_opencode_plugin_dir", - "run_opencode_diagnostics", -] +__all__ = ["OPENCODE_PLUGIN_NAME", "opencode_config_dir", "opencode_executable", "require_complete_plugin"] + + +class OpenCodeInstaller: + def install(self, source): + opencode_executable() + _version() + root = opencode_config_dir() + with source.package("opencode", linked=True) as package: + require_complete_plugin(package) + plugin = root / "plugins" / f"{OPENCODE_PLUGIN_NAME}.js" + legacy = plugin.with_name(f"{OPENCODE_PLUGIN_NAME}-tui.js") + skill = root / "skills" / "powercontext-project-context" + require_replaceable_plugin(plugin) + require_replaceable_plugin(legacy) + require_replaceable_skill(skill) + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".powercontext-", dir=root) as temporary: + candidate = Path(temporary) / "skill" + _stage_skill(package / OPENCODE_SKILL.parent, skill, candidate) + writes = { + _plugin_manifest_path(plugin): ( + json.dumps({"schema": 1, "owner": "powercontext", "integration": "opencode-plugin"}, indent=2) + + "\n" + ).encode(), + plugin: (package / OPENCODE_BUNDLE).read_bytes(), + legacy: None, + } + tui_path, text = _prepare_tui_config(root, package / OPENCODE_TUI_BUNDLE) + if text is not None: + writes[tui_path] = text.encode() + publish([(candidate, skill)], writes) + return { + "plugin": OPENCODE_PLUGIN_NAME, + "plugin_path": str(package), + "skill_path": str(skill), + "data_dir": str(powercontext_data_dir()), + } + + def configure(self, connection): + if not (opencode_config_dir() / "plugins" / f"{OPENCODE_PLUGIN_NAME}.js").is_file(): + raise SetupError("PowerContext is not installed in opencode; run powercontext setup opencode") # noqa: TRY003 + return {"authorization_state": configure_connection(connection)} + + def verify(self) -> dict[str, Diagnostic]: + """Collect model-free diagnostics for the optional OpenCode integration.""" + + try: + executable = opencode_executable() + actual = _version() + except SetupError as error: + return { + "opencode": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, detail="not checked because OpenCode is unavailable" + ), + "skill": Diagnostic( + status=DiagnosticStatus.SKIPPED, detail="not checked because OpenCode is unavailable" + ), + } + try: + config_dir = opencode_config_dir() + output, activated = _probe_plugin_activation() + configured = _configured_plugin(output) or _owned_plugin( + config_dir / "plugins" / f"{OPENCODE_PLUGIN_NAME}.js" + ) + except SetupError as error: + return { + "opencode": Diagnostic(status=DiagnosticStatus.OK, detail=f"{executable} ({actual})"), + "plugin": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), + "skill": Diagnostic( + status=DiagnosticStatus.SKIPPED, detail="not checked because config is unavailable" + ), + } + skill = config_dir / "skills" / "powercontext-project-context" + skill_ok = _owned_skill(skill) and (skill / "SKILL.md").is_file() + return { + "opencode": Diagnostic(status=DiagnosticStatus.OK, detail=f"{executable} ({actual})"), + "plugin": Diagnostic( + status=DiagnosticStatus.OK if configured and activated else DiagnosticStatus.FAILED, + detail=( + f"{OPENCODE_PLUGIN_NAME} is configured and active" + if configured and activated + else ( + "PowerContext OpenCode plugin is configured but did not activate" + if configured + else "PowerContext OpenCode plugin is not configured" + ) + ), + ), + "skill": Diagnostic( + status=DiagnosticStatus.OK if skill_ok else DiagnosticStatus.FAILED, + detail=(str(skill) if skill_ok else "PowerContext OpenCode Skill is not installed"), + ), + } diff --git a/src/powercontext/cli/pi.py b/src/powercontext/cli/pi.py index 1b1615aad8..db22101e3e 100644 --- a/src/powercontext/cli/pi.py +++ b/src/powercontext/cli/pi.py @@ -16,27 +16,17 @@ from __future__ import annotations -import json import os import shutil import subprocess import tempfile -from dataclasses import dataclass from pathlib import Path from shutil import which -from powercontext.cli.git_source import ( - InvalidGitHubSourceError, - clone_github_source, -) -from powercontext.cli.git_source import ( - github_clone_url as _github_clone_url, -) -from powercontext.cli.git_source import ( - is_local_source as _is_local_source, -) -from powercontext.cli.guidance import HOST_GUIDANCE, SKILL_FILE -from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError +from powercontext.cli.guidance import HOST_GUIDANCE, SKILL_FILE, preserve_installed_guidance +from powercontext.cli.installation import SetupError, publish +from powercontext.cli.system import Diagnostic, DiagnosticStatus +from powercontext.cli.transport import configure_connection from powercontext.paths import powercontext_data_dir PI_PACKAGE_NAME = "powercontext-pi" @@ -45,102 +35,13 @@ PI_SKILL = SKILL_FILE -@dataclass(frozen=True, slots=True) -class PiSetupResult: - package: str - package_path: str - data_dir: str - authorization_state: str = "not_attempted" - - -@dataclass(frozen=True, slots=True) -class _PiPackageListing: - source: str - installed_path: Path | None - scope: str - - -def install_pi_plugin(*, source: str, ref: str, server_url: str = "http://127.0.0.1:8000") -> PiSetupResult: - """Install the native Pi package from a checkout or Git source.""" - - pi_executable() - data_dir = powercontext_data_dir() - try: - data_dir.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise SetupError.data_directory(data_dir, error) from error - package_dir = resolve_pi_plugin_dir(source=source, ref=ref) - require_pi_package(package_dir) - _run_pi("install", str(package_dir)) - _remove_existing_pi_packages(keep=package_dir) - from powercontext.cli.authorization import ( - configure_stored_authorization, - setup_authorization_value, - ) - - return PiSetupResult( - package=PI_PACKAGE_NAME, - package_path=str(package_dir), - data_dir=str(data_dir), - authorization_state=configure_stored_authorization( - "pi", - server_url=server_url, - value=setup_authorization_value("pi"), - ), - ) - - -def resolve_pi_plugin_dir(*, source: str, ref: str) -> Path: - """Return the Pi package directory for a local checkout or Git ref.""" - - if _is_local_source(source): - return plugin_dir_from_checkout(Path(source).expanduser().resolve()) - return plugin_dir_from_checkout(_materialize_remote_checkout(source, ref)) - - -def plugin_dir_from_checkout(root: Path) -> Path: - """Accept either the Pi package directory or a PowerContext repository root.""" - - if _is_pi_package(root): - return root - package = root / PI_PLUGIN_RELATIVE - if _is_pi_package(package): - return package - raise SetupError.missing_pi_package(root) - - def require_pi_package(path: Path) -> None: """Reject a package that cannot expose PowerContext to Pi.""" if not (path / PI_EXTENSION).is_file() or not (path / PI_SKILL).is_file(): - raise SetupError.incomplete_pi_package(path) - - -def run_pi_diagnostics() -> dict[str, Diagnostic]: - """Collect diagnostics for the optional Pi integration.""" - - try: - executable = pi_executable() - except SetupError: - return { - "pi": Diagnostic(status=DiagnosticStatus.FAILED, detail="Pi CLI is not installed or is not on PATH"), - "package": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="not checked because Pi CLI is unavailable"), - } - try: - output = _run_pi("list") - except SetupError as error: - return { - "pi": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), - "package": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="package list is unavailable"), - } - installed = pi_package_installed(output) - return { - "pi": Diagnostic(status=DiagnosticStatus.OK, detail=executable), - "package": Diagnostic( - status=DiagnosticStatus.OK if installed else DiagnosticStatus.FAILED, - detail=(f"{PI_PACKAGE_NAME} is installed" if installed else "PowerContext Pi package is not installed"), - ), - } + raise SetupError( # noqa: TRY003 + f"PowerContext Pi package at {path} is missing its extension or powercontext-project-context skill." + ) def pi_executable() -> str: @@ -152,7 +53,7 @@ def pi_executable() -> str: return cmd executable = which("pi") if executable is None: - raise SetupError.pi_unavailable() + raise SetupError("Pi CLI is not installed or is not on PATH.") # noqa: TRY003 return executable @@ -160,145 +61,12 @@ def pi_package_installed(output: str) -> bool: """Return True when Pi lists an installed package with PowerContext's manifest.""" return any( - listing.installed_path is not None and _is_pi_package(listing.installed_path) - for listing in _listed_pi_packages(output) + HOST_GUIDANCE["pi"].recognizes(Path(line.strip())) + for line in output.splitlines() + if line.startswith(" ") and line.strip() ) -def github_clone_url(source: str) -> str: - """Accept a GitHub slug or repository URL and return a clone URL.""" - - try: - return _github_clone_url(source) - except InvalidGitHubSourceError: - raise SetupError.invalid_pi_source() from None - - -def checkout_target(ref: str) -> Path: - """Validate a Git ref and return Pi's stable remote checkout directory.""" - - root = (powercontext_data_dir() / "checkouts" / "pi").resolve() - if not ref or ref in {".", ".."} or "\x00" in ref: - raise SetupError.invalid_pi_ref(ref) - candidate = (root / ref).resolve() - try: - candidate.relative_to(root) - except ValueError as error: - raise SetupError.invalid_pi_ref(ref) from error - if candidate == root: - raise SetupError.invalid_pi_ref(ref) - return root / "current" - - -def _is_pi_package(path: Path) -> bool: - manifest = path / "package.json" - if not manifest.is_file(): - return False - try: - payload = json.loads(manifest.read_text(encoding="utf-8")) - except (OSError, ValueError): - return False - return payload.get("name") == PI_PACKAGE_NAME - - -def _materialize_remote_checkout(source: str, ref: str) -> Path: - target = checkout_target(ref) - target.parent.mkdir(parents=True, exist_ok=True) - staging = Path(tempfile.mkdtemp(prefix=f".{target.name}-", dir=target.parent)) - backup: Path | None = None - try: - _clone_github_source(source, ref, staging) - if target.exists(): - backup = Path(tempfile.mkdtemp(prefix=f".{target.name}-previous-", dir=target.parent)) - backup.rmdir() - target.replace(backup) - staging.replace(target) - except BaseException: - if backup is not None and backup.exists() and not target.exists(): - backup.replace(target) - raise - finally: - if staging.exists(): - shutil.rmtree(staging, ignore_errors=True) - if backup is not None and backup.exists() and target.exists(): - shutil.rmtree(backup, ignore_errors=True) - return target - - -def _listed_pi_packages(output: str) -> list[_PiPackageListing]: - """Parse the source/path pairs emitted by ``pi list``.""" - - listings: list[_PiPackageListing] = [] - source: str | None = None - installed_path: Path | None = None - scope = "user" - - def append_listing() -> None: - if source is not None: - listings.append(_PiPackageListing(source=source, installed_path=installed_path, scope=scope)) - - for raw_line in output.splitlines(): - indentation = len(raw_line) - len(raw_line.lstrip()) - line = raw_line.strip() - if line == "User packages:": - append_listing() - source = None - installed_path = None - scope = "user" - elif line == "Project packages:": - append_listing() - source = None - installed_path = None - scope = "project" - elif indentation == 2 and line: - append_listing() - source = line.removesuffix(" (filtered)") - installed_path = None - elif indentation >= 4 and source is not None and line: - installed_path = Path(line) - append_listing() - return listings - - -def _is_powercontext_pi_listing(listing: _PiPackageListing) -> bool: - if listing.installed_path is not None and _is_pi_package(listing.installed_path): - return True - source = listing.source.replace("\\", "/") - return source == f"npm:{PI_PACKAGE_NAME}" or PI_PLUGIN_RELATIVE.as_posix() in source - - -def _remove_existing_pi_packages(*, keep: Path) -> None: - """Remove superseded user-scoped PowerContext package paths after a successful install.""" - - for listing in _listed_pi_packages(_run_pi("list")): - if listing.scope != "user" or not _is_powercontext_pi_listing(listing): - continue - if _is_current_pi_package_listing(listing, keep): - continue - if listing.source.startswith(("npm:", "git:", "github:", "http:", "https:", "ssh:")): - _run_pi("remove", listing.source) - elif listing.installed_path is not None: - _run_pi("remove", str(listing.installed_path)) - elif Path(listing.source).is_absolute(): - _run_pi("remove", listing.source) - - -def _is_current_pi_package_listing(listing: _PiPackageListing, keep: Path) -> bool: - """Return whether a Pi listing already points at the package just installed.""" - - current = keep.resolve() - if listing.installed_path is not None and listing.installed_path.resolve() == current: - return True - return Path(listing.source).expanduser().resolve() == current - - -def _clone_github_source(source: str, ref: str, target: Path) -> None: - try: - clone_github_source(source, ref, target) - except InvalidGitHubSourceError: - raise SetupError.invalid_pi_source() from None - - def _run_pi(*arguments: str) -> str: command = [pi_executable(), *arguments] try: @@ -312,24 +80,92 @@ def _run_pi(*arguments: str) -> str: timeout=120, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 if completed.returncode != 0: detail = ( (completed.stderr or "").strip() or (completed.stdout or "").strip() or f"exit code {completed.returncode}" ) - raise SetupError.command_failed(command, detail) + raise SetupError(f"`{' '.join(command)}` failed: {detail}") # noqa: TRY003 return completed.stdout or "" -__all__ = [ - "PI_PACKAGE_NAME", - "PiSetupResult", - "checkout_target", - "install_pi_plugin", - "pi_executable", - "pi_package_installed", - "plugin_dir_from_checkout", - "require_pi_package", - "resolve_pi_plugin_dir", - "run_pi_diagnostics", -] +__all__ = ["PI_PACKAGE_NAME", "pi_executable", "pi_package_installed", "require_pi_package"] + + +class PiInstaller: + def install(self, source): + pi_executable() + with source.package("pi") as package: + require_pi_package(package) + destination = powercontext_data_dir() / "plugins" / "pi" + marker = ".powercontext-owned" + if destination.exists() and not (destination / marker).is_file(): + raise SetupError(f"Pi package path {destination} is not owned by PowerContext") # noqa: TRY003 + superseded = [] + user_packages = False + registered_source = None + for line in _run_pi("list").splitlines(): + if line and not line.startswith(" "): + user_packages = line == "User packages:" + registered_source = None + elif user_packages and line.startswith(" "): + resolved = Path(line.strip()) + if ( + registered_source is not None + and resolved.resolve() != destination.resolve() + and HOST_GUIDANCE["pi"].recognizes(resolved) + ): + if registered_source.endswith(" (filtered)"): + raise SetupError( # noqa: TRY003 + f"Pi package {resolved} has resource filters. Migrate its package entry and filters to {destination} in Pi settings before retrying setup." + ) + superseded.append(registered_source) + elif user_packages and line.startswith(" "): + registered_source = line.strip() + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".powercontext-", dir=destination.parent) as temporary: + candidate = Path(temporary) / "package" + shutil.copytree(package, candidate, ignore=shutil.ignore_patterns("node_modules", ".git")) + preserve_installed_guidance(destination, candidate) + (candidate / marker).write_text("powercontext-pi\n", encoding="utf-8") + publish([(candidate, destination)], action=lambda: _run_pi("install", str(destination))) + for registered_source in superseded: + _run_pi("remove", registered_source) + return { + "package": PI_PACKAGE_NAME, + "package_path": str(destination), + "data_dir": str(powercontext_data_dir()), + } + + def configure(self, connection): + if not (powercontext_data_dir() / "plugins/pi/.powercontext-owned").is_file(): + raise SetupError("PowerContext is not installed in pi; run powercontext setup pi") # noqa: TRY003 + return {"authorization_state": configure_connection(connection)} + + def verify(self) -> dict[str, Diagnostic]: + """Collect diagnostics for the optional Pi integration.""" + + try: + executable = pi_executable() + except SetupError: + return { + "pi": Diagnostic(status=DiagnosticStatus.FAILED, detail="Pi CLI is not installed or is not on PATH"), + "package": Diagnostic( + status=DiagnosticStatus.SKIPPED, detail="not checked because Pi CLI is unavailable" + ), + } + try: + output = _run_pi("list") + except SetupError as error: + return { + "pi": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), + "package": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="package list is unavailable"), + } + installed = pi_package_installed(output) + return { + "pi": Diagnostic(status=DiagnosticStatus.OK, detail=executable), + "package": Diagnostic( + status=DiagnosticStatus.OK if installed else DiagnosticStatus.FAILED, + detail=(f"{PI_PACKAGE_NAME} is installed" if installed else "PowerContext Pi package is not installed"), + ), + } diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 8cef0ab974..970d5fd072 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -23,7 +23,7 @@ import subprocess import sys from contextlib import suppress -from dataclasses import asdict, dataclass +from dataclasses import dataclass from enum import StrEnum from importlib.metadata import version from pathlib import Path @@ -33,16 +33,16 @@ from time import monotonic from typing import Annotated, Any, cast from urllib.error import HTTPError -from urllib.parse import urlsplit, urlunsplit +from urllib.parse import urlsplit from urllib.request import Request, urlopen -import click import typer from pydantic import ValidationError from powercontext.cli.dsh_runtime import DshProfile, resolve_dsh_target -from powercontext.cli.hosts import setup_host, stdin_is_tty -from powercontext.cli.transport import add_transport_diagnostic, is_remote_http +from powercontext.cli.hosts import stdin_is_tty +from powercontext.cli.installation import SetupError, setup +from powercontext.cli.transport import is_remote_http from powercontext.client.settings import normalize_server_url from powercontext.client.transport_policy import resolve_client_transport from powercontext.http import HealthResponse, ReadinessResponse, ReadinessStatus @@ -117,7 +117,7 @@ name="setup", context_settings={"help_option_names": HELP_OPTION_NAMES}, help="Install and configure PowerContext integrations.", - no_args_is_help=True, + invoke_without_command=True, ) doctor_app = typer.Typer( name="doctor", @@ -127,295 +127,6 @@ ) -class SetupError(RuntimeError): - """Report a failed external setup command.""" - - @classmethod - def codex_unavailable(cls) -> SetupError: - return cls("Codex CLI is not installed or is not on PATH.") - - @classmethod - def claude_unavailable(cls) -> SetupError: - return cls("Claude Code CLI is not installed or is not on PATH.") - - @classmethod - def dsh_unavailable(cls) -> SetupError: - return cls("DeepSeek Harness CLI is not installed or is not on PATH.") - - @classmethod - def openclaw_unavailable(cls) -> SetupError: - return cls("OpenClaw CLI is not installed or is not on PATH.") - - @classmethod - def pnpm_unavailable(cls) -> SetupError: - return cls("pnpm is not installed or is not on PATH; it is required to build the OpenClaw plugin.") - - @classmethod - def missing_openclaw_plugin(cls, path: Path) -> SetupError: - return cls(f"PowerContext OpenClaw plugin was not found under {path}.") - - @classmethod - def unbuilt_openclaw_plugin(cls, path: Path) -> SetupError: - return cls(f"PowerContext OpenClaw plugin at {path} is missing dist/index.js after build.") - - @classmethod - def invalid_openclaw_ref(cls, ref: str) -> SetupError: - return cls(f"invalid OpenClaw ref: {ref}") - - @classmethod - def invalid_openclaw_source(cls, source: str) -> SetupError: - return cls(f"invalid OpenClaw source: {source}") - - @classmethod - def unsupported_openclaw_version(cls, version_text: str) -> SetupError: - return cls(f"OpenClaw {version_text or 'version unknown'} is unsupported; upgrade to >= 2026.8.1-beta.2") - - @classmethod - def openclaw_server_url_scheme(cls) -> SetupError: - return cls("OpenClaw PowerContext Server URL must use HTTP or HTTPS") - - @classmethod - def openclaw_server_url_credentials(cls) -> SetupError: - return cls("OpenClaw PowerContext Server URL must not contain credentials") - - @classmethod - def openclaw_server_url_suffix(cls) -> SetupError: - return cls("OpenClaw PowerContext Server URL must not contain a query or fragment") - - @classmethod - def pi_unavailable(cls) -> SetupError: - return cls("Pi CLI is not installed or is not on PATH.") - - @classmethod - def opencode_unavailable(cls) -> SetupError: - return cls("OpenCode CLI is not installed or is not on PATH.") - - @classmethod - def hermes_unavailable(cls) -> SetupError: - return cls("Hermes CLI is not installed or is not on PATH.") - - @classmethod - def missing_dsh_plugin(cls, path: Path) -> SetupError: - return cls(f"PowerContext DSH plugin was not found under {path}.") - - @classmethod - def unbuilt_dsh_plugin(cls, path: Path) -> SetupError: - return cls(f"PowerContext DSH plugin at {path} is missing lib/index.js. Build the plugin before setup.") - - @classmethod - def missing_pi_package(cls, path: Path) -> SetupError: - return cls(f"PowerContext Pi package was not found under {path}.") - - @classmethod - def incomplete_pi_package(cls, path: Path) -> SetupError: - return cls(f"PowerContext Pi package at {path} is missing its extension or powercontext-project-context skill.") - - @classmethod - def missing_opencode_plugin(cls, path: Path) -> SetupError: - return cls(f"PowerContext OpenCode plugin was not found under {path}.") - - @classmethod - def incomplete_opencode_plugin(cls, path: Path) -> SetupError: - return cls( - f"PowerContext OpenCode plugin at {path} is missing lib/index.js, lib/tui.js," - " or powercontext-project-context Skill." - ) - - @classmethod - def invalid_opencode_ref(cls, ref: str) -> SetupError: - return cls(f"invalid OpenCode ref: {ref}") - - @classmethod - def invalid_opencode_source(cls) -> SetupError: - return cls("invalid OpenCode source; use a local path or an HTTPS/SSH GitHub repository") - - @classmethod - def unsupported_opencode_version(cls, actual: str) -> SetupError: - return cls( - f"OpenCode v{actual} is unsupported; PowerContext requires OpenCode v1.18.21 or newer in the 1.x line." - ) - - @classmethod - def opencode_skill_conflict(cls, path: Path) -> SetupError: - return cls(f"OpenCode Skill path {path} already exists and is not owned by PowerContext.") - - @classmethod - def opencode_plugin_conflict(cls, path: Path) -> SetupError: - return cls(f"OpenCode plugin path {path} already exists and is not owned by PowerContext.") - - @classmethod - def invalid_dsh_ref(cls, ref: str) -> SetupError: - return cls(f"invalid DeepSeek Harness ref: {ref}") - - @classmethod - def invalid_dsh_source(cls) -> SetupError: - return cls("invalid DeepSeek Harness source; use a local path or an HTTPS/SSH GitHub repository") - - @classmethod - def invalid_pi_ref(cls, ref: str) -> SetupError: - return cls(f"invalid Pi ref: {ref}") - - @classmethod - def invalid_pi_source(cls) -> SetupError: - return cls("invalid Pi source; use a local path or an HTTPS/SSH GitHub repository") - - @classmethod - def git_clone_failed(cls) -> SetupError: - return cls("failed to clone the GitHub source") - - @classmethod - def invalid_hermes_ref(cls, ref: str) -> SetupError: - return cls(f"invalid Hermes ref: {ref}") - - @classmethod - def invalid_hermes_source(cls, source: str) -> SetupError: - return cls(f"invalid Hermes source: {source}") - - @classmethod - def missing_hermes_plugin(cls, path: Path) -> SetupError: - return cls(f"PowerContext Hermes plugin was not found under {path}.") - - @classmethod - def hermes_plugin_write(cls, path: Path, error: OSError) -> SetupError: - return cls(f"Cannot install PowerContext Hermes plugin at {path}: {error}") - - @classmethod - def unsupported_hermes_version(cls, actual: str, minimum: str) -> SetupError: - return cls(f"Hermes Agent v{actual} is unsupported; PowerContext requires Hermes Agent v{minimum} or newer.") - - @classmethod - def missing_workbuddy_plugin(cls, path: Path) -> SetupError: - return cls(f"PowerContext WorkBuddy plugin was not found under {path}.") - - @classmethod - def invalid_workbuddy_ref(cls, ref: str) -> SetupError: - return cls(f"invalid WorkBuddy ref: {ref}") - - @classmethod - def invalid_workbuddy_source(cls, source: str) -> SetupError: - return cls(f"invalid WorkBuddy source: {source}") - - @classmethod - def workbuddy_home_unavailable(cls, path: Path, error: OSError) -> SetupError: - return cls(f"Cannot create WorkBuddy home directory {path}: {error}") - - @classmethod - def workbuddy_hooks_write(cls, path: Path, error: OSError) -> SetupError: - return cls(f"Cannot install PowerContext WorkBuddy hooks at {path}: {error}") - - @classmethod - def workbuddy_skill_write(cls, path: Path, error: OSError) -> SetupError: - return cls(f"Cannot install PowerContext WorkBuddy skill at {path}: {error}") - - @classmethod - def workbuddy_skill_conflict(cls, path: Path) -> SetupError: - return cls(f"WorkBuddy Skill path {path} already exists and is not owned by PowerContext.") - - @classmethod - def workbuddy_settings_write(cls, path: Path, error: OSError) -> SetupError: - return cls(f"Cannot update WorkBuddy settings at {path}: {error}") - - @classmethod - def workbuddy_mcp_write(cls, path: Path, error: OSError) -> SetupError: - return cls(f"Cannot update WorkBuddy MCP configuration at {path}: {error}") - - @classmethod - def invalid_workbuddy_settings(cls, path: Path) -> SetupError: - return cls(f"WorkBuddy settings at {path} must contain a JSON object with a hooks mapping.") - - @classmethod - def invalid_workbuddy_mcp(cls, path: Path) -> SetupError: - return cls(f"WorkBuddy MCP configuration at {path} must contain a JSON object with an mcpServers mapping.") - - @classmethod - def data_directory(cls, path: Path, error: OSError) -> SetupError: - return cls(f"Cannot create PowerContext data directory {path}: {error}") - - @classmethod - def command_unavailable(cls, command: list[str], error: BaseException) -> SetupError: - return cls(f"Cannot run {' '.join(command)}: {error}") - - @classmethod - def command_failed(cls, command: list[str], detail: str) -> SetupError: - return cls(f"`{' '.join(command)}` failed: {detail}") - - @classmethod - def invalid_command_output(cls, command: list[str], detail: str) -> SetupError: - return cls(f"`{' '.join(command)}` returned {detail}") - - @classmethod - def missing_result(cls, name: str) -> SetupError: - return cls(f"Integration CLI did not return {name}") - - @classmethod - def post_install_verification(cls, failures: list[str]) -> SetupError: - return cls(f"post-install verification failed: {'; '.join(failures)}") - - @classmethod - def claude_plugin_not_enabled(cls) -> SetupError: - return cls("Claude Code did not report an enabled PowerContext plugin after installation.") - - @classmethod - def claude_marketplace_source_mismatch(cls, requested: str, existing: str) -> SetupError: - return cls( - f"Claude Code marketplace `{CLAUDE_MARKETPLACE_NAME}` uses {existing}, " - f"but setup requested {requested}. Remove it with " - f"`claude plugin marketplace remove {CLAUDE_MARKETPLACE_NAME}`, then rerun setup." - ) - - @classmethod - def invalid_claude_settings(cls, path: Path) -> SetupError: - return cls(f"Claude Code settings at {path} must contain a JSON object with object-valued plugin options.") - - @classmethod - def claude_settings_write(cls, path: Path, error: OSError) -> SetupError: - return cls(f"Cannot update Claude Code settings at {path}: {error}") - - @classmethod - def claude_server_url_credentials(cls) -> SetupError: - return cls("PowerContext Server URL must not contain credentials.") - - @classmethod - def claude_server_url_scheme(cls) -> SetupError: - return cls("PowerContext Server URL must use HTTP or HTTPS.") - - @classmethod - def claude_server_url_suffix(cls) -> SetupError: - return cls("PowerContext Server URL must not contain a query or fragment.") - - @classmethod - def claude_server_url_transport(cls) -> SetupError: - return cls("Unencrypted PowerContext Server URLs must be loopback addresses.") - - -@dataclass(frozen=True, slots=True) -class CodexSetupResult: - marketplace: str - plugin: str - plugin_version: str - data_dir: str - authorization_state: str = "not_attempted" - - -@dataclass(frozen=True, slots=True) -class ClaudeCodeSetupResult: - marketplace: str - plugin: str - plugin_version: str - settings_file: str - cache_dir: str - data_dir: str - authorization_state: str = "not_attempted" - - -@dataclass(frozen=True, slots=True) -class OpenClawSetupResult: - plugin: str - plugin_path: str - server_url: str - data_dir: str - - class DiagnosticStatus(StrEnum): """Outcome of one installation diagnostic.""" @@ -450,943 +161,159 @@ def as_json(self) -> dict[str, object]: return result -@setup_app.callback() -def setup_configuration( - context: typer.Context, - env_file: Annotated[ - Path | None, typer.Option(help="Setup environment file; defaults to .env in this directory.") - ] = None, -) -> None: - """Select a safely parsed configuration file for all setup targets.""" - from powercontext.cli.transport import setup_environment_file - - token = setup_environment_file.set(env_file) - context.call_on_close(lambda: setup_environment_file.reset(token)) +SourceOption = Annotated[str, typer.Option(help="PowerContext Git source or local checkout path.")] +RefOption = Annotated[str, typer.Option(help="Git ref used for a remote source.")] +ServerOption = Annotated[str | None, typer.Option(help="Server URL; resolves environment and saved settings.")] +HttpOption = Annotated[ + bool | None, + typer.Option("--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP."), +] +JsonOption = Annotated[bool, typer.Option("--json", help="Write the result as JSON.")] +CaptureOption = Annotated[ + bool | None, + typer.Option( + "--capture-prompts/--no-capture-prompts", + help="Claude Code, Kimi Code and ZCode only; omission preserves the capture preference.", + ), +] +AgentsArgument = Annotated[list[str] | None, typer.Argument(help="Agent names; select interactively when omitted.")] -@setup_app.command("codex") -def setup_codex( - source: Annotated[ - str, - typer.Option(help="Codex marketplace Git source or local path."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote marketplace source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server URL; resolves host/common environment and saved settings."), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), - ] = None, - json_output: Annotated[ +@setup_app.callback() +def setup_agents( + context: typer.Context, + agents: AgentsArgument = None, + source: SourceOption = DEFAULT_MARKETPLACE_SOURCE, + ref: RefOption = DEFAULT_MARKETPLACE_REF, + server_url: ServerOption = None, + allow_insecure_http: HttpOption = None, + json_output: JsonOption = False, + capture_prompts: CaptureOption = None, + scope_id: Annotated[str | None, typer.Option(help="Existing Scope ID for Kimi Code.")] = None, + workspace_scope: Annotated[ bool, - typer.Option("--json", help="Write the result as JSON."), + typer.Option("--workspace-scope", help="Kimi Code: resolve workspace bindings, then the Server default Scope."), ] = False, -) -> None: - """Install the PowerContext Codex plugin and prepare local storage.""" - - try: - result = setup_host( - "codex", - source=source, - ref=ref, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - - diagnostics = run_codex_diagnostics() - # Installing against a Server with basic Memory support remains valid. Doctor - # separately reports missing full-profile tools instead of failing installation. - connection_checks = codex_setup_checks(diagnostics) - if not _diagnostics_ok(connection_checks): - _write_diagnostics(diagnostics, json_output=json_output) - raise typer.Exit(code=1) - - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo("PowerContext Codex setup complete.") - typer.echo(f"Plugin: {result.plugin}@{result.marketplace} ({result.plugin_version})") - typer.echo(f"Data directory: {result.data_dir}") - typer.echo(f"Authorization: {result.authorization_state}") - typer.echo("Next: run `powercontext server run`, start a new Codex session, then review `/hooks`.") - - -@setup_app.command("claude-code") -def setup_claude_code( - source: Annotated[ - str, - typer.Option(help="Claude Code marketplace Git source or local path."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote marketplace source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server URL; resolves host/common environment and saved settings."), - ] = None, - capture_prompts: Annotated[ - bool, - typer.Option(help="Capture Claude Code user prompts as ordinary Source evidence."), - ] = True, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), + boundary_flush: Annotated[ + bool | None, typer.Option("--boundary-flush/--no-boundary-flush", help="ZCode boundary capture preference.") ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Install the PowerContext Claude Code plugin.""" - - plan = _claude_setup_plan() - _write_claude_setup_plan(plan) - try: - result = setup_host( - "claude-code", - source=source, - ref=ref, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - capture_prompts=capture_prompts, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo("PowerContext Claude Code setup complete.") - typer.echo(f"Plugin: {result.plugin}@{result.marketplace} ({result.plugin_version})") - typer.echo(f"Settings: {result.settings_file}") - typer.echo("Next: run `powercontext server run`, start a new Claude Code session, then review `/hooks` and `/mcp`.") - - -@setup_app.command("dsh") -def setup_dsh( profile: Annotated[ - DshProfile | None, typer.Option(help="DSH profile; prompts on a TTY, otherwise defaults to web.") - ] = None, - dsh_command: Annotated[ - Path | None, typer.Option(help="Path to the DSH launcher; Desktop requires its installed command.") - ] = None, - source: Annotated[ - str, - typer.Option(help="PowerContext Git source or local checkout path."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server URL; resolves host/common environment and saved settings."), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), + DshProfile | None, typer.Option(help="DSH profile; defaults to the saved profile or web.") ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, + dsh_command: Annotated[Path | None, typer.Option(help="DSH launcher path.")] = None, + env_file: Annotated[Path | None, typer.Option(help="Setup environment file; defaults to .env.")] = None, ) -> None: - """Install the PowerContext DeepSeek Harness plugin and prepare local storage.""" - - from powercontext.cli.dsh import run_dsh_diagnostics - - try: - if profile is None: - profile = ( - DshProfile(typer.prompt("DSH profile", default="web", type=click.Choice(["web", "desktop"]))) - if not json_output and stdin_is_tty() - else DshProfile.WEB - ) - target = resolve_dsh_target(profile, dsh_command) - if not json_output: - typer.echo(f"DSH target: {profile} ({target.directory})") - if profile == DshProfile.DESKTOP: - typer.echo("Desktop must be fully quit before installation (closing its window only hides it).", err=True) - result = setup_host( - "dsh", - source=source, - ref=ref, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - dsh_target=target, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - - diagnostics = run_dsh_diagnostics(target=target) - if not _diagnostics_ok(diagnostics): - _write_diagnostics(diagnostics, json_output=json_output) - raise typer.Exit(code=1) - - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo("PowerContext DeepSeek Harness setup complete.") - typer.echo(f"Plugin: {result.plugin} ({result.plugin_path})") - typer.echo(f"Data directory: {result.data_dir}") - typer.echo( - "Next: run `powercontext server run`, then reopen DeepSeek Harness Desktop." - if profile == DshProfile.DESKTOP - else "Next: run `powercontext server run`, then start `dsh web`." + """Install, configure, activate and diagnose the selected agent integrations.""" + from powercontext.cli.hosts import ( + diagnostic_result, + doctor_checks, + native_options, + report_agents, + select_hosts, + validate_native_options, ) + from powercontext.cli.transport import setup_environment_file - -@setup_app.command("kimi-code") -def setup_kimi_code( - source: Annotated[Path, typer.Option(help="Local PowerContext checkout or plugin directory.")], - server_url: Annotated[ - str | None, typer.Option(help="Server origin; omission preserves the installed endpoint.") - ] = None, - scope_id: Annotated[ - str | None, typer.Option(help="Existing explicit Scope ID; overrides workspace bindings.") - ] = None, - workspace_scope: Annotated[ - bool, typer.Option("--workspace-scope", help="Resolve workspace bindings, then the Server default Scope.") - ] = False, - capture_prompts: Annotated[ - bool | None, - typer.Option("--capture-prompts/--no-capture-prompts", help="Omission preserves capture preference."), - ] = None, - json_output: Annotated[bool, typer.Option("--json", help="Write installation metadata as JSON.")] = False, -) -> None: - """Install the minimal Kimi Code memory plugin from a local checkout.""" - from powercontext.cli.kimi_code import install_kimi_plugin - - try: - result = install_kimi_plugin( - source=source, - server_url=server_url, - scope_id=scope_id, - capture_prompts=capture_prompts, - workspace_scope=workspace_scope, - ) - except (SetupError, OSError, ValueError) as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - if json_output: - typer.echo(json.dumps(asdict(result), ensure_ascii=False, indent=2)) - else: - typer.echo(f"Kimi Code setup complete: {result.plugin_path}") - typer.echo( - "Open a new Kimi session, then run powercontext doctor kimi-code. Existing disablement is preserved." - ) - - -@doctor_app.command("kimi-code") -def doctor_kimi_code( - json_output: Annotated[bool, typer.Option("--json", help="Write diagnostics as JSON.")] = False, - runtime: Annotated[bool, typer.Option("--runtime", help="Inspect local Hook history for this workspace.")] = False, - cwd: Annotated[ - Path | None, - typer.Option(help="Workspace for Scope resolution and runtime history; defaults to the current directory."), - ] = None, - session_id: Annotated[ - str | None, typer.Option(help="Exact Kimi session; omission selects workspace history.") - ] = None, -) -> None: - """Check Kimi plugin declarations and readonly Server/Scope connectivity.""" - from powercontext.cli.kimi_code import run_kimi_diagnostics - - if session_id is not None and not runtime: - raise typer.BadParameter("--session-id requires --runtime") # noqa: TRY003 - CLI option validation. - diagnostics = run_kimi_diagnostics(runtime=runtime, cwd=cwd, session_id=session_id) - required = { - name: value - for name, value in diagnostics.items() - if name != "runtime" or value.status is DiagnosticStatus.FAILED - } - status = _diagnostics_status(required) - _write_diagnostics(diagnostics, json_output=json_output, summary_status=status) - if status is not DiagnosticStatus.OK: - raise typer.Exit(code=1) - - -@setup_app.command("zcode") -def setup_zcode( - source: Annotated[ - str, typer.Option(help="PowerContext Git source or local checkout path.") - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[str, typer.Option(help="Git ref for a remote source.")] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, typer.Option(help="PowerContext Server URL; resolves environment and saved settings.") - ] = None, - capture_prompts: Annotated[ - bool | None, - typer.Option( - "--capture-prompts/--no-capture-prompts", help="Capture prompts; omission preserves saved preference." - ), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option("--allow-insecure-http/--no-allow-insecure-http", help="Allow unencrypted remote HTTP."), - ] = None, - json_output: Annotated[bool, typer.Option("--json", help="Write the result as JSON.")] = False, -) -> None: - """Install the ZCode CLI or Windows desktop plugin.""" - - from powercontext.cli.zcode import preserve_zcode_installation, run_zcode_diagnostics, saved_zcode_capture_prompts - - try: - with preserve_zcode_installation(): - result = setup_host( - "zcode", - source=source, - ref=ref, - server_url=server_url, - capture_prompts=capture_prompts if capture_prompts is not None else saved_zcode_capture_prompts(), - allow_insecure_http=allow_insecure_http, - json_output=json_output, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - diagnostics = run_zcode_diagnostics() - required = { - name: item - for name, item in diagnostics.items() - if not (name in {"runtime", "mcp_session"} and item.status is DiagnosticStatus.SKIPPED) + token = setup_environment_file.set(env_file) + context.call_on_close(lambda: setup_environment_file.reset(token)) + selected = select_hosts(agents, json_output=json_output) + options = { + "capture_prompts": capture_prompts, + "scope_id": scope_id, + "boundary_flush": boundary_flush, + "workspace_scope": workspace_scope or None, } - status = _diagnostics_status(required) - if status is not DiagnosticStatus.OK: - _write_diagnostics(diagnostics, json_output=json_output, summary_status=status) - raise typer.Exit(code=1) - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo(f"PowerContext ZCode setup complete: {result.plugin_path}") - typer.echo("Next: fully quit and reopen ZCode, then start a new session and run `powercontext doctor zcode`.") - - -@setup_app.command("openclaw") -def setup_openclaw( - source: Annotated[ - str, - typer.Option(help="OpenClaw plugin Git source or local PowerContext checkout path."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server URL; resolves host/common environment and saved settings."), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), - ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Build, install, and configure the PowerContext OpenClaw memory plugin.""" - - try: - result = setup_host( - "openclaw", - source=source, - ref=ref, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo("PowerContext OpenClaw setup complete.") - typer.echo(f"Plugin: {result.plugin}") - typer.echo(f"Plugin path: {result.plugin_path}") - typer.echo(f"Server: {result.server_url}") - typer.echo(f"Data directory: {result.data_dir}") - typer.echo("Next: start a new OpenClaw session.") - - -@setup_app.command("pi") -def setup_pi( - source: Annotated[ - str, - typer.Option(help="PowerContext Git source or local checkout path."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server URL; resolves host/common environment and saved settings."), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), - ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Install the PowerContext Pi package and prepare local storage.""" - - from powercontext.cli.pi import run_pi_diagnostics - - try: - result = setup_host( - "pi", - source=source, - ref=ref, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - - diagnostics = run_pi_diagnostics() - if not _diagnostics_ok(diagnostics): - _write_diagnostics(diagnostics, json_output=json_output) - raise typer.Exit(code=1) - - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo("PowerContext Pi setup complete.") - typer.echo(f"Package: {result.package} ({result.package_path})") - typer.echo(f"Data directory: {result.data_dir}") - typer.echo("Next: run `powercontext server run`, then start a new Pi session.") - - -@setup_app.command("opencode") -def setup_opencode( - source: Annotated[ - str, - typer.Option(help="PowerContext Git source or local checkout path."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server URL; resolves host/common environment and saved settings."), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), - ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Install the PowerContext OpenCode plugin and Skill.""" - - from powercontext.cli.opencode import run_opencode_diagnostics - - try: - result = setup_host( - "opencode", - source=source, - ref=ref, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - - diagnostics = run_opencode_diagnostics() - if not _diagnostics_ok(diagnostics): - _write_diagnostics(diagnostics, json_output=json_output) - raise typer.Exit(code=1) - - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo("PowerContext OpenCode setup complete.") - typer.echo(f"Plugin: {result.plugin} ({result.plugin_path})") - typer.echo(f"Skill: {result.skill_path}") - typer.echo(f"Data directory: {result.data_dir}") - typer.echo("Next: run `powercontext server run`, then start a new OpenCode session.") - - -@setup_app.command("hermes") -def setup_hermes( - source: Annotated[ - str, - typer.Option(help="PowerContext Git source or local checkout path."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server URL; resolves host/common environment and saved settings."), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), - ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Install the PowerContext Hermes provider and /pc command companion.""" - - from powercontext.cli.hermes import run_hermes_diagnostics - - try: - result = setup_host( - "hermes", - source=source, - ref=ref, - server_url=server_url, - allow_insecure_http=allow_insecure_http, - json_output=json_output, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - - diagnostics = run_hermes_diagnostics() - if not _diagnostics_ok(diagnostics): - _write_diagnostics(diagnostics, json_output=json_output) - raise typer.Exit(code=1) - - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo("PowerContext Hermes setup complete.") - typer.echo(f"Plugin: {result.plugin} ({result.plugin_path})") - typer.echo(f"Command companion: {result.command_plugin_path}") - typer.echo(f"Hermes home: {result.hermes_home}") - typer.echo(f"Data directory: {result.data_dir}") - typer.echo("Next: run `hermes memory setup`, select PowerContext, then start Hermes.") - - -@setup_app.command("select") -def setup_select( - host: Annotated[ - list[str] | None, - typer.Option(help="First-class host to install. Repeatable. Required with --json or a non-TTY."), - ] = None, - source: Annotated[ - str, - typer.Option(help="Git source or local path passed to each selected installer."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server base URL override for every selected Agent."), - ] = None, - capture_prompts: Annotated[ - bool, - typer.Option(help="Capture Claude Code user prompts as ordinary Source evidence."), - ] = True, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), - ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Install selected first-class host plugins without scanning PATH.""" - - from powercontext.cli.hosts import run_setup_select - - run_setup_select( - hosts=host, - source=source, - ref=ref, - server_url=server_url, - capture_prompts=capture_prompts, - json_output=json_output, - allow_insecure_http=allow_insecure_http, - ) - - -@setup_app.command("workbuddy") -def setup_workbuddy( - source: Annotated[ - str, - typer.Option(help="PowerContext Git source or local checkout path."), - ] = DEFAULT_MARKETPLACE_SOURCE, - ref: Annotated[ - str, - typer.Option(help="Git ref used for a remote source."), - ] = DEFAULT_MARKETPLACE_REF, - server_url: Annotated[ - str | None, - typer.Option(help="PowerContext Server URL; resolves host/common environment and saved settings."), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), - ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Install the PowerContext WorkBuddy hooks, MCP server, and Skill.""" - - from powercontext.cli.workbuddy import run_workbuddy_diagnostics - - try: - result = setup_host( - "workbuddy", - source=source, - ref=ref, + validate_native_options(selected, **options, profile=profile, dsh_command=dsh_command) + + def complete(host): + native = native_options(host, **options) + if host == "dsh": + native["dsh_target"] = resolve_dsh_target(profile, dsh_command) + if native["dsh_target"].profile == DshProfile.DESKTOP: + typer.echo("Fully quit DSH Desktop before replacing its plugin.", err=True) + if host == "kimi-code" and not scope_id and not workspace_scope and not json_output and stdin_is_tty(): + from powercontext.cli.kimi_code import kimi_home + + if not (kimi_home() / "plugins/managed/powercontext/powercontext.json").is_file(): + native["scope_id"] = typer.prompt("Existing Scope ID for Kimi Code") + result = setup( + host, + source, + ref, server_url=server_url, allow_insecure_http=allow_insecure_http, json_output=json_output, - ).result - except SetupError as error: - typer.echo(str(error), err=True) - raise typer.Exit(code=1) from error - - diagnostics = run_workbuddy_diagnostics() - if not _diagnostics_ok(diagnostics): - _write_diagnostics(diagnostics, json_output=json_output) - raise typer.Exit(code=1) + **native, + ) + checks = doctor_checks(host, **native) + return {"setup": "complete", "result": result, **diagnostic_result(checks)} - if json_output: - typer.echo(json.dumps(asdict(result), indent=2)) - return - typer.echo("PowerContext WorkBuddy setup complete.") - typer.echo(f"Plugin: {result.plugin} ({result.plugin_path})") - typer.echo(f"WorkBuddy home: {result.workbuddy_home}") - typer.echo(f"Hooks directory: {result.hooks_dir}") - typer.echo(f"Data directory: {result.data_dir}") - typer.echo("Next: run `powercontext server run`, restart WorkBuddy, then send a prompt.") + report_agents(selected, complete, json_output=json_output) @doctor_app.callback() def doctor( - context: typer.Context, - server_url: Annotated[ - str | None, - typer.Option( - envvar="POWERCONTEXT_CLIENT_SERVER_URL", - help="PowerContext Server base URL.", - ), - ] = None, - allow_insecure_http: Annotated[ - bool | None, - typer.Option( - "--allow-insecure-http/--no-allow-insecure-http", help="Explicitly allow unencrypted remote HTTP." - ), - ] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the installed package and configured Server.""" - - if context.invoked_subcommand is not None: - return - diagnostics = run_diagnostics(server_url=server_url, allow_insecure_http=allow_insecure_http) - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("codex") -def doctor_codex( - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the optional Codex CLI and PowerContext plugin.""" - - diagnostics = run_codex_diagnostics() - add_transport_diagnostic(diagnostics, "codex") - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("claude-code") -def doctor_claude_code( - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the optional Claude Code CLI and PowerContext plugin.""" - - diagnostics = run_claude_code_diagnostics() - add_transport_diagnostic(diagnostics, "claude-code") - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("dsh") -def doctor_dsh( - profile: Annotated[DshProfile, typer.Option(help="DSH profile to inspect.")] = DshProfile.WEB, - dsh_command: Annotated[Path | None, typer.Option(help="Path to the DSH launcher.")] = None, - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the optional DeepSeek Harness CLI and PowerContext plugin.""" - - from powercontext.cli.dsh import run_dsh_diagnostics - - try: - target = resolve_dsh_target(profile, dsh_command) - except SetupError as error: - diagnostics = { - "dsh": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), - "plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because the selected DeepSeek Harness profile is unavailable", - ), - } - else: - diagnostics = run_dsh_diagnostics(target=target) - add_transport_diagnostic(diagnostics, "dsh", dsh_target=target) - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("zcode") -def doctor_zcode( - json_output: Annotated[bool, typer.Option("--json", help="Write the result as JSON.")] = False, - runtime_data_dir: Annotated[ - Path | None, typer.Option("--runtime-data-dir", help="Actual ZCode plugin data directory.") - ] = None, - prepare: Annotated[bool, typer.Option("--prepare", help="Probe readonly context preparation.")] = False, -) -> None: - """Check ZCode plugin registration, Hook, MCP, and Server readiness.""" - - from powercontext.cli.zcode import run_zcode_diagnostics - - diagnostics = run_zcode_diagnostics(runtime_data_dir=runtime_data_dir, prepare=prepare) - add_transport_diagnostic(diagnostics, "zcode") - # Unobserved optional history is not a failed connectivity probe. Preserve its - # skipped status in the report rather than claiming the session was observed. - required = { - name: item - for name, item in diagnostics.items() - if not (name in {"runtime", "mcp_session"} and item.status is DiagnosticStatus.SKIPPED) - } - status = _diagnostics_status(required) - _write_diagnostics(diagnostics, json_output=json_output, summary_status=status) - if status is not DiagnosticStatus.OK: - raise typer.Exit(code=1) - - -@doctor_app.command("pi") -def doctor_pi( - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the optional Pi CLI and PowerContext package.""" - - from powercontext.cli.pi import run_pi_diagnostics - - diagnostics = run_pi_diagnostics() - - add_transport_diagnostic(diagnostics, "pi") - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("opencode") -def doctor_opencode( - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the optional OpenCode CLI, plugin, and Skill.""" - - from powercontext.cli.opencode import run_opencode_diagnostics - - diagnostics = run_opencode_diagnostics() - add_transport_diagnostic(diagnostics, "opencode") - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("hermes") -def doctor_hermes( - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the optional Hermes CLI and PowerContext memory provider.""" - - from powercontext.cli.hermes import run_hermes_diagnostics - - diagnostics = run_hermes_diagnostics() - add_transport_diagnostic(diagnostics, "hermes") - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("workbuddy") -def doctor_workbuddy( - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the optional WorkBuddy hooks, MCP server, and Skill.""" - - from powercontext.cli.workbuddy import run_workbuddy_diagnostics - - diagnostics = run_workbuddy_diagnostics() - add_transport_diagnostic(diagnostics, "workbuddy") - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("openclaw") -def doctor_openclaw( - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Check the optional OpenClaw CLI and PowerContext memory plugin.""" - - from powercontext.cli.openclaw import run_openclaw_diagnostics - - diagnostics = run_openclaw_diagnostics() - add_transport_diagnostic(diagnostics, "openclaw") - _write_diagnostics(diagnostics, json_output=json_output) - if not _diagnostics_ok(diagnostics): - raise typer.Exit(code=1) - - -@doctor_app.command("integrations") -def doctor_integrations( - json_output: Annotated[ - bool, - typer.Option("--json", help="Write the result as JSON."), - ] = False, -) -> None: - """Report first-class host CLI and integration status without failing on missing CLIs.""" - - from powercontext.cli.hosts import run_doctor_integrations - - run_doctor_integrations(json_output=json_output) - - -def install_codex_plugin(*, source: str, ref: str, server_url: str | None = None) -> CodexSetupResult: - """Install the plugin from one local or Git marketplace source.""" - - if which("codex") is None: - raise SetupError.codex_unavailable() - - data_dir = powercontext_data_dir() - try: - data_dir.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise SetupError.data_directory(data_dir, error) from error - - marketplace_source, is_local = _normalize_marketplace_source(source) - marketplace_arguments = ["plugin", "marketplace", "add", marketplace_source] - if not is_local: - marketplace_arguments.extend(("--ref", ref)) - marketplace = _run_codex_json(*marketplace_arguments) - marketplace_name = _required_string(marketplace, "marketplaceName") - - plugin = _run_codex_json("plugin", "add", f"{PLUGIN_NAME}@{marketplace_name}") - from powercontext.cli.authorization import ( - configure_codex_desktop_authorization, - configure_stored_authorization, - credential_path, - read_stored_authorization, - setup_authorization_value, + agents: AgentsArgument = None, + server_url: ServerOption = None, + allow_insecure_http: HttpOption = None, + json_output: JsonOption = False, + profile: Annotated[ + DshProfile | None, typer.Option(help="DSH profile; defaults to the saved profile or web.") + ] = None, + dsh_command: Annotated[Path | None, typer.Option(help="DSH launcher path.")] = None, + runtime: Annotated[bool, typer.Option(help="Inspect Kimi Hook history.")] = False, + cwd: Annotated[Path | None, typer.Option(help="Kimi workspace for Scope resolution and runtime history.")] = None, + session_id: Annotated[str | None, typer.Option(help="Exact Kimi session.")] = None, + runtime_data_dir: Annotated[Path | None, typer.Option(help="Actual ZCode plugin data directory.")] = None, + prepare: Annotated[bool, typer.Option(help="Probe ZCode readonly context preparation.")] = False, +) -> None: + """Check selected integrations, or saved integrations and the environment by default.""" + from powercontext.cli.hosts import ( + diagnostic_result, + doctor_checks, + report_agents, + select_hosts, + validate_native_options, ) + from powercontext.cli.installation import installers + from powercontext.cli.transport import client_config_file - authorization_server_url = server_url or DEFAULT_CLAUDE_CODE_SERVER_URL - authorization_state = configure_stored_authorization( - "codex", - server_url=authorization_server_url, - value=setup_authorization_value("codex"), - ) - authorization = read_stored_authorization(credential_path("codex"), server_url=authorization_server_url) - _configure_codex_endpoint(marketplace_name, _required_string(plugin, "version"), authorization_server_url) - if authorization.authorization is not None: + if session_id is not None and not runtime: + raise typer.BadParameter("--session-id requires --runtime") # noqa: TRY003 - CLI input validation. + explicit_server = server_url is not None or allow_insecure_http is not None + if agents is None and not explicit_server: + path = client_config_file() try: - configure_codex_desktop_authorization(authorization.authorization) - except OSError as error: - raise SetupError(f"Cannot configure Codex Desktop authorization: {error}") from error # noqa: TRY003 - return CodexSetupResult( - marketplace=marketplace_name, - plugin=_required_string(plugin, "name"), - plugin_version=_required_string(plugin, "version"), - data_dir=str(data_dir), - authorization_state=authorization_state, - ) + saved = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 1, "hosts": {}} + configured = saved.get("hosts") if isinstance(saved, dict) else None + if not isinstance(saved, dict) or saved.get("version") != 1 or not isinstance(configured, dict): + raise ValueError # noqa: TRY301 - invalid saved connections. + agents = [name for name in configured if name in installers()] + except (OSError, ValueError) as error: + raise typer.BadParameter("Cannot read saved agent connections") from error # noqa: TRY003 - CLI input validation. + options = { + "runtime": True if runtime else None, + "cwd": cwd, + "session_id": session_id, + "runtime_data_dir": runtime_data_dir, + "prepare": True if prepare else None, + "profile": profile, + "dsh_command": dsh_command, + } + selected = select_hosts(agents, json_output=json_output) if agents else () + validate_native_options(selected, **options) + if not selected: + checks = run_diagnostics(server_url=server_url, allow_insecure_http=allow_insecure_http) + _write_diagnostics(checks, json_output=json_output) + if not _diagnostics_ok(checks): + raise typer.Exit(code=1) + return + if explicit_server: + raise typer.BadParameter("Agent diagnostics use their saved connections; change them with config agent") # noqa: TRY003 - CLI input validation. + report_agents(selected, lambda host: diagnostic_result(doctor_checks(host, **options)), json_output=json_output) -def _configure_codex_endpoint(marketplace: str, plugin_version: str, server_url: str) -> None: +def _codex_endpoint_updates(marketplace: str, plugin_version: str, server_url: str) -> dict[Path, bytes]: """Keep the installed native MCP and Hook endpoint and saved credential source identical.""" from powercontext.cli.authorization import credential_path @@ -1418,119 +345,26 @@ def _configure_codex_endpoint(marketplace: str, plugin_version: str, server_url: entry["http_headers_helper"] = ( subprocess.list2cmdline(helper) if sys.platform == "win32" else shlex.join(helper) ) - _write_bytes_atomically(path, (json.dumps(config, indent=2) + "\n").encode()) + return {path: (json.dumps(config, indent=2) + "\n").encode()} except (OSError, ValueError, KeyError, TypeError) as error: raise SetupError( # noqa: TRY003 f"Cannot configure the installed Codex MCP endpoint at {path}; rerun setup after checking the plugin cache" ) from error -def install_claude_code_plugin( - *, - source: str, - ref: str, - server_url: str, - capture_prompts: bool, - allow_insecure_http: bool = False, -) -> ClaudeCodeSetupResult: - """Install and verify the plugin from one local or Git marketplace source.""" - - if which("claude") is None: - raise SetupError.claude_unavailable() - server_url = _normalize_claude_server_url(server_url, allow_insecure_http=allow_insecure_http) - - marketplace_source = _normalize_claude_marketplace_source(source, ref=ref) - marketplaces = _run_claude_json("plugin", "marketplace", "list") - marketplace = _claude_marketplace(marketplaces, CLAUDE_MARKETPLACE_NAME) - if marketplace is not None and not _claude_marketplace_matches(marketplace, marketplace_source): - raise SetupError.claude_marketplace_source_mismatch( - marketplace_source, - _describe_claude_marketplace_source(marketplace), - ) - marketplace_existed = marketplace is not None - - plugins = _run_claude_json("plugin", "list") - previous_plugin = _claude_plugin(plugins, scope="user") - plugin_existed = previous_plugin is not None - settings_snapshot = _snapshot_claude_settings() - marketplace_added = False - plugin_added = False - try: - if not marketplace_existed: - _run_claude("plugin", "marketplace", "add", marketplace_source, "--scope", "user") - marketplace_added = True - else: - # An existing marketplace and plugin keep their cached version until - # both are refreshed, so setup would otherwise configure a status - # line for a cache this release never wrote. - _run_claude("plugin", "marketplace", "update", CLAUDE_MARKETPLACE_NAME) - if plugin_existed: - _run_claude( - "plugin", - "update", - f"{PLUGIN_NAME}@{CLAUDE_MARKETPLACE_NAME}", - "--scope", - "user", - ) - _run_claude( - "plugin", - "install", - f"{PLUGIN_NAME}@{CLAUDE_MARKETPLACE_NAME}", - "--scope", - "user", - ) - plugin_added = not plugin_existed - installed = _run_claude_json("plugin", "list") - plugin = _require_enabled_claude_plugin(installed, scope="user") - _configure_claude_plugin( - plugin=plugin, - server_url=server_url, - capture_prompts=capture_prompts, - allow_insecure_http=allow_insecure_http, - ) - except SetupError: - if plugin_added: - with suppress(SetupError): - _run_claude( - "plugin", - "uninstall", - f"{PLUGIN_NAME}@{CLAUDE_MARKETPLACE_NAME}", - "--scope", - "user", - ) - with suppress(OSError): - _restore_claude_settings(settings_snapshot) - if marketplace_added: - with suppress(SetupError): - _run_claude( - "plugin", - "marketplace", - "remove", - CLAUDE_MARKETPLACE_NAME, - ) - raise - - plan = _claude_setup_plan() - from powercontext.cli.authorization import configure_stored_authorization, setup_authorization_value - - authorization_state = configure_stored_authorization( - "claude-code", server_url=server_url, value=setup_authorization_value("claude-code") - ) - return ClaudeCodeSetupResult( - marketplace=CLAUDE_MARKETPLACE_NAME, - plugin=PLUGIN_NAME, - plugin_version=_required_string(plugin, "version"), - settings_file=plan["settings_file"], - cache_dir=plan["cache_dir"], - data_dir=plan["data_dir"], - authorization_state=authorization_state, - ) - - def run_diagnostics(*, server_url: str | None = None, allow_insecure_http: bool | None = None) -> dict[str, Diagnostic]: """Collect installed-environment diagnostics without changing state.""" - package = Diagnostic(status=DiagnosticStatus.OK, detail=f"powercontext {version('powercontext')}") + return { + "package": Diagnostic(status=DiagnosticStatus.OK, detail=f"powercontext {version('powercontext')}"), + **server_diagnostics(server_url=server_url, allow_insecure_http=allow_insecure_http), + } + + +def server_diagnostics( + *, server_url: str | None, allow_insecure_http: bool | None, readiness: Diagnostic | None = None +) -> dict[str, Diagnostic]: + """Check the selected Server and its optional local service registration.""" service: dict[str, Diagnostic] = {} try: server_url, allowed = resolve_client_transport( @@ -1546,6 +380,8 @@ def run_diagnostics(*, server_url: str | None = None, allow_insecure_http: bool status=DiagnosticStatus.DEGRADED, detail="Insecure HTTP explicitly enabled; credentials and content are unencrypted in transit", ) + if readiness is not None: + return {**service, "server": readiness} liveness = _server_liveness_diagnostic(server_url) readiness = ( _server_readiness_diagnostic(server_url) @@ -1556,7 +392,6 @@ def run_diagnostics(*, server_url: str | None = None, allow_insecure_http: bool ) ) return { - "package": package, **service, "server_liveness": liveness, "server_readiness": readiness, @@ -1570,7 +405,11 @@ def _local_service_diagnostics(server_url: str) -> dict[str, Diagnostic]: if not is_loopback_host(parsed.hostname): return {} - from powercontext.service.controller import ServiceController + try: + from powercontext.service.controller import ServiceController + except ModuleNotFoundError: + # Personal service inspection is optional for CLI-only installations. + return {} from powercontext.service.model import ( DefinitionState, ManagerState, @@ -1650,172 +489,6 @@ def _local_service_diagnostics(server_url: str) -> dict[str, Diagnostic]: return diagnostics -def codex_setup_checks(diagnostics: dict[str, Diagnostic]) -> dict[str, Diagnostic]: - """Require working basic connectivity at installation, leaving full coverage to doctor.""" - return {name: check for name, check in diagnostics.items() if name != "mcp_full_profile"} - - -def run_codex_diagnostics() -> dict[str, Diagnostic]: - """Collect plugin and native MCP diagnostics for the optional Codex integration.""" - - executable = which("codex") - if executable is None: - return { - "codex": Diagnostic( - status=DiagnosticStatus.FAILED, - detail="Codex CLI is not installed or is not on PATH", - ), - "plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because Codex CLI is unavailable", - ), - } - try: - result = _run_codex_json("plugin", "list") - except SetupError as error: - return { - "codex": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), - "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="plugin list is unavailable"), - } - installed = result.get("installed") - plugin = None - if isinstance(installed, list): - plugin = next( - ( - item - for item in installed - if isinstance(item, dict) - and item.get("name") == PLUGIN_NAME - and item.get("installed") is True - and item.get("enabled") is True - ), - None, - ) - diagnostics = { - "codex": Diagnostic(status=DiagnosticStatus.OK, detail=executable), - "mcp_full_profile": Diagnostic( - status=DiagnosticStatus.SKIPPED, detail="not checked because native MCP discovery has not succeeded" - ), - "plugin": Diagnostic( - status=DiagnosticStatus.OK if plugin is not None else DiagnosticStatus.FAILED, - detail=( - f"{plugin.get('pluginId')} enabled={plugin.get('enabled')}" - if plugin is not None - else "PowerContext plugin is not installed" - ), - ), - } - if plugin is None: - diagnostics["mcp_configuration"] = Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because the PowerContext plugin is unavailable", - ) - diagnostics["authorization"] = Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because the PowerContext MCP entry is unavailable", - ) - diagnostics["mcp_tools"] = Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because the PowerContext plugin is unavailable", - ) - return diagnostics - - try: - servers = _run_codex_mcp_list() - except SetupError as error: - diagnostics["mcp_configuration"] = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) - diagnostics["authorization"] = Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because native MCP configuration is unavailable", - ) - diagnostics["mcp_tools"] = Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because native MCP configuration is unavailable", - ) - return diagnostics - - server = next((item for item in servers if item.get("name") == PLUGIN_NAME), None) - transport = server.get("transport") if server is not None else None - environment_headers = transport.get("env_http_headers") if isinstance(transport, dict) else None - mcp_url = transport.get("url") if isinstance(transport, dict) else None - configuration_ok = ( - server is not None - and server.get("enabled") is True - and isinstance(mcp_url, str) - and bool(mcp_url) - and environment_headers == {"Authorization": "POWERCONTEXT_CODEX_AUTHORIZATION"} - ) - diagnostics["mcp_configuration"] = Diagnostic( - status=DiagnosticStatus.OK if configuration_ok else DiagnosticStatus.FAILED, - detail=( - f"native HTTP MCP enabled with environment overrides; auth_status={server.get('auth_status', 'unknown')}" - if configuration_ok and server is not None - else "PowerContext native MCP entry is missing, disabled, or lacks environment-backed authorization; " - "reinstall the current plugin" - ), - ) - if not configuration_ok or not isinstance(mcp_url, str): - diagnostics["authorization"] = Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because native MCP configuration is invalid", - ) - diagnostics["mcp_tools"] = Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because native MCP configuration is invalid", - ) - return diagnostics - - credential_helper = isinstance(transport, dict) and bool(transport.get("http_headers_helper")) - authorization_diagnostic, native_authorization = _resolve_codex_native_authorization( - mcp_url, credential_helper=credential_helper - ) - diagnostics["authorization"] = authorization_diagnostic - if not authorization_diagnostic.ok: - diagnostics["mcp_tools"] = Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because Codex host authorization is invalid", - ) - return diagnostics - - try: - native_server = _probe_codex_mcp_status(authorization=native_authorization) - except SetupError as error: - diagnostics["mcp_tools"] = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) - return diagnostics - tools = native_server.get("tools") - tool_names = set(tools) if isinstance(tools, dict) else set() - missing = sorted(_CODEX_REQUIRED_MCP_TOOLS - tool_names) - if native_authorization is None and ( - not credential_helper or (authorization_diagnostic.checks or {}).get("setup_managed") == "url_mismatch" - ): - failure_hint = ( - "; check Server availability and, for an authenticated Server, set " - "POWERCONTEXT_CODEX_AUTHORIZATION while rerunning `powercontext setup codex`" - ) - else: - failure_hint = "; check Server availability and whether the effective host credential is still valid" - diagnostics["mcp_tools"] = Diagnostic( - status=DiagnosticStatus.OK if not missing else DiagnosticStatus.FAILED, - detail=( - f"Codex native MCP initialized and discovered {len(tool_names)} tools" - if not missing - else "Codex native MCP did not discover required tools: " + ", ".join(missing) + failure_hint - ), - ) - missing_full = sorted(_CODEX_FULL_MCP_TOOLS - tool_names) - diagnostics["mcp_full_profile"] = Diagnostic( - status=DiagnosticStatus.OK if not missing_full else DiagnosticStatus.DEGRADED, - detail=( - "Codex native MCP exposes all full-profile tools; model readiness and Hook execution are separate checks" - if not missing_full - else "Full-profile tools unavailable: " - + ", ".join(missing_full) - + "; upgrade the Server and refresh the plugin, then open a new Codex session" - ), - ) - return diagnostics - - def _codex_authorization_checks( *, stored_state: str, @@ -2027,16 +700,16 @@ def _run_codex_mcp_list() -> list[dict[str, Any]]: timeout=30, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command[:-1], error) from error + raise SetupError(f"Cannot run {' '.join(command[:-1])}: {error}") from error # noqa: TRY003 if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" - raise SetupError.command_failed(command[:-1], detail) + raise SetupError(f"`{' '.join(command[:-1])}` failed: {detail}") # noqa: TRY003 try: payload = json.loads(completed.stdout) except json.JSONDecodeError as error: - raise SetupError.invalid_command_output(command[:-1], "invalid JSON") from error + raise SetupError(f"`{' '.join(command[:-1])}` returned invalid JSON") from error # noqa: TRY003 if not isinstance(payload, list) or any(not isinstance(item, dict) for item in payload): - raise SetupError.invalid_command_output(command[:-1], "an unexpected result") + raise SetupError(f"`{' '.join(command[:-1])}` returned an unexpected result") # noqa: TRY003 return cast(list[dict[str, Any]], payload) @@ -2045,7 +718,7 @@ def _probe_codex_mcp_status(*, authorization: str | None = None) -> dict[str, An executable = which("codex") if executable is None: - raise SetupError.codex_unavailable() + raise SetupError("Codex CLI is not installed or is not on PATH.") # noqa: TRY003 environment = os.environ.copy() if authorization is None: environment.pop("POWERCONTEXT_CODEX_AUTHORIZATION", None) @@ -2064,7 +737,7 @@ def _probe_codex_mcp_status(*, authorization: str | None = None) -> dict[str, An env=environment, ) except OSError as error: - raise SetupError.command_unavailable(command, error) from error + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 stdin = process.stdin stdout = process.stdout if stdin is None or stdout is None: @@ -2156,43 +829,6 @@ def receive(request_id: int) -> dict[str, Any]: reader.join(timeout=1) -def run_claude_code_diagnostics() -> dict[str, Diagnostic]: - """Collect diagnostics for the optional Claude Code integration.""" - - executable = which("claude") - if executable is None: - return { - "claude_code": Diagnostic( - status=DiagnosticStatus.FAILED, - detail="Claude Code CLI is not installed or is not on PATH", - ), - "plugin": Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because Claude Code CLI is unavailable", - ), - } - try: - result = _run_claude_json("plugin", "list") - except SetupError as error: - return { - "claude_code": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), - "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="plugin list is unavailable"), - } - plugin = _claude_plugin(result) - plugin_enabled = plugin is not None and plugin.get("enabled") is True - return { - "claude_code": Diagnostic(status=DiagnosticStatus.OK, detail=executable), - "plugin": Diagnostic( - status=DiagnosticStatus.OK if plugin_enabled else DiagnosticStatus.FAILED, - detail=( - f"{plugin.get('id')} enabled={plugin.get('enabled')}" - if plugin is not None - else "PowerContext plugin is not installed" - ), - ), - } - - def _server_liveness_diagnostic(server_url: str) -> Diagnostic: try: status_code, payload = _request_json(server_url, "/health/live") @@ -2309,53 +945,11 @@ def _normalize_claude_marketplace_source(source: str, *, ref: str) -> str: return f"{source}#{ref}" -def _normalize_claude_server_url(value: str, *, allow_insecure_http: bool = False) -> str: - normalized = value.strip().rstrip("/") - parsed = urlsplit(normalized) - if parsed.username is not None or parsed.password is not None: - raise SetupError.claude_server_url_credentials() - if parsed.hostname is None or parsed.scheme not in {"http", "https"}: - raise SetupError.claude_server_url_scheme() - if parsed.query or parsed.fragment: - raise SetupError.claude_server_url_suffix() - if parsed.scheme == "http" and not is_loopback_host(parsed.hostname) and not allow_insecure_http: - raise SetupError.claude_server_url_transport() - path = parsed.path.rstrip("/") - if path.endswith("/mcp"): - path = path.removesuffix("/mcp") - return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/") - - def _claude_config_dir() -> Path: configured = os.environ.get("CLAUDE_CONFIG_DIR") return Path(configured).expanduser() if configured else Path.home() / ".claude" -def _claude_setup_plan() -> dict[str, str]: - config_dir = _claude_config_dir() - return { - "settings_file": str(config_dir / "settings.json"), - "cache_dir": str(config_dir / "plugins" / "cache" / CLAUDE_MARKETPLACE_NAME / PLUGIN_NAME / ""), - "data_dir": str(config_dir / "plugins" / "data" / f"{PLUGIN_NAME}-{CLAUDE_MARKETPLACE_NAME}"), - } - - -def _write_claude_setup_plan(plan: dict[str, str]) -> None: - typer.echo("Claude Code setup plan (no changes made yet):", err=True) - typer.echo(f" Settings entry: {plan['settings_file']}", err=True) - typer.echo(f" Plugin cache: {plan['cache_dir']}", err=True) - typer.echo(f" Plugin data: {plan['data_dir']}", err=True) - typer.echo(" Permissions: read/write access to the Claude Code configuration directory", err=True) - typer.echo( - f" Rollback: claude plugin uninstall {PLUGIN_NAME}@{CLAUDE_MARKETPLACE_NAME} --scope user", - err=True, - ) - typer.echo( - f" Rollback: claude plugin marketplace remove {CLAUDE_MARKETPLACE_NAME}", - err=True, - ) - - def _claude_marketplace(value: object, name: str) -> dict[str, Any] | None: if not isinstance(value, list): return None @@ -2417,28 +1011,20 @@ def _claude_plugin(value: object, *, scope: str | None = None) -> dict[str, Any] return None -def _require_enabled_claude_plugin(value: object, *, scope: str | None = None) -> dict[str, Any]: +def _require_claude_plugin(value: object, *, scope: str | None = None) -> dict[str, Any]: plugin = _claude_plugin(value, scope=scope) - if plugin is None or plugin.get("enabled") is not True: - raise SetupError.claude_plugin_not_enabled() + if plugin is None: + raise SetupError("Claude Code did not report an installed PowerContext plugin after installation.") # noqa: TRY003 return plugin -def _snapshot_claude_settings() -> bytes | None: - settings_file = _claude_config_dir() / "settings.json" - try: - return settings_file.read_bytes() - except FileNotFoundError: - return None - - -def _configure_claude_plugin( +def _claude_configuration_updates( *, plugin: dict[str, Any], server_url: str, - capture_prompts: bool, + capture_prompts: bool | None, allow_insecure_http: bool = False, -) -> None: +) -> dict[Path, bytes]: """Merge non-sensitive plugin options unsupported by the Claude install CLI.""" settings_file = _claude_config_dir() / "settings.json" @@ -2446,27 +1032,39 @@ def _configure_claude_plugin( settings = json.loads(settings_file.read_text(encoding="utf-8")) if settings_file.exists() else {} except (OSError, UnicodeError, json.JSONDecodeError) as error: if isinstance(error, OSError): - raise SetupError.claude_settings_write(settings_file, error) from error - raise SetupError.invalid_claude_settings(settings_file) from error + raise SetupError(f"Cannot update Claude Code settings at {settings_file}: {error}") from error # noqa: TRY003 + raise SetupError( # noqa: TRY003 + f"Claude Code settings at {settings_file} must contain a JSON object with object-valued plugin options." + ) from error if not isinstance(settings, dict): - raise SetupError.invalid_claude_settings(settings_file) + raise SetupError( # noqa: TRY003 + f"Claude Code settings at {settings_file} must contain a JSON object with object-valued plugin options." + ) plugin_configs = settings.setdefault("pluginConfigs", {}) if not isinstance(plugin_configs, dict): - raise SetupError.invalid_claude_settings(settings_file) + raise SetupError( # noqa: TRY003 + f"Claude Code settings at {settings_file} must contain a JSON object with object-valued plugin options." + ) plugin_id = f"{PLUGIN_NAME}@{CLAUDE_MARKETPLACE_NAME}" plugin_config = plugin_configs.setdefault(plugin_id, {}) if not isinstance(plugin_config, dict): - raise SetupError.invalid_claude_settings(settings_file) + raise SetupError( # noqa: TRY003 + f"Claude Code settings at {settings_file} must contain a JSON object with object-valued plugin options." + ) options = plugin_config.setdefault("options", {}) if not isinstance(options, dict): - raise SetupError.invalid_claude_settings(settings_file) + raise SetupError( # noqa: TRY003 + f"Claude Code settings at {settings_file} must contain a JSON object with object-valued plugin options." + ) options.update({ "server_url": server_url, - "capture_prompts": capture_prompts, "allow_insecure_http": allow_insecure_http, }) + if capture_prompts is not None: + options["capture_prompts"] = capture_prompts + install_path = _claude_plugin_install_path(plugin) statusline_command = shlex.join([ "python3", @@ -2481,10 +1079,7 @@ def _configure_claude_plugin( "command": statusline_command, "refreshInterval": 30, } - try: - _write_bytes_atomically(settings_file, (json.dumps(settings, indent=2) + "\n").encode()) - except OSError as error: - raise SetupError.claude_settings_write(settings_file, error) from error + return {settings_file: (json.dumps(settings, indent=2) + "\n").encode()} def _claude_plugin_install_path(plugin: dict[str, Any]) -> Path: @@ -2511,32 +1106,6 @@ def _is_powercontext_statusline(value: object) -> bool: ) -def _restore_claude_settings(snapshot: bytes | None) -> None: - settings_file = _claude_config_dir() / "settings.json" - if snapshot is None: - settings_file.unlink(missing_ok=True) - return - _write_bytes_atomically(settings_file, snapshot) - - -def _write_bytes_atomically(path: Path, content: bytes) -> None: - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - temporary_path = path.with_name(f".{path.name}.{os.getpid()}.tmp") - descriptor: int | None = None - try: - descriptor = os.open(temporary_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0), 0o600) - os.write(descriptor, content) - os.fsync(descriptor) - os.close(descriptor) - descriptor = None - os.replace(temporary_path, path) - finally: - if descriptor is not None: - os.close(descriptor) - with suppress(FileNotFoundError): - temporary_path.unlink() - - def _run_codex_json(*arguments: str) -> dict[str, Any]: command = ["codex", *arguments, "--json"] try: @@ -2548,23 +1117,34 @@ def _run_codex_json(*arguments: str) -> dict[str, Any]: timeout=120, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command[:-1], error) from error + raise SetupError(f"Cannot run {' '.join(command[:-1])}: {error}") from error # noqa: TRY003 if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" - raise SetupError.command_failed(command[:-1], detail) + raise SetupError(f"`{' '.join(command[:-1])}` failed: {detail}") # noqa: TRY003 try: result = json.loads(completed.stdout) except json.JSONDecodeError as error: - raise SetupError.invalid_command_output(command[:-1], "invalid JSON") from error + raise SetupError(f"`{' '.join(command[:-1])}` returned invalid JSON") from error # noqa: TRY003 if not isinstance(result, dict): - raise SetupError.invalid_command_output(command[:-1], "an unexpected result") + raise SetupError(f"`{' '.join(command[:-1])}` returned an unexpected result") # noqa: TRY003 return result +def _installed_codex_plugin() -> dict[str, Any] | None: + plugins = [ + item + for item in _run_codex_json("plugin", "list")["installed"] + if item["name"] == PLUGIN_NAME and item["installed"] + ] + if len(plugins) > 1: + raise SetupError("Expected one installed PowerContext plugin in Codex; inspect codex plugin list") # noqa: TRY003 + return plugins[0] if plugins else None + + def _run_claude(*arguments: str) -> subprocess.CompletedProcess[str]: executable = which("claude") if executable is None: - raise SetupError.claude_unavailable() + raise SetupError("Claude Code CLI is not installed or is not on PATH.") # noqa: TRY003 command = [executable, *arguments] try: completed = subprocess.run( # noqa: S603 - arguments are passed directly to the fixed Claude executable. @@ -2577,10 +1157,10 @@ def _run_claude(*arguments: str) -> subprocess.CompletedProcess[str]: timeout=120, ) except (OSError, subprocess.SubprocessError) as error: - raise SetupError.command_unavailable(command, error) from error + raise SetupError(f"Cannot run {' '.join(command)}: {error}") from error # noqa: TRY003 if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() or f"exit code {completed.returncode}" - raise SetupError.command_failed(command, detail) + raise SetupError(f"`{' '.join(command)}` failed: {detail}") # noqa: TRY003 return completed @@ -2590,29 +1170,296 @@ def _run_claude_json(*arguments: str) -> object: try: return json.loads(completed.stdout) except json.JSONDecodeError as error: - raise SetupError.invalid_command_output(["claude", *command], "invalid JSON") from error + raise SetupError(f"`{' '.join(['claude', *command])}` returned invalid JSON") from error # noqa: TRY003 def _required_string(value: dict[str, Any], name: str) -> str: result = value.get(name) if not isinstance(result, str) or not result: - raise SetupError.missing_result(name) + raise SetupError(f"Integration CLI did not return {name}") # noqa: TRY003 return result -__all__ = [ - "ClaudeCodeSetupResult", - "CodexSetupResult", - "Diagnostic", - "DiagnosticStatus", - "OpenClawSetupResult", - "SetupError", - "doctor_app", - "install_claude_code_plugin", - "install_codex_plugin", - "run_claude_code_diagnostics", - "run_codex_diagnostics", - "run_diagnostics", - "setup_app", - "setup_openclaw", -] +__all__ = ["Diagnostic", "DiagnosticStatus", "SetupError", "doctor_app", "run_diagnostics", "setup_app"] + + +class CodexInstaller: + def install(self, source): + if which("codex") is None: + raise SetupError("Codex CLI is not installed or is not on PATH.") # noqa: TRY003 + marketplace_source, local = _normalize_marketplace_source(source.location) + arguments = ["plugin", "marketplace", "add", marketplace_source] + if not local: + arguments.extend(("--ref", source.ref)) + marketplace = _required_string(_run_codex_json(*arguments), "marketplaceName") + plugin = next( + ( + item + for item in _run_codex_json("plugin", "list")["installed"] + if item["name"] == PLUGIN_NAME and item["marketplaceName"] == marketplace and item["installed"] + ), + None, + ) + if plugin is None or plugin.get("enabled") is not False: + plugin = _run_codex_json("plugin", "add", f"{PLUGIN_NAME}@{marketplace}") + return { + "marketplace": marketplace, + "plugin": _required_string(plugin, "name"), + "plugin_version": _required_string(plugin, "version"), + "data_dir": str(powercontext_data_dir()), + } + + def configure(self, connection): + from powercontext.cli.authorization import ( + configure_codex_desktop_authorization, + credential_path, + read_stored_authorization, + ) + from powercontext.cli.transport import configure_connection + + record = _installed_codex_plugin() + if record is None: + raise SetupError("PowerContext is not installed in Codex; run powercontext setup codex") # noqa: TRY003 + marketplace = _required_string(record, "marketplaceName") + state = configure_connection( + connection, + writes=_codex_endpoint_updates(marketplace, _required_string(record, "version"), connection.server_url), + ) + value = read_stored_authorization(credential_path("codex"), server_url=connection.server_url).authorization + if value is not None: + configure_codex_desktop_authorization(value) + return {"authorization_state": state} + + def verify(self) -> dict[str, Diagnostic]: + """Collect plugin and native MCP diagnostics for the optional Codex integration.""" + + executable = which("codex") + if executable is None: + return { + "codex": Diagnostic( + status=DiagnosticStatus.FAILED, + detail="Codex CLI is not installed or is not on PATH", + ), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because Codex CLI is unavailable", + ), + } + try: + plugin = _installed_codex_plugin() + except SetupError as error: + return { + "codex": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), + "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="plugin list is unavailable"), + } + plugin_enabled = plugin is not None and plugin.get("enabled") is True + detail = ( + f"{plugin.get('pluginId')} enabled={plugin.get('enabled')}" + if plugin is not None + else "PowerContext plugin is not installed" + ) + if plugin is not None and not plugin_enabled: + detail += ( + f"; setup preserves disabled plugin version {plugin.get('version')}; " + "use codex plugin add to refresh its source and enable it" + ) + diagnostics = { + "codex": Diagnostic(status=DiagnosticStatus.OK, detail=executable), + "mcp_full_profile": Diagnostic( + status=DiagnosticStatus.SKIPPED, detail="not checked because native MCP discovery has not succeeded" + ), + "plugin": Diagnostic( + status=DiagnosticStatus.OK if plugin_enabled else DiagnosticStatus.FAILED, + detail=detail, + ), + } + if not plugin_enabled: + diagnostics["mcp_configuration"] = Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because the PowerContext plugin is unavailable", + ) + diagnostics["authorization"] = Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because the PowerContext MCP entry is unavailable", + ) + diagnostics["mcp_tools"] = Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because the PowerContext plugin is unavailable", + ) + return diagnostics + + try: + servers = _run_codex_mcp_list() + except SetupError as error: + diagnostics["mcp_configuration"] = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) + diagnostics["authorization"] = Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because native MCP configuration is unavailable", + ) + diagnostics["mcp_tools"] = Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because native MCP configuration is unavailable", + ) + return diagnostics + + server = next((item for item in servers if item.get("name") == PLUGIN_NAME), None) + transport = server.get("transport") if server is not None else None + environment_headers = transport.get("env_http_headers") if isinstance(transport, dict) else None + mcp_url = transport.get("url") if isinstance(transport, dict) else None + configuration_ok = ( + server is not None + and server.get("enabled") is True + and isinstance(mcp_url, str) + and bool(mcp_url) + and environment_headers == {"Authorization": "POWERCONTEXT_CODEX_AUTHORIZATION"} + ) + diagnostics["mcp_configuration"] = Diagnostic( + status=DiagnosticStatus.OK if configuration_ok else DiagnosticStatus.FAILED, + detail=( + f"native HTTP MCP enabled with environment overrides; auth_status={server.get('auth_status', 'unknown')}" + if configuration_ok and server is not None + else "PowerContext native MCP entry is missing, disabled, or lacks environment-backed authorization; " + "reinstall the current plugin" + ), + ) + if not configuration_ok or not isinstance(mcp_url, str): + diagnostics["authorization"] = Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because native MCP configuration is invalid", + ) + diagnostics["mcp_tools"] = Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because native MCP configuration is invalid", + ) + return diagnostics + + credential_helper = isinstance(transport, dict) and bool(transport.get("http_headers_helper")) + authorization_diagnostic, native_authorization = _resolve_codex_native_authorization( + mcp_url, credential_helper=credential_helper + ) + diagnostics["authorization"] = authorization_diagnostic + if not authorization_diagnostic.ok: + diagnostics["mcp_tools"] = Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because Codex host authorization is invalid", + ) + return diagnostics + + try: + native_server = _probe_codex_mcp_status(authorization=native_authorization) + except SetupError as error: + diagnostics["mcp_tools"] = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) + return diagnostics + tools = native_server.get("tools") + tool_names = set(tools) if isinstance(tools, dict) else set() + missing = sorted(_CODEX_REQUIRED_MCP_TOOLS - tool_names) + if native_authorization is None and ( + not credential_helper or (authorization_diagnostic.checks or {}).get("setup_managed") == "url_mismatch" + ): + failure_hint = ( + "; check Server availability and, for an authenticated Server, set " + "POWERCONTEXT_CODEX_AUTHORIZATION while rerunning `powercontext setup codex`" + ) + else: + failure_hint = "; check Server availability and whether the effective host credential is still valid" + diagnostics["mcp_tools"] = Diagnostic( + status=DiagnosticStatus.OK if not missing else DiagnosticStatus.FAILED, + detail=( + f"Codex native MCP initialized and discovered {len(tool_names)} tools" + if not missing + else "Codex native MCP did not discover required tools: " + ", ".join(missing) + failure_hint + ), + ) + missing_full = sorted(_CODEX_FULL_MCP_TOOLS - tool_names) + diagnostics["mcp_full_profile"] = Diagnostic( + status=DiagnosticStatus.OK if not missing_full else DiagnosticStatus.DEGRADED, + detail=( + "Codex native MCP exposes all full-profile tools; model readiness and Hook execution are separate checks" + if not missing_full + else "Full-profile tools unavailable: " + + ", ".join(missing_full) + + "; upgrade the Server and refresh the plugin, then open a new Codex session" + ), + ) + return diagnostics + + +@dataclass +class ClaudeInstaller: + capture_prompts: bool | None = None + + def install(self, source): + if which("claude") is None: + raise SetupError("Claude Code CLI is not installed or is not on PATH.") # noqa: TRY003 + location = _normalize_claude_marketplace_source(source.location, ref=source.ref) + market = _claude_marketplace(_run_claude_json("plugin", "marketplace", "list"), CLAUDE_MARKETPLACE_NAME) + if market is not None and not _claude_marketplace_matches(market, location): + raise SetupError( # noqa: TRY003 + f"Claude Code marketplace `powercontext` uses {_describe_claude_marketplace_source(market)}, but setup requested {location}. Remove it with `claude plugin marketplace remove powercontext`, then rerun setup." + ) + installed = _claude_plugin(_run_claude_json("plugin", "list"), scope="user") + if market is None: + _run_claude("plugin", "marketplace", "add", location, "--scope", "user") + else: + _run_claude("plugin", "marketplace", "update", CLAUDE_MARKETPLACE_NAME) + operation = "install" if installed is None else "update" + _run_claude("plugin", operation, f"{PLUGIN_NAME}@{CLAUDE_MARKETPLACE_NAME}", "--scope", "user") + plugin = _require_claude_plugin(_run_claude_json("plugin", "list"), scope="user") + config_dir = _claude_config_dir() + return { + "marketplace": CLAUDE_MARKETPLACE_NAME, + "plugin": PLUGIN_NAME, + "plugin_version": _required_string(plugin, "version"), + "settings_file": str(config_dir / "settings.json"), + "cache_dir": str(_claude_plugin_install_path(plugin)), + "data_dir": str(config_dir / "plugins/data" / f"{PLUGIN_NAME}-{CLAUDE_MARKETPLACE_NAME}"), + } + + def configure(self, connection): + from powercontext.cli.transport import configure_connection + + plugin = _require_claude_plugin(_run_claude_json("plugin", "list"), scope="user") + updates = _claude_configuration_updates( + plugin=plugin, + server_url=connection.server_url, + capture_prompts=self.capture_prompts, + allow_insecure_http=connection.allow_insecure_http, + ) + return {"authorization_state": configure_connection(connection, writes=updates)} + + def verify(self) -> dict[str, Diagnostic]: + """Collect diagnostics for the optional Claude Code integration.""" + + executable = which("claude") + if executable is None: + return { + "claude_code": Diagnostic( + status=DiagnosticStatus.FAILED, + detail="Claude Code CLI is not installed or is not on PATH", + ), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because Claude Code CLI is unavailable", + ), + } + try: + result = _run_claude_json("plugin", "list") + except SetupError as error: + return { + "claude_code": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), + "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="plugin list is unavailable"), + } + plugin = _claude_plugin(result) + errors = plugin.get("errors") if plugin is not None else None + plugin_enabled = plugin is not None and plugin.get("enabled") is True and not errors + return { + "claude_code": Diagnostic(status=DiagnosticStatus.OK, detail=executable), + "plugin": Diagnostic( + status=DiagnosticStatus.OK if plugin_enabled else DiagnosticStatus.FAILED, + detail=( + f"{plugin.get('id')} enabled={plugin.get('enabled')}" + (f"; errors: {errors}" if errors else "") + if plugin is not None + else "PowerContext plugin is not installed" + ), + ), + } diff --git a/src/powercontext/cli/transport.py b/src/powercontext/cli/transport.py index f6c6c05cbc..2bd86f52c4 100644 --- a/src/powercontext/cli/transport.py +++ b/src/powercontext/cli/transport.py @@ -38,8 +38,8 @@ from powercontext.transport import is_loopback_host if TYPE_CHECKING: + from powercontext.cli.authorization import SetupAuthorizationStatus from powercontext.cli.dsh_runtime import DshTarget - from powercontext.cli.system import Diagnostic setup_environment_file: ContextVar[Path | None] = ContextVar("setup_environment_file", default=None) @@ -124,6 +124,14 @@ def prepare_setup_transport( "protected in transit. TLS verification and Server authentication remain unchanged.", err=True, ) + if host == "kimi-code": + from powercontext.cli.kimi_code import _endpoint + + endpoint = _endpoint(endpoint) + if host == "zcode": + from powercontext.cli.zcode import _endpoint + + endpoint = _endpoint(endpoint) if host == "dsh": from powercontext.cli.dsh_transport import validate_dsh_setup_transport @@ -260,88 +268,34 @@ def existing_native_endpoint(host: str) -> str | None: return None -def save_setup_transport(settings: SetupTransport) -> None: - """Save endpoint-bound consent, rolling back paired native writes on failure.""" +def configure_connection( + settings: SetupTransport, *, dsh_target: DshTarget | None = None, writes: dict[Path, bytes] | None = None +) -> SetupAuthorizationStatus: + """Publish the native endpoint, shared settings, and credentials as one connection.""" + from powercontext.cli.authorization import authorization_updates + from powercontext.cli.installation import publish + + credentials, state = authorization_updates(settings) + publish(writes={**(writes or {}), **setup_transport_updates(settings, dsh_target=dsh_target), **credentials}) + return state - from powercontext.cli.system import SetupError, _write_bytes_atomically + +def setup_transport_updates(settings: SetupTransport, *, dsh_target: DshTarget | None = None) -> dict[Path, bytes]: + """Prepare the shared nonsecret Client settings for publication.""" + from powercontext.cli.system import SetupError path = client_config_file() - completed: list[tuple[Path, bytes | None]] = [] try: config: Any = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 1, "hosts": {}} if not isinstance(config, dict) or config.get("version") != 1 or not isinstance(config.get("hosts"), dict): - raise ValueError("Client configuration must have version 1 and a hosts object") # noqa: TRY003, TRY301 + raise ValueError # noqa: TRY301 - normalize invalid persisted configuration. entry = config["hosts"].setdefault(settings.host, {}) if not isinstance(entry, dict): - raise ValueError("Client host configuration must be an object") # noqa: TRY003, TRY004, TRY301 + raise ValueError # noqa: TRY301, TRY004 - normalize invalid persisted configuration. entry.update(server_url=settings.server_url, allow_insecure_http=settings.allow_insecure_http) - updates: list[tuple[Path, dict[str, Any]]] = [] - if settings.host == "hermes": - native_path = hermes_config_file() - native = json.loads(native_path.read_text(encoding="utf-8")) if native_path.exists() else {} - if not isinstance(native, dict): - raise ValueError("Hermes client configuration must be an object") # noqa: TRY003, TRY301 - native.update(base_url=settings.server_url, allow_insecure_http=settings.allow_insecure_http) - updates.append((native_path, native)) - updates.append((path, config)) - # Validate every destination before writing either half of a native/shared pair. - writes = [ - (destination, destination.read_bytes() if destination.exists() else None, payload) - for destination, payload in updates - ] - for destination, snapshot, payload in writes: - _write_bytes_atomically(destination, (json.dumps(payload, indent=2) + "\n").encode()) - completed.append((destination, snapshot)) + if dsh_target is not None: + entry.update(profile=dsh_target.profile, dsh_command=dsh_target.command) + updates: dict[Path, bytes] = {path: (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode()} except (OSError, ValueError) as error: - for destination, snapshot in reversed(completed): - try: - if snapshot is None: - destination.unlink(missing_ok=True) - else: - _write_bytes_atomically(destination, snapshot) - except OSError: - typer.echo( - f"WARNING: could not restore {destination}; check its configuration before restarting.", err=True - ) raise SetupError(f"Could not save PowerContext Client settings at {path}") from error # noqa: TRY003 - - -def hermes_config_file() -> Path: - """Locate the same native configuration file used by the Hermes provider.""" - - override = os.environ.get("POWERCONTEXT_HERMES_CONFIG") - if override: - return Path(override) - return Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))) / "powercontext" / "config.json" - - -def transport_diagnostic(host: str, *, dsh_target: DshTarget | None = None) -> Diagnostic: - """Report explicitly insecure transport as degraded rather than silently green.""" - - from powercontext.cli.native_transport import resolve_host_transport - from powercontext.cli.system import Diagnostic, DiagnosticStatus - - try: - endpoint, allowed = resolve_host_transport(host, **({"dsh_target": dsh_target} if dsh_target else {})) - except ValueError as error: - return Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) - if is_remote_http(endpoint): - return Diagnostic( - status=DiagnosticStatus.DEGRADED if allowed else DiagnosticStatus.FAILED, - detail=( - f"{endpoint}: insecure HTTP explicitly enabled; credentials and content are unencrypted" - if allowed - else f"{endpoint}: remote HTTP blocked; use HTTPS or explicitly allow insecure HTTP" - ), - ) - return Diagnostic(status=DiagnosticStatus.OK, detail=endpoint) - - -def add_transport_diagnostic( - diagnostics: dict[str, Diagnostic], host: str, *, dsh_target: DshTarget | None = None -) -> None: - """Append policy problems without changing healthy installation reports.""" - - diagnostic = transport_diagnostic(host, **({"dsh_target": dsh_target} if dsh_target else {})) - if not diagnostic.ok: - diagnostics["transport"] = diagnostic + return updates diff --git a/src/powercontext/cli/workbuddy.py b/src/powercontext/cli/workbuddy.py index 4a45a66cf0..3ecd8cf900 100644 --- a/src/powercontext/cli/workbuddy.py +++ b/src/powercontext/cli/workbuddy.py @@ -16,7 +16,6 @@ from __future__ import annotations -import hashlib import json import os import shlex @@ -24,23 +23,10 @@ import subprocess import sys import tempfile -import uuid from collections.abc import Callable -from contextlib import suppress -from dataclasses import dataclass from pathlib import Path from typing import Any, cast -from powercontext.cli.git_source import ( - InvalidGitHubSourceError, - clone_github_source, -) -from powercontext.cli.git_source import ( - github_clone_url as _github_clone_url, -) -from powercontext.cli.git_source import ( - is_local_source as _is_local_source, -) from powercontext.cli.guidance import ( ENTRY_NAME, HOST_GUIDANCE, @@ -49,7 +35,9 @@ refresh_skill, write_if_changed, ) +from powercontext.cli.installation import publish, write_json from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError +from powercontext.cli.transport import configure_connection from powercontext.paths import powercontext_data_dir WORKBUDDY_HOME_ENV = "WORKBUDDY_HOME" @@ -74,134 +62,18 @@ WORKBUDDY_AUTHORIZATION_ENV = "POWERCONTEXT_WORKBUDDY_AUTHORIZATION" WORKBUDDY_MCP_URL = f"${{{WORKBUDDY_SERVER_URL_ENV}:-http://127.0.0.1:8000}}/mcp" WORKBUDDY_MCP_AUTHORIZATION = f"${{{WORKBUDDY_AUTHORIZATION_ENV}:-}}" -WORKBUDDY_LEGACY_MCP_URL = "http://127.0.0.1:8000/mcp" WORKBUDDY_MCP_DESCRIPTION = "PowerContext agent memory & handoff MCP server (local service on port 8000)" WORKBUDDY_HOOK_STATUS_MESSAGE = "Syncing PowerContext" WORKBUDDY_HOOK_TIMEOUT = 30 -@dataclass(frozen=True, slots=True) -class WorkBuddySetupResult: - plugin: str - plugin_path: str - workbuddy_home: str - hooks_dir: str - data_dir: str - authorization_state: str = "not_attempted" - - -def install_workbuddy_plugin(*, source: str, ref: str, server_url: str | None = None) -> WorkBuddySetupResult: - """Install the PowerContext hooks, MCP server, and Skill into WorkBuddy's user directory.""" - - data_dir = powercontext_data_dir() - try: - data_dir.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise SetupError.data_directory(data_dir, error) from error - - plugin_dir = resolve_workbuddy_plugin_dir(source=source, ref=ref) - require_workbuddy_plugin(plugin_dir) - - home = workbuddy_home() - hooks_dir = home / WORKBUDDY_HOOKS_DIRNAME - skills_dir = home / WORKBUDDY_SKILLS_DIRNAME - skill_dir = skills_dir / WORKBUDDY_SKILL_NAME - settings_file = home / "settings.json" - mcp_file = home / "mcp.json" - - try: - home.mkdir(parents=True, exist_ok=True) - except OSError as error: - raise SetupError.workbuddy_home_unavailable(home, error) from error - - require_replaceable_workbuddy_skill(skill_dir) - settings_snapshot = _read_bytes_or_none( - settings_file, - io_error=lambda error: SetupError.workbuddy_settings_write(settings_file, error), - ) - mcp_snapshot = _read_bytes_or_none( - mcp_file, - io_error=lambda error: SetupError.workbuddy_mcp_write(mcp_file, error), - ) - hooks_backup = _snapshot_directory(hooks_dir) - skill_backup = _snapshot_directory(skill_dir) - try: - _install_hook_files(plugin_dir, hooks_dir) - _merge_workbuddy_settings(settings_file, hooks_dir) - _merge_workbuddy_mcp(mcp_file, server_url=server_url) - _install_workbuddy_skill( - plugin_dir, - skills_dir, - hooks_dir, - legacy_replacements=_previous_workbuddy_replacements(settings_snapshot, hooks_dir), - ) - except BaseException: - _restore_file(settings_file, settings_snapshot) - _restore_file(mcp_file, mcp_snapshot) - _restore_directory(hooks_dir, hooks_backup) - _restore_directory(skill_dir, skill_backup) - raise - finally: - _remove_path(hooks_backup) - _remove_path(skill_backup) - - from powercontext.cli.authorization import ( - configure_stored_authorization, - setup_authorization_value, - ) - - return WorkBuddySetupResult( - plugin=WORKBUDDY_PLUGIN_NAME, - plugin_path=str(plugin_dir), - workbuddy_home=str(home), - hooks_dir=str(hooks_dir), - data_dir=str(data_dir), - authorization_state=configure_stored_authorization( - "workbuddy", - server_url=server_url or "http://127.0.0.1:8000", - value=setup_authorization_value("workbuddy"), - ), - ) - - -def resolve_workbuddy_plugin_dir(*, source: str, ref: str) -> Path: - """Return the WorkBuddy plugin directory from a local or remote checkout.""" - - if _is_local_source(source): - return plugin_dir_from_checkout(Path(source).expanduser().resolve()) - return plugin_dir_from_checkout(_materialize_remote_checkout(source, ref)) - - -def plugin_dir_from_checkout(root: Path) -> Path: - """Accept either the plugin directory or a PowerContext repository root.""" - - if _is_workbuddy_plugin(root): - return root - plugin = root / WORKBUDDY_PLUGIN_RELATIVE - if _is_workbuddy_plugin(plugin): - return plugin - raise SetupError.missing_workbuddy_plugin(root) - - def require_workbuddy_plugin(path: Path) -> None: """Reject a directory that cannot provide the WorkBuddy hooks, MCP, and Skill.""" - if not _is_workbuddy_plugin(path): - raise SetupError.missing_workbuddy_plugin(path) - - -def run_workbuddy_diagnostics() -> dict[str, Diagnostic]: - """Collect diagnostics for the optional WorkBuddy integration.""" - - home = workbuddy_home() - hooks_dir = home / WORKBUDDY_HOOKS_DIRNAME - skill_file = home / WORKBUDDY_SKILLS_DIRNAME / WORKBUDDY_SKILL_NAME / "SKILL.md" - return { - "hooks": _hooks_diagnostic(hooks_dir), - "settings": _settings_diagnostic(home / "settings.json"), - "mcp": _mcp_diagnostic(home / "mcp.json"), - "skill": _skill_diagnostic(skill_file), - } + if not all((path / "hooks" / name).is_file() for name in WORKBUDDY_HOOK_MODULES) or not all( + (path / "scripts" / name).is_file() for name in WORKBUDDY_SCRIPT_MODULES + ): + raise SetupError(f"PowerContext WorkBuddy plugin was not found under {path}.") # noqa: TRY003 def workbuddy_home() -> Path: @@ -211,32 +83,6 @@ def workbuddy_home() -> Path: return Path(configured).expanduser().resolve() if configured else (Path.home() / ".workbuddy").resolve() -def github_clone_url(source: str) -> str: - """Accept a GitHub slug or repository URL and return a clone URL.""" - - try: - return _github_clone_url(source) - except InvalidGitHubSourceError: - raise SetupError.invalid_workbuddy_source(source) from None - - -def checkout_target(source: str, ref: str) -> Path: - """Resolve a source and Git ref to a directory under the WorkBuddy checkout cache.""" - - root = (powercontext_data_dir() / "checkouts" / "workbuddy").resolve() - if not ref or ref in {".", ".."} or "\x00" in ref: - raise SetupError.invalid_workbuddy_ref(ref) - source_key = hashlib.sha256(github_clone_url(source).encode("utf-8")).hexdigest()[:16] - target = (root / source_key / ref).resolve() - try: - target.relative_to(root) - except ValueError as error: - raise SetupError.invalid_workbuddy_ref(ref) from error - if target == root: - raise SetupError.invalid_workbuddy_ref(ref) - return target - - def _install_hook_files(plugin_dir: Path, hooks_dir: Path) -> None: """Copy the hook driver, its modules, and the scope resolver into the hooks directory.""" @@ -247,7 +93,7 @@ def _install_hook_files(plugin_dir: Path, hooks_dir: Path) -> None: shutil.copy2(source_hooks / name, hooks_dir / name) shutil.copy2(plugin_dir / "scripts" / "workspace_scope.py", hooks_dir / WORKBUDDY_SCOPE_RESOLVER) except OSError as error: - raise SetupError.workbuddy_hooks_write(hooks_dir, error) from error + raise SetupError(f"Cannot install PowerContext WorkBuddy hooks at {hooks_dir}: {error}") from error # noqa: TRY003 def _merge_workbuddy_settings(settings_file: Path, hooks_dir: Path) -> None: @@ -255,23 +101,25 @@ def _merge_workbuddy_settings(settings_file: Path, hooks_dir: Path) -> None: settings = _load_json_object( settings_file, - invalid=lambda: SetupError.invalid_workbuddy_settings(settings_file), - io_error=lambda error: SetupError.workbuddy_settings_write(settings_file, error), + invalid=lambda: SetupError( + f"WorkBuddy settings at {settings_file} must contain a JSON object with a hooks mapping." + ), + io_error=lambda error: SetupError(f"Cannot update WorkBuddy settings at {settings_file}: {error}"), ) hooks = settings.setdefault("hooks", {}) if not isinstance(hooks, dict): - raise SetupError.invalid_workbuddy_settings(settings_file) + raise SetupError(f"WorkBuddy settings at {settings_file} must contain a JSON object with a hooks mapping.") # noqa: TRY003 hooks_dict = cast(dict[str, Any], hooks) matchers = hooks_dict.setdefault("UserPromptSubmit", []) if not isinstance(matchers, list): - raise SetupError.invalid_workbuddy_settings(settings_file) + raise SetupError(f"WorkBuddy settings at {settings_file} must contain a JSON object with a hooks mapping.") # noqa: TRY003 _upsert_powercontext_hook(cast(list[Any], matchers), hooks_dir, settings_file) try: - _write_json_atomically(settings_file, settings) + write_json(settings_file, settings) except OSError as error: - raise SetupError.workbuddy_settings_write(settings_file, error) from error + raise SetupError(f"Cannot update WorkBuddy settings at {settings_file}: {error}") from error # noqa: TRY003 def _merge_workbuddy_mcp(mcp_file: Path, *, server_url: str | None = None) -> None: @@ -279,12 +127,16 @@ def _merge_workbuddy_mcp(mcp_file: Path, *, server_url: str | None = None) -> No config = _load_json_object( mcp_file, - invalid=lambda: SetupError.invalid_workbuddy_mcp(mcp_file), - io_error=lambda error: SetupError.workbuddy_mcp_write(mcp_file, error), + invalid=lambda: SetupError( + f"WorkBuddy MCP configuration at {mcp_file} must contain a JSON object with an mcpServers mapping." + ), + io_error=lambda error: SetupError(f"Cannot update WorkBuddy MCP configuration at {mcp_file}: {error}"), ) servers = config.setdefault("mcpServers", {}) if not isinstance(servers, dict): - raise SetupError.invalid_workbuddy_mcp(mcp_file) + raise SetupError( # noqa: TRY003 + f"WorkBuddy MCP configuration at {mcp_file} must contain a JSON object with an mcpServers mapping." + ) servers_dict = cast(dict[str, Any], servers) existing = servers_dict.get(WORKBUDDY_PLUGIN_NAME) @@ -297,9 +149,9 @@ def _merge_workbuddy_mcp(mcp_file: Path, *, server_url: str | None = None) -> No servers_dict[WORKBUDDY_PLUGIN_NAME] = entry try: - _write_json_atomically(mcp_file, config) + write_json(mcp_file, config) except OSError as error: - raise SetupError.workbuddy_mcp_write(mcp_file, error) from error + raise SetupError(f"Cannot update WorkBuddy MCP configuration at {mcp_file}: {error}") from error # noqa: TRY003 def _workbuddy_mcp_entry(existing: Any) -> dict[str, Any]: @@ -310,7 +162,8 @@ def _workbuddy_mcp_entry(existing: Any) -> dict[str, Any]: "description": WORKBUDDY_MCP_DESCRIPTION, "disabled": False, } - if isinstance(existing, dict) and not _is_legacy_workbuddy_mcp_entry(existing): + if isinstance(existing, dict): + entry["disabled"] = existing.get("disabled", False) existing_url = existing.get("url") if isinstance(existing_url, str) and existing_url.strip(): entry["url"] = existing_url @@ -320,19 +173,9 @@ def _workbuddy_mcp_entry(existing: Any) -> dict[str, Any]: return entry -def _is_legacy_workbuddy_mcp_entry(existing: dict[str, Any]) -> bool: - return existing == { - "type": "http", - "url": WORKBUDDY_LEGACY_MCP_URL, - "headers": {}, - "description": WORKBUDDY_MCP_DESCRIPTION, - "disabled": False, - } - - def require_replaceable_workbuddy_skill(target: Path) -> None: if target.exists() and not _owned_workbuddy_skill(target): - raise SetupError.workbuddy_skill_conflict(target) + raise SetupError(f"WorkBuddy Skill path {target} already exists and is not owned by PowerContext.") # noqa: TRY003 def _owned_workbuddy_skill(path: Path) -> bool: @@ -343,35 +186,7 @@ def _owned_workbuddy_skill(path: Path) -> bool: return payload == {"schema": 1, "owner": "powercontext", "integration": "workbuddy"} -def _previous_workbuddy_replacements(settings_snapshot: bytes | None, hooks_dir: Path) -> dict[str, str]: - """Recover the exact quoted interpreter from the previous owned hook, without executing it.""" - if settings_snapshot is None: - return {} - settings = json.loads(settings_snapshot) - if not isinstance(settings, dict) or not isinstance(hooks := settings.get("hooks"), dict): - return {} - matchers = hooks.get("UserPromptSubmit", []) - if not isinstance(matchers, list): - return {} - suffix = " " + _shell_argument((hooks_dir / WORKBUDDY_HOOK_DRIVER).as_posix()) - for matcher in matchers: - if not isinstance(matcher, dict) or not isinstance(entries := matcher.get("hooks"), list): - continue - for entry in entries: - command = entry.get("command") if isinstance(entry, dict) else None - if isinstance(command, str) and command.endswith(suffix) and (interpreter := command.removesuffix(suffix)): - return { - WORKBUDDY_PYTHON_PLACEHOLDER: interpreter, - WORKBUDDY_SCOPE_BINDING_PLACEHOLDER: _shell_argument( - (hooks_dir / WORKBUDDY_SCOPE_RESOLVER).as_posix() - ), - } - return {} - - -def _install_workbuddy_skill( - plugin_dir: Path, skills_dir: Path, hooks_dir: Path, *, legacy_replacements: dict[str, str] | None = None -) -> None: +def _install_workbuddy_skill(plugin_dir: Path, skills_dir: Path, hooks_dir: Path) -> None: """Copy the powercontext-project-context Skill and resolve its hooks directory placeholder.""" source = plugin_dir / WORKBUDDY_SKILLS_DIRNAME / WORKBUDDY_SKILL_NAME @@ -385,7 +200,6 @@ def _install_workbuddy_skill( WORKBUDDY_PYTHON_PLACEHOLDER: _shell_argument(_python_executable()), WORKBUDDY_SCOPE_BINDING_PLACEHOLDER: _shell_argument((hooks_dir / WORKBUDDY_SCOPE_RESOLVER).as_posix()), }, - legacy_replacements=legacy_replacements, ) write_if_changed( target / WORKBUDDY_SKILL_MANIFEST, @@ -394,7 +208,7 @@ def _install_workbuddy_skill( ), ) except OSError as error: - raise SetupError.workbuddy_skill_write(target, error) from error + raise SetupError(f"Cannot install PowerContext WorkBuddy skill at {target}: {error}") from error # noqa: TRY003 except GuidanceError as error: raise SetupError(str(error)) from error @@ -410,13 +224,13 @@ def _upsert_powercontext_hook(matchers: list[Any], hooks_dir: Path, settings_fil } for matcher in matchers: if not isinstance(matcher, dict): - raise SetupError.invalid_workbuddy_settings(settings_file) + raise SetupError(f"WorkBuddy settings at {settings_file} must contain a JSON object with a hooks mapping.") # noqa: TRY003 matcher_dict = cast(dict[str, Any], matcher) group = matcher_dict.get("hooks") if group is None: continue if not isinstance(group, list): - raise SetupError.invalid_workbuddy_settings(settings_file) + raise SetupError(f"WorkBuddy settings at {settings_file} must contain a JSON object with a hooks mapping.") # noqa: TRY003 group_list = cast(list[Any], group) for index, existing in enumerate(group_list): if isinstance(existing, dict) and _is_powercontext_hook(cast(dict[str, Any], existing)): @@ -485,6 +299,8 @@ def _mcp_diagnostic(mcp_file: Path) -> Diagnostic: status=DiagnosticStatus.FAILED, detail="PowerContext WorkBuddy MCP server is not registered in mcp.json", ) + if servers[WORKBUDDY_PLUGIN_NAME].get("disabled") is True: + return Diagnostic(DiagnosticStatus.FAILED, "PowerContext MCP is disabled; enable it in WorkBuddy") return Diagnostic(status=DiagnosticStatus.OK, detail=str(mcp_file)) @@ -522,38 +338,6 @@ def _settings_have_powercontext_hook(settings: dict[str, Any]) -> bool: return False -def _is_workbuddy_plugin(path: Path) -> bool: - hooks = path / WORKBUDDY_HOOKS_DIRNAME - scripts = path / "scripts" - skill_markdown = path / WORKBUDDY_SKILLS_DIRNAME / WORKBUDDY_SKILL_NAME / "SKILL.md" - return ( - all((hooks / name).is_file() for name in WORKBUDDY_HOOK_MODULES) - and all((scripts / name).is_file() for name in WORKBUDDY_SCRIPT_MODULES) - and skill_markdown.is_file() - ) - - -def _materialize_remote_checkout(source: str, ref: str) -> Path: - target = checkout_target(source, ref) - target.parent.mkdir(parents=True, exist_ok=True) - staging = _new_staging_directory(target) - shutil.rmtree(staging) - try: - _clone_github_source(source, ref, staging) - _replace_directory(staging, target) - except BaseException: - _remove_path(staging) - raise - return target - - -def _clone_github_source(source: str, ref: str, target: Path) -> None: - try: - clone_github_source(source, ref, target) - except InvalidGitHubSourceError: - raise SetupError.invalid_workbuddy_source(source) from None - - def _load_json_object( path: Path, *, @@ -573,98 +357,65 @@ def _load_json_object( return cast(dict[str, Any], payload) -def _write_json_atomically(path: Path, payload: dict[str, Any]) -> None: - _write_bytes_atomically(path, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) - - -def _write_bytes_atomically(path: Path, content: bytes) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") - descriptor: int | None = None - try: - descriptor = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0), 0o600) - os.write(descriptor, content) - os.fsync(descriptor) - os.close(descriptor) - descriptor = None - os.replace(temporary, path) - finally: - if descriptor is not None: - os.close(descriptor) - with suppress(FileNotFoundError): - temporary.unlink() - - -def _read_bytes_or_none(path: Path, *, io_error: Callable[[OSError], SetupError]) -> bytes | None: - try: - return path.read_bytes() - except FileNotFoundError: - return None - except OSError as error: - raise io_error(error) from error - - -def _snapshot_directory(path: Path) -> Path | None: - if not path.is_dir(): - return None - backup = Path(tempfile.mkdtemp(prefix=f".{path.name}-rollback-")) - shutil.rmtree(backup) - shutil.copytree(path, backup) - return backup - - -def _restore_file(path: Path, original: bytes | None) -> None: - if original is None: - path.unlink(missing_ok=True) - return - _write_bytes_atomically(path, original) - - -def _restore_directory(path: Path, backup: Path | None) -> None: - _remove_path(path) - if backup is not None and backup.is_dir(): - shutil.copytree(backup, path) - - -def _new_staging_directory(target: Path) -> Path: - return Path(tempfile.mkdtemp(prefix=f".{target.name}-", dir=target.parent)) - - -def _replace_directory(staging: Path, target: Path) -> None: - backup = target.with_name(f".{target.name}.backup-{uuid.uuid4().hex}") - moved_old = False - try: - if target.exists() or target.is_symlink(): - os.replace(target, backup) - moved_old = True - os.replace(staging, target) - except BaseException: - if moved_old and not (target.exists() or target.is_symlink()) and backup.exists(): - os.replace(backup, target) - raise - finally: - _remove_path(staging) - _remove_path(backup) - - -def _remove_path(path: Path | None) -> None: - if path is None: - return - if path.is_symlink() or path.is_file(): - path.unlink(missing_ok=True) - elif path.is_dir(): - shutil.rmtree(path) - - -__all__ = [ - "WORKBUDDY_PLUGIN_NAME", - "WorkBuddySetupResult", - "checkout_target", - "github_clone_url", - "install_workbuddy_plugin", - "plugin_dir_from_checkout", - "require_workbuddy_plugin", - "resolve_workbuddy_plugin_dir", - "run_workbuddy_diagnostics", - "workbuddy_home", -] +__all__ = ["WORKBUDDY_PLUGIN_NAME", "require_workbuddy_plugin", "workbuddy_home"] + + +class WorkBuddyInstaller: + def install(self, source): + root = workbuddy_home() + require_replaceable_workbuddy_skill(root / WORKBUDDY_SKILLS_DIRNAME / WORKBUDDY_SKILL_NAME) + with source.package("workbuddy") as package: + require_workbuddy_plugin(package) + hooks = root / WORKBUDDY_HOOKS_DIRNAME + skill = root / WORKBUDDY_SKILLS_DIRNAME / WORKBUDDY_SKILL_NAME + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".powercontext-", dir=root) as temporary: + staging = Path(temporary) + staged_hooks, staged_skills = staging / "hooks", staging / "skills" + if skill.exists(): + shutil.copytree(skill, staged_skills / WORKBUDDY_SKILL_NAME, symlinks=True) + settings_file = root / "settings.json" + _install_hook_files(package, staged_hooks) + _install_workbuddy_skill(package, staged_skills, hooks) + candidate = staging / "settings.json" + if settings_file.exists(): + shutil.copy2(settings_file, candidate) + _merge_workbuddy_settings(candidate, hooks) + replacements = [ + (staged_hooks / name, hooks / name) for name in (*WORKBUDDY_HOOK_MODULES, WORKBUDDY_SCOPE_RESOLVER) + ] + replacements.append((staged_skills / WORKBUDDY_SKILL_NAME, skill)) + publish(replacements, {settings_file: candidate.read_bytes()}) + return { + "plugin": WORKBUDDY_PLUGIN_NAME, + "plugin_path": str(root), + "workbuddy_home": str(root), + "hooks_dir": str(hooks), + "data_dir": str(powercontext_data_dir()), + } + + def configure(self, connection): + if not (workbuddy_home() / "hooks" / WORKBUDDY_HOOK_MODULES[0]).is_file(): + raise SetupError("PowerContext is not installed in workbuddy; run powercontext setup workbuddy") # noqa: TRY003 + root = workbuddy_home() + path = root / "mcp.json" + with tempfile.TemporaryDirectory(prefix=".powercontext-", dir=root) as temporary: + candidate = Path(temporary) / "mcp.json" + if path.exists(): + shutil.copy2(path, candidate) + _merge_workbuddy_mcp(candidate, server_url=connection.server_url) + state = configure_connection(connection, writes={path: candidate.read_bytes()}) + return {"authorization_state": state} + + def verify(self) -> dict[str, Diagnostic]: + """Collect diagnostics for the optional WorkBuddy integration.""" + + home = workbuddy_home() + hooks_dir = home / WORKBUDDY_HOOKS_DIRNAME + skill_file = home / WORKBUDDY_SKILLS_DIRNAME / WORKBUDDY_SKILL_NAME / "SKILL.md" + return { + "hooks": _hooks_diagnostic(hooks_dir), + "settings": _settings_diagnostic(home / "settings.json"), + "mcp": _mcp_diagnostic(home / "mcp.json"), + "skill": _skill_diagnostic(skill_file), + } diff --git a/src/powercontext/cli/zcode.py b/src/powercontext/cli/zcode.py index f1bf7e98ba..da9e27f719 100644 --- a/src/powercontext/cli/zcode.py +++ b/src/powercontext/cli/zcode.py @@ -21,8 +21,6 @@ import shutil import subprocess import tempfile -from collections.abc import Iterator -from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from shutil import which @@ -30,10 +28,10 @@ from urllib.parse import urlsplit from powercontext.cli.authorization import normalize_authorization -from powercontext.cli.git_source import InvalidGitHubSourceError, clone_github_source, is_local_source from powercontext.cli.guidance import HOST_GUIDANCE, preserve_installed_guidance +from powercontext.cli.installation import publish from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError -from powercontext.cli.transport import is_remote_http, setup_environment +from powercontext.cli.transport import setup_environment, setup_transport_updates from powercontext.client.transport_policy import normalize_client_url PLUGIN_RELATIVE = Path(HOST_GUIDANCE["zcode"].plugin) @@ -41,12 +39,11 @@ DEFAULT_SERVER_URL = "http://127.0.0.1:8000" -@dataclass(frozen=True, slots=True) -class ZCodeSetupResult: - plugin: str - plugin_path: str - config_file: str - server_url: str +def _endpoint(value: str) -> str: + endpoint = normalize_client_url(value).removesuffix("/mcp").rstrip("/") + if urlsplit(endpoint).path: + raise SetupError("ZCode Server URL must be an HTTP(S) origin without path, credentials, query, or fragment") # noqa: TRY003 + return endpoint def zcode_config_file() -> Path: @@ -80,29 +77,6 @@ def zcode_executable() -> str | None: return None -def _plugin_from_checkout(root: Path) -> Path: - for candidate in (root, root / PLUGIN_RELATIVE): - try: - manifest = json.loads((candidate / ".zcode-plugin" / "plugin.json").read_text(encoding="utf-8")) - if manifest.get("name") == "powercontext" and (candidate / "hooks" / "user_prompt_submit.mjs").is_file(): - return candidate - except (OSError, ValueError, AttributeError): - continue - raise SetupError(f"PowerContext ZCode plugin was not found under {root}") # noqa: TRY003 - - -def _source_plugin(source: str, ref: str, checkout: Path) -> Path: - if is_local_source(source): - return _plugin_from_checkout(Path(source).expanduser().resolve()) - if not ref or ref in {".", ".."} or "\x00" in ref: - raise SetupError("invalid ZCode Git ref") # noqa: TRY003 - try: - clone_github_source(source, ref, checkout) - except InvalidGitHubSourceError: - raise SetupError("invalid ZCode source; use a local path or HTTPS/SSH GitHub repository") from None # noqa: TRY003 - return _plugin_from_checkout(checkout) - - def _read_config(path: Path) -> dict[str, Any]: if not path.exists(): return {} @@ -122,157 +96,6 @@ def _read_config(path: Path) -> dict[str, Any]: return config -def _write_json(path: Path, value: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - handle, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - temporary = Path(name) - try: - with os.fdopen(handle, "w", encoding="utf-8") as stream: - json.dump(value, stream, ensure_ascii=False, indent=2) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) - - -@contextmanager -def preserve_zcode_installation() -> Iterator[None]: - """Restore managed ZCode state if later setup steps, including shared settings, fail.""" - - config_path = zcode_config_file() - destination = zcode_plugin_dir() - old_config = config_path.read_bytes() if config_path.exists() else None - existed = destination.exists() - owned = (destination / OWNER_MARKER).is_file() - with tempfile.TemporaryDirectory(prefix="powercontext-zcode-rollback-") as temporary: - backup = Path(temporary) / "plugin" - if owned: - shutil.copytree(destination, backup) - try: - yield - except BaseException: - try: - if owned or not existed: - if destination.exists(): - shutil.rmtree(destination) - if owned: - shutil.copytree(backup, destination) - if old_config is None: - config_path.unlink(missing_ok=True) - else: - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_bytes(old_config) - except OSError as error: - raise SetupError("ZCode setup failed and could not restore its previous configuration") from error # noqa: TRY003 - raise - - -def saved_zcode_capture_prompts() -> bool: - """Keep an installed capture preference when setup has no explicit override.""" - - return _read_config(zcode_plugin_dir() / "powercontext.json").get("capture_prompts") is not False - - -def install_zcode_plugin( # noqa: C901 - stages and rolls back two user-owned destinations. - *, - source: str, - ref: str, - server_url: str = DEFAULT_SERVER_URL, - allow_insecure_http: bool = False, - capture_prompts: bool | None = None, - boundary_flush: bool | None = None, -) -> ZCodeSetupResult: - """Install a managed plugin copy and register it without replacing other ZCode settings.""" - - if zcode_executable() is None: - raise SetupError("ZCode CLI or Windows desktop app was not found; set ZCODE_CLI_BIN for a built checkout") # noqa: TRY003 - endpoint = normalize_client_url(server_url).removesuffix("/mcp").rstrip("/") - parsed = urlsplit(endpoint) - if parsed.path or parsed.query or parsed.fragment or parsed.username or parsed.password: - raise SetupError("ZCode Server URL must be an HTTP(S) origin without path, credentials, query, or fragment") # noqa: TRY003 - if is_remote_http(endpoint) and not allow_insecure_http: - raise SetupError("Remote ZCode HTTP requires explicit --allow-insecure-http consent") # noqa: TRY003 - use_authorization_env = bool((setup_environment() | dict(os.environ)).get("POWERCONTEXT_ZCODE_AUTHORIZATION")) - config_path = zcode_config_file() - destination = zcode_plugin_dir() - config = _read_config(config_path) - if destination.exists() and not (destination / OWNER_MARKER).is_file(): - raise SetupError(f"ZCode plugin path {destination} already exists and is not owned by PowerContext") # noqa: TRY003 - previous = _read_config(destination / "powercontext.json") - if capture_prompts is None: - capture_prompts = previous.get("capture_prompts") is not False - if boundary_flush is None: - boundary = (setup_environment() | dict(os.environ)).get("POWERCONTEXT_ZCODE_BOUNDARY_FLUSH") - boundary_flush = ( - boundary.lower() not in {"0", "false", "no", "off"} - if boundary is not None - else previous.get("boundary_flush") is True - ) - plugins = config.setdefault("plugins", {}) - dirs = plugins.setdefault("dirs", []) - if str(destination) not in dirs: - dirs.append(str(destination)) - if plugins.get("enabled") is False: - raise SetupError("ZCode plugins are disabled in user config; enable them before setup") # noqa: TRY003 - - with tempfile.TemporaryDirectory(prefix="powercontext-zcode-source-") as temporary: - plugin = _source_plugin(source, ref, Path(temporary) / "checkout") - destination.parent.mkdir(parents=True, exist_ok=True) - staged = Path(tempfile.mkdtemp(prefix=".powercontext-stage-", dir=destination.parent)) - backup = destination.with_name(".powercontext-backup") - if backup.exists(): - shutil.rmtree(staged) - raise SetupError(f"Cannot install ZCode plugin while {backup} exists") # noqa: TRY003 - old_config = config_path.read_bytes() if config_path.exists() else None - moved_old = False - installed = False - try: - shutil.copytree(plugin, staged, dirs_exist_ok=True, ignore=shutil.ignore_patterns("tests", "node_modules")) - preserve_installed_guidance(destination, staged) - (staged / OWNER_MARKER).write_text("powercontext-zcode-v1\n", encoding="utf-8") - _write_json( - staged / "powercontext.json", - { - "server_url": endpoint, - "allow_insecure_http": allow_insecure_http, - "capture_prompts": capture_prompts, - "boundary_flush": boundary_flush, - }, - ) - mcp_path = staged / ".mcp.json" - mcp = json.loads(mcp_path.read_text(encoding="utf-8")) - mcp["mcpServers"]["powercontext"]["url"] = endpoint + "/mcp" - if use_authorization_env: - mcp["mcpServers"]["powercontext"]["headers"] = { - "Authorization": "${POWERCONTEXT_ZCODE_AUTHORIZATION}", - } - _write_json(mcp_path, mcp) - if destination.exists(): - destination.rename(backup) - moved_old = True - staged.rename(destination) - installed = True - _write_json(config_path, config) - except (OSError, ValueError, KeyError) as error: - if installed: - shutil.rmtree(destination) - if moved_old: - backup.rename(destination) - if old_config is None: - config_path.unlink(missing_ok=True) - else: - config_path.write_bytes(old_config) - raise SetupError("Could not install ZCode plugin; previous config and plugin were restored") from error # noqa: TRY003 - finally: - if staged.exists(): - shutil.rmtree(staged) - if moved_old: - shutil.rmtree(backup) - return ZCodeSetupResult("powercontext", str(destination), str(config_path), endpoint) - - def _mcp_authorization_matches(entry: dict[str, Any]) -> bool: authorization = os.environ.get("POWERCONTEXT_ZCODE_AUTHORIZATION") if entry.get("headers", {}).get("Authorization") != "${POWERCONTEXT_ZCODE_AUTHORIZATION}": @@ -349,7 +172,7 @@ def _runtime_observation_detail(item: dict[str, Any]) -> str: def _decode_zcode_probe(output: str, prepare: bool) -> dict[str, Diagnostic]: result = json.loads(output) - if result.get("schema") != "powercontext.zcode.diagnostics.v1": + if result.get("schema") != "powercontext.zcode.diagnostics.v2": raise ValueError checks = result["checks"] expected = {"server", "protected_api", "scope_probe", "runtime", "mcp_session"} @@ -375,7 +198,9 @@ def _decode_zcode_probe(output: str, prepare: bool) -> dict[str, Diagnostic]: for key, value in stages.items() ): raise ValueError - diagnostics[name] = Diagnostic(status, message, checks=stages or None) + diagnostics[name] = Diagnostic( + status, message, checks=item.get("checks") if name == "server" else stages or None + ) return diagnostics @@ -393,47 +218,130 @@ def _probe_zcode(destination: Path, runtime_data_dir: Path | None, prepare: bool process = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", check=False, timeout=6) # noqa: S603 diagnostics = _decode_zcode_probe(process.stdout, prepare) except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, AttributeError): - return {"probe": Diagnostic(DiagnosticStatus.FAILED, "diagnostic probe unavailable or invalid")} + return { + "probe": Diagnostic( + DiagnosticStatus.FAILED, "diagnostic probe unavailable or unsupported; run powercontext setup zcode" + ) + } return diagnostics -def run_zcode_diagnostics(*, runtime_data_dir: Path | None = None, prepare: bool = False) -> dict[str, Diagnostic]: - """Check CLI, registration, Hook, MCP, and Server independently.""" - - ok = DiagnosticStatus.OK - failed = DiagnosticStatus.FAILED - executable = zcode_executable() - diagnostics = { - "zcode": Diagnostic( - ok if executable else failed, executable or "ZCode CLI or Windows desktop app was not found" - ), - } - config_path = zcode_config_file() - destination = zcode_plugin_dir() - try: - config = _read_config(config_path) - plugins = config.get("plugins", {}) - registered = str(destination) in plugins.get("dirs", []) and plugins.get("enabled") is not False - diagnostics["plugin"] = Diagnostic( - ok if registered else failed, "registered" if registered else "not registered" +@dataclass +class ZCodeInstaller: + capture_prompts: bool | None = None + boundary_flush: bool | None = None + + def install(self, source): + if zcode_executable() is None: + raise SetupError("ZCode CLI or Windows desktop app was not found; set ZCODE_CLI_BIN for a built checkout") # noqa: TRY003 + with source.package("zcode") as plugin: + config_path, destination = zcode_config_file(), zcode_plugin_dir() + config = _read_config(config_path) + if destination.exists() and not (destination / OWNER_MARKER).is_file(): + raise SetupError(f"ZCode plugin path {destination} already exists and is not owned by PowerContext") # noqa: TRY003 + plugins = config.setdefault("plugins", {}) + plugins.setdefault("enabled", True) + dirs = plugins.setdefault("dirs", []) + if str(destination) not in dirs: + dirs.append(str(destination)) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".powercontext-", dir=destination.parent) as temporary: + staged = Path(temporary) / "plugin" + shutil.copytree(plugin, staged, ignore=shutil.ignore_patterns("tests", "node_modules")) + preserve_installed_guidance(destination, staged) + for name in ("powercontext.json", ".mcp.json"): + if (destination / name).is_file(): + shutil.copy2(destination / name, staged / name) + (staged / OWNER_MARKER).write_text("powercontext-zcode-v1\n", encoding="utf-8") + publish( + [(staged, destination)], + {config_path: (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode()}, + ) + previous = _read_config(destination / "powercontext.json") + return { + "plugin": "powercontext", + "plugin_path": str(destination), + "config_file": str(config_path), + "server_url": previous.get("server_url", DEFAULT_SERVER_URL), + } + + def configure(self, connection): + if not (zcode_plugin_dir() / OWNER_MARKER).is_file(): + raise SetupError("PowerContext is not installed in zcode; run powercontext setup zcode") # noqa: TRY003 + endpoint = _endpoint(connection.server_url) + destination = zcode_plugin_dir() + previous = _read_config(destination / "powercontext.json") + capture = self.capture_prompts if self.capture_prompts is not None else previous.get("capture_prompts", True) + boundary = self.boundary_flush + if boundary is None: + value = (setup_environment() | dict(os.environ)).get("POWERCONTEXT_ZCODE_BOUNDARY_FLUSH") + boundary = ( + value.lower() not in {"0", "false", "no", "off"} + if value is not None + else previous.get("boundary_flush", False) + ) + mcp = _read_config(destination / ".mcp.json") + server = mcp["mcpServers"]["powercontext"] + headers = server.setdefault("headers", {}) + authorization = (setup_environment() | dict(os.environ)).get("POWERCONTEXT_ZCODE_AUTHORIZATION") + if authorization or endpoint != _endpoint(server["url"]): + headers = {name: value for name, value in headers.items() if name.lower() != "authorization"} + server["headers"] = headers + if authorization: + headers["Authorization"] = "${POWERCONTEXT_ZCODE_AUTHORIZATION}" + server["url"] = endpoint + "/mcp" + settings = { + "server_url": endpoint, + "allow_insecure_http": connection.allow_insecure_http, + "capture_prompts": capture, + "boundary_flush": boundary, + } + publish( + writes={ + **setup_transport_updates(connection), + destination / "powercontext.json": (json.dumps(settings, ensure_ascii=False, indent=2) + "\n").encode(), + destination / ".mcp.json": (json.dumps(mcp, ensure_ascii=False, indent=2) + "\n").encode(), + } ) - except SetupError: - diagnostics["plugin"] = Diagnostic(failed, "ZCode user config is invalid or unreadable") - diagnostics["hooks"] = _check_zcode_modules(destination) - try: - mcp = json.loads((destination / ".mcp.json").read_text(encoding="utf-8")) - endpoint = json.loads((destination / "powercontext.json").read_text(encoding="utf-8"))["server_url"] - entry = mcp["mcpServers"]["powercontext"] - mcp_ok = entry["url"] == endpoint + "/mcp" and _mcp_authorization_matches(entry) - if not isinstance(endpoint, str) or not endpoint: + return {"server_url": endpoint} + + def verify(self, *, runtime_data_dir: Path | None = None, prepare: bool = False) -> dict[str, Diagnostic]: + """Check CLI, registration, Hook, MCP, and Server independently.""" + + ok = DiagnosticStatus.OK + failed = DiagnosticStatus.FAILED + executable = zcode_executable() + diagnostics = { + "zcode": Diagnostic( + ok if executable else failed, executable or "ZCode CLI or Windows desktop app was not found" + ), + } + config_path = zcode_config_file() + destination = zcode_plugin_dir() + try: + config = _read_config(config_path) + plugins = config.get("plugins", {}) + registered = str(destination) in plugins.get("dirs", []) and plugins.get("enabled") is not False + diagnostics["plugin"] = Diagnostic( + ok if registered else failed, "registered" if registered else "not registered" + ) + except SetupError: + diagnostics["plugin"] = Diagnostic(failed, "ZCode user config is invalid or unreadable") + diagnostics["hooks"] = _check_zcode_modules(destination) + try: + mcp = json.loads((destination / ".mcp.json").read_text(encoding="utf-8")) + endpoint = json.loads((destination / "powercontext.json").read_text(encoding="utf-8"))["server_url"] + entry = mcp["mcpServers"]["powercontext"] + mcp_ok = entry["url"] == endpoint + "/mcp" and _mcp_authorization_matches(entry) + if not isinstance(endpoint, str) or not endpoint: + mcp_ok = False + except (OSError, ValueError, KeyError, TypeError, AttributeError): + endpoint = None mcp_ok = False - except (OSError, ValueError, KeyError, TypeError, AttributeError): - endpoint = None - mcp_ok = False - diagnostics["mcp"] = Diagnostic(ok if mcp_ok else failed, "configured" if mcp_ok else "missing or mismatched") - if not endpoint: - diagnostics["server"] = Diagnostic(DiagnosticStatus.SKIPPED, "endpoint is unavailable") - else: - diagnostics.update(_probe_zcode(destination, runtime_data_dir, prepare)) - return diagnostics + diagnostics["mcp"] = Diagnostic(ok if mcp_ok else failed, "configured" if mcp_ok else "missing or mismatched") + if not endpoint: + diagnostics["server"] = Diagnostic(DiagnosticStatus.SKIPPED, "endpoint is unavailable") + else: + diagnostics.update(_probe_zcode(destination, runtime_data_dir, prepare)) + return diagnostics diff --git a/src/powercontext/client/transport_policy.py b/src/powercontext/client/transport_policy.py index 7bd0d6febc..5fe8c91c42 100644 --- a/src/powercontext/client/transport_policy.py +++ b/src/powercontext/client/transport_policy.py @@ -95,6 +95,8 @@ def load_client_settings(host: str) -> dict[str, Any]: if not isinstance(saved["allow_insecure_http"], bool): raise ValueError("Saved PowerContext allow_insecure_http must be a JSON boolean") # noqa: TRY003 result["allow_insecure_http"] = saved["allow_insecure_http"] + if host == "dsh" and "profile" in saved: + result.update(profile=saved["profile"], dsh_command=saved.get("dsh_command")) return result diff --git a/tests/conftest.py b/tests/conftest.py index 384382d772..91bda3b638 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -172,3 +172,49 @@ def pytest_ignore_collect(collection_path: Path, config: pytest.Config) -> bool except ValueError: return None return True + + +@pytest.fixture +def installed_agent_checks(monkeypatch): + """Isolate native installation behavior from separately exercised runtime probes.""" + from powercontext.cli import hosts + from powercontext.cli.system import Diagnostic, DiagnosticStatus + + monkeypatch.setattr( + hosts, "doctor_checks", lambda *args, **kwargs: {"integration": Diagnostic(DiagnosticStatus.OK, "available")} + ) + + +@pytest.fixture +def server_checks(monkeypatch): + """Exercise native agent diagnostics without contacting an external Server.""" + from powercontext.cli import system + + monkeypatch.setattr(system, "server_diagnostics", lambda **kwargs: {}) + + +@pytest.fixture +def hermes_activation(monkeypatch): + """Model Hermes' native config writes for filesystem installation scenarios.""" + from subprocess import CompletedProcess + + import yaml + + from powercontext.cli import hermes + + run = hermes._run_hermes_command + + def native(command): + if command[1:3] not in (["plugins", "enable"], ["memory", "setup"]): + return run(command) + path = hermes.hermes_home() / "config.yaml" + config = yaml.safe_load(path.read_text()) if path.exists() else {} + if command[1] == "plugins": + config.setdefault("plugins", {}).setdefault("enabled", []).append("powercontext-command") + else: + config.setdefault("memory", {})["provider"] = "powercontext" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(config)) + return CompletedProcess(command, 0, "", "") + + monkeypatch.setattr(hermes, "_run_hermes_command", native) diff --git a/tests/e2e/atomic_memory_models.py b/tests/e2e/atomic_memory_models.py index e5f940d08f..f53261b3b0 100644 --- a/tests/e2e/atomic_memory_models.py +++ b/tests/e2e/atomic_memory_models.py @@ -26,12 +26,15 @@ def independent_atomic_memory_model(text: str, *, kind: str = "decision") -> Fun """Retain a supplied fixture fact with the actual input evidence identities.""" def respond(messages, _info) -> ModelResponse: - request = next( - json.loads(part.content) + prompt = next( + part.content for message in reversed(messages) for part in message.parts if isinstance(part, UserPromptPart) and isinstance(part.content, str) ) + if prompt == "Reply with one token.": + return ModelResponse(parts=[TextPart("ok")]) + request = json.loads(prompt) if "proposal" in request: proposal = request["proposal"] value = { diff --git a/tests/e2e/test_codex_native_mcp_live.py b/tests/e2e/test_codex_native_mcp_live.py index 37d0b2b9d5..8d1653407d 100644 --- a/tests/e2e/test_codex_native_mcp_live.py +++ b/tests/e2e/test_codex_native_mcp_live.py @@ -20,7 +20,7 @@ import pytest -from powercontext.cli.system import DiagnosticStatus, run_codex_diagnostics +from powercontext.cli.system import CodexInstaller, DiagnosticStatus pytestmark = [pytest.mark.real_e2e, pytest.mark.skipif(sys.platform != "win32", reason="Windows Codex Desktop only")] @@ -34,7 +34,7 @@ def test_codex_native_mcp_discovers_tools_without_process_authorization( pytest.skip("requires --run-real-e2e with configured Codex and a running PowerContext Server") monkeypatch.delenv("POWERCONTEXT_CODEX_AUTHORIZATION", raising=False) - diagnostics = run_codex_diagnostics() + diagnostics = CodexInstaller().verify() assert diagnostics["codex"].status is DiagnosticStatus.OK assert diagnostics["plugin"].status is DiagnosticStatus.OK diff --git a/tests/e2e/test_kimi_code_host.py b/tests/e2e/test_kimi_code_host.py index c639623abb..fb8f32a422 100644 --- a/tests/e2e/test_kimi_code_host.py +++ b/tests/e2e/test_kimi_code_host.py @@ -36,7 +36,8 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime.config import InferenceConfig, RuntimeConfig -from powercontext.cli.kimi_code import install_kimi_plugin, run_kimi_diagnostics +from powercontext.cli.installation import setup as install_plugin +from powercontext.cli.kimi_code import KimiInstaller from powercontext.server.factory import create_server_app from powercontext.server.settings import BearerAuthConfig, ServerSettings @@ -316,9 +317,9 @@ def test_real_kimi_memory_capture_recall_and_fail_open(tmp_path: Path, monkeypat '[[permission.rules]]\ndecision = "allow"\npattern = "mcp__plugin_powercontext_powercontext__*"\n', encoding="utf-8", ) - installed = install_kimi_plugin(source=REPOSITORY, server_url=endpoint, scope_id=scope_id) - install_kimi_plugin(source=REPOSITORY) - diagnostics = run_kimi_diagnostics() + installed = install_plugin("kimi-code", source=str(REPOSITORY), server_url=endpoint, scope_id=scope_id) + install_plugin("kimi-code", source=str(REPOSITORY)) + diagnostics = KimiInstaller().verify() assert all(check.ok for check in diagnostics.values()), diagnostics command = [node, str(cli)] if cli.suffix in {".mjs", ".js", ".cjs"} else [str(cli)] version = subprocess.run([*command, "--version"], capture_output=True, text=True, check=True, timeout=15) @@ -404,7 +405,7 @@ def invoke(prompt, token: str | None = "kimi-synthetic-server-token", *, cwd: Pa history = subprocess.run( [ node, - str(Path(installed.plugin_path) / "scripts/status.mjs"), + str(Path(installed["plugin_path"]) / "scripts/status.mjs"), "--cwd", str(workspace), "--session-id", @@ -422,27 +423,27 @@ def invoke(prompt, token: str | None = "kimi-synthetic-server-token", *, cwd: Pa assert observed["stages"]["prepare"]["state"] == "ready" assert observed["stages"]["capture"]["state"] == "accepted" assert observed["stages"]["injection"]["state"] == "emitted" - diagnostics = run_kimi_diagnostics(runtime=True, cwd=workspace) + diagnostics = KimiInstaller().verify(runtime=True, cwd=workspace) assert diagnostics["runtime"].ok, diagnostics assert diagnostics["runtime"].checks is not None assert diagnostics["runtime"].checks["capture"] == "accepted" - install_kimi_plugin(source=REPOSITORY, capture_prompts=False) + install_plugin("kimi-code", source=str(REPOSITORY), capture_prompts=False) count = len(client.get(f"/v1/scopes/{scope_id}/sources").json()["items"]) invoke("An ordinary task with capture disabled.") assert len(client.get(f"/v1/scopes/{scope_id}/sources").json()["items"]) == count invoke("Ordinary task with rejected credentials.", token="wrong-synthetic-token") # noqa: S106 invoke("Ordinary task with missing credentials.", token=None) monkeypatch.delenv("POWERCONTEXT_KIMI_TOKEN") - assert run_kimi_diagnostics()["credentials"].detail == "authentication_failed" + assert KimiInstaller().verify()["credentials"].detail == "authentication_failed" monkeypatch.setenv("POWERCONTEXT_KIMI_TOKEN", "wrong-synthetic-token") - assert run_kimi_diagnostics()["scope"].detail == "authentication_failed" + assert KimiInstaller().verify()["scope"].detail == "authentication_failed" monkeypatch.setenv("POWERCONTEXT_KIMI_TOKEN", "kimi-synthetic-server-token") server.should_exit = True server_thread.join(timeout=10) assert not server_thread.is_alive(), "PowerContext Server must actually stop before outage acceptance" invoke("An ordinary task during a PowerContext outage.") - assert run_kimi_diagnostics()["server"].detail == "server_unavailable" + assert KimiInstaller().verify()["server"].detail == "server_unavailable" basic_app = create_server_app( settings=ServerSettings( database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'basic.db'}"), @@ -460,8 +461,8 @@ def invoke(prompt, token: str | None = "kimi-synthetic-server-token", *, cwd: Pa new_scope.raise_for_status() gateway.scope_id = new_scope.json()["scope_id"] monkeypatch.delenv("POWERCONTEXT_KIMI_TOKEN") - install_kimi_plugin(source=REPOSITORY, server_url=basic_endpoint, scope_id=gateway.scope_id) - assert all(check.ok for check in run_kimi_diagnostics().values()) + install_plugin("kimi-code", source=str(REPOSITORY), server_url=basic_endpoint, scope_id=gateway.scope_id) + assert all(check.ok for check in KimiInstaller().verify().values()) start = len(gateway.requests) invoke("PC_KIMI_SAVE: Save explicitly without Generation.", token=None) assert any( @@ -472,7 +473,7 @@ def invoke(prompt, token: str | None = "kimi-synthetic-server-token", *, cwd: Pa ) invoke("PC_KIMI_SEARCH: Search in basic memory mode.", token=None) - install_kimi_plugin(source=REPOSITORY, workspace_scope=True, capture_prompts=True) + install_plugin("kimi-code", source=str(REPOSITORY), workspace_scope=True, capture_prompts=True) default = basic.put("/v1/scopes/default", json={"scope_id": gateway.scope_id}) default.raise_for_status() second_scope = basic.post( @@ -487,7 +488,7 @@ def invoke(prompt, token: str | None = "kimi-synthetic-server-token", *, cwd: Pa other_scope_id = second_scope.json()["scope_id"] other_workspace = tmp_path / "other workspace" other_workspace.mkdir() - scope_script = Path(installed.plugin_path) / "scripts/scope.mjs" + scope_script = Path(installed["plugin_path"]) / "scripts/scope.mjs" def scope_action(action, cwd, target=None): arguments = [node, str(scope_script), action, "--cwd", str(cwd)] @@ -523,7 +524,7 @@ def scope_action(action, cwd, target=None): for obj in _objects(request["messages"]) if obj.get("schema") == "powercontext.kimi-code.request-binding.v1" ) - observed = run_kimi_diagnostics(runtime=True, cwd=cwd) + observed = KimiInstaller().verify(runtime=True, cwd=cwd) assert observed["scope"].checks is not None assert observed["scope"].checks["scope_id"] == target assert observed["runtime"].checks is not None @@ -532,12 +533,12 @@ def scope_action(action, cwd, target=None): captured = basic.get(f"/v1/scopes/{target}/sources") captured.raise_for_status() assert any(item["source_id"].startswith("kimi-user-prompt:") for item in captured.json()["items"]) - install_kimi_plugin(source=REPOSITORY, scope_id=gateway.scope_id) + install_plugin("kimi-code", source=str(REPOSITORY), scope_id=gateway.scope_id) shadowed = scope_action("bind", other_workspace, other_scope_id) assert shadowed["binding"]["status"] == "saved" assert shadowed["bound_scope_is_current"] is False assert shadowed["scope_id"] == gateway.scope_id - install_kimi_plugin(source=REPOSITORY, workspace_scope=True) + install_plugin("kimi-code", source=str(REPOSITORY), workspace_scope=True) assert scope_action("resolve", other_workspace)["scope_id"] == other_scope_id assert scope_action("unbind", other_workspace)["binding"]["status"] == "cleared" assert scope_action("resolve", other_workspace)["scope_id"] == gateway.scope_id @@ -566,7 +567,7 @@ def scope_action(action, cwd, target=None): variants.extend([Path(str(other_workspace).lower()), Path(str(other_workspace).upper())]) for variant in variants: assert scope_action("resolve", variant)["scope_id"] == other_scope_id - observed = run_kimi_diagnostics(cwd=variant) + observed = KimiInstaller().verify(cwd=variant) assert observed["scope"].checks is not None assert observed["scope"].checks["scope_id"] == other_scope_id start = len(gateway.requests) @@ -589,7 +590,7 @@ def scope_action(action, cwd, target=None): untouched = other_workspace if os.name == "nt" else lexical untouched_scope = other_scope_id if os.name == "nt" else gateway.scope_id assert scope_action("resolve", compound)["scope_id"] == expected - observed = run_kimi_diagnostics(cwd=compound) + observed = KimiInstaller().verify(cwd=compound) assert observed["scope"].checks is not None assert observed["scope"].checks["scope_id"] == expected assert scope_action("bind", compound, other_scope_id)["scope_id"] == other_scope_id @@ -602,7 +603,7 @@ def scope_action(action, cwd, target=None): "host_version": version.stdout.strip(), "platform": os.name, "inference": "controlled", - "installation": installed.plugin_path, + "installation": installed["plugin_path"], "explicit_memory": True, "exact_readback": True, "source_generation": True, diff --git a/tests/e2e/test_workbuddy_service_chain.py b/tests/e2e/test_workbuddy_service_chain.py index 597cf89691..ac62c557fc 100644 --- a/tests/e2e/test_workbuddy_service_chain.py +++ b/tests/e2e/test_workbuddy_service_chain.py @@ -31,10 +31,11 @@ from fastmcp.client.transports import StreamableHttpTransport from httpx import Client as HttpClient from pydantic import SecretStr +from typer.testing import CliRunner from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import InferenceConfig -from powercontext.cli.workbuddy import install_workbuddy_plugin +from powercontext.cli.app import create_cli from powercontext.server.factory import create_server_app from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings from tests.e2e.atomic_memory_models import independent_atomic_memory_model @@ -43,7 +44,6 @@ WORKBUDDY_PLUGIN = PROJECT_ROOT / "integrations" / "workbuddy" / "plugins" / "powercontext" AUTH_TOKEN = "workbuddy-e2e-token" # noqa: S105 - non-secret test credential. AUTHORIZATION = f"Bearer {AUTH_TOKEN}" -_SERVER_URL_TEMPLATE = "${POWERCONTEXT_WORKBUDDY_SERVER_URL:-http://127.0.0.1:8000}/mcp" _AUTHORIZATION_TEMPLATE = "${POWERCONTEXT_WORKBUDDY_AUTHORIZATION:-}" @@ -81,7 +81,23 @@ def test_workbuddy_hook_and_mcp_share_one_service_configuration( home = tmp_path / "WorkBuddy Home" monkeypatch.setenv("WORKBUDDY_HOME", str(home)) monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "PowerContext Data")) - install_workbuddy_plugin(source=str(PROJECT_ROOT), ref="master") + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke( + create_cli(), ["setup", "workbuddy", "--source", str(PROJECT_ROOT), "--server-url", base_url, "--json"] + ) + assert result.exit_code == 0, result.output + report = json.loads(result.output)["hosts"]["workbuddy"] + assert report["setup"] == "complete" + assert report["checks"]["server_liveness"]["ok"] + assert report["checks"]["server_readiness"]["ok"] + configured_hook = home / "hooks/workbuddy_powercontext_hook.py" + before = configured_hook.read_bytes() + updated = CliRunner().invoke(create_cli(), ["config", "agent", "workbuddy", "--server-url", base_url, "--json"]) + assert updated.exit_code == 0, updated.output + assert configured_hook.read_bytes() == before + diagnosed = CliRunner().invoke(create_cli(), ["doctor", "--json"]) + assert diagnosed.exit_code == 0, diagnosed.output + assert set(json.loads(diagnosed.output)["hosts"]) == {"workbuddy"} settings = json.loads((home / "settings.json").read_text(encoding="utf-8")) command = _powercontext_hook_command(settings) @@ -114,7 +130,7 @@ def test_workbuddy_hook_and_mcp_share_one_service_configuration( plugin_mcp_entry = json.loads((WORKBUDDY_PLUGIN / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"][ "powercontext" ] - assert mcp_entry == plugin_mcp_entry + assert mcp_entry == {**plugin_mcp_entry, "url": "${POWERCONTEXT_WORKBUDDY_SERVER_URL:-" + base_url + "}/mcp"} endpoint, headers = _workbuddy_mcp_connection(mcp_entry, environment) async def verify_mcp() -> None: @@ -212,12 +228,10 @@ def _workbuddy_mcp_connection( entry: dict[str, object], environment: dict[str, str], ) -> tuple[str, dict[str, str]]: - assert entry["url"] == _SERVER_URL_TEMPLATE + configured_url = environment["POWERCONTEXT_WORKBUDDY_SERVER_URL"] + assert entry["url"] == f"${{POWERCONTEXT_WORKBUDDY_SERVER_URL:-{configured_url}}}/mcp" assert entry["headers"] == {"Authorization": _AUTHORIZATION_TEMPLATE} - endpoint = _SERVER_URL_TEMPLATE.replace( - "${POWERCONTEXT_WORKBUDDY_SERVER_URL:-http://127.0.0.1:8000}", - environment.get("POWERCONTEXT_WORKBUDDY_SERVER_URL", "http://127.0.0.1:8000"), - ) + endpoint = f"{configured_url}/mcp" authorization = environment.get("POWERCONTEXT_WORKBUDDY_AUTHORIZATION", "") return endpoint, {"Authorization": authorization} diff --git a/tests/e2e/zcode_acceptance/runner.py b/tests/e2e/zcode_acceptance/runner.py index 3d80a7f464..ab42befbca 100644 --- a/tests/e2e/zcode_acceptance/runner.py +++ b/tests/e2e/zcode_acceptance/runner.py @@ -345,8 +345,8 @@ def install(self) -> None: completed = self.process([ sys.executable, "-c", - "from powercontext.cli.zcode import install_zcode_plugin; " - 'import sys; install_zcode_plugin(source=sys.argv[1], ref="master", server_url=sys.argv[2])', + "from powercontext.cli.installation import setup; " + 'import sys; setup("zcode", source=sys.argv[1], ref="master", server_url=sys.argv[2])', str(REPOSITORY), self.base_url, ]) diff --git a/tests/install_support.py b/tests/install_support.py new file mode 100644 index 0000000000..3d7989533a --- /dev/null +++ b/tests/install_support.py @@ -0,0 +1,66 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Native-operation substitutes for CLI routing tests; the shared driver still runs.""" + +from types import SimpleNamespace + +from powercontext.cli.installation import installers + + +def stub_installer(monkeypatch, host, callback): + from powercontext.cli import transport + + installer_type = type(installers()[host]()) + prepare = transport.prepare_setup_transport + connection = None + + def prepare_connection(*args, **kwargs): + nonlocal connection + connection = prepare(*args, **kwargs) + return connection + + def install(self, source): + assert connection is not None + options = vars(self) | { + "host": host, + "source": source.location, + "ref": source.ref, + "server_url": connection.server_url, + "allow_insecure_http": connection.allow_insecure_http, + } + return callback(SimpleNamespace(**options)) + + def configure(self, connection): + from powercontext.cli.installation import publish + + publish(writes=transport.setup_transport_updates(connection)) + return {} + + monkeypatch.setattr(transport, "prepare_setup_transport", prepare_connection) + monkeypatch.setattr(installer_type, "install", install) + monkeypatch.setattr(installer_type, "configure", configure) + if hasattr(installer_type, "enable"): + monkeypatch.setattr(installer_type, "enable", lambda self: None) + + +def configure_opencode(monkeypatch, config): + from powercontext.cli import opencode + + monkeypatch.setenv("OPENCODE_CONFIG_DIR", str(config)) + monkeypatch.setenv("POWERCONTEXT_HOME", str(config.parent / "data")) + monkeypatch.setattr(opencode, "opencode_executable", lambda: "opencode") + monkeypatch.setattr(opencode, "_version", lambda: "1.18.21") + monkeypatch.setattr(opencode, "opencode_config_dir", lambda: config) diff --git a/tests/test_agent_lifecycle_cli.py b/tests/test_agent_lifecycle_cli.py new file mode 100644 index 0000000000..9a92cdb45f --- /dev/null +++ b/tests/test_agent_lifecycle_cli.py @@ -0,0 +1,204 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""The default CLI completes selected integrations and reports their actual checks.""" + +import json + +import pytest +from typer.testing import CliRunner + +from powercontext.cli import hosts, installation, system +from powercontext.cli.app import create_cli +from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError +from powercontext.cli.transport import configure_connection + + +@pytest.fixture +def agents(tmp_path, monkeypatch): # noqa: C901 - executable fixture adapters for the public lifecycle. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) + monkeypatch.setattr(hosts, "stdin_is_tty", lambda: False) + monkeypatch.setattr( + system, "server_diagnostics", lambda **kwargs: {"server": Diagnostic(DiagnosticStatus.OK, "available")} + ) + monkeypatch.setattr(system, "_run_codex_json", lambda *_args: {"installed": []}) + failures = {} + + def adapter(name): + path = tmp_path / name + + class Agent: + def __init__(self, **options): + pass + + def install(self, source): + if failures.get(name) == "install": + raise SetupError("native installation failed") # noqa: TRY003 + path.mkdir(exist_ok=True) + (path / "plugin").write_text(source.location) + return {"plugin": name, "plugin_path": str(path)} + + def configure(self, connection): + if not (path / "plugin").exists(): + raise SetupError("integration is not installed") # noqa: TRY003 + (path / "connection").write_text(connection.server_url) + configure_connection(connection) + return {} + + def enable(self): + (path / "enabled").touch() + + def verify(self): + status = DiagnosticStatus.OK if (path / "enabled").exists() else DiagnosticStatus.FAILED + checks = {"plugin": Diagnostic(status, "native plugin loading")} + if failures.get(name) == "malformed": + raise KeyError("missing native configuration field") # noqa: TRY003 + if failures.get(name) not in {None, "install"}: + checks[failures[name]] = Diagnostic(DiagnosticStatus.FAILED, "native check failed") + return checks + + return Agent + + monkeypatch.setattr(installation, "installers", lambda: {name: adapter(name) for name in ("codex", "claude-code")}) + return tmp_path, failures + + +def invoke(*arguments, stdin_text=None): + return CliRunner().invoke(create_cli(), list(arguments), input=stdin_text) + + +def test_setup_completes_multiple_agents_in_one_command(agents): + root, _ = agents + result = invoke("setup", "codex", "claude-code", "--server-url", "https://memory.example", "--json") + assert result.exit_code == 0, result.output + report = json.loads(result.output) + assert report["ok"] is True + assert set(report["hosts"]) == {"codex", "claude-code"} + for name, row in report["hosts"].items(): + assert row["setup"] == "complete" + assert row["checks"]["plugin"]["status"] == "ok" + assert (root / name / "enabled").exists() + assert (root / name / "connection").read_text() == "https://memory.example" + + +def test_setup_keeps_failed_verification_in_its_result(agents): + _, failures = agents + failures["codex"] = "mcp_tools" + result = invoke("setup", "codex", "--json") + assert result.exit_code == 1 + report = json.loads(result.output) + assert report["ok"] is False + row = report["hosts"]["codex"] + assert row["setup"] == "complete" + assert row["checks"]["mcp_tools"]["status"] == "failed" + + +def test_one_agent_failure_does_not_stop_the_other_agent(agents): + root, failures = agents + failures["codex"] = "install" + result = invoke("setup", "codex", "claude-code", "--json") + assert result.exit_code == 1 + rows = json.loads(result.output)["hosts"] + assert rows["codex"]["error"] == "native installation failed" + assert rows["claude-code"]["setup"] == "complete" + assert (root / "claude-code/enabled").exists() + + +def test_setup_prompts_once_for_multiple_targets(agents, monkeypatch): + root, _ = agents + monkeypatch.setattr(hosts, "stdin_is_tty", lambda: True) + result = invoke("setup", stdin_text="1,2,1\n") + assert result.exit_code == 0, result.output + assert (root / "codex/enabled").exists() + assert (root / "claude-code/enabled").exists() + + +@pytest.mark.parametrize( + "arguments", + [("setup",), ("setup", "codex", "unknown")], +) +def test_invalid_or_implicit_noninteractive_targets_make_no_changes(agents, arguments): + root, _ = agents + result = invoke(*arguments) + assert result.exit_code != 0 + assert not (root / "codex").exists() + assert not (root / "clients.json").exists() + + +def test_config_changes_connection_without_installing_or_enabling(agents): + root, _ = agents + assert invoke("setup", "codex", "--json").exit_code == 0 + plugin = root / "codex/plugin" + plugin.write_text("local plugin edit") + (root / "codex/enabled").unlink() + result = invoke("config", "agent", "codex", "--server-url", "https://new.example", "--json") + assert result.exit_code == 0, result.output + assert json.loads(result.output)["hosts"]["codex"]["status"] == "configured" + assert plugin.read_text() == "local plugin edit" + assert not (root / "codex/enabled").exists() + assert (root / "codex/connection").read_text() == "https://new.example" + + +def test_doctor_defaults_to_saved_connections_and_keeps_missing_targets_visible(agents): + root, _ = agents + assert invoke("setup", "codex", "--json").exit_code == 0 + (root / "codex/enabled").unlink() + before = {p: p.read_bytes() for p in root.rglob("*") if p.is_file()} + result = invoke("doctor", "--json") + assert result.exit_code == 1 + report = json.loads(result.output) + assert set(report["hosts"]) == {"codex"} + assert report["hosts"]["codex"]["checks"]["plugin"]["status"] == "failed" + assert {p: p.read_bytes() for p in root.rglob("*") if p.is_file()} == before + + +@pytest.mark.parametrize( + "arguments", + [ + ("setup", "codex", "--no-capture-prompts"), + ("config", "agent", "codex", "--scope-id", "wrong-agent"), + ("doctor", "codex", "--runtime"), + ("doctor", "codex", "--prepare"), + ], +) +def test_native_options_require_an_applicable_target_before_writes(agents, arguments): + root, _ = agents + result = invoke(*arguments) + assert result.exit_code == 2 + assert not (root / "codex").exists() + assert not (root / "clients.json").exists() + + +def test_malformed_native_configuration_does_not_hide_completed_setup_or_other_targets(agents): + _, failures = agents + failures["codex"] = "malformed" + result = invoke("setup", "codex", "claude-code", "--json") + assert result.exit_code == 1 + rows = json.loads(result.output)["hosts"] + assert rows["codex"]["setup"] == "complete" + assert rows["codex"]["checks"]["verification"]["status"] == "failed" + assert rows["claude-code"]["ok"] is True + + +def test_explicit_server_diagnostics_remain_available_after_agent_setup(agents): + assert invoke("setup", "codex", "--json").exit_code == 0 + result = invoke("doctor", "--server-url", "https://new.example", "--json") + assert result.exit_code == 0 + assert "hosts" not in json.loads(result.output) + assert json.loads(result.output)["checks"]["server"]["status"] == "ok" diff --git a/tests/test_authorization.py b/tests/test_authorization.py index b120e2599a..03cc16f02f 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -18,7 +18,6 @@ import os import stat from pathlib import Path -from unittest.mock import Mock import pytest @@ -26,15 +25,14 @@ AuthorizationResolution, clear_stored_authorization, configure_codex_desktop_authorization, - configure_stored_authorization, credential_path, normalize_authorization, read_codex_desktop_authorization, read_stored_authorization, setup_authorization_value, - setup_server_url, write_stored_authorization, ) +from powercontext.cli.transport import resolve_setup_endpoint @pytest.fixture(autouse=True) @@ -52,7 +50,7 @@ def test_setup_url_uses_local_server_port(monkeypatch: pytest.MonkeyPatch, port: for name in ("POWERCONTEXT_CODEX_SERVER_URL", "POWERCONTEXT_CLIENT_SERVER_URL", "POWERCONTEXT_SERVER_PUBLIC_URL"): monkeypatch.delenv(name, raising=False) monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_PORT", port) - assert setup_server_url("codex", "http://127.0.0.1:8000") == f"http://127.0.0.1:{port}" + assert resolve_setup_endpoint("codex", default="http://127.0.0.1:8000") == f"http://127.0.0.1:{port}" @pytest.mark.parametrize("port", ["", "invalid", "0", "65536", "1.5"]) @@ -62,7 +60,7 @@ def test_setup_url_rejects_invalid_server_port(monkeypatch: pytest.MonkeyPatch, monkeypatch.delenv(name, raising=False) monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_PORT", port) with pytest.raises(ValueError, match="must be an integer between 1 and 65535"): - setup_server_url("codex", "http://127.0.0.1:8000") + resolve_setup_endpoint("codex", default="http://127.0.0.1:8000") def test_setup_url_preserves_explicit_access_urls(monkeypatch: pytest.MonkeyPatch) -> None: @@ -72,14 +70,14 @@ def test_setup_url_preserves_explicit_access_urls(monkeypatch: pytest.MonkeyPatc monkeypatch.setenv("POWERCONTEXT_CLIENT_SERVER_URL", "https://client.example.com") monkeypatch.setenv("POWERCONTEXT_SERVER_PUBLIC_URL", "https://proxy.example.com") with pytest.raises(ValueError, match="Conflicting PowerContext endpoints"): - setup_server_url("codex", "fallback") + resolve_setup_endpoint("codex", default="fallback") monkeypatch.delenv("POWERCONTEXT_CODEX_SERVER_URL") - assert setup_server_url("codex", "fallback") == "https://client.example.com" + assert resolve_setup_endpoint("codex", default="fallback") == "https://client.example.com" monkeypatch.delenv("POWERCONTEXT_CLIENT_SERVER_URL") - assert setup_server_url("codex", "fallback") == "https://proxy.example.com" + assert resolve_setup_endpoint("codex", default="fallback") == "https://proxy.example.com" monkeypatch.delenv("POWERCONTEXT_SERVER_PUBLIC_URL") monkeypatch.delenv("POWERCONTEXT_SERVER_HTTP_PORT") - assert setup_server_url("codex", "fallback") == "fallback" + assert resolve_setup_endpoint("codex", default="fallback") == "fallback" def test_normalize_authorization_accepts_bare_tokens_and_headers() -> None: @@ -195,7 +193,7 @@ def test_setup_uses_host_specific_credentials_and_endpoints(monkeypatch) -> None monkeypatch.setenv("POWERCONTEXT_CLIENT_API_TOKEN", "Bearer shared-token") assert setup_authorization_value("opencode") == "Bearer host-token" - assert setup_server_url("opencode", "http://127.0.0.1:8000") == "https://memory.example/api" + assert resolve_setup_endpoint("opencode", default="http://127.0.0.1:8000") == "https://memory.example/api" @pytest.mark.parametrize( @@ -242,58 +240,6 @@ def test_stored_authorization_remains_bound_to_proxy_path(tmp_path, server_url): ) -@pytest.mark.parametrize("replacement_token", [None, "replacement-token"]) -def test_setup_endpoint_change_preserves_or_replaces_url_bound_credential(replacement_token): - """Only a supplied new token can replace the previous endpoint's credential.""" - path = credential_path("codex") - old_url = "http://127.0.0.1:8100" - new_url = "http://127.0.0.1:17429" - write_stored_authorization(path, server_url=old_url, value="old-token") - original = path.read_bytes() - - status = configure_stored_authorization("codex", server_url=new_url, value=replacement_token) - - if replacement_token is None: - assert status == "url_mismatch" - assert path.read_bytes() == original - assert read_stored_authorization(path, server_url=new_url) == AuthorizationResolution("url_mismatch", None) - else: - assert status == "configured" - assert read_stored_authorization(path, server_url=new_url) == AuthorizationResolution( - "configured", "Bearer replacement-token" - ) - assert read_stored_authorization(path, server_url=old_url) == AuthorizationResolution("url_mismatch", None) - - -def test_credential_path_uses_host_owned_roots(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) - monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude")) - monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path / "pi")) - monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) - - assert credential_path("codex") == tmp_path / "codex" / "powercontext" / "credentials.json" - assert credential_path("claude-code") == tmp_path / "claude" / "powercontext" / "credentials.json" - assert credential_path("pi") == tmp_path / "pi" / "powercontext" / "credentials.json" - assert credential_path("workbuddy") == tmp_path / "workbuddy" / "powercontext" / "credentials.json" - - -def test_codex_desktop_authorization_uses_the_windows_user_environment(monkeypatch: pytest.MonkeyPatch) -> None: - import powercontext.cli.authorization as authorization - - write = Mock() - monkeypatch.setattr(authorization.sys, "platform", "win32") - monkeypatch.setattr(authorization, "_write_windows_user_environment", write) - monkeypatch.setattr( - authorization, - "_read_windows_user_environment", - lambda _name: "Bearer saved-token", - ) - - assert configure_codex_desktop_authorization("saved-token") is True - assert read_codex_desktop_authorization() == "Bearer saved-token" - write.assert_called_once_with("POWERCONTEXT_CODEX_AUTHORIZATION", "Bearer saved-token") - - def test_codex_desktop_authorization_is_windows_only(monkeypatch: pytest.MonkeyPatch) -> None: import powercontext.cli.authorization as authorization diff --git a/tests/test_cli_kimi_code.py b/tests/test_cli_kimi_code.py index 7478db820b..36168340ab 100644 --- a/tests/test_cli_kimi_code.py +++ b/tests/test_cli_kimi_code.py @@ -17,14 +17,17 @@ from __future__ import annotations import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from threading import Thread import pytest from click import unstyle from typer.testing import CliRunner -from powercontext.cli import kimi_code +from powercontext.cli import installation, kimi_code from powercontext.cli.app import create_cli +from powercontext.cli.installation import setup as install_plugin from powercontext.cli.system import SetupError, doctor_app, setup_app REPOSITORY = Path(__file__).parents[1] @@ -40,10 +43,10 @@ def kimi_home(tmp_path, monkeypatch): def install(**options): - return kimi_code.install_kimi_plugin(source=REPOSITORY, scope_id="scope-中文", **options) + return install_plugin("kimi-code", source=str(REPOSITORY), scope_id="scope-中文", **options) -def test_runtime_history_survives_setup_and_doctor_reports_missing_history(kimi_home): +def test_runtime_history_survives_setup_and_doctor_reports_missing_history(kimi_home, server_checks): install() history = kimi_home / "powercontext/runtime" history.mkdir(parents=True) @@ -51,20 +54,20 @@ def test_runtime_history_survives_setup_and_doctor_reports_missing_history(kimi_ preserved.write_text("Preserve local state.", encoding="utf-8") install() assert preserved.read_text(encoding="utf-8") == "Preserve local state." - report = kimi_code.run_kimi_diagnostics(runtime=True, cwd=REPOSITORY, session_id="new-session") + report = kimi_code.KimiInstaller().verify(runtime=True, cwd=REPOSITORY, session_id="new-session") assert report["runtime"].status.value == "skipped" assert report["runtime"].detail == "not_observed" assert report["runtime"].checks == {"selection": "session"} -def test_doctor_runtime_option_requires_explicit_selection(kimi_home): +def test_doctor_runtime_option_requires_explicit_selection(kimi_home, server_checks): install() result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "kimi-code", "--session-id", "session"]) assert result.exit_code != 0 assert "--session-id requires --runtime" in unstyle(result.output) -def test_cli_installs_repeatably_and_preserves_configuration(kimi_home): +def test_cli_installs_repeatably_and_preserves_configuration(kimi_home, installed_agent_checks): registry = kimi_home / "plugins/installed.json" registry.parent.mkdir(parents=True) other = {"id": "other", "root": "/other", "enabled": True, "custom": {"keep": "原样"}} @@ -87,7 +90,7 @@ def test_cli_installs_repeatably_and_preserves_configuration(kimi_home): ], ) assert result.exit_code == 0, result.output - installed = Path(json.loads(result.output)["plugin_path"]) + installed = Path(json.loads(result.output)["hosts"]["kimi-code"]["result"]["plugin_path"]) value = json.loads(registry.read_text(encoding="utf-8")) value["plugins"][1].update(enabled=False, capabilities={"mcpServers": {"powercontext": {"enabled": False}}}) registry.write_text(json.dumps(value), encoding="utf-8") @@ -95,7 +98,7 @@ def test_cli_installs_repeatably_and_preserves_configuration(kimi_home): skill_file.write_text( skill_file.read_text(encoding="utf-8") + "\nPersonal instruction to retain.\n", encoding="utf-8" ) - kimi_code.install_kimi_plugin(source=REPOSITORY) + install_plugin("kimi-code", source=str(REPOSITORY)) assert "Personal instruction to retain." in skill_file.read_text(encoding="utf-8") value = json.loads(registry.read_text(encoding="utf-8")) assert value["plugins"][0] == other @@ -114,8 +117,8 @@ def test_cli_installs_repeatably_and_preserves_configuration(kimi_home): manifest = json.loads((installed / "kimi.plugin.json").read_text(encoding="utf-8")) assert manifest["mcpServers"]["powercontext"]["url"] == settings["server_url"] + "/mcp" assert "bearerTokenEnvVar" not in manifest["mcpServers"]["powercontext"] - assert "scope-中文" in (installed / "skills/powercontext-project-context/SKILL.md").read_text(encoding="utf-8") - report = kimi_code.run_kimi_diagnostics() + assert '"scope-中文"' in (installed / "skills/powercontext-project-context/SKILL.md").read_text(encoding="utf-8") + report = kimi_code.KimiInstaller().verify() assert report["plugin"].detail == "disabled" assert not report["mcp"].ok @@ -129,7 +132,7 @@ def test_install_rejects_unsupported_transport_before_writing(kimi_home, url): def test_install_requires_explicit_scope_and_preserves_unowned_directory(kimi_home): with pytest.raises(SetupError, match="scope-id"): - kimi_code.install_kimi_plugin(source=REPOSITORY) + install_plugin("kimi-code", source=str(REPOSITORY)) destination = kimi_home / "plugins/managed/powercontext" destination.mkdir(parents=True) (destination / "user.txt").write_text("keep", encoding="utf-8") @@ -141,15 +144,15 @@ def test_install_requires_explicit_scope_and_preserves_unowned_directory(kimi_ho def test_failed_registry_write_restores_plugin_and_registration(kimi_home, monkeypatch): result = install(capture_prompts=False) before = {p.relative_to(kimi_home): p.read_bytes() for p in kimi_home.rglob("*") if p.is_file()} - original = kimi_code._write_json + original = installation.write_bytes def fail_registration(path, value): - if path == Path(result.config_file): + if path == Path(result["config_file"]): raise OSError("synthetic write failure") # noqa: TRY003 original(path, value) - monkeypatch.setattr(kimi_code, "_write_json", fail_registration) - with pytest.raises(OSError): + monkeypatch.setattr(installation, "write_bytes", fail_registration) + with pytest.raises(SetupError): install(capture_prompts=True) assert {p.relative_to(kimi_home): p.read_bytes() for p in kimi_home.rglob("*") if p.is_file()} == before @@ -157,10 +160,10 @@ def fail_registration(path, value): @pytest.mark.parametrize( "url", ["http://127.0.0.1:1", "http://127.0.0.2:1", "http://127.255.255.254:1", "http://[::1]:1"] ) -def test_doctor_reports_unavailable_server_without_writes(kimi_home, url): +def test_doctor_reports_unavailable_server_without_writes(kimi_home, url, server_checks): install(server_url=url) before = {p.relative_to(kimi_home): p.read_bytes() for p in kimi_home.rglob("*") if p.is_file()} - report = kimi_code.run_kimi_diagnostics() + report = kimi_code.KimiInstaller().verify() assert report["plugin"].ok and report["mcp"].ok and report["hook"].ok assert report["server"].detail == "server_unavailable" assert report["scope"].detail == "server_unavailable" @@ -170,70 +173,175 @@ def test_doctor_reports_unavailable_server_without_writes(kimi_home, url): def test_authentication_mode_is_preserved_without_storing_token(kimi_home, monkeypatch): monkeypatch.setenv("POWERCONTEXT_KIMI_TOKEN", "synthetic-only") result = install() - plugin = Path(result.plugin_path) + plugin = Path(result["plugin_path"]) assert all(b"synthetic-only" not in path.read_bytes() for path in plugin.rglob("*") if path.is_file()) monkeypatch.delenv("POWERCONTEXT_KIMI_TOKEN") - kimi_code.install_kimi_plugin(source=REPOSITORY) + install_plugin("kimi-code", source=str(REPOSITORY)) manifest = json.loads((plugin / "kimi.plugin.json").read_text(encoding="utf-8")) assert manifest["mcpServers"]["powercontext"]["bearerTokenEnvVar"] == "POWERCONTEXT_KIMI_TOKEN" - assert kimi_code.run_kimi_diagnostics()["credentials"].detail == "authentication_failed" - - -def test_failed_rollback_keeps_previous_plugin_for_recovery(kimi_home, monkeypatch): - result = install() - destination = Path(result.plugin_path) - original_files = {p.relative_to(destination): p.read_bytes() for p in destination.rglob("*") if p.is_file()} - write = kimi_code._write_json - rename = Path.rename - - def fail_write(path, value): - if path == Path(result.config_file): - raise OSError("publication failed") # noqa: TRY003 - write(path, value) - - def fail_restore(path, target): - if path.name == ".powercontext-kimi-backup": - raise OSError("restore denied") # noqa: TRY003 - return rename(path, target) - - monkeypatch.setattr(kimi_code, "_write_json", fail_write) - monkeypatch.setattr(Path, "rename", fail_restore) - with pytest.raises(SetupError, match="backup is retained"): - install() - backup = destination.with_name(".powercontext-kimi-backup") - assert {p.relative_to(backup): p.read_bytes() for p in backup.rglob("*") if p.is_file()} == original_files + assert kimi_code.KimiInstaller().verify()["credentials"].detail == "authentication_failed" def test_reconfiguration_rejects_installed_source_with_stale_scope(kimi_home): - installed = Path(install().plugin_path) + installed = Path(install()["plugin_path"]) before = (installed / "powercontext.json").read_bytes() with pytest.raises(SetupError, match="original PowerContext checkout"): - kimi_code.install_kimi_plugin(source=installed, scope_id="another-scope") + install_plugin("kimi-code", source=str(installed), scope_id="another-scope") assert (installed / "powercontext.json").read_bytes() == before -def test_workspace_scope_setup_preserves_mode_and_can_restore_explicit_scope(kimi_home): +@pytest.mark.parametrize("command", ["setup", "config"]) +def test_scope_changes_preserve_personal_guidance(kimi_home, installed_agent_checks, command): + installed = install() + skill = Path(installed["plugin_path"]) / "skills/powercontext-project-context/SKILL.md" + notes = ( + '\r\nPersonal example: The configured explicit Scope is "scope-中文".\r\n' + "Literal templates: __POWERCONTEXT_SCOPE_JSON__ __POWERCONTEXT_SCOPE_SCRIPT_JSON__\r\n" + ).encode() + skill.write_bytes(skill.read_bytes() + notes) + arguments = ( + ["setup", "kimi-code", "--source", str(REPOSITORY)] if command == "setup" else ["config", "agent", "kimi-code"] + ) + result = CliRunner().invoke(create_cli(), [*arguments, "--scope-id", "new-scope", "--json"]) + assert result.exit_code == 0, result.output + content = skill.read_bytes() + assert content.endswith(notes) + assert b'The configured explicit Scope is "new-scope".' in content + + +def test_configuration_updates_scope_without_reinstalling(kimi_home): + from powercontext.cli.transport import SetupTransport + + installed = install() + root = Path(installed["plugin_path"]) + hook = root / "hooks/user_prompt_submit.mjs" + original = hook.read_bytes() + manifest_path = root / "kimi.plugin.json" + manifest = json.loads(manifest_path.read_text()) + manifest["mcpServers"]["other"] = {"url": "https://other.example"} + manifest["mcpServers"]["powercontext"].update(enabled=False, enabledTools=["search_memory"], toolTimeoutMs=12345) + manifest_path.write_text(json.dumps(manifest)) + registry_path = Path(installed["config_file"]) + registry = json.loads(registry_path.read_text()) + record = registry["plugins"][0] + record.update( + enabled=False, capabilities={"mcpServers": {"powercontext": {"enabled": False}, "other": {"enabled": False}}} + ) + registry_path.write_text(json.dumps(registry)) + installer = kimi_code.KimiInstaller(scope_id="new-scope", capture_prompts=False) + updated = installer.configure(SetupTransport("kimi-code", "https://new.example", False)) + assert updated["scope_id"] == "new-scope" + assert hook.read_bytes() == original + assert json.loads((root / "powercontext.json").read_text())["capture_prompts"] is False + skill = (root / "skills/powercontext-project-context/SKILL.md").read_text() + assert 'The configured explicit Scope is "new-scope".' in skill + assert 'The configured explicit Scope is "scope-中文".' not in skill + manifest = json.loads(manifest_path.read_text()) + assert manifest["mcpServers"]["other"] == {"url": "https://other.example"} + assert manifest["mcpServers"]["powercontext"]["enabledTools"] == ["search_memory"] + assert manifest["mcpServers"]["powercontext"]["toolTimeoutMs"] == 12345 + assert manifest["mcpServers"]["powercontext"]["enabled"] is False + assert json.loads(registry_path.read_text()) == registry + + +@pytest.mark.parametrize( + "readiness, old_probe, service_failed, http_status, with_checks", + [ + ("ready", False, False, 200, True), + ("degraded", False, False, 200, True), + ("ready", True, False, 200, True), + ("ready", False, True, 200, True), + ("ready", False, False, 201, True), + ("ready", False, False, 200, False), + ], +) +def test_doctor_preserves_server_readiness_without_changing_installed_settings( + kimi_home, monkeypatch, readiness, old_probe, service_failed, http_status, with_checks +): + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(http_status) + self.end_headers() + response = {"status": readiness} + if with_checks: + response["checks"] = {"inference.generation": "unavailable" if readiness == "degraded" else "ready"} + self.wfile.write(json.dumps(response).encode()) + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps({"scope_id": "scope-中文"}).encode()) + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 - stdlib override. + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + install(server_url=f"http://127.0.0.1:{server.server_port}") + if old_probe: + script = kimi_home / "plugins/managed/powercontext/scripts/doctor.mjs" + script.write_text(script.read_text().replace("diagnostics.v2", "diagnostics.v1")) + if service_failed: + from powercontext.cli.system import Diagnostic, DiagnosticStatus + + monkeypatch.setattr( + "powercontext.cli.system._local_service_diagnostics", + lambda _endpoint: { + "service_manager": Diagnostic(DiagnosticStatus.FAILED, "registered service failed"), + }, + ) + original = {path: path.read_bytes() for path in kimi_home.rglob("*") if path.is_file()} + result = CliRunner().invoke(create_cli(), ["doctor", "kimi-code", "--json"]) + valid = http_status == 200 and with_checks + assert result.exit_code == ( + 0 if valid and readiness == "ready" and not old_probe and not service_failed else 1 + ), result.output + checks = json.loads(result.output)["hosts"]["kimi-code"]["checks"] + if old_probe: + assert checks["probe"]["status"] == "failed" + assert "powercontext setup kimi-code" in checks["probe"]["detail"] + else: + assert checks["server"]["status"] == ( + "failed" if not valid else "ok" if readiness == "ready" else "degraded" + ) + if valid: + assert checks["server"]["checks"] == { + "inference.generation": "unavailable" if readiness == "degraded" else "ready" + } + if service_failed: + assert checks["service_manager"]["status"] == "failed" + assert {path: path.read_bytes() for path in kimi_home.rglob("*") if path.is_file()} == original + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def test_workspace_scope_setup_preserves_mode_and_can_restore_explicit_scope(kimi_home, installed_agent_checks): result = CliRunner().invoke( create_cli([setup_app]), ["setup", "kimi-code", "--source", str(REPOSITORY), "--workspace-scope", "--json"] ) assert result.exit_code == 0, result.output - metadata = json.loads(result.output) + metadata = json.loads(result.output)["hosts"]["kimi-code"]["result"] assert metadata["scope_id"] is None installed = Path(metadata["plugin_path"]) - kimi_code.install_kimi_plugin(source=REPOSITORY) + install_plugin("kimi-code", source=str(REPOSITORY)) settings = json.loads((installed / "powercontext.json").read_text(encoding="utf-8")) assert settings["scope_id"] is None skill = (installed / "skills/powercontext-project-context/SKILL.md").read_text(encoding="utf-8") assert "explicit Scope is null" in skill assert "__POWERCONTEXT_" not in skill assert (installed / "scripts/scope.mjs").is_file() - kimi_code.install_kimi_plugin(source=REPOSITORY, scope_id="scope-explicit") + install_plugin("kimi-code", source=str(REPOSITORY), scope_id="scope-explicit") assert json.loads((installed / "powercontext.json").read_text(encoding="utf-8"))["scope_id"] == "scope-explicit" - kimi_code.install_kimi_plugin(source=REPOSITORY, workspace_scope=True) + install_plugin("kimi-code", source=str(REPOSITORY), workspace_scope=True) assert json.loads((installed / "powercontext.json").read_text(encoding="utf-8"))["scope_id"] is None def test_workspace_mode_rejects_an_ambiguous_explicit_scope_before_writing(kimi_home): with pytest.raises(SetupError, match="mutually exclusive"): - kimi_code.install_kimi_plugin(source=REPOSITORY, workspace_scope=True, scope_id="scope-conflict") + install_plugin("kimi-code", source=str(REPOSITORY), workspace_scope=True, scope_id="scope-conflict") assert not (kimi_home / "plugins/installed.json").exists() diff --git a/tests/test_cli_workbuddy.py b/tests/test_cli_workbuddy.py index 0a060191e3..853147886a 100644 --- a/tests/test_cli_workbuddy.py +++ b/tests/test_cli_workbuddy.py @@ -19,7 +19,9 @@ import shlex import subprocess import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from threading import Thread from typing import Any import pytest @@ -27,7 +29,8 @@ import powercontext.cli.workbuddy as workbuddy_cli from powercontext.cli.app import create_cli -from powercontext.cli.system import doctor_app, setup_app +from powercontext.cli.installation import setup as install_plugin +from powercontext.cli.system import SetupError, doctor_app, setup_app _HOOK_MODULES = ( "workbuddy_powercontext_hook.py", @@ -90,7 +93,112 @@ def _shell_argument(value: str | Path) -> str: return shlex.quote(text) -def test_setup_workbuddy_installs_from_a_local_checkout(tmp_path: Path, monkeypatch) -> None: +def test_setup_and_doctor_workbuddy_reach_loopback_server_without_server_dependencies(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + payload = {"status": "ok"} if self.path == "/health/live" else {"status": "ready", "checks": {}} + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib signature. + del format, args + + script = """ +import builtins +import json +import sys + +real_import = builtins.__import__ + +def guarded_import(name, *args, **kwargs): + if name == "sqlalchemy" or name.startswith("sqlalchemy."): + raise ModuleNotFoundError("Server dependency is not installed", name="sqlalchemy") + return real_import(name, *args, **kwargs) + +builtins.__import__ = guarded_import +from typer.testing import CliRunner +from powercontext.cli.app import create_cli +from powercontext.cli.system import setup_app, doctor_app + +cli = create_cli([setup_app, doctor_app]) +for arguments in ( + ["setup", "workbuddy", "--source", sys.argv[1], "--server-url", sys.argv[2], "--json"], + ["doctor", "workbuddy", "--json"], +): + result = CliRunner().invoke(cli, arguments) + assert result.exit_code == 0, result.output + report = json.loads(result.output) + assert report["ok"] is True + checks = report["hosts"]["workbuddy"]["checks"] + assert checks["server_liveness"]["status"] == "ok" + assert checks["server_readiness"]["status"] == "ok" +""" + with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + result = subprocess.run( + [ + sys.executable, + "-c", + script, + str(Path(__file__).parents[1]), + f"http://127.0.0.1:{server.server_port}", + ], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + finally: + server.shutdown() + thread.join(timeout=5) + + +@pytest.mark.parametrize("installed", [False, True]) +@pytest.mark.parametrize("from_environment_file", [False, True]) +def test_setup_workbuddy_rejects_invalid_credentials_without_changing_host( + tmp_path: Path, monkeypatch, installed_agent_checks, installed: bool, from_environment_file: bool +) -> None: + checkout = tmp_path / "checkout" + plugin = _write_plugin(checkout) + home = tmp_path / "home" + monkeypatch.setenv("WORKBUDDY_HOME", str(home / "workbuddy")) + monkeypatch.setenv("POWERCONTEXT_HOME", str(home / "data")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(home / "clients.json")) + env_file = tmp_path / "setup.env" + env_file.write_text("", encoding="utf-8") + arguments = ["setup", "--env-file", str(env_file), "workbuddy", "--source", str(checkout), "--json"] + cli = create_cli([setup_app]) + if installed: + result = CliRunner().invoke(cli, arguments) + assert result.exit_code == 0, result.output + before = {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} + (plugin / "hooks/workbuddy_powercontext_hook.py").write_text("# replacement\n", encoding="utf-8") + if from_environment_file: + env_file.write_text('POWERCONTEXT_CLIENT_API_TOKEN="invalid token"\n', encoding="utf-8") + else: + monkeypatch.setenv("POWERCONTEXT_WORKBUDDY_AUTHORIZATION", "invalid token") + + result = CliRunner().invoke(cli, arguments) + + assert result.exit_code == 1, result.output + assert "single-token credential" in json.loads(result.output)["hosts"]["workbuddy"]["error"] + assert "invalid token" not in result.output + assert {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} == before + + +def test_setup_workbuddy_installs_from_a_local_checkout(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy home" @@ -106,9 +214,9 @@ def test_setup_workbuddy_installs_from_a_local_checkout(tmp_path: Path, monkeypa ) assert result.exit_code == 0 - assert json.loads(result.output) == { + assert json.loads(result.output)["hosts"]["workbuddy"]["result"] == { "plugin": "powercontext", - "plugin_path": str(checkout / "integrations" / "workbuddy" / "plugins" / "powercontext"), + "plugin_path": str(home), "workbuddy_home": str(home), "hooks_dir": str(home / "hooks"), "data_dir": str(tmp_path / "data"), @@ -165,7 +273,9 @@ def test_setup_workbuddy_installs_from_a_local_checkout(tmp_path: Path, monkeypa assert mcp["mcpServers"]["powercontext"]["disabled"] is False -def test_setup_workbuddy_preserves_existing_settings_and_mcp(tmp_path: Path, monkeypatch) -> None: +def test_setup_workbuddy_preserves_existing_settings_and_mcp( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy" @@ -207,7 +317,32 @@ def test_setup_workbuddy_preserves_existing_settings_and_mcp(tmp_path: Path, mon ) -def test_setup_workbuddy_preserves_remote_authenticated_mcp_configuration(tmp_path: Path, monkeypatch) -> None: +@pytest.mark.skipif(os.name == "nt", reason="creating symlinks requires Windows privileges") +def test_install_preserves_unowned_hook_symlinks(tmp_path: Path, monkeypatch) -> None: + checkout = tmp_path / "checkout" + _write_plugin(checkout) + home = tmp_path / "workbuddy" + hooks = home / "hooks" + hooks.mkdir(parents=True) + external = tmp_path / "other.py" + external.write_text("# other plugin\n", encoding="utf-8") + linked = hooks / "other.py" + linked.symlink_to(external) + dangling = hooks / "missing.py" + dangling.symlink_to(tmp_path / "missing.py") + monkeypatch.setenv("WORKBUDDY_HOME", str(home)) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + + install_plugin("workbuddy", source=str(str(checkout)), ref="main") + + assert linked.is_symlink() and linked.readlink() == external + assert dangling.is_symlink() and dangling.readlink() == tmp_path / "missing.py" + assert external.read_text(encoding="utf-8") == "# other plugin\n" + + +def test_setup_workbuddy_preserves_remote_authenticated_mcp_configuration( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy" @@ -241,48 +376,12 @@ def test_setup_workbuddy_preserves_remote_authenticated_mcp_configuration(tmp_pa mcp = json.loads((home / "mcp.json").read_text(encoding="utf-8")) assert mcp["mcpServers"]["powercontext"]["url"] == "https://memory.example.test/mcp" assert mcp["mcpServers"]["powercontext"]["headers"] == {"Authorization": "Bearer existing-token"} - assert mcp["mcpServers"]["powercontext"]["disabled"] is False - - -def test_setup_workbuddy_migrates_the_legacy_generated_mcp_entry(tmp_path: Path, monkeypatch) -> None: - checkout = tmp_path / "powercontext" - _write_plugin(checkout) - home = tmp_path / "workbuddy" - home.mkdir() - monkeypatch.setenv("WORKBUDDY_HOME", str(home)) - monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) - - (home / "mcp.json").write_text( - json.dumps({ - "mcpServers": { - "powercontext": { - "type": "http", - "url": "http://127.0.0.1:8000/mcp", - "headers": {}, - "description": "PowerContext agent memory & handoff MCP server (local service on port 8000)", - "disabled": False, - } - } - }), - encoding="utf-8", - ) + assert mcp["mcpServers"]["powercontext"]["disabled"] is True - result = CliRunner().invoke( - create_cli([setup_app]), - ["setup", "workbuddy", "--source", str(checkout)], - ) - assert result.exit_code == 0 - mcp = json.loads((home / "mcp.json").read_text(encoding="utf-8")) - assert mcp["mcpServers"]["powercontext"]["url"] == ( - "${POWERCONTEXT_WORKBUDDY_SERVER_URL:-http://127.0.0.1:8000}/mcp" - ) - assert mcp["mcpServers"]["powercontext"]["headers"] == { - "Authorization": "${POWERCONTEXT_WORKBUDDY_AUTHORIZATION:-}" - } - - -def test_setup_workbuddy_preserves_other_hooks_shared_scripts(tmp_path: Path, monkeypatch) -> None: +def test_setup_workbuddy_preserves_other_hooks_shared_scripts( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy" @@ -301,7 +400,7 @@ def test_setup_workbuddy_preserves_other_hooks_shared_scripts(tmp_path: Path, mo assert shared_script.read_text(encoding="utf-8") == "# owned by another hook\n" -def test_setup_workbuddy_refuses_an_unowned_skill(tmp_path: Path, monkeypatch) -> None: +def test_setup_workbuddy_refuses_an_unowned_skill(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy" @@ -324,7 +423,7 @@ def test_setup_workbuddy_refuses_an_unowned_skill(tmp_path: Path, monkeypatch) - assert not (home / "mcp.json").exists() -def test_setup_workbuddy_refreshes_an_owned_skill(tmp_path: Path, monkeypatch) -> None: +def test_setup_workbuddy_refreshes_an_owned_skill(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: from powercontext.cli.guidance import END, RESOURCE_MANIFEST, START checkout = tmp_path / "powercontext" @@ -335,7 +434,6 @@ def test_setup_workbuddy_refreshes_an_owned_skill(tmp_path: Path, monkeypatch) - json.dumps({ "schema_version": 1, "resources": ["SKILL.md", "references/scope-memory.md"], - "legacy_sha256": {}, }), encoding="utf-8", ) @@ -360,7 +458,9 @@ def test_setup_workbuddy_refreshes_an_owned_skill(tmp_path: Path, monkeypatch) - ) -def test_setup_workbuddy_stops_before_writes_when_settings_snapshot_is_unreadable(tmp_path: Path, monkeypatch) -> None: +def test_setup_workbuddy_preserves_installation_when_settings_are_unreadable( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy" @@ -374,12 +474,14 @@ def test_setup_workbuddy_stops_before_writes_when_settings_snapshot_is_unreadabl ) assert result.exit_code == 1 - assert "Cannot update WorkBuddy settings" in result.output + assert "settings.json" in result.output assert not (home / "hooks").exists() assert not (home / "mcp.json").exists() -def test_setup_workbuddy_updates_an_existing_powercontext_hook(tmp_path: Path, monkeypatch) -> None: +def test_setup_workbuddy_updates_an_existing_powercontext_hook( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy" @@ -423,7 +525,9 @@ def test_setup_workbuddy_updates_an_existing_powercontext_hook(tmp_path: Path, m assert hook["custom"] == "preserved" -def test_setup_workbuddy_rolls_back_json_changes_on_failure(tmp_path: Path, monkeypatch) -> None: +def test_setup_workbuddy_rolls_back_json_changes_on_failure( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy" @@ -441,20 +545,20 @@ def fail_skill_install(*_args, **_kwargs) -> None: monkeypatch.setattr(workbuddy_cli, "_install_workbuddy_skill", fail_skill_install) - with pytest.raises(OSError): - workbuddy_cli.install_workbuddy_plugin(source=str(checkout), ref="master") + with pytest.raises(SetupError): + install_plugin("workbuddy", source=str(str(checkout)), ref="master") assert json.loads((home / "settings.json").read_text(encoding="utf-8")) == previous_settings assert json.loads((home / "mcp.json").read_text(encoding="utf-8")) == previous_mcp -def test_doctor_workbuddy_reports_failures_before_install(tmp_path: Path, monkeypatch) -> None: +def test_doctor_workbuddy_reports_failures_before_install(tmp_path: Path, monkeypatch, server_checks) -> None: monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "workbuddy", "--json"]) assert result.exit_code == 1 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["workbuddy"] assert payload["ok"] is False assert payload["status"] == "failed" assert {name: check["status"] for name, check in payload["checks"].items()} == { @@ -465,7 +569,7 @@ def test_doctor_workbuddy_reports_failures_before_install(tmp_path: Path, monkey } -def test_doctor_workbuddy_reports_ok_after_install(tmp_path: Path, monkeypatch) -> None: +def test_doctor_workbuddy_reports_ok_after_install(tmp_path: Path, monkeypatch, server_checks) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "workbuddy" @@ -481,7 +585,7 @@ def test_doctor_workbuddy_reports_ok_after_install(tmp_path: Path, monkeypatch) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "workbuddy", "--json"]) assert result.exit_code == 0 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["workbuddy"] assert payload["ok"] is True assert payload["status"] == "ok" assert {name: check["status"] for name, check in payload["checks"].items()} == { @@ -492,7 +596,7 @@ def test_doctor_workbuddy_reports_ok_after_install(tmp_path: Path, monkeypatch) } -def test_setup_workbuddy_rejects_a_missing_plugin(tmp_path: Path, monkeypatch) -> None: +def test_setup_workbuddy_rejects_a_missing_plugin(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) @@ -502,32 +606,51 @@ def test_setup_workbuddy_rejects_a_missing_plugin(tmp_path: Path, monkeypatch) - ) assert result.exit_code == 1 - assert "WorkBuddy plugin was not found" in result.output + assert "workbuddy plugin was not found" in result.output.casefold() -def test_setup_workbuddy_remote_checkout_refreshes_the_requested_ref(short_tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("POWERCONTEXT_HOME", str(short_tmp_path)) - generation = 0 - - def fake_clone(source: str, ref: str, target: Path) -> None: - nonlocal generation - generation += 1 - plugin = _write_plugin(target) - (plugin / "revision.txt").write_text(f"{source}@{ref}:{generation}\n", encoding="utf-8") +def test_workbuddy_home_honors_the_environment_override(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) - monkeypatch.setattr(workbuddy_cli, "clone_github_source", fake_clone) + assert workbuddy_cli.workbuddy_home() == (tmp_path / "workbuddy").resolve() - first = workbuddy_cli.resolve_workbuddy_plugin_dir(source="oceanbase/powercontext", ref="master") - refreshed = workbuddy_cli.resolve_workbuddy_plugin_dir(source="oceanbase/powercontext", ref="master") - checkout_root = workbuddy_cli.checkout_target("oceanbase/powercontext", "master") - expected = checkout_root / "integrations" / "workbuddy" / "plugins" / "powercontext" - assert first == expected - assert refreshed == expected - assert (refreshed / "revision.txt").read_text(encoding="utf-8") == "oceanbase/powercontext@master:2\n" +@pytest.mark.parametrize("failure", ["invalid_credential", "credential_write"]) +def test_config_agent_failure_preserves_the_working_connection(tmp_path, monkeypatch, failure): + from powercontext.cli import installation + from powercontext.cli.authorization import credential_path, read_stored_authorization + from powercontext.client.transport_policy import load_client_settings + checkout = tmp_path / "checkout" + _write_plugin(checkout) + home = tmp_path / "workbuddy" + monkeypatch.setenv("WORKBUDDY_HOME", str(home)) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) + monkeypatch.setenv("POWERCONTEXT_WORKBUDDY_AUTHORIZATION", "old-token") + install_plugin("workbuddy", source=str(checkout), server_url="https://old.example", json_output=True) + credential = credential_path("workbuddy") + paths = (home / "mcp.json", tmp_path / "clients.json", credential) + original = {path: path.read_bytes() for path in paths} + monkeypatch.setenv( + "POWERCONTEXT_WORKBUDDY_AUTHORIZATION", "bad token" if failure == "invalid_credential" else "new-token" + ) + if failure == "credential_write": + write = installation.write_bytes -def test_workbuddy_home_honors_the_environment_override(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) + def fail_credential(path, content): + if path == credential: + raise OSError("credential write failed") # noqa: TRY003 + write(path, content) - assert workbuddy_cli.workbuddy_home() == (tmp_path / "workbuddy").resolve() + monkeypatch.setattr(installation, "write_bytes", fail_credential) + result = CliRunner().invoke( + create_cli(), ["config", "agent", "workbuddy", "--server-url", "https://new.example", "--json"] + ) + assert result.exit_code == 1, result.output + assert json.loads(result.output)["hosts"]["workbuddy"]["status"] == "failed" + assert {path: path.read_bytes() for path in paths} == original + assert load_client_settings("workbuddy")["server_url"] == "https://old.example" + assert read_stored_authorization(credential, server_url="https://old.example").authorization == "Bearer old-token" + assert "old-token" not in result.output + assert "new-token" not in result.output diff --git a/tests/test_cli_zcode.py b/tests/test_cli_zcode.py index e165514a0b..9a40934ea9 100644 --- a/tests/test_cli_zcode.py +++ b/tests/test_cli_zcode.py @@ -25,8 +25,9 @@ import pytest from typer.testing import CliRunner -from powercontext.cli import zcode +from powercontext.cli import git_source, installation, zcode from powercontext.cli.app import create_cli +from powercontext.cli.installation import setup as install_plugin from powercontext.cli.system import SetupError, doctor_app, setup_app @@ -73,7 +74,8 @@ def _checkout(root: Path) -> Path: return plugin -def test_zcode_install_preserves_other_config_and_is_repeatable(tmp_path: Path, monkeypatch) -> None: +@pytest.mark.parametrize("endpoint", ["https://memory.example.test", "https://memory.example.test/mcp/"]) +def test_zcode_install_preserves_other_config_and_is_repeatable(tmp_path: Path, monkeypatch, endpoint: str) -> None: home = tmp_path / "home" monkeypatch.setattr(zcode, "zcode_config_file", lambda: home / ".zcode" / "cli" / "config.json") monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") @@ -81,22 +83,26 @@ def test_zcode_install_preserves_other_config_and_is_repeatable(tmp_path: Path, _checkout(checkout) config_path = zcode.zcode_config_file() config_path.parent.mkdir(parents=True) - original = {"model": {"main": "glm"}, "plugins": {"dirs": ["C:/other/plugin"], "enabled": True}} + original = {"model": {"main": "glm"}, "plugins": {"dirs": ["C:/other/plugin"], "enabled": False}} config_path.write_text(json.dumps(original), encoding="utf-8") for _ in range(2): - result = zcode.install_zcode_plugin( - source=str(checkout), ref="unused", server_url="https://memory.example.test", capture_prompts=False + result = install_plugin( + "zcode", + source=str(str(checkout)), + ref="unused", + server_url=endpoint, + capture_prompts=False, ) config = json.loads(config_path.read_text(encoding="utf-8")) assert config["model"] == original["model"] - assert config["plugins"]["dirs"] == ["C:/other/plugin", result.plugin_path] - plugin = Path(result.plugin_path) + assert config["plugins"]["dirs"] == ["C:/other/plugin", result["plugin_path"]] + plugin = Path(result["plugin_path"]) assert json.loads((plugin / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"]["powercontext"]["url"] == ( "https://memory.example.test/mcp" ) assert json.loads((plugin / "powercontext.json").read_text(encoding="utf-8"))["capture_prompts"] is False - assert not list(config_path.parent.glob(".powercontext-backup")) + assert config["plugins"]["enabled"] is False def test_zcode_install_rejects_unowned_plugin_without_changing_config(tmp_path: Path, monkeypatch) -> None: @@ -110,11 +116,71 @@ def test_zcode_install_rejects_unowned_plugin_without_changing_config(tmp_path: _checkout(checkout) with pytest.raises(SetupError, match="not owned"): - zcode.install_zcode_plugin(source=str(checkout), ref="unused") + install_plugin("zcode", source=str(str(checkout)), ref="unused") assert (plugin / "other.txt").read_text(encoding="utf-8") == "keep" assert not zcode.zcode_config_file().exists() +@pytest.mark.parametrize("installed", [False, True]) +@pytest.mark.parametrize( + "endpoint", + [ + "https://memory.example.test/prefix", + "https://memory.example.test/prefix/mcp", + ], +) +def test_zcode_setup_rejects_invalid_origin_without_changing_installation( + tmp_path: Path, monkeypatch, installed: bool, endpoint: str +) -> None: + home = tmp_path / "home" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(zcode, "zcode_config_file", lambda: home / ".zcode/cli/config.json") + monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(home / "clients.json")) + checkout = tmp_path / "checkout" + plugin = _checkout(checkout) + if installed: + install_plugin("zcode", source=str(checkout), server_url="https://memory.example.test") + before = {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} + (plugin / "updated.txt").write_text("new package", encoding="utf-8") + + result = CliRunner().invoke( + create_cli([setup_app]), + ["setup", "zcode", "--source", str(checkout), "--server-url", endpoint, "--json"], + ) + + assert result.exit_code == 1, result.output + assert "origin" in json.loads(result.output)["hosts"]["zcode"]["error"] + assert {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} == before + + +@pytest.mark.parametrize("authorization", ["raw-token", "Bearer invalid token"]) +@pytest.mark.parametrize("command", ["setup", "config"]) +def test_zcode_rejects_invalid_runtime_authorization_without_changing_installation( + tmp_path: Path, monkeypatch, authorization: str, command: str +) -> None: + home = tmp_path / "home" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(zcode, "zcode_config_file", lambda: home / ".zcode/cli/config.json") + monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") + monkeypatch.delenv("POWERCONTEXT_ZCODE_AUTHORIZATION", raising=False) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(home / "clients.json")) + checkout = tmp_path / "checkout" + _checkout(checkout) + if command == "config": + install_plugin("zcode", source=str(checkout), server_url="https://old.example") + before = {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} + monkeypatch.setenv("POWERCONTEXT_ZCODE_AUTHORIZATION", authorization) + arguments = ["setup", "zcode", "--source", str(checkout)] if command == "setup" else ["config", "agent", "zcode"] + + result = CliRunner().invoke(create_cli(), [*arguments, "--server-url", "https://new.example", "--json"]) + + assert result.exit_code == 1, result.output + assert "authorization" in json.loads(result.output)["hosts"]["zcode"]["error"].lower() + assert authorization not in result.output + assert {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} == before + + def test_zcode_upgrade_rejects_missing_packaged_reference_and_preserves_installation( tmp_path: Path, monkeypatch ) -> None: @@ -126,15 +192,15 @@ def test_zcode_upgrade_rejects_missing_packaged_reference_and_preserves_installa monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) checkout = tmp_path / "checkout" source = _checkout(checkout) - shutil.copytree(Path(__file__).parents[1] / HOST_GUIDANCE["zcode"].skill, source / SKILL_DIRECTORY) - result = zcode.install_zcode_plugin(source=str(checkout), ref="unused", server_url="https://memory.example.test") - installed = Path(result.plugin_path) + shutil.copytree(Path(__file__).parents[1] / HOST_GUIDANCE["agent-plugin"].skill, source / SKILL_DIRECTORY) + result = install_plugin("zcode", source=str(str(checkout)), ref="unused", server_url="https://memory.example.test") + installed = Path(result["plugin_path"]) (installed / SKILL_DIRECTORY / "personal.md").write_bytes(b"My instructions\r\n") before = {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} (source / SKILL_DIRECTORY / "references/work-handoff.md").unlink() - with pytest.raises(SetupError, match="restored"): - zcode.install_zcode_plugin(source=str(checkout), ref="unused", server_url="https://memory.example.test") + with pytest.raises(SetupError, match="missing guidance resource"): + install_plugin("zcode", source=str(str(checkout)), ref="unused", server_url="https://memory.example.test") assert {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} == before assert not list(installed.parent.glob(".powercontext-stage-*")) @@ -145,24 +211,27 @@ def test_zcode_diagnostics_distinguish_registration_hook_mcp_and_server(tmp_path monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") checkout = tmp_path / "checkout" _checkout(checkout) - zcode.install_zcode_plugin(source=str(checkout), ref="unused", server_url="http://127.0.0.1:1") - diagnostics = zcode.run_zcode_diagnostics() + install_plugin("zcode", source=str(str(checkout)), ref="unused", server_url="http://127.0.0.1:1") + diagnostics = zcode.ZCodeInstaller().verify() assert diagnostics["plugin"].ok assert diagnostics["hooks"].ok assert diagnostics["mcp"].ok assert not diagnostics["server"].ok (zcode.zcode_plugin_dir() / ".mcp.json").unlink() - diagnostics = zcode.run_zcode_diagnostics() + diagnostics = zcode.ZCodeInstaller().verify() assert diagnostics["hooks"].ok assert not diagnostics["mcp"].ok (zcode.zcode_plugin_dir() / ".mcp.json").write_text( '{"mcpServers":{"powercontext":{"url":123,"headers":[]}}}', encoding="utf-8" ) - assert not zcode.run_zcode_diagnostics()["mcp"].ok + assert not zcode.ZCodeInstaller().verify()["mcp"].ok -def test_zcode_doctor_uses_installed_url_with_stale_environment_override(tmp_path: Path, monkeypatch) -> None: +@pytest.mark.parametrize("readiness", ["ready", "degraded"]) +def test_zcode_doctor_uses_installed_url_with_stale_environment_override( + tmp_path: Path, monkeypatch, server_checks, readiness +) -> None: home = tmp_path / "home" monkeypatch.setattr(zcode, "zcode_config_file", lambda: home / ".zcode" / "cli" / "config.json") monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") @@ -173,7 +242,7 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.end_headers() - self.wfile.write(b"{}") + self.wfile.write(json.dumps({"status": readiness, "checks": {}}).encode()) def do_POST(self): self.rfile.read(int(self.headers.get("Content-Length", "0"))) @@ -189,13 +258,14 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib o thread.start() try: endpoint = f"http://127.0.0.1:{server.server_address[1]}" - zcode.install_zcode_plugin(source=str(checkout), ref="unused", server_url=endpoint) + install_plugin("zcode", source=str(str(checkout)), ref="unused", server_url=endpoint) monkeypatch.setenv("POWERCONTEXT_ZCODE_SERVER_URL", "http://127.0.0.1:1") monkeypatch.setenv("POWERCONTEXT_CLIENT_SERVER_URL", "http://127.0.0.1:2") result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "zcode", "--json"]) - assert result.exit_code == 0, result.output - report = json.loads(result.output) - assert report["ok"] is True + assert result.exit_code == (0 if readiness == "ready" else 1), result.output + report = json.loads(result.output)["hosts"]["zcode"] + assert report["ok"] is (readiness == "ready") + assert report["checks"]["server"]["status"] == ("ok" if readiness == "ready" else "degraded") assert "transport" not in report["checks"] assert report["checks"]["protected_api"]["status"] == "ok" assert report["checks"]["mcp_session"]["status"] == "skipped" @@ -204,7 +274,7 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib o setup = CliRunner().invoke( create_cli([setup_app]), ["setup", "zcode", "--source", str(checkout), "--server-url", endpoint] ) - assert setup.exit_code == 0, setup.output + assert setup.exit_code == (0 if readiness == "ready" else 1), setup.output assert "setup complete" in setup.output finally: server.shutdown() @@ -218,20 +288,20 @@ def test_zcode_install_restores_config_and_plugin_when_config_write_fails(tmp_pa monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") checkout = tmp_path / "checkout" _checkout(checkout) - zcode.install_zcode_plugin(source=str(checkout), ref="unused") + install_plugin("zcode", source=str(str(checkout)), ref="unused") plugin = zcode.zcode_plugin_dir() (plugin / "keep.txt").write_text("prior installation", encoding="utf-8") config_before = zcode.zcode_config_file().read_bytes() - original_write = zcode._write_json + original_write = installation.write_bytes - def fail_config(path: Path, value: dict[str, Any]) -> None: + def fail_config(path: Path, value: bytes) -> None: if path == zcode.zcode_config_file(): raise OSError("simulated write failure") # noqa: TRY003 original_write(path, value) - monkeypatch.setattr(zcode, "_write_json", fail_config) - with pytest.raises(SetupError, match="restored"): - zcode.install_zcode_plugin(source=str(checkout), ref="unused", server_url="https://new.example.test") + monkeypatch.setattr(installation, "write_bytes", fail_config) + with pytest.raises(SetupError, match="simulated write failure"): + install_plugin("zcode", source=str(str(checkout)), ref="unused", server_url="https://new.example.test") assert zcode.zcode_config_file().read_bytes() == config_before assert (plugin / "keep.txt").read_text(encoding="utf-8") == "prior installation" @@ -244,25 +314,29 @@ def test_zcode_remote_http_requires_consent_and_auth_uses_runtime_env(tmp_path: checkout = tmp_path / "checkout" _checkout(checkout) with pytest.raises(SetupError, match="explicit"): - zcode.install_zcode_plugin(source=str(checkout), ref="unused", server_url="http://memory.example.test") + install_plugin("zcode", source=str(str(checkout)), ref="unused", server_url="http://memory.example.test") assert not zcode.zcode_config_file().exists() monkeypatch.setenv("POWERCONTEXT_ZCODE_AUTHORIZATION", "Bearer test-value") - result = zcode.install_zcode_plugin( - source=str(checkout), ref="unused", server_url="http://memory.example.test", allow_insecure_http=True + result = install_plugin( + "zcode", + source=str(str(checkout)), + ref="unused", + server_url="http://memory.example.test", + allow_insecure_http=True, ) - entry = json.loads((Path(result.plugin_path) / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"][ + entry = json.loads((Path(result["plugin_path"]) / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"][ "powercontext" ] assert entry["headers"] == {"Authorization": "${POWERCONTEXT_ZCODE_AUTHORIZATION}"} - assert "test-value" not in (Path(result.plugin_path) / ".mcp.json").read_text(encoding="utf-8") + assert "test-value" not in (Path(result["plugin_path"]) / ".mcp.json").read_text(encoding="utf-8") - assert zcode.run_zcode_diagnostics()["mcp"].ok + assert zcode.ZCodeInstaller().verify()["mcp"].ok monkeypatch.delenv("POWERCONTEXT_ZCODE_AUTHORIZATION") - assert not zcode.run_zcode_diagnostics()["mcp"].ok + assert not zcode.ZCodeInstaller().verify()["mcp"].ok -def test_zcode_git_ref_route_and_later_setup_failure_restore_prior_state(tmp_path: Path, monkeypatch) -> None: +def test_zcode_configuration_failure_keeps_completed_installation(tmp_path: Path, monkeypatch) -> None: home = tmp_path / "home" monkeypatch.setattr(zcode, "zcode_config_file", lambda: home / ".zcode" / "cli" / "config.json") monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") @@ -274,16 +348,25 @@ def clone(source: str, ref: str, target: Path) -> None: requested.append((source, ref)) shutil.copytree(checkout, target) - monkeypatch.setattr(zcode, "clone_github_source", clone) - first = zcode.install_zcode_plugin(source="owner/powercontext", ref="v1.2.3") + monkeypatch.setattr(git_source, "clone_github_source", clone) + first = install_plugin("zcode", source="owner/powercontext", ref="v1.2.3") config_before = zcode.zcode_config_file().read_bytes() - (Path(first.plugin_path) / "keep.txt").write_text("previous", encoding="utf-8") - with pytest.raises(SetupError, match="later setup failure"), zcode.preserve_zcode_installation(): - zcode.install_zcode_plugin(source="owner/powercontext", ref="v1.2.3", server_url="https://new.test") - raise SetupError("later setup failure") # noqa: TRY003 + (checkout / "integrations/zcode/plugins/powercontext/updated.txt").write_text("new package", encoding="utf-8") + + def fail_persistence(path, value): + if path.name == "clients.json": + raise OSError("later setup failure") # noqa: TRY003 + original_write(path, value) + + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) + original_write = installation.write_bytes + monkeypatch.setattr(installation, "write_bytes", fail_persistence) + with pytest.raises(SetupError, match="later setup failure") as failure: + install_plugin("zcode", source="owner/powercontext", ref="v1.2.3", server_url="https://new.test") assert requested == [("owner/powercontext", "v1.2.3")] * 2 assert zcode.zcode_config_file().read_bytes() == config_before - assert (Path(first.plugin_path) / "keep.txt").read_text(encoding="utf-8") == "previous" + assert (Path(first["plugin_path"]) / "updated.txt").read_text(encoding="utf-8") == "new package" + assert "Plugin installation completed" in " ".join(failure.value.__notes__) def test_zcode_diagnostics_detect_missing_installed_scope_dependency(tmp_path: Path, monkeypatch) -> None: @@ -291,9 +374,9 @@ def test_zcode_diagnostics_detect_missing_installed_scope_dependency(tmp_path: P monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") checkout = tmp_path / "checkout" _checkout(checkout) - result = zcode.install_zcode_plugin(source=str(checkout), ref="unused") - (Path(result.plugin_path) / "shared/scope.mjs").unlink() - assert not zcode.run_zcode_diagnostics()["hooks"].ok + result = install_plugin("zcode", source=str(str(checkout)), ref="unused") + (Path(result["plugin_path"]) / "shared/scope.mjs").unlink() + assert not zcode.ZCodeInstaller().verify()["hooks"].ok def test_zcode_upgrade_keeps_saved_capture_and_boundary_options(tmp_path: Path, monkeypatch) -> None: @@ -302,14 +385,66 @@ def test_zcode_upgrade_keeps_saved_capture_and_boundary_options(tmp_path: Path, monkeypatch.delenv("POWERCONTEXT_ZCODE_BOUNDARY_FLUSH", raising=False) checkout = tmp_path / "checkout" _checkout(checkout) - first = zcode.install_zcode_plugin(source=str(checkout), ref="unused", capture_prompts=False, boundary_flush=True) - zcode.install_zcode_plugin(source=str(checkout), ref="unused") - saved = json.loads((Path(first.plugin_path) / "powercontext.json").read_text(encoding="utf-8")) + first = install_plugin("zcode", source=str(str(checkout)), ref="unused", capture_prompts=False, boundary_flush=True) + install_plugin("zcode", source=str(str(checkout)), ref="unused") + saved = json.loads((Path(first["plugin_path"]) / "powercontext.json").read_text(encoding="utf-8")) assert saved["capture_prompts"] is False assert saved["boundary_flush"] is True monkeypatch.setenv("POWERCONTEXT_ZCODE_BOUNDARY_FLUSH", "false") - zcode.install_zcode_plugin(source=str(checkout), ref="unused") + install_plugin("zcode", source=str(str(checkout)), ref="unused") assert ( - json.loads((Path(first.plugin_path) / "powercontext.json").read_text(encoding="utf-8"))["boundary_flush"] + json.loads((Path(first["plugin_path"]) / "powercontext.json").read_text(encoding="utf-8"))["boundary_flush"] is False ) + + +@pytest.mark.parametrize("command", ["setup", "config"]) +@pytest.mark.parametrize( + ("mcp_endpoint", "selected_endpoint"), + [ + ("https://old.example", "https://old.example"), + ("https://mcp-previous.example", "https://old.example"), + ("https://mcp-previous.example", "https://mcp-previous.example"), + ], +) +@pytest.mark.parametrize( + ("header", "authorization"), + [ + ("Authorization", "Bearer previous-server-token"), + ("authorization", "Bearer previous-server-token"), + ("Authorization", "${POWERCONTEXT_ZCODE_AUTHORIZATION}"), + ], +) +def test_zcode_rebinding_preserves_other_headers_and_drops_old_authorization( + tmp_path, monkeypatch, installed_agent_checks, command, header, authorization, mcp_endpoint, selected_endpoint +): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(zcode, "zcode_config_file", lambda: tmp_path / "zcode/config.json") + monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") + monkeypatch.delenv("POWERCONTEXT_ZCODE_AUTHORIZATION", raising=False) + source = str(Path(__file__).parents[1]) + installed = install_plugin("zcode", source=source, server_url="https://old.example") + mcp_path = Path(installed["plugin_path"]) / ".mcp.json" + mcp = json.loads(mcp_path.read_text()) + mcp["mcpServers"]["powercontext"]["url"] = mcp_endpoint + "/mcp" + mcp["mcpServers"]["powercontext"]["headers"] = {header: authorization, "X-Custom": "keep"} + mcp_path.write_text(json.dumps(mcp)) + arguments = ["setup", "zcode", "--source", source] if command == "setup" else ["config", "agent", "zcode"] + cli, runner = create_cli(), CliRunner() + result = runner.invoke(cli, [*arguments, "--server-url", selected_endpoint, "--json"]) + assert result.exit_code == 0, result.output + expected = {"X-Custom": "keep"} + if mcp_endpoint == selected_endpoint: + expected[header] = authorization + assert json.loads(mcp_path.read_text())["mcpServers"]["powercontext"]["headers"] == expected + result = runner.invoke(cli, [*arguments, "--server-url", "https://new.example", "--json"]) + assert result.exit_code == 0, result.output + assert json.loads(mcp_path.read_text())["mcpServers"]["powercontext"]["headers"] == {"X-Custom": "keep"} + monkeypatch.setenv("POWERCONTEXT_ZCODE_AUTHORIZATION", "Bearer replacement-token") + result = runner.invoke(cli, [*arguments, "--server-url", "https://third.example", "--json"]) + assert result.exit_code == 0, result.output + assert json.loads(mcp_path.read_text())["mcpServers"]["powercontext"]["headers"] == { + "Authorization": "${POWERCONTEXT_ZCODE_AUTHORIZATION}", + "X-Custom": "keep", + } + assert "replacement-token" not in mcp_path.read_text() diff --git a/tests/test_config_wizard_agents.py b/tests/test_config_wizard_agents.py index 535b949fc2..ba4395b6b6 100644 --- a/tests/test_config_wizard_agents.py +++ b/tests/test_config_wizard_agents.py @@ -18,11 +18,6 @@ import powercontext.cli.config_wizard_agents as wizard_agents from powercontext.cli.config_wizard_agents import AGENT_SPECS -from powercontext.cli.hosts import FIRST_CLASS_HOSTS - - -def test_wizard_catalog_covers_first_class_hosts() -> None: - assert tuple(spec.identifier for spec in AGENT_SPECS) == tuple(host.name for host in FIRST_CLASS_HOSTS) def test_openclaw_keeps_its_plugin_configuration_contract() -> None: diff --git a/tests/test_doctor_integrations.py b/tests/test_doctor_integrations.py deleted file mode 100644 index 6e26974fd3..0000000000 --- a/tests/test_doctor_integrations.py +++ /dev/null @@ -1,254 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import json -from unittest.mock import Mock - -from typer.testing import CliRunner - -import powercontext.cli.dsh as dsh_cli -import powercontext.cli.hermes as hermes_cli -import powercontext.cli.openclaw as openclaw_cli -import powercontext.cli.opencode as opencode_cli -import powercontext.cli.pi as pi_cli -import powercontext.cli.system as system_cli -import powercontext.cli.workbuddy as workbuddy_cli -from powercontext.cli.app import create_cli -from powercontext.cli.system import Diagnostic, DiagnosticStatus, doctor_app - -FIRST_CLASS_HOSTS = ("codex", "claude-code", "dsh", "openclaw", "opencode", "pi", "hermes", "workbuddy") -CLI_KEYS = { - "codex": "codex", - "claude-code": "claude_code", - "dsh": "dsh", - "openclaw": "openclaw", - "opencode": "opencode", - "pi": "pi", - "hermes": "hermes", - "workbuddy": "hooks", -} -INTEGRATION_KEYS = { - "codex": ("plugin",), - "claude-code": ("plugin",), - "dsh": ("plugin",), - "openclaw": ("plugin",), - "opencode": ("plugin", "skill"), - "pi": ("package",), - "hermes": ("plugin",), - "workbuddy": ("settings", "mcp", "skill"), -} -PATH_MISSING = { - "codex": "Codex CLI is not installed or is not on PATH", - "claude-code": "Claude Code CLI is not installed or is not on PATH", - "dsh": "DeepSeek Harness CLI is not installed or is not on PATH", - "openclaw": "OpenClaw CLI is not installed or is not on PATH", - "opencode": "OpenCode CLI is not installed or is not on PATH", - "pi": "Pi CLI is not installed or is not on PATH", - "hermes": "Hermes CLI is not installed or is not on PATH", - "workbuddy": "WorkBuddy hooks are not installed", -} - - -def _cli(): - return create_cli([doctor_app]) - - -def _invoke(arguments: list[str]): - return CliRunner().invoke(_cli(), arguments) - - -def _missing(host: str) -> dict[str, Diagnostic]: - diagnostics = { - CLI_KEYS[host]: Diagnostic(status=DiagnosticStatus.FAILED, detail=PATH_MISSING[host]), - } - diagnostics.update({ - key: Diagnostic( - status=DiagnosticStatus.SKIPPED, - detail="not checked because the host CLI is unavailable", - ) - for key in INTEGRATION_KEYS[host] - }) - return diagnostics - - -def _ok_codex() -> dict[str, Diagnostic]: - return { - "codex": Diagnostic(status=DiagnosticStatus.OK, detail="/usr/bin/codex"), - "plugin": Diagnostic(status=DiagnosticStatus.OK, detail="powercontext@powercontext enabled=True"), - } - - -def _failed_plugin_codex() -> dict[str, Diagnostic]: - return { - "codex": Diagnostic(status=DiagnosticStatus.OK, detail="/usr/bin/codex"), - "plugin": Diagnostic(status=DiagnosticStatus.FAILED, detail="PowerContext plugin is not installed"), - } - - -def _list_failed_codex() -> dict[str, Diagnostic]: - return { - "codex": Diagnostic(status=DiagnosticStatus.FAILED, detail="codex plugin list failed: timeout"), - "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="plugin list is unavailable"), - } - - -def _failed_skill_opencode() -> dict[str, Diagnostic]: - return { - "opencode": Diagnostic(status=DiagnosticStatus.OK, detail="/usr/bin/opencode (1.18.21)"), - "plugin": Diagnostic(status=DiagnosticStatus.OK, detail="powercontext is configured and active"), - "skill": Diagnostic(status=DiagnosticStatus.FAILED, detail="PowerContext OpenCode Skill is not installed"), - } - - -def _patch_diagnostics(monkeypatch, **replacements: Mock) -> dict[str, Mock]: - probes = {host: Mock(name=f"run_{host}_diagnostics", return_value=_missing(host)) for host in FIRST_CLASS_HOSTS} - probes.update(replacements) - monkeypatch.setattr(system_cli, "run_codex_diagnostics", probes["codex"]) - monkeypatch.setattr(system_cli, "run_claude_code_diagnostics", probes["claude-code"]) - monkeypatch.setattr(dsh_cli, "run_dsh_diagnostics", probes["dsh"]) - monkeypatch.setattr(openclaw_cli, "run_openclaw_diagnostics", probes["openclaw"]) - monkeypatch.setattr(opencode_cli, "run_opencode_diagnostics", probes["opencode"]) - monkeypatch.setattr(pi_cli, "run_pi_diagnostics", probes["pi"]) - monkeypatch.setattr(hermes_cli, "run_hermes_diagnostics", probes["hermes"]) - monkeypatch.setattr(workbuddy_cli, "run_workbuddy_diagnostics", probes["workbuddy"]) - return probes - - -def test_doctor_integrations_json_includes_every_first_class_host(monkeypatch) -> None: - _patch_diagnostics(monkeypatch) - - result = _invoke(["doctor", "integrations", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert list(payload["hosts"]) == list(FIRST_CLASS_HOSTS) - for host in FIRST_CLASS_HOSTS: - assert payload["hosts"][host]["presence"] == "missing" - assert CLI_KEYS[host] in payload["hosts"][host] - for integration_key in INTEGRATION_KEYS[host]: - assert integration_key in payload["hosts"][host] - - -def test_doctor_integrations_treats_missing_clis_as_success(monkeypatch) -> None: - _patch_diagnostics(monkeypatch, codex=Mock(return_value=_ok_codex())) - - result = _invoke(["doctor", "integrations", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["ok"] is True - assert payload["status"] == "ok" - assert payload["hosts"]["codex"]["presence"] == "present" - assert payload["hosts"]["codex"]["plugin"]["ok"] is True - assert payload["hosts"]["pi"]["presence"] == "missing" - assert payload["hosts"]["pi"]["package"]["status"] == "skipped" - - -def test_doctor_integrations_prints_a_human_matrix(monkeypatch) -> None: - _patch_diagnostics(monkeypatch, codex=Mock(return_value=_ok_codex())) - - result = _invoke(["doctor", "integrations"]) - - assert result.exit_code == 0 - assert "codex: present - cli=ok plugin=ok" in result.output - assert "claude-code: missing - cli=failed plugin=skipped" in result.output - assert "opencode: missing - cli=failed plugin=skipped skill=skipped" in result.output - assert "pi: missing - cli=failed package=skipped" in result.output - - -def test_doctor_integrations_fails_when_a_present_plugin_is_broken(monkeypatch) -> None: - _patch_diagnostics(monkeypatch, codex=Mock(return_value=_failed_plugin_codex())) - - result = _invoke(["doctor", "integrations", "--json"]) - - assert result.exit_code == 1 - payload = json.loads(result.output) - assert payload["ok"] is False - assert payload["status"] == "failed" - assert payload["hosts"]["codex"]["presence"] == "present" - assert payload["hosts"]["codex"]["plugin"]["status"] == "failed" - assert payload["hosts"]["dsh"]["presence"] == "missing" - - -def test_doctor_integrations_fails_when_a_present_cli_cannot_list_plugins(monkeypatch) -> None: - _patch_diagnostics(monkeypatch, codex=Mock(return_value=_list_failed_codex())) - - result = _invoke(["doctor", "integrations", "--json"]) - - assert result.exit_code == 1 - payload = json.loads(result.output) - assert payload["hosts"]["codex"]["presence"] == "present" - assert payload["hosts"]["codex"]["codex"]["status"] == "failed" - assert payload["hosts"]["codex"]["plugin"]["status"] == "skipped" - assert "is not installed or is not on PATH" not in payload["hosts"]["codex"]["codex"]["detail"] - - -def test_doctor_integrations_fails_when_a_present_opencode_skill_is_broken(monkeypatch) -> None: - _patch_diagnostics(monkeypatch, opencode=Mock(return_value=_failed_skill_opencode())) - - result = _invoke(["doctor", "integrations", "--json"]) - - assert result.exit_code == 1 - payload = json.loads(result.output) - assert payload["hosts"]["opencode"]["presence"] == "present" - assert payload["hosts"]["opencode"]["plugin"]["status"] == "ok" - assert payload["hosts"]["opencode"]["skill"]["status"] == "failed" - - -def test_doctor_integrations_succeeds_when_every_host_is_missing(monkeypatch) -> None: - _patch_diagnostics(monkeypatch) - - result = _invoke(["doctor", "integrations"]) - - assert result.exit_code == 0 - assert "codex: missing - cli=failed plugin=skipped" in result.output - assert "hermes: missing - cli=failed plugin=skipped" in result.output - - -def test_default_doctor_does_not_scan_first_class_hosts(monkeypatch) -> None: - probes = _patch_diagnostics( - monkeypatch, - **{ - host: Mock(side_effect=AssertionError(f"default doctor must not inspect {host}")) - for host in FIRST_CLASS_HOSTS - }, - ) - monkeypatch.setattr( - system_cli, - "run_diagnostics", - lambda **_kwargs: { - "package": Diagnostic(status=DiagnosticStatus.OK, detail="powercontext 0.0.1"), - "server_liveness": Diagnostic(status=DiagnosticStatus.OK, detail="http://127.0.0.1:8000 status=ok"), - "server_readiness": Diagnostic(status=DiagnosticStatus.OK, detail="http://127.0.0.1:8000 status=ready"), - }, - ) - - result = _invoke(["doctor", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert list(payload["checks"]) == ["package", "server_liveness", "server_readiness"] - for probe in probes.values(): - probe.assert_not_called() - - -def test_doctor_codex_still_fails_when_the_cli_is_missing(monkeypatch) -> None: - monkeypatch.setattr(system_cli, "which", lambda _name: None) - - result = _invoke(["doctor", "codex"]) - - assert result.exit_code == 1 - assert "Codex CLI is not installed or is not on PATH" in result.output diff --git a/tests/test_dsh_cli.py b/tests/test_dsh_cli.py index f315c8bb36..b5d8adaf9e 100644 --- a/tests/test_dsh_cli.py +++ b/tests/test_dsh_cli.py @@ -23,6 +23,7 @@ from typer.testing import CliRunner from powercontext.cli.app import create_cli +from powercontext.cli.git_source import resolve_plugin_source from powercontext.cli.system import SetupError, doctor_app, setup_app @@ -47,7 +48,7 @@ def test_dsh_executable_prefers_the_windows_cmd_shim(tmp_path: Path, monkeypatch assert dsh_cli.dsh_executable() == str(cmd) -def test_setup_dsh_rejects_plugin_without_bundle(tmp_path: Path, monkeypatch) -> None: +def test_setup_dsh_rejects_plugin_without_bundle(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: import powercontext.cli.dsh as dsh_cli checkout = tmp_path / "powercontext" @@ -64,29 +65,21 @@ def test_setup_dsh_rejects_plugin_without_bundle(tmp_path: Path, monkeypatch) -> run_dsh.assert_not_called() -def test_setup_dsh_rejects_a_ref_that_escapes_the_checkout_root(tmp_path: Path, monkeypatch) -> None: - import powercontext.cli.dsh as dsh_cli - - monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) - - with pytest.raises(SetupError, match="invalid DeepSeek Harness ref"): - dsh_cli.resolve_dsh_plugin_dir(source="oceanbase/powercontext", ref="../../etc") - - -def test_setup_dsh_does_not_echo_source_credentials(tmp_path: Path, monkeypatch) -> None: - import powercontext.cli.dsh as dsh_cli +def test_setup_dsh_does_not_echo_source_credentials(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: marker = "redacted-value" source = f"https://{marker}@github.com/oceanbase/powercontext" monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) with pytest.raises(SetupError) as raised: - dsh_cli.resolve_dsh_plugin_dir(source=source, ref="master") + resolve_plugin_source(source=source, ref="master", host="dsh") assert marker not in str(raised.value) -def test_setup_dsh_clones_a_github_url_and_replaces_a_broken_checkout(tmp_path: Path, monkeypatch) -> None: +def test_setup_dsh_clones_a_github_url_and_replaces_a_broken_checkout( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: import powercontext.cli.dsh as dsh_cli home = tmp_path / "data" @@ -104,19 +97,16 @@ def fake_run(command, **_kwargs): monkeypatch.setattr(dsh_cli.subprocess, "run", fake_run) - plugin = dsh_cli.resolve_dsh_plugin_dir( - source="https://github.com/oceanbase/powercontext", - ref="master", - ) + plugin = resolve_plugin_source(source="https://github.com/oceanbase/powercontext", ref="master", host="dsh") - assert plugin == stale / "integrations" / "dsh" / "plugins" / "powercontext" + assert (plugin / "package.json").is_file() assert captured[0][:4] == ["git", "clone", "--depth", "1"] assert captured[0][4:6] == ["--branch", "master"] assert captured[0][6] == "https://github.com/oceanbase/powercontext.git" - assert not (stale / "README").exists() + assert (stale / "README").read_text() == "incomplete" -def test_doctor_dsh_requires_the_plugin_id_field(monkeypatch) -> None: +def test_doctor_dsh_requires_the_plugin_id_field(monkeypatch, server_checks) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: "/usr/bin/dsh") @@ -128,7 +118,7 @@ def test_doctor_dsh_requires_the_plugin_id_field(monkeypatch) -> None: assert "plugin: failed - PowerContext DSH plugin is not registered" in result.output -def test_doctor_dsh_reports_an_installed_plugin(monkeypatch) -> None: +def test_doctor_dsh_reports_an_installed_plugin(monkeypatch, server_checks) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: "/usr/bin/dsh") @@ -141,7 +131,7 @@ def test_doctor_dsh_reports_an_installed_plugin(monkeypatch) -> None: result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh", "--json"]) assert result.exit_code == 0 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["dsh"] assert payload["ok"] is True plugin = payload["checks"]["plugin"] assert plugin["ok"] is True @@ -150,8 +140,12 @@ def test_doctor_dsh_reports_an_installed_plugin(monkeypatch) -> None: assert plugin["checks"]["server_readiness"] == "not_checked" assert "/pc doctor" in plugin["detail"] + human = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh"]) + assert "running_host_configuration: not_observed" in human.output + assert "/pc doctor" in human.output + -def test_doctor_dsh_does_not_expose_host_config_or_claim_to_check_its_server(monkeypatch) -> None: +def test_doctor_dsh_does_not_expose_host_config_or_claim_to_check_its_server(monkeypatch, server_checks) -> None: import powercontext.cli.dsh as dsh_cli marker = "private-dsh-config-marker" @@ -164,19 +158,19 @@ def test_doctor_dsh_does_not_expose_host_config_or_claim_to_check_its_server(mon result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh", "--json"]) assert result.exit_code == 0 assert marker not in result.output - checks = json.loads(result.output)["checks"]["plugin"]["checks"] + checks = json.loads(result.output)["hosts"]["dsh"]["checks"]["plugin"]["checks"] assert checks["server_liveness"] == "not_checked" assert checks["route_compatibility"] == "not_checked" -def test_doctor_dsh_redacts_config_dump_failures(monkeypatch) -> None: +def test_doctor_dsh_redacts_config_dump_failures(monkeypatch, server_checks) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: "/usr/bin/dsh") monkeypatch.setattr( dsh_cli, "_run_dsh", - Mock(side_effect=SetupError.command_failed(["dsh", "--dump-config"], "private-dsh-config-marker")), + Mock(side_effect=SetupError("`dsh --dump-config` failed: private-dsh-config-marker")), ) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh"]) assert result.exit_code == 1 @@ -185,7 +179,7 @@ def test_doctor_dsh_redacts_config_dump_failures(monkeypatch) -> None: @pytest.mark.parametrize("timeout", [False, True]) -def test_doctor_dsh_identifies_config_dump_exit_and_timeout(monkeypatch, timeout: bool) -> None: +def test_doctor_dsh_identifies_config_dump_exit_and_timeout(monkeypatch, timeout: bool, server_checks) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: "/usr/bin/dsh") @@ -197,7 +191,7 @@ def test_doctor_dsh_identifies_config_dump_exit_and_timeout(monkeypatch, timeout result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh", "--json"]) assert result.exit_code == 1 assert "private-config" not in result.output - check = json.loads(result.output)["checks"]["dsh"] + check = json.loads(result.output)["hosts"]["dsh"]["checks"]["dsh"] if timeout: assert check["checks"]["dump_config"] == "timeout" assert "120-second deadline" in check["detail"] diff --git a/tests/test_dsh_profiles.py b/tests/test_dsh_profiles.py index 3c5e34d0fe..b106857f3b 100644 --- a/tests/test_dsh_profiles.py +++ b/tests/test_dsh_profiles.py @@ -57,36 +57,41 @@ def setup_boundary(tmp_path, monkeypatch): return plugin, run, inspect -@pytest.mark.parametrize("mode", ["interactive", "explicit", "json", "noninteractive"]) -def test_setup_selects_profile_and_reports_destination(tmp_path, monkeypatch, desktop_command, setup_boundary, mode): - import powercontext.cli.system as system - - plugin, run, inspect = setup_boundary - profile = "web" if mode in {"json", "noninteractive"} else "desktop" +@pytest.mark.parametrize("profile", ["web", "desktop"]) +def test_setup_and_default_doctor_reuse_the_selected_native_target( + tmp_path, monkeypatch, desktop_command, setup_boundary, profile, server_checks +): + plugin, _, _ = setup_boundary desktop_dir = Path(os.environ["DSH_HOME"]) / "profiles/desktop" desktop_dir.mkdir(parents=True) (desktop_dir / "package.json").write_text("{}") - monkeypatch.setattr(system, "stdin_is_tty", lambda: mode in {"interactive", "json"}) - args = ["setup", "dsh", "--source", str(plugin), "--dsh-command", str(desktop_command)] - if mode == "explicit": - args += ["--profile", "desktop"] - if mode == "json": - args += ["--json"] - result = CliRunner().invoke(create_cli([setup_app]), args, input="desktop\n") - assert result.exit_code == 0, result.output - assert run.call_args_list[0].args[2] == profile - assert run.call_args_list[0].kwargs["executable"] == str(desktop_command.resolve()) - assert {call.kwargs["profile"] for call in inspect.call_args_list} == {profile} - assert str(Path(os.environ["DSH_HOME"]) / "profiles" / profile) in ( - json.loads(result.stdout)["profile_dir"] if mode == "json" else result.output + result = CliRunner().invoke( + create_cli(), + [ + "setup", + "dsh", + "--source", + str(plugin), + "--dsh-command", + str(desktop_command), + "--profile", + profile, + "--json", + ], ) - if mode == "json": - assert json.loads(result.stdout)["profile"] == "web" - assert "DSH profile" not in result.output - if profile == "desktop": - assert "reopen DeepSeek Harness Desktop" in result.output - assert "fully quit" in result.output - assert all("--dump-config" not in call.args for call in run.call_args_list) + assert result.exit_code == 0, result.output + installed = json.loads(result.stdout)["hosts"]["dsh"]["result"] + assert installed["profile"] == profile + assert installed["profile_dir"] == str(Path(os.environ["DSH_HOME"]) / "profiles" / profile) + original = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + doctor = CliRunner().invoke(create_cli(), ["doctor", "--json"]) + assert doctor.exit_code == 0, doctor.output + plugin_check = json.loads(doctor.output)["hosts"]["dsh"]["checks"]["plugin"] + assert ("Desktop" if profile == "desktop" else "Web") in plugin_check["detail"] + assert plugin_check["checks"]["running_host_configuration"] == "not_observed" + assert {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} == original + configured = CliRunner().invoke(create_cli(), ["config", "agent", "dsh", "--json"]) + assert configured.exit_code == 0, configured.output def test_uninitialized_desktop_fails_before_installation(tmp_path, desktop_command, setup_boundary): @@ -129,13 +134,10 @@ def test_doctor_keeps_plugin_check_when_target_is_unavailable( ["doctor", "dsh", "--profile", profile, "--dsh-command", str(command), "--json"], ) assert result.exit_code == 1 - report = json.loads(result.stdout) + report = json.loads(result.stdout)["hosts"]["dsh"] assert report["status"] == "failed" - assert set(report["checks"]) == {"dsh", "plugin"} - assert report["checks"]["dsh"]["status"] == "failed" - assert error in report["checks"]["dsh"]["detail"] - assert report["checks"]["plugin"]["status"] == "skipped" - assert "not checked" in report["checks"]["plugin"]["detail"] + assert report["checks"]["verification"]["status"] == "failed" + assert error in report["checks"]["verification"]["detail"] assert not (Path(os.environ["DSH_HOME"]) / "profiles").exists() @@ -169,17 +171,6 @@ def test_invalid_profile_never_installs(setup_boundary): run.assert_not_called() -def test_cancelled_selection_has_no_installation_effects(monkeypatch, setup_boundary): - import powercontext.cli.system as system - - _, run, inspect = setup_boundary - monkeypatch.setattr(system, "stdin_is_tty", lambda: True) - result = CliRunner().invoke(create_cli([setup_app]), ["setup", "dsh"], input="\x03") - assert result.exit_code == 1 - run.assert_not_called() - inspect.assert_not_called() - - @pytest.mark.skipif(os.name != "nt", reason="Windows installed command layout") def test_desktop_searches_path_past_npm_shim(tmp_path, monkeypatch, desktop_command): npm = tmp_path / "npm" diff --git a/tests/test_dsh_transport.py b/tests/test_dsh_transport.py index 22e8605aaa..d8e3e2faf4 100644 --- a/tests/test_dsh_transport.py +++ b/tests/test_dsh_transport.py @@ -144,7 +144,9 @@ def plugin_source(tmp_path): return plugin -def test_existing_customizations_allow_public_setup_and_preserve_files(dsh_profile, tmp_path, monkeypatch): +def test_existing_customizations_allow_public_setup_and_preserve_files( + dsh_profile, tmp_path, monkeypatch, installed_agent_checks +): import powercontext.cli.dsh as dsh patch = write_patch( @@ -174,7 +176,7 @@ def test_existing_customizations_allow_public_setup_and_preserve_files(dsh_profi @pytest.mark.parametrize("relative", ["profiles/web/cordis.patch.yml", "cordis.patch.yml"]) -def test_matching_native_transport_passes_and_conflicts_do_not_install(dsh_profile, tmp_path, monkeypatch, relative): +def test_conflicting_native_transport_is_rejected(dsh_profile, tmp_path, monkeypatch, relative): import powercontext.cli.dsh as dsh patch = tmp_path / "dsh" / relative @@ -187,18 +189,17 @@ def test_matching_native_transport_passes_and_conflicts_do_not_install(dsh_profi ) assert result.exit_code == 1 assert "baseUrl conflicts" in result.output - installer.assert_not_called() assert not (tmp_path / "clients.json").exists() -def test_setup_uses_web_profile_and_home_layer_wins(dsh_profile, monkeypatch): +def test_setup_uses_web_profile_and_home_layer_wins(dsh_profile, monkeypatch, installed_agent_checks): write_patch(dsh_profile, "- id: powercontext-dsh\n config:\n baseUrl: https://profile.example\n") write_patch(dsh_profile.parent.parent, "- id: powercontext-dsh\n config:\n baseUrl: https://home.example\n") monkeypatch.setenv("DSH_PROFILE", "other") assert prepare_setup_transport("dsh").server_url == "https://home.example" -def test_doctor_reads_the_selected_runtime_profile(dsh_profile, monkeypatch): +def test_doctor_reads_the_selected_runtime_profile(dsh_profile, monkeypatch, server_checks): profile = dsh_profile.parent / "custom" profile.mkdir() (profile / "package.json").write_bytes((dsh_profile / "package.json").read_bytes()) @@ -236,7 +237,7 @@ def test_unverifiable_relevant_configuration_is_redacted(dsh_profile, patch): ) @pytest.mark.parametrize("outer_group", [None, {"group": True}, {"name": "cordis:group"}]) def test_setup_rejects_duplicate_powercontext_inside_native_groups( - request, profile_fixture, group, outer_group, tmp_path, monkeypatch + request, profile_fixture, group, outer_group, tmp_path, monkeypatch, installed_agent_checks ): import powercontext.cli.dsh as dsh from powercontext.cli.authorization import credential_path, write_stored_authorization @@ -272,7 +273,6 @@ def test_setup_rejects_duplicate_powercontext_inside_native_groups( ) assert result.exit_code == 1 assert "Multiple PowerContext entries" in result.output - installer.assert_not_called() assert {path: path.read_bytes() for path in original} == original @@ -306,7 +306,6 @@ def test_a_single_powercontext_inside_a_native_group_is_inspected(request, profi "baseUrl": "https://selected.example", "allowInsecureHttp": False, } - assert read_dsh_settings(candidate=source) == settings shutil.copytree(source, profile / "node_modules/powercontext-dsh") (profile / "package.json").write_text( json.dumps({ @@ -350,14 +349,13 @@ def test_native_groups_reject_disabled_and_conditional_powercontext( ) assert result.exit_code == 1 assert "renamed, disabled, or conditionally enabled" in result.output - installer.assert_not_called() assert {path: path.read_bytes() for path in original} == original @pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) @pytest.mark.parametrize("nested_group", [None, "@deepseek-ai/cordis-plugin-group", "cordis:group"]) def test_setup_rejects_powercontext_in_native_include_trees( - request, profile_fixture, nested_group, tmp_path, monkeypatch + request, profile_fixture, nested_group, tmp_path, monkeypatch, installed_agent_checks ): from powercontext.cli import dsh from powercontext.cli.authorization import credential_path, write_stored_authorization @@ -427,13 +425,12 @@ def test_setup_rejects_powercontext_in_native_include_trees( assert result.exit_code == 1 assert "Multiple PowerContext entries" in result.output assert "secret-token" not in result.output - installer.assert_not_called() assert {path: path.read_bytes() for path in original} == original @pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) def test_setup_preserves_static_native_ui_includes_and_unevaluated_model_expressions( - request, profile_fixture, tmp_path, monkeypatch + request, profile_fixture, tmp_path, monkeypatch, installed_agent_checks ): from powercontext.cli import dsh @@ -543,17 +540,10 @@ def test_installed_native_include_patches_define_powercontext_transport(request, }) ) settings = {"baseUrl": "http://selected.example", "allowInsecureHttp": True} - assert read_dsh_settings(candidate=source) == settings assert read_dsh_settings(require_installed=True) == settings assert prepare_setup_transport("dsh", server_url="http://selected.example", json_output=True).allow_insecure_http with pytest.raises(ValueError, match="HTTP consent"): validate_dsh_setup_transport(settings, "http://selected.example", False) - bundle_patch = source / "cordis.patch.yml" - patches = json.loads(bundle_patch.read_text()) - patches[0]["insert"].append({**patches[0]["insert"][0], "id": "second-include"}) - bundle_patch.write_text(json.dumps(patches)) - with pytest.raises(ValueError, match="Multiple PowerContext entries"): - read_dsh_settings(candidate=source) @pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) @@ -659,7 +649,6 @@ def test_unverifiable_native_include_trees_fail_without_writes_or_expression_eva ) assert result.exit_code == 1 assert "secret-value" not in result.output - installer.assert_not_called() assert not (tmp_path / "clients.json").exists() assert not (profile.parent.parent / "powercontext/credentials.json").exists() assert patch.exists() @@ -719,33 +708,12 @@ def install(*_args, **_kwargs): ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], ) assert result.exit_code == 1 - assert "resulting configuration" in result.output assert "Multiple PowerContext entries" in result.output assert "secret-token" not in result.output assert {path: path.read_bytes() for path in original} == original -def test_candidate_bundle_is_checked_before_install(dsh_profile, tmp_path, monkeypatch): - import powercontext.cli.dsh as dsh - - source = plugin_source(tmp_path) - (source / "cordis.patch.yml").write_text( - "- insert:\n - id: powercontext-dsh\n name: powercontext-dsh\n config:\n baseUrl: https://candidate.example\n" - ) - installer = Mock() - monkeypatch.setattr(dsh, "_run_dsh", installer) - monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) - result = CliRunner().invoke( - create_cli([setup_app]), - ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], - ) - assert result.exit_code == 1 - assert "baseUrl conflicts" in result.output - installer.assert_not_called() - assert not (tmp_path / "clients.json").exists() - - -def test_candidate_replaces_installed_bundle_without_duplicate_entries(dsh_profile, tmp_path): +def test_installed_bundle_uses_the_native_transport_override(dsh_profile, tmp_path): source = plugin_source(tmp_path) installed = dsh_profile / "node_modules/powercontext-dsh" installed.mkdir(parents=True) @@ -756,7 +724,7 @@ def test_candidate_replaces_installed_bundle_without_duplicate_entries(dsh_profi dsh_profile, "- id: powercontext-dsh\n config:\n baseUrl: http://memory.example\n allowInsecureHttp: true\n", ) - assert read_dsh_settings(candidate=source)["baseUrl"] == "http://memory.example" + assert read_dsh_settings()["baseUrl"] == "http://memory.example" assert resolve_host_transport("dsh") == ("http://memory.example", True) @@ -829,7 +797,7 @@ def test_explicit_refusal_cannot_be_overridden_by_native_permission(monkeypatch) ], ) def test_setup_checks_actual_installed_transport_before_saving_connection( - request, profile_fixture, tmp_path, monkeypatch, patch, reason + request, profile_fixture, tmp_path, monkeypatch, patch, reason, installed_agent_checks ): import powercontext.cli.dsh as dsh from powercontext.cli.authorization import credential_path, write_stored_authorization @@ -858,7 +826,6 @@ def install(*_args, **_kwargs): ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], ) assert result.exit_code == 1 - assert "resulting configuration" in result.output assert reason in result.output assert {path: path.read_bytes() for path in original} == original @@ -905,7 +872,7 @@ def test_current_transport_does_not_activate_an_inactive_dependency(dsh_profile, ], ) def test_setup_transport_refusal_preserves_saved_state_without_a_native_runtime( - tmp_path, monkeypatch, settings, arguments, reason + tmp_path, monkeypatch, settings, arguments, reason, installed_agent_checks ): from powercontext.cli import dsh, dsh_transport from powercontext.cli.authorization import credential_path, write_stored_authorization @@ -918,7 +885,7 @@ def test_setup_transport_refusal_preserves_saved_state_without_a_native_runtime( monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(clients)) monkeypatch.setattr(dsh_transport, "read_dsh_settings", lambda **_options: settings) installer = Mock() - monkeypatch.setattr(dsh, "install_dsh_plugin", installer) + monkeypatch.setattr(dsh.DshInstaller, "install", installer) original = clients.read_bytes() credentials = credential_path("dsh") write_stored_authorization(credentials, server_url="https://saved.example", value="saved-token") @@ -930,7 +897,6 @@ def test_setup_transport_refusal_preserves_saved_state_without_a_native_runtime( assert result.exit_code == 1 assert reason in result.output - installer.assert_not_called() assert clients.read_bytes() == original assert credentials.read_bytes() == original_credentials @@ -979,7 +945,7 @@ def compatibility_profile(profile, tmp_path, *, incompatible="custom-bundle"): def test_incompatible_third_party_bundle_is_excluded_from_first_and_repeated_setup( - dsh_native_api_profile, tmp_path, monkeypatch + dsh_native_api_profile, tmp_path, monkeypatch, installed_agent_checks ): from powercontext.cli import dsh @@ -993,7 +959,7 @@ def test_incompatible_third_party_bundle_is_excluded_from_first_and_repeated_set ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], ) assert result.exit_code == 0, result.output - assert json.loads(result.stdout)["plugin"] == "powercontext-dsh" + assert json.loads(result.stdout)["hosts"]["dsh"]["setup"] == "complete" assert "incompatible third-party bundle" in result.stderr assert json.loads((tmp_path / "clients.json").read_text())["hosts"]["dsh"]["server_url"] == ( "https://selected.example" @@ -1001,7 +967,7 @@ def test_incompatible_third_party_bundle_is_excluded_from_first_and_repeated_set assert {path: path.read_bytes() for path in profile.rglob("*") if path.is_file()} == original -def test_incompatible_powercontext_still_fails_before_installation(dsh_native_api_profile, tmp_path, monkeypatch): +def test_incompatible_powercontext_bundle_is_rejected(dsh_native_api_profile, tmp_path, monkeypatch): from powercontext.cli import dsh source = compatibility_profile(dsh_native_api_profile, tmp_path, incompatible="powercontext-dsh") @@ -1013,7 +979,6 @@ def test_incompatible_powercontext_still_fails_before_installation(dsh_native_ap ) assert result.exit_code == 1 assert "PowerContext is incompatible" in result.output - installer.assert_not_called() assert not (tmp_path / "clients.json").exists() @@ -1030,7 +995,6 @@ def test_version_exemption_keeps_third_party_transport_overrides(dsh_native_api_ ) assert result.exit_code == 1 assert "baseUrl conflicts" in result.output - installer.assert_not_called() assert not (tmp_path / "clients.json").exists() @@ -1065,7 +1029,7 @@ def test_selected_profile_controls_setup_transport_and_preserves_other_profile(d def test_desktop_registration_does_not_boot_and_requires_an_enabled_bundle(dsh_profile, tmp_path): - from powercontext.cli.dsh import dsh_executable, run_dsh_diagnostics + from powercontext.cli.dsh import DshInstaller, dsh_executable from powercontext.cli.dsh_runtime import DshProfile, DshTarget desktop = dsh_profile.parent / "desktop" @@ -1073,7 +1037,7 @@ def test_desktop_registration_does_not_boot_and_requires_an_enabled_bundle(dsh_p manifest = desktop / "package.json" manifest.write_text('{"dsh":{"profile":{"bundles":[]}}}') target = DshTarget(DshProfile.DESKTOP, dsh_executable()) - assert not run_dsh_diagnostics(target=target)["plugin"].ok + assert not DshInstaller(dsh_target=target).verify()["plugin"].ok source = plugin_source(tmp_path) installed = desktop / "node_modules/powercontext-dsh" @@ -1081,12 +1045,12 @@ def test_desktop_registration_does_not_boot_and_requires_an_enabled_bundle(dsh_p for name in ("package.json", "cordis.patch.yml"): (installed / name).write_bytes((source / name).read_bytes()) manifest.write_text('{"dsh":{"profile":{"bundles":["powercontext-dsh"]}}}') - assert run_dsh_diagnostics(target=target)["plugin"].ok + assert DshInstaller(dsh_target=target).verify()["plugin"].ok write_patch(desktop, "- id: powercontext-dsh\n disabled: true\n") - assert not run_dsh_diagnostics(target=target)["plugin"].ok + assert not DshInstaller(dsh_target=target).verify()["plugin"].ok -def test_explicit_web_doctor_does_not_read_desktop_environment_profile(dsh_profile, monkeypatch): +def test_explicit_web_doctor_does_not_read_desktop_environment_profile(dsh_profile, monkeypatch, server_checks): import powercontext.cli.dsh as dsh from powercontext.cli.system import doctor_app @@ -1102,4 +1066,4 @@ def test_explicit_web_doctor_does_not_read_desktop_environment_profile(dsh_profi monkeypatch.setattr(dsh, "_run_dsh", lambda *_args, **_kwargs: "id: powercontext-dsh\n") result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh", "--profile", "web", "--json"]) assert result.exit_code == 0, result.output - assert json.loads(result.stdout)["ok"] is True + assert json.loads(result.stdout)["hosts"]["dsh"]["ok"] is True diff --git a/tests/test_guidance_generation.py b/tests/test_guidance_generation.py index 80d470cd10..67ecf668d5 100644 --- a/tests/test_guidance_generation.py +++ b/tests/test_guidance_generation.py @@ -14,7 +14,6 @@ from __future__ import annotations -import hashlib import json import shutil import subprocess @@ -32,6 +31,8 @@ merge_guidance, refresh_skill, ) +from powercontext.cli.installation import setup as install_plugin +from tests.install_support import configure_opencode @pytest.fixture @@ -148,15 +149,6 @@ def test_malformed_markers_never_overwrite_user_content(content: str) -> None: merge_guidance(content, START + "\nnew\n" + END) -def test_only_pristine_legacy_guidance_is_automatically_migrated() -> None: - legacy = "original\n" - digest = hashlib.sha256(legacy.encode()).hexdigest() - desired = START + "\nnew\n" + END - assert merge_guidance(legacy, desired, legacy_sha256=digest) == desired - with pytest.raises(GuidanceError, match="unrecognized content"): - merge_guidance(legacy + "user edits\n", desired, legacy_sha256=digest) - - def test_installed_skill_refresh_preserves_notes_extra_files_and_mtimes(tmp_path: Path) -> None: source = ROOT / FILE_HOSTS["opencode"] target = tmp_path / "installed" @@ -171,17 +163,16 @@ def test_installed_skill_refresh_preserves_notes_extra_files_and_mtimes(tmp_path @pytest.mark.parametrize("host", ["workbuddy", "opencode"]) -def test_real_skill_installers_preserve_local_notes_on_repeat(tmp_path: Path, host: str) -> None: - from powercontext.cli.opencode import _install_skill - from powercontext.cli.workbuddy import _install_workbuddy_skill +def test_real_skill_installers_preserve_local_notes_on_repeat(tmp_path: Path, host: str, monkeypatch) -> None: target = tmp_path / "skills/powercontext-project-context" + package = ROOT / HOST_GUIDANCE[host].plugin + configure_opencode(monkeypatch, tmp_path) + monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path)) + def install() -> None: - if host == "workbuddy": - _install_workbuddy_skill(ROOT / HOST_GUIDANCE[host].plugin, target.parent, tmp_path / "hooks") - else: - _install_skill(ROOT / FILE_HOSTS[host], target) + install_plugin(host, source=str(package)) install() skill = target / "SKILL.md" @@ -220,7 +211,9 @@ def pre_marker_packages(tmp_path: Path) -> dict[str, Path]: for host, files in skills.items(): plugin = tmp_path / "old-packages" / host shutil.copytree( - ROOT / HOST_GUIDANCE[host].plugin, plugin, ignore=shutil.ignore_patterns("skills", "__pycache__") + ROOT / HOST_GUIDANCE[host].plugin, + plugin, + ignore=shutil.ignore_patterns("skills", "__pycache__", "node_modules"), ) for name, content in files.items(): destination = plugin / "skills/powercontext-project-context" / name @@ -235,66 +228,33 @@ def test_pre_marker_downgrade_rejects_before_losing_user_content( tmp_path: Path, monkeypatch, pre_marker_packages: dict[str, Path], host: str ) -> None: from powercontext.cli.guidance import SKILL_DIRECTORY, preserve_installed_guidance - from powercontext.cli.opencode import _install_skill from powercontext.cli.system import SetupError - from powercontext.cli.workbuddy import install_workbuddy_plugin home = tmp_path / "installed" target = home / SKILL_DIRECTORY monkeypatch.setenv("WORKBUDDY_HOME", str(home)) monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) if host == "workbuddy": - install_workbuddy_plugin(source=str(ROOT), ref="master") + install_plugin("workbuddy", source=str(str(ROOT)), ref="master") elif host == "opencode": - _install_skill(ROOT / FILE_HOSTS[host], target) + configure_opencode(monkeypatch, home) + install_plugin(host, source=str(ROOT)) else: shutil.copytree(ROOT / FILE_HOSTS[host], target) skill = target / "SKILL.md" skill.write_bytes(skill.read_bytes() + b"\r\nUser notes must survive a downgrade.\r\n") (target / "my-notes.md").write_text("Keep my additional file.\n", encoding="utf-8") before = {path: data for path, (data, _) in snapshot(home).items()} - with pytest.raises((GuidanceError, SetupError, OSError), match=r"unmarked|pre-marker"): + with pytest.raises((GuidanceError, SetupError, OSError), match=r"unmarked|pre-marker|explicit migration"): if host == "workbuddy": - install_workbuddy_plugin(source=str(pre_marker_packages[host]), ref="master") + install_plugin("workbuddy", source=str(str(pre_marker_packages[host])), ref="master") elif host == "opencode": - _install_skill(pre_marker_packages[host] / SKILL_DIRECTORY, target) + install_plugin(host, source=str(pre_marker_packages[host])) else: preserve_installed_guidance(home, pre_marker_packages[host]) assert {path: data for path, (data, _) in snapshot(home).items()} == before -@pytest.mark.parametrize("edited", [False, True]) -def test_workbuddy_legacy_upgrade_uses_previous_interpreter_without_accepting_edits( - tmp_path: Path, monkeypatch, pre_marker_packages: dict[str, Path], edited: bool -) -> None: - import powercontext.cli.workbuddy as workbuddy - from powercontext.cli.system import SetupError - - home = tmp_path / "workbuddy home" - monkeypatch.setenv("WORKBUDDY_HOME", str(home)) - monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) - old_python = (tmp_path / "old environment/python").as_posix() - new_python = (tmp_path / "new environment/python3").as_posix() - monkeypatch.setattr(workbuddy, "_python_executable", lambda: old_python) - workbuddy.install_workbuddy_plugin(source=str(pre_marker_packages["workbuddy"]), ref="master") - scope = home / "skills/powercontext-project-context/references/scope-memory.md" - assert old_python in scope.read_text(encoding="utf-8") - if edited: - scope.write_bytes(scope.read_bytes() + b"\nRetain my unmarked edits.\n") - before = {path: data for path, (data, _) in snapshot(home).items()} - monkeypatch.setattr(workbuddy, "_python_executable", lambda: new_python) - if edited: - with pytest.raises(SetupError, match="unrecognized content"): - workbuddy.install_workbuddy_plugin(source=str(ROOT), ref="master") - assert {path: data for path, (data, _) in snapshot(home).items()} == before - else: - workbuddy.install_workbuddy_plugin(source=str(ROOT), ref="master") - updated = scope.read_text(encoding="utf-8") - assert new_python in updated - assert old_python not in updated - assert START in updated and END in updated - - def test_git_crlf_checkout_passes_generation_without_rewriting_user_bytes(guidance_tree: Path) -> None: shutil.copy2(ROOT / ".gitattributes", guidance_tree / ".gitattributes") git = shutil.which("git") @@ -336,10 +296,11 @@ def test_unchanged_pre_marker_staging_keeps_additional_user_files( @pytest.mark.parametrize("host", ["workbuddy", "opencode", "hermes", "zcode"]) -@pytest.mark.parametrize("damage", ["reference", "manifest", "invalid-manifest"]) -def test_incomplete_package_never_uses_old_files_to_repair_it(tmp_path: Path, host: str, damage: str) -> None: +@pytest.mark.parametrize("damage", ["reference", "manifest", "unsupported-manifest"]) +def test_incomplete_package_never_uses_old_files_to_repair_it( + tmp_path: Path, host: str, damage: str, monkeypatch +) -> None: from powercontext.cli.guidance import SKILL_DIRECTORY, preserve_installed_guidance - from powercontext.cli.opencode import _install_skill from powercontext.cli.system import SetupError from powercontext.cli.workbuddy import _install_workbuddy_skill @@ -348,12 +309,18 @@ def test_incomplete_package_never_uses_old_files_to_repair_it(tmp_path: Path, ho installed = tmp_path / "installed" target = installed / SKILL_DIRECTORY shutil.copytree(ROOT / FILE_HOSTS[host], source) + if host == "opencode": + configure_opencode(monkeypatch, installed) + (package / "package.json").write_text('{"name":"powercontext-opencode"}', encoding="utf-8") + (package / "lib").mkdir() + for name in ("index.js", "tui.js"): + (package / "lib" / name).write_text("export default {}", encoding="utf-8") def install() -> None: if host == "workbuddy": _install_workbuddy_skill(package, target.parent, installed / "hooks") elif host == "opencode": - _install_skill(source, target) + install_plugin(host, source=str(package)) else: preserve_installed_guidance(installed, package) @@ -364,8 +331,8 @@ def install() -> None: (target / "personal.md").write_bytes(b"Keep user notes\r\n") before = snapshot(installed) damaged = source / ("references/work-handoff.md" if damage == "reference" else RESOURCE_MANIFEST) - if damage == "invalid-manifest": - damaged.write_text("[]", encoding="utf-8") + if damage == "unsupported-manifest": + damaged.write_text(json.dumps({"SKILL.md": "a" * 64}), encoding="utf-8") else: damaged.unlink() package_before = snapshot(package) @@ -387,10 +354,7 @@ def test_retired_owned_file_requires_migration_instead_of_becoming_a_user_file(t retired.write_bytes((START + "\nOld packaged guidance\n" + END + "\nUser notes\n").encode()) manifest = previous / SKILL_DIRECTORY / RESOURCE_MANIFEST inventory = json.loads(manifest.read_text(encoding="utf-8")) - if "resources" in inventory: - inventory["resources"].append("references/retired.md") - else: - inventory["references/retired.md"] = "a" * 64 + inventory["resources"].append("references/retired.md") manifest.write_text(json.dumps(inventory), encoding="utf-8") before = snapshot(previous) staged_before = snapshot(staged) @@ -410,23 +374,6 @@ def test_fresh_install_requires_all_declared_resources_including_unlinked_files( assert not target.exists() -def test_flat_manifest_upgrade_retains_notes_and_mtimes(tmp_path: Path) -> None: - source, target = tmp_path / "source", tmp_path / "installed" - shutil.copytree(ROOT / FILE_HOSTS["opencode"], source) - manifest = source / RESOURCE_MANIFEST - payload = json.loads(manifest.read_text(encoding="utf-8")) - manifest.write_text(json.dumps(payload["legacy_sha256"]), encoding="utf-8") - refresh_skill(source, target) - note = target / "personal.md" - note.write_bytes(b"Keep notes from an initial generated package\r\n") - before = (note.read_bytes(), note.stat().st_mtime_ns) - refresh_skill(ROOT / FILE_HOSTS["opencode"], target) - assert (note.read_bytes(), note.stat().st_mtime_ns) == before - after = snapshot(target) - refresh_skill(ROOT / FILE_HOSTS["opencode"], target) - assert snapshot(target) == after - - def test_legacy_package_missing_a_linked_reference_is_rejected( tmp_path: Path, pre_marker_packages: dict[str, Path] ) -> None: diff --git a/tests/test_guidance_installation.py b/tests/test_guidance_installation.py index 7ae2d9f71e..ccaee390a5 100644 --- a/tests/test_guidance_installation.py +++ b/tests/test_guidance_installation.py @@ -21,18 +21,21 @@ import pytest -from powercontext.cli import authorization, hermes, workbuddy +from powercontext.cli import authorization, hermes from powercontext.cli.guidance import HOST_GUIDANCE, SKILL_DIRECTORY +from powercontext.cli.installation import setup as install_plugin from powercontext.cli.system import SetupError @pytest.mark.parametrize("host", ["hermes", "workbuddy"]) -def test_incomplete_guidance_preserves_installation_and_retry_succeeds(host: str, tmp_path: Path, monkeypatch) -> None: +def test_incomplete_guidance_preserves_installation_and_retry_succeeds( + host: str, tmp_path: Path, monkeypatch, hermes_activation +) -> None: repo = Path(__file__).parents[1] descriptor = HOST_GUIDANCE[host] checkout = tmp_path / "source" source = checkout / descriptor.plugin - shutil.copytree(repo / descriptor.plugin, source, ignore=shutil.ignore_patterns("__pycache__", "node_modules")) + shutil.copytree(repo / descriptor.plugin, source, ignore=shutil.ignore_patterns("node_modules", "__pycache__")) home = tmp_path / "home" monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) monkeypatch.setenv(f"{host.upper()}_HOME", str(home)) @@ -42,16 +45,12 @@ def test_incomplete_guidance_preserves_installation_and_retry_succeeds(host: str shutil.copytree(repo / companion, checkout / companion) monkeypatch.setattr(hermes, "hermes_executable", lambda: "hermes") monkeypatch.setattr(hermes, "get_hermes_version", lambda _executable: "0.20.4") - monkeypatch.setattr(hermes, "_run_plugin_doctor", lambda *_args: None) - monkeypatch.setattr(hermes, "_enable_hermes_plugin", lambda *_args: None) - install = hermes.install_hermes_plugin installed = home / "plugins/powercontext" payload = Path("__init__.py") else: - install = workbuddy.install_workbuddy_plugin installed = home payload = Path("hooks/workbuddy_powercontext_hook.py") - install(source=str(checkout), ref="unused") + install_plugin(host, source=str(checkout), ref="unused") note = installed / SKILL_DIRECTORY / "personal.md" note.write_bytes(b"Keep my notes\r\n") before = {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()} @@ -61,9 +60,9 @@ def test_incomplete_guidance_preserves_installation_and_retry_succeeds(host: str updated_payload = (source / payload).read_bytes() + b"\n# updated package\n" (source / payload).write_bytes(updated_payload) with pytest.raises(SetupError): - install(source=str(checkout), ref="unused") + install_plugin(host, source=str(checkout), ref="unused") assert {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()} == before resource.write_bytes(original) - install(source=str(checkout), ref="unused") + install_plugin(host, source=str(checkout), ref="unused") assert (installed / payload).read_bytes() == updated_payload assert note.read_bytes() == b"Keep my notes\r\n" diff --git a/tests/test_hermes_cli.py b/tests/test_hermes_cli.py index 3daccb17dd..f93bd1c6b2 100644 --- a/tests/test_hermes_cli.py +++ b/tests/test_hermes_cli.py @@ -17,13 +17,13 @@ import json from pathlib import Path from subprocess import CompletedProcess -from unittest.mock import Mock +import pytest from typer.testing import CliRunner import powercontext.cli.hermes as hermes_cli from powercontext.cli.app import create_cli -from powercontext.cli.system import SetupError, doctor_app, setup_app +from powercontext.cli.system import doctor_app, setup_app def _write_plugin(root: Path) -> Path: @@ -63,12 +63,12 @@ def _successful_hermes_run(command: list[str], **_kwargs) -> CompletedProcess[st return CompletedProcess(command, 0, stdout="Hermes Agent v0.20.4 (2026.8.18)\n", stderr="") if command[1:3] == ["plugins", "doctor"]: return CompletedProcess(command, 0, stdout="Plugin Doctor: OK\n", stderr="") - if command[1:3] == ["plugins", "enable"]: - return CompletedProcess(command, 0, stdout="Plugin enabled\n", stderr="") raise AssertionError(command) -def test_setup_hermes_copies_provider_from_a_local_checkout(tmp_path: Path, monkeypatch) -> None: +def test_setup_hermes_copies_provider_from_a_local_checkout( + tmp_path: Path, monkeypatch, installed_agent_checks, hermes_activation +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) hermes_home = tmp_path / "hermes" @@ -89,7 +89,7 @@ def test_setup_hermes_copies_provider_from_a_local_checkout(tmp_path: Path, monk ) assert result.exit_code == 0 - assert json.loads(result.output) == { + assert json.loads(result.output)["hosts"]["hermes"]["result"] == { "plugin": "powercontext", "plugin_path": str(hermes_home / "plugins" / "powercontext"), "hermes_home": str(hermes_home), @@ -102,7 +102,9 @@ def test_setup_hermes_copies_provider_from_a_local_checkout(tmp_path: Path, monk assert (unrelated_plugin / "keep.txt").read_text(encoding="utf-8") == "keep\n" -def test_setup_hermes_refuses_package_omitting_installed_guidance(tmp_path: Path, monkeypatch) -> None: +def test_setup_hermes_refuses_package_omitting_installed_guidance( + tmp_path: Path, monkeypatch, installed_agent_checks, hermes_activation +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) home = tmp_path / "hermes" @@ -124,7 +126,9 @@ def test_setup_hermes_refuses_package_omitting_installed_guidance(tmp_path: Path assert sorted(path.name for path in (home / "plugins").iterdir()) == ["powercontext", "powercontext-command"] -def test_setup_hermes_restores_both_plugins_when_second_replace_fails(tmp_path: Path, monkeypatch) -> None: +def test_setup_hermes_restores_both_plugins_when_second_replace_fails( + tmp_path: Path, monkeypatch, installed_agent_checks, hermes_activation +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) hermes_home = tmp_path / "hermes" @@ -145,7 +149,7 @@ def test_setup_hermes_restores_both_plugins_when_second_replace_fails(tmp_path: def fail_second_replace(source, destination): nonlocal failed - if not failed and Path(destination) == old_command and Path(source).name.startswith(f".{old_command.name}-"): + if not failed and Path(destination) == old_command and Path(source).name == old_command.name: failed = True raise OSError original_replace(source, destination) @@ -165,7 +169,9 @@ def fail_second_replace(source, destination): assert not list((hermes_home / "plugins").glob(".powercontext*")) -def test_setup_hermes_restores_both_plugins_when_enable_fails(tmp_path: Path, monkeypatch) -> None: +def test_setup_hermes_keeps_both_plugins_when_validation_fails( + tmp_path: Path, monkeypatch, installed_agent_checks, hermes_activation +) -> None: checkout = tmp_path / "powercontext" _write_plugin(checkout) hermes_home = tmp_path / "hermes" @@ -181,14 +187,7 @@ def test_setup_hermes_restores_both_plugins_when_enable_fails(tmp_path: Path, mo monkeypatch.setattr(hermes_cli, "which", lambda _name: "/usr/bin/hermes") monkeypatch.setattr(hermes_cli.subprocess, "run", _successful_hermes_run) - enabled = False - - def fail_enable(_executable: str, _name: str) -> None: - nonlocal enabled - enabled = True - raise SetupError - - monkeypatch.setattr(hermes_cli, "_enable_hermes_plugin", fail_enable) + (checkout / "integrations/hermes/plugins/powercontext-command/plugin.yaml").unlink() result = CliRunner().invoke( create_cli([setup_app]), @@ -196,14 +195,15 @@ def fail_enable(_executable: str, _name: str) -> None: ) assert result.exit_code == 1 - assert enabled assert (old_provider / "version.txt").read_text(encoding="utf-8") == "old provider\n" assert (old_command / "version.txt").read_text(encoding="utf-8") == "old command\n" assert (unrelated_plugin / "keep.txt").read_text(encoding="utf-8") == "keep\n" assert not list((hermes_home / "plugins").glob(".powercontext*")) -def test_setup_hermes_reports_missing_cli(tmp_path: Path, monkeypatch) -> None: +def test_setup_hermes_reports_missing_cli( + tmp_path: Path, monkeypatch, installed_agent_checks, hermes_activation +) -> None: monkeypatch.setattr(hermes_cli, "which", lambda _name: None) result = CliRunner().invoke( @@ -215,9 +215,12 @@ def test_setup_hermes_reports_missing_cli(tmp_path: Path, monkeypatch) -> None: assert "Hermes CLI is not installed" in result.output -def test_doctor_hermes_reports_an_installed_provider(tmp_path: Path, monkeypatch) -> None: +def test_doctor_hermes_reports_an_installed_provider(tmp_path: Path, monkeypatch, server_checks) -> None: hermes_home = tmp_path / "hermes" _write_installed_plugins(hermes_home) + (hermes_home / "config.yaml").write_text( + "memory:\n provider: powercontext\nplugins:\n enabled: [powercontext-command]\n" + ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setattr(hermes_cli, "which", lambda _name: "/usr/bin/hermes") monkeypatch.setattr(hermes_cli.subprocess, "run", _successful_hermes_run) @@ -225,7 +228,7 @@ def test_doctor_hermes_reports_an_installed_provider(tmp_path: Path, monkeypatch result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "hermes", "--json"]) assert result.exit_code == 0 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["hermes"] assert payload["ok"] is True assert payload["checks"]["hermes"] == { "ok": True, @@ -244,7 +247,7 @@ def test_doctor_hermes_reports_an_installed_provider(tmp_path: Path, monkeypatch } -def test_doctor_hermes_reports_missing_provider(tmp_path: Path, monkeypatch) -> None: +def test_doctor_hermes_reports_missing_provider(tmp_path: Path, monkeypatch, server_checks) -> None: monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setattr(hermes_cli, "which", lambda _name: "/usr/bin/hermes") monkeypatch.setattr(hermes_cli.subprocess, "run", _successful_hermes_run) @@ -252,13 +255,15 @@ def test_doctor_hermes_reports_missing_provider(tmp_path: Path, monkeypatch) -> result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "hermes"]) assert result.exit_code == 1 - assert "hermes: ok - /usr/bin/hermes (Hermes Agent v0.20.4)" in result.output assert "plugin: failed - PowerContext Hermes plugin is not installed" in result.output -def test_doctor_hermes_rejects_a_broken_provider(tmp_path: Path, monkeypatch) -> None: +def test_doctor_hermes_rejects_a_broken_provider(tmp_path: Path, monkeypatch, server_checks) -> None: hermes_home = tmp_path / "hermes" _write_installed_plugins(hermes_home) + (hermes_home / "config.yaml").write_text( + "memory:\n provider: powercontext\nplugins:\n enabled: [powercontext-command]\n" + ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setattr(hermes_cli, "which", lambda _name: "/usr/bin/hermes") @@ -272,18 +277,16 @@ def failed_plugin_doctor(command: list[str], **_kwargs) -> CompletedProcess[str] result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "hermes", "--json"]) assert result.exit_code == 1 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["hermes"] assert payload["ok"] is False assert payload["checks"]["plugin"]["status"] == "failed" assert "ImportError" in payload["checks"]["plugin"]["detail"] -def test_doctor_hermes_rejects_an_unsupported_version(monkeypatch) -> None: +def test_doctor_hermes_rejects_an_unsupported_version(monkeypatch, server_checks) -> None: monkeypatch.setattr(hermes_cli, "which", lambda _name: "/usr/bin/hermes") - calls: list[list[str]] = [] def old_hermes_run(command: list[str], **_kwargs) -> CompletedProcess[str]: - calls.append(command) return CompletedProcess(command, 0, stdout="Hermes Agent v0.20.3\n", stderr="") monkeypatch.setattr(hermes_cli.subprocess, "run", old_hermes_run) @@ -291,48 +294,60 @@ def old_hermes_run(command: list[str], **_kwargs) -> CompletedProcess[str]: result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "hermes", "--json"]) assert result.exit_code == 1 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["hermes"] assert payload["checks"]["hermes"]["status"] == "failed" assert "requires Hermes Agent v0.20.4 or newer" in payload["checks"]["hermes"]["detail"] assert payload["checks"]["plugin"]["status"] == "skipped" - assert len(calls) == 1 - - -def test_setup_hermes_remote_checkout_uses_the_requested_ref(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) - captured: list[list[str]] = [] - - def fake_run(command, **_kwargs): - captured.append(command) - _write_plugin(Path(command[-1])) - return Mock(returncode=0, stdout="", stderr="") - - monkeypatch.setattr(hermes_cli.subprocess, "run", fake_run) - - source = "oceanbase/powercontext" - ref = "v0.0.2" - plugin = hermes_cli.resolve_hermes_plugin_dir(source=source, ref=ref) - - assert plugin == hermes_cli.checkout_target(source, ref) / "integrations" / "hermes" / "plugins" / "powercontext" - assert captured[0][:6] == ["git", "clone", "--depth", "1", "--branch", "v0.0.2"] - assert captured[0][6] == "https://github.com/oceanbase/powercontext.git" - - -def test_remote_checkout_is_source_scoped_and_refreshes_mutable_refs(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) - captured: list[list[str]] = [] - - def fake_run(command, **_kwargs): - captured.append(command) - _write_plugin(Path(command[-1])) - return CompletedProcess(command, 0, stdout="", stderr="") - monkeypatch.setattr(hermes_cli.subprocess, "run", fake_run) - first = hermes_cli.resolve_hermes_plugin_dir(source="oceanbase/powercontext", ref="master") - refreshed = hermes_cli.resolve_hermes_plugin_dir(source="oceanbase/powercontext", ref="master") - other_source = hermes_cli.resolve_hermes_plugin_dir(source="other/project", ref="master") - - assert first == refreshed - assert first != other_source - assert len(captured) == 3 +@pytest.mark.parametrize("provider", ["", "other-provider"]) +def test_setup_preserves_hermes_provider_and_companion_disablement( + tmp_path, monkeypatch, hermes_activation, server_checks, provider +): + checkout = tmp_path / "source" + _write_plugin(checkout) + home = tmp_path / "hermes" + home.mkdir() + config = home / "config.yaml" + original = f"memory:\n provider: '{provider}'\nplugins:\n disabled: [powercontext-command]\n" + config.write_text(original) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(hermes_cli, "which", lambda _name: "/usr/bin/hermes") + monkeypatch.setattr(hermes_cli.subprocess, "run", _successful_hermes_run) + result = CliRunner().invoke(create_cli([setup_app]), ["setup", "hermes", "--source", str(checkout), "--json"]) + assert result.exit_code == 1, result.output + row = json.loads(result.output)["hosts"]["hermes"] + assert row["setup"] == "complete" + assert row["checks"]["provider"]["status"] == "failed" + assert row["checks"]["command_enabled"]["status"] == "failed" + assert config.read_text() == original + + +def test_failed_hermes_configuration_can_finish_activation_on_retry( + tmp_path, monkeypatch, hermes_activation, server_checks +): + from powercontext.cli import installation + from powercontext.cli.transport import client_config_file + + checkout = tmp_path / "source" + _write_plugin(checkout) + home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(hermes_cli, "which", lambda _name: "/usr/bin/hermes") + monkeypatch.setattr(hermes_cli.subprocess, "run", _successful_hermes_run) + write = installation.write_bytes + + def failed_connection(path, content): + if path == client_config_file(): + raise OSError("cannot write connection") # noqa: TRY003 + write(path, content) + + arguments = ["setup", "hermes", "--source", str(checkout), "--json"] + with monkeypatch.context() as failure: + failure.setattr(installation, "write_bytes", failed_connection) + first = CliRunner().invoke(create_cli([setup_app]), arguments) + assert first.exit_code == 1 + assert not (home / "config.yaml").exists() + retried = CliRunner().invoke(create_cli([setup_app]), arguments) + assert retried.exit_code == 0, retried.output + assert json.loads(retried.output)["hosts"]["hermes"]["checks"]["provider"]["ok"] diff --git a/tests/test_installation.py b/tests/test_installation.py new file mode 100644 index 0000000000..d08a09b94a --- /dev/null +++ b/tests/test_installation.py @@ -0,0 +1,185 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Failures across the full public installation lifecycle retain recoverable state.""" + +import json +import shutil +from pathlib import Path +from subprocess import CompletedProcess + +import pytest +from typer.testing import CliRunner + +from powercontext.cli import openclaw +from powercontext.cli.app import create_cli +from powercontext.cli.installation import SetupError, setup + +ROOT = Path(__file__).parents[1] + + +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) + monkeypatch.chdir(tmp_path) + + +def test_failed_openclaw_rebuild_keeps_the_native_managed_bundle(tmp_path, monkeypatch): + source = tmp_path / "source" + source.mkdir() + (source / "package.json").write_text('{"name":"@oceanbase/openclaw-memory-powercontext"}', encoding="utf-8") + monkeypatch.setattr(openclaw, "openclaw_executable", lambda: "openclaw") + monkeypatch.setattr(openclaw, "require_supported_openclaw", lambda _executable: None) + monkeypatch.setattr(openclaw, "configure_openclaw", lambda **_kwargs: None) + monkeypatch.setattr( + openclaw, + "read_config_value", + lambda _executable, path: ( + {"entries": {"memory-powercontext": {"enabled": False}}} if path == "plugins" else "memory-powercontext" + ), + ) + loaded = json.dumps({ + "plugins": [{"id": "memory-powercontext", "enabled": True, "status": "loaded", "memorySlotSelected": True}] + }) + managed = tmp_path / "native-managed" + + def native_command(*args): + if "install" in args: + shutil.copytree(Path(args[-1]).parent / "plugin", managed) + output = json.dumps({"install": {"installPath": str(managed)}}) if "inspect" in args else loaded + return CompletedProcess([], 0, output, "") + + monkeypatch.setattr(openclaw, "run_openclaw", native_command) + monkeypatch.setattr(openclaw, "pnpm_executable", lambda: "pnpm") + monkeypatch.setattr( + openclaw, "run_process", lambda command, **kwargs: (Path(command[-1]) / "plugin.tgz").write_bytes(b"archive") + ) + + def build(candidate): + (candidate / "dist").mkdir() + (candidate / "dist/index.js").write_bytes(b"working bundle") + + monkeypatch.setattr(openclaw, "build_openclaw_plugin", build) + first = setup("openclaw", source=str(source)) + linked = Path(first["plugin_path"]) + + def fail_build(candidate): + shutil.rmtree(candidate / "dist", ignore_errors=True) + raise SetupError("build failed") # noqa: TRY003 + + monkeypatch.setattr(openclaw, "build_openclaw_plugin", fail_build) + with pytest.raises(SetupError, match="build failed"): + setup("openclaw", source=str(source)) + assert (linked / "dist/index.js").read_bytes() == b"working bundle" + + +def test_connection_can_be_configured_without_reinstalling(tmp_path, monkeypatch): + from powercontext.cli import authorization, workbuddy + from powercontext.cli.installation import PluginSource + from powercontext.cli.transport import SetupTransport + from powercontext.client.transport_policy import load_client_settings + + monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) + monkeypatch.setattr(authorization, "setup_authorization_value", lambda _host: None) + installer = workbuddy.WorkBuddyInstaller() + installed = installer.install(PluginSource(str(ROOT))) + hook = Path(installed["hooks_dir"]) / "workbuddy_powercontext_hook.py" + hook.write_bytes(hook.read_bytes() + b"\n# local hook edit\n") + original = hook.read_bytes() + installer.configure(SetupTransport("workbuddy", "https://memory.example", False)) + assert hook.read_bytes() == original + assert load_client_settings("workbuddy")["server_url"] == "https://memory.example" + mcp = json.loads((Path(installed["workbuddy_home"]) / "mcp.json").read_text()) + assert "https://memory.example" in mcp["mcpServers"]["powercontext"]["url"] + + +@pytest.mark.parametrize("fail", [False, True], ids=["success", "rollback"]) +def test_connection_readers_keep_a_configured_endpoint_during_update( + tmp_path, monkeypatch, installed_agent_checks, fail +): + from powercontext.cli import installation + from powercontext.client.transport_policy import load_client_settings + + monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) + cli = create_cli() + runner = CliRunner() + result = runner.invoke(cli, ["setup", "workbuddy", "--source", str(ROOT), "--server-url", "https://old.example"]) + assert result.exit_code == 0, result.output + client = tmp_path / "clients.json" + observed = [] + write, replace = installation.write_bytes, installation.os.replace + + def read_endpoint(): + observed.append(load_client_settings("workbuddy").get("server_url")) + + def write_connection(path, value): + if path == client: + read_endpoint() + write(path, value) + if path == client and fail: + raise OSError("connection write failed") # noqa: TRY003 + + def replace_file(source, target): + if target == client: + read_endpoint() + replace(source, target) + if target == client: + read_endpoint() + + monkeypatch.setattr(installation, "write_bytes", write_connection) + monkeypatch.setattr(installation.os, "replace", replace_file) + result = runner.invoke(cli, ["config", "agent", "workbuddy", "--server-url", "https://new.example"]) + assert result.exit_code == int(fail), result.output + assert set(observed) <= {"https://old.example", "https://new.example"} + assert load_client_settings("workbuddy")["server_url"] == ("https://old.example" if fail else "https://new.example") + + +@pytest.mark.parametrize("host", ["kimi-code", "zcode", "workbuddy"]) +def test_failed_connection_publication_keeps_previous_configuration(host, tmp_path, monkeypatch): + from powercontext.cli import installation, kimi_code, workbuddy, zcode + from powercontext.cli.installation import PluginSource + from powercontext.cli.transport import SetupTransport + + monkeypatch.setenv("KIMI_CODE_HOME", str(tmp_path / "kimi")) + monkeypatch.setenv("WORKBUDDY_HOME", str(tmp_path / "workbuddy")) + monkeypatch.setattr(kimi_code, "kimi_executable", lambda: "kimi") + monkeypatch.setattr(zcode, "zcode_executable", lambda: "zcode") + monkeypatch.setattr(zcode, "zcode_config_file", lambda: tmp_path / "zcode/config.json") + installer = installation.installers()[host](**({"scope_id": "scope"} if host == "kimi-code" else {})) + installer.install(PluginSource(str(ROOT))) + installer.configure(SetupTransport(host, "https://old.example", False)) + roots = { + "kimi-code": kimi_code.kimi_home(), + "zcode": zcode.zcode_config_file().parent, + "workbuddy": workbuddy.workbuddy_home(), + } + root = roots[host] + before = {path.relative_to(root): path.read_bytes() for path in root.rglob("*") if path.is_file()} + client = tmp_path / "clients.json" + original = client.read_bytes() + write = installation.write_bytes + + def fail_connection(path, value): + if path == client: + raise OSError("client configuration write failed") # noqa: TRY003 + write(path, value) + + monkeypatch.setattr(installation, "write_bytes", fail_connection) + with pytest.raises(OSError, match="client configuration write failed"): + installer.configure(SetupTransport(host, "https://new.example", False)) + assert {path.relative_to(root): path.read_bytes() for path in root.rglob("*") if path.is_file()} == before + assert client.read_bytes() == original diff --git a/tests/test_integration_manifest.py b/tests/test_integration_manifest.py index d5cad3dfa7..6f67c8e496 100644 --- a/tests/test_integration_manifest.py +++ b/tests/test_integration_manifest.py @@ -36,14 +36,14 @@ ) from pydantic import ValidationError -from powercontext.cli.system import setup_app - def test_manifest_matches_setup_catalog_evidence_and_actual_tool_surfaces() -> None: manifest = load_integration_manifest() agent_hosts = {integration.id for integration in manifest.integrations if integration.kind == "agent_host"} - setup_targets = {command.name for command in setup_app.registered_commands if command.name != "select"} + from powercontext.cli.installation import installers + + setup_targets = set(installers()) assert agent_hosts == setup_targets assert integration_directory_errors(manifest) == () diff --git a/tests/test_native_transport_diagnostics.py b/tests/test_native_transport_diagnostics.py index 3e052203d5..84b016c9ac 100644 --- a/tests/test_native_transport_diagnostics.py +++ b/tests/test_native_transport_diagnostics.py @@ -118,35 +118,6 @@ def test_openclaw_config_path_and_common_url_precedence(tmp_path, monkeypatch): assert _resolve("openclaw") == ("http://common.example", False) -def test_codex_installed_mcp_endpoint_is_authoritative(tmp_path, monkeypatch): - _write( - tmp_path / "CODEX_HOME/plugins/cache/local/powercontext/0.1.0/.mcp.json", - { - "mcpServers": {"powercontext": {"url": "http://memory.example:8000/mcp"}}, - }, - ) - _save_shared(tmp_path, "codex", "http://memory.example:8000/", True) - monkeypatch.setenv("POWERCONTEXT_CODEX_SERVER_URL", "https://irrelevant.example") - monkeypatch.setenv("POWERCONTEXT_CLIENT_SERVER_URL", "https://irrelevant-common.example") - assert _resolve("codex") == ("http://memory.example:8000", True) - - -@pytest.mark.parametrize("second_url", ["http://memory.example/mcp/", "http://another.example/mcp"]) -def test_codex_multiple_cache_versions_require_an_unambiguous_endpoint(tmp_path, second_url): - for version, url in (("1", "http://memory.example/mcp"), ("2", second_url)): - _write( - tmp_path / f"CODEX_HOME/plugins/cache/local/powercontext/{version}/.mcp.json", - { - "mcpServers": {"powercontext": {"url": url}}, - }, - ) - if "another" in second_url: - with pytest.raises(ValueError, match="determine"): - _resolve("codex") - else: - assert _resolve("codex") == ("http://memory.example", False) - - def test_workbuddy_native_mcp_template_matches_hook_endpoint(tmp_path, monkeypatch): _write( tmp_path / "WORKBUDDY_HOME/mcp.json", diff --git a/tests/test_openclaw_cli.py b/tests/test_openclaw_cli.py index dff714e777..bea4a36101 100644 --- a/tests/test_openclaw_cli.py +++ b/tests/test_openclaw_cli.py @@ -25,9 +25,9 @@ from typer.testing import CliRunner import powercontext.cli.openclaw as openclaw_cli -import powercontext.cli.system as system_cli from powercontext.cli.app import create_cli -from powercontext.cli.system import DiagnosticStatus, OpenClawSetupResult, SetupError, doctor_app, setup_app +from powercontext.cli.installation import setup as install_plugin +from powercontext.cli.system import DiagnosticStatus, SetupError, doctor_app def _write_openclaw_plugin(root: Path) -> Path: @@ -40,79 +40,64 @@ def _write_openclaw_plugin(root: Path) -> Path: return plugin -def test_resolve_openclaw_plugin_dir_accepts_checkout_root(tmp_path: Path) -> None: - checkout = tmp_path / "powercontext" - plugin = _write_openclaw_plugin(checkout) - - assert openclaw_cli.resolve_openclaw_plugin_dir(source=str(checkout), ref="master") == plugin - - -def test_checkout_target_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) - - with pytest.raises(system_cli.SetupError, match="invalid OpenClaw ref"): - openclaw_cli.checkout_target("../../outside") - - -def test_configure_openclaw_preserves_existing_tools_and_adds_missing_tools(monkeypatch: pytest.MonkeyPatch) -> None: - run_process = Mock( - return_value=CompletedProcess( - ["openclaw", "config", "get"], - 0, - '["custom_tool", "powercontext_memory_get"]', - "", - ) - ) - run_openclaw = Mock() - monkeypatch.setattr(openclaw_cli, "run_process", run_process) - monkeypatch.setattr(openclaw_cli, "run_openclaw", run_openclaw) - +def test_configure_openclaw_changes_only_connection_settings(monkeypatch): + commands = [] + monkeypatch.setattr(openclaw_cli, "run_openclaw", lambda *args: commands.append(args)) openclaw_cli.configure_openclaw( - executable="openclaw", - server_url="http://127.0.0.1:8765", - allow_insecure_http=False, + executable="openclaw", server_url="https://memory.example", allow_insecure_http=False ) - - allowlist_call = run_openclaw.call_args_list[1] - assert allowlist_call.args[:3] == ("openclaw", "config", "set") - assert json.loads(allowlist_call.args[4]) == [ - "custom_tool", - "powercontext_memory_get", - "powercontext_memory_search", - "powercontext_memory_store", - "powercontext_memory_revise", - "powercontext_memory_retire", + settings = [item for command in commands for item in json.loads(command[-1])] + assert settings == [ + {"path": "plugins.entries.memory-powercontext.config.endpoint", "value": "https://memory.example"}, + {"path": "plugins.entries.memory-powercontext.config.allowInsecureHttp", "value": False}, ] - assert run_openclaw.call_args_list[-1].args == ("openclaw", "gateway", "restart") -def test_configure_openclaw_initializes_local_gateway_when_mode_is_missing( - monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("failure", [None, "local_write", "native_rejection"]) +def test_config_agent_openclaw_keeps_connection_settings_consistent( + tmp_path: Path, monkeypatch, failure: str | None ) -> None: - def read_config(command: list[str], *, timeout: int, check: bool = True) -> CompletedProcess[str]: - del timeout, check - if command[1:4] == ["config", "get", "gateway.mode"]: - return CompletedProcess(command, 1, "", "Config path not found") - if command[1:4] == ["config", "get", "tools.alsoAllow"]: - return CompletedProcess(command, 0, "[]", "") - raise AssertionError(command) - - run_openclaw = Mock() - monkeypatch.setattr(openclaw_cli, "run_process", read_config) - monkeypatch.setattr(openclaw_cli, "run_openclaw", run_openclaw) - - openclaw_cli.configure_openclaw( - executable="openclaw", - server_url="http://127.0.0.1:8765", - allow_insecure_http=False, + from powercontext.cli import installation + from powercontext.cli.config import app as config_app + + clients = tmp_path / "clients.json" + previous = {"version": 1, "hosts": {"openclaw": {"server_url": "https://old.example"}}} + clients.write_text(json.dumps(previous), encoding="utf-8") + native = tmp_path / "openclaw.json" + native.write_text(json.dumps({"endpoint": "https://old.example"}), encoding="utf-8") + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(clients)) + monkeypatch.setattr(openclaw_cli, "openclaw_executable", lambda: "openclaw") + + def native_command(_executable, *arguments): + if arguments[:2] == ("config", "set"): + if failure == "native_rejection": + message = "Native configuration rejected" + raise SetupError(message) + settings = json.loads(native.read_text(encoding="utf-8")) + for update in json.loads(arguments[-1]): + settings[update["path"].rsplit(".", 1)[1]] = update["value"] + native.write_text(json.dumps(settings), encoding="utf-8") + return CompletedProcess([], 0, "{}", "") + + monkeypatch.setattr(openclaw_cli, "run_openclaw", native_command) + write_bytes = installation.write_bytes + + def write(path, value): + if failure == "local_write" and path == clients: + message = "Connection file is read-only" + raise PermissionError(message) + write_bytes(path, value) + + monkeypatch.setattr(installation, "write_bytes", write) + result = CliRunner().invoke( + create_cli([config_app]), + ["config", "agent", "openclaw", "--server-url", "https://new.example", "--json"], ) - settings = json.loads(run_openclaw.call_args_list[0].args[4]) - assert settings[0] == {"path": "gateway.mode", "value": "local"} - assert { - "path": "plugins.entries.memory-powercontext.hooks.allowConversationAccess", - "value": True, - } in settings + assert result.exit_code == (1 if failure else 0), result.output + expected = "https://old.example" if failure else "https://new.example" + assert json.loads(native.read_text(encoding="utf-8"))["endpoint"] == expected + assert json.loads(clients.read_text(encoding="utf-8"))["hosts"]["openclaw"]["server_url"] == expected def test_install_openclaw_plugin_builds_installs_and_configures( @@ -120,36 +105,50 @@ def test_install_openclaw_plugin_builds_installs_and_configures( monkeypatch: pytest.MonkeyPatch, ) -> None: plugin = _write_openclaw_plugin(tmp_path / "checkout") - run_openclaw = Mock() + managed = tmp_path / "native-managed" + managed.mkdir() + + def native_command(*args): + output = ( + json.dumps({"install": {"installPath": str(managed)}}) + if "inspect" in args + else _openclaw_plugin_list_output() + ) + return CompletedProcess([], 0, output, "") + + run_openclaw = Mock(side_effect=native_command) + monkeypatch.setattr( + openclaw_cli, + "read_config_value", + lambda _executable, path: ( + {"entries": {"memory-powercontext": {"enabled": False}}} if path == "plugins" else "memory-powercontext" + ), + ) build = Mock() + + def pack(command, **kwargs): + (Path(command[-1]) / "plugin.tgz").write_bytes(b"archive") + + monkeypatch.setattr(openclaw_cli, "run_process", pack) + monkeypatch.setattr(openclaw_cli, "pnpm_executable", lambda: "pnpm") configure = Mock() monkeypatch.setattr(openclaw_cli, "openclaw_executable", lambda: "/usr/bin/openclaw") monkeypatch.setattr(openclaw_cli, "require_supported_openclaw", Mock()) - monkeypatch.setattr(openclaw_cli, "resolve_openclaw_plugin_dir", lambda **_kwargs: plugin) + monkeypatch.setattr("powercontext.cli.git_source.resolve_plugin_source", lambda *_args, **_kwargs: plugin) monkeypatch.setattr(openclaw_cli, "build_openclaw_plugin", build) monkeypatch.setattr(openclaw_cli, "run_openclaw", run_openclaw) monkeypatch.setattr(openclaw_cli, "configure_openclaw", configure) monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) - result = openclaw_cli.install_openclaw_plugin( - source="oceanbase/powercontext", - ref="tested-ref", - server_url="http://127.0.0.1:8765/", - ) + result = install_plugin("openclaw", source=str(plugin), ref="tested-ref", server_url="http://127.0.0.1:8765/") - assert result == OpenClawSetupResult( - plugin="memory-powercontext", - plugin_path=str(plugin), - server_url="http://127.0.0.1:8765", - data_dir=str(tmp_path / "data"), - ) - build.assert_called_once_with(plugin) - run_openclaw.assert_called_once_with("/usr/bin/openclaw", "plugins", "install", "--link", "--force", str(plugin)) - configure.assert_called_once_with( - executable="/usr/bin/openclaw", - server_url="http://127.0.0.1:8765", - allow_insecure_http=False, - ) + assert result == { + "plugin": "memory-powercontext", + "plugin_path": result["plugin_path"], + "server_url": "http://127.0.0.1:8765", + "data_dir": str(tmp_path / "data"), + } + assert Path(result["plugin_path"]) == managed def test_build_openclaw_plugin_runs_pnpm_install_non_interactively( @@ -189,63 +188,6 @@ def test_build_openclaw_plugin_runs_pnpm_install_non_interactively( assert (plugin / "dist" / "index.js").is_file() -def test_setup_openclaw_exposes_source_ref_and_runtime_options(monkeypatch: pytest.MonkeyPatch) -> None: - import powercontext.cli.openclaw as openclaw_module - - install = Mock( - return_value=OpenClawSetupResult( - plugin="memory-powercontext", - plugin_path="plugin-path", - server_url="http://127.0.0.1:8765", - data_dir="data-dir", - ) - ) - monkeypatch.setattr(openclaw_module, "install_openclaw_plugin", install) - - result = CliRunner().invoke( - create_cli([setup_app]), - [ - "setup", - "openclaw", - "--source", - "oceanbase/powercontext", - "--ref", - "tested-ref", - "--server-url", - "http://127.0.0.1:8765", - "--json", - ], - ) - - assert result.exit_code == 0 - assert json.loads(result.output)["plugin"] == "memory-powercontext" - install.assert_called_once_with( - source="oceanbase/powercontext", - ref="tested-ref", - server_url="http://127.0.0.1:8765", - allow_insecure_http=False, - ) - - -def test_setup_openclaw_defaults_to_the_server_default_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: - import powercontext.cli.openclaw as openclaw_module - - def install(**kwargs: str) -> OpenClawSetupResult: - return OpenClawSetupResult( - plugin="memory-powercontext", - plugin_path="plugin-path", - server_url=kwargs["server_url"], - data_dir="data-dir", - ) - - monkeypatch.setattr(openclaw_module, "install_openclaw_plugin", install) - - result = CliRunner().invoke(create_cli([setup_app]), ["setup", "openclaw", "--json"]) - - assert result.exit_code == 0 - assert json.loads(result.output)["server_url"] == "http://127.0.0.1:8000" - - _OPENCLAW_PLUGIN_LIST_COMMAND = ["openclaw", "plugins", "list", "--enabled", "--json"] @@ -278,11 +220,13 @@ def test_run_openclaw_diagnostics_reports_installed_plugin(monkeypatch: pytest.M monkeypatch.setattr(openclaw_cli, "openclaw_executable", lambda: "/usr/bin/openclaw") monkeypatch.setattr(openclaw_cli, "run_process", run_process) - diagnostics = openclaw_cli.run_openclaw_diagnostics() + diagnostics = openclaw_cli.OpenClawInstaller().verify() assert diagnostics["openclaw"].status is DiagnosticStatus.OK assert diagnostics["plugin"].status is DiagnosticStatus.OK - assert diagnostics["plugin"].detail == "memory-powercontext is installed and active" + assert "installed, enabled and selected" in diagnostics["plugin"].detail + assert diagnostics["plugin"].checks is not None + assert diagnostics["plugin"].checks["running_host_configuration"] == "not_observed" def test_run_openclaw_diagnostics_reads_memory_slot_when_plugin_list_omits_selection( @@ -301,7 +245,7 @@ def run_process(command: list[str], *, timeout: int, check: bool = True) -> Comp monkeypatch.setattr(openclaw_cli, "openclaw_executable", lambda: "/usr/bin/openclaw") monkeypatch.setattr(openclaw_cli, "run_process", run_process) - diagnostics = openclaw_cli.run_openclaw_diagnostics() + diagnostics = openclaw_cli.OpenClawInstaller().verify() assert diagnostics["plugin"].status is DiagnosticStatus.OK @@ -315,13 +259,12 @@ def test_run_openclaw_diagnostics_reports_missing_plugin(monkeypatch: pytest.Mon monkeypatch.setattr(openclaw_cli, "openclaw_executable", lambda: "/usr/bin/openclaw") monkeypatch.setattr(openclaw_cli, "run_process", run_process) - diagnostics = openclaw_cli.run_openclaw_diagnostics() + diagnostics = openclaw_cli.OpenClawInstaller().verify() assert diagnostics["openclaw"].status is DiagnosticStatus.OK assert diagnostics["plugin"].status is DiagnosticStatus.FAILED assert ( - diagnostics["plugin"].detail - == "PowerContext OpenClaw plugin is not enabled, loaded, and selected as the memory plugin" + diagnostics["plugin"].detail == "PowerContext OpenClaw plugin is not enabled and selected as the memory plugin" ) @@ -349,7 +292,7 @@ def test_run_openclaw_diagnostics_rejects_inactive_plugin( monkeypatch.setattr(openclaw_cli, "openclaw_executable", lambda: "/usr/bin/openclaw") monkeypatch.setattr(openclaw_cli, "run_process", run_process) - diagnostics = openclaw_cli.run_openclaw_diagnostics() + diagnostics = openclaw_cli.OpenClawInstaller().verify() assert diagnostics["openclaw"].status is DiagnosticStatus.OK assert diagnostics["plugin"].status is DiagnosticStatus.FAILED @@ -357,19 +300,19 @@ def test_run_openclaw_diagnostics_rejects_inactive_plugin( def test_run_openclaw_diagnostics_reports_missing_cli(monkeypatch: pytest.MonkeyPatch) -> None: def missing_executable() -> str: - raise SetupError.openclaw_unavailable() + message = "OpenClaw CLI is not installed or is not on PATH." + raise SetupError(message) monkeypatch.setattr(openclaw_cli, "openclaw_executable", missing_executable) - diagnostics = openclaw_cli.run_openclaw_diagnostics() + diagnostics = openclaw_cli.OpenClawInstaller().verify() assert diagnostics["openclaw"].status is DiagnosticStatus.FAILED assert diagnostics["plugin"].status is DiagnosticStatus.SKIPPED def test_doctor_openclaw_reports_an_installed_plugin_as_json( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, server_checks ) -> None: monkeypatch.setenv("POWERCONTEXT_OPENCLAW_BASE_URL", "http://127.0.0.1:8000") run_process = Mock( @@ -381,12 +324,12 @@ def test_doctor_openclaw_reports_an_installed_plugin_as_json( result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "openclaw", "--json"]) assert result.exit_code == 0 - report = json.loads(result.output) + report = json.loads(result.output)["hosts"]["openclaw"] assert report["checks"]["openclaw"]["ok"] is True assert report["checks"]["plugin"]["ok"] is True -def test_doctor_openclaw_exits_nonzero_when_plugin_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: +def test_doctor_openclaw_exits_nonzero_when_plugin_is_missing(monkeypatch: pytest.MonkeyPatch, server_checks) -> None: run_process = Mock( return_value=CompletedProcess( _OPENCLAW_PLUGIN_LIST_COMMAND, 0, _openclaw_plugin_list_output(plugin_id=None), "" @@ -398,3 +341,94 @@ def test_doctor_openclaw_exits_nonzero_when_plugin_is_missing(monkeypatch: pytes result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "openclaw"]) assert result.exit_code == 1 + + +@pytest.mark.parametrize( + "plugins", + [ + {"enabled": False}, + {"entries": {"memory-powercontext": {"enabled": False}}}, + {"deny": ["memory-powercontext"]}, + {"slots": {"memory": "other-memory-provider"}}, + ], +) +def test_openclaw_activation_preserves_existing_native_choices(monkeypatch, plugins): + from copy import deepcopy + + before = deepcopy(plugins) + monkeypatch.setattr(openclaw_cli, "openclaw_executable", lambda: "openclaw") + monkeypatch.setattr( + openclaw_cli, "read_config_value", lambda _command, path: plugins if path == "plugins" else None + ) + + def native(executable, *arguments): + if arguments[:2] == ("plugins", "enable"): + plugins.setdefault("entries", {}).setdefault("memory-powercontext", {})["enabled"] = True + return CompletedProcess([], 0, "", "") + + monkeypatch.setattr(openclaw_cli, "run_openclaw", native) + openclaw_cli.OpenClawInstaller().enable() + assert plugins == before + + +def test_openclaw_rejects_installers_without_policy_preserving_installation(monkeypatch): + def native(command, **options): + return CompletedProcess( + command, 0, "OpenClaw 2026.8.33" if command[-1] == "--version" else "plugins install", "" + ) + + monkeypatch.setattr(openclaw_cli, "run_process", native) + with pytest.raises(SetupError, match="--no-enable"): + openclaw_cli.require_supported_openclaw("openclaw") + + +def test_openclaw_config_read_failure_is_not_an_absent_setting(monkeypatch): + monkeypatch.setattr( + openclaw_cli, "run_process", lambda *args, **kwargs: CompletedProcess([], 1, "", "configuration is invalid") + ) + with pytest.raises(SetupError, match="Cannot read OpenClaw configuration"): + openclaw_cli.read_config_value("openclaw", "plugins") + + +def test_openclaw_repeat_activation_preserves_removed_native_tool_grants(monkeypatch): + plugins = { + "enabled": True, + "slots": {"memory": "memory-powercontext"}, + "entries": { + "memory-powercontext": { + "enabled": True, + "config": {"autoRecall": False, "autoCapture": False}, + "hooks": {"allowConversationAccess": False}, + } + }, + } + allowed = ["powercontext_memory_get"] + monkeypatch.setattr(openclaw_cli, "openclaw_executable", lambda: "openclaw") + monkeypatch.setattr( + openclaw_cli, + "read_config_value", + lambda _executable, path: {"plugins": plugins, "tools.alsoAllow": allowed, "gateway.mode": "local"}[path], + ) + + def native(*_args): + pytest.fail("Repeated setup must not rewrite the existing native activation policy") + + monkeypatch.setattr(openclaw_cli, "run_openclaw", native) + openclaw_cli.OpenClawInstaller().enable() + + +def test_openclaw_recognizes_native_json_unset_without_treating_other_errors_as_absence(monkeypatch): + output = { + "ok": False, + "error": { + "type": "cli_error", + "message": "Config path is valid but unset: gateway.mode. The runtime default applies.", + }, + } + monkeypatch.setattr( + openclaw_cli, "run_process", lambda *args, **kwargs: CompletedProcess([], 1, json.dumps(output), "") + ) + assert openclaw_cli.read_config_value("openclaw", "gateway.mode") is None + output["error"]["message"] = "Unknown config path: gateway.mode." + with pytest.raises(SetupError, match="Cannot read OpenClaw configuration"): + openclaw_cli.read_config_value("openclaw", "gateway.mode") diff --git a/tests/test_opencode_cli.py b/tests/test_opencode_cli.py index 2cdaeb6282..3923a7d125 100644 --- a/tests/test_opencode_cli.py +++ b/tests/test_opencode_cli.py @@ -28,7 +28,9 @@ from typer.testing import CliRunner from powercontext.cli.app import create_cli +from powercontext.cli.installation import setup as install_plugin from powercontext.cli.system import SetupError, doctor_app, setup_app +from tests.install_support import configure_opencode def _write_plugin(root: Path, *, built: bool = True) -> Path: @@ -180,7 +182,7 @@ def test_process_exit_assertion_rejects_a_live_windows_process(tmp_path: Path) - process.wait(timeout=5) -def test_setup_opencode_installs_plugin_and_owned_skill(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_installs_plugin_and_owned_skill(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: import powercontext.cli.opencode as opencode_cli checkout = tmp_path / "checkout" @@ -212,7 +214,7 @@ def test_setup_opencode_installs_plugin_and_owned_skill(tmp_path: Path, monkeypa skill = config / "skills" / "powercontext-project-context" assert result.exit_code == 0 - assert "PowerContext OpenCode setup complete." in result.output + assert "opencode: ok" in result.output assert (config / "plugins" / "powercontext-opencode.js").is_file() tui_config = json.loads((config / "tui.json").read_text(encoding="utf-8")) assert tui_config["plugin"][0] == "@mem9/opencode" @@ -239,7 +241,9 @@ def _patch_opencode_runtime(tmp_path: Path, monkeypatch, plugin: Path, config: P ) -def test_setup_opencode_appends_tui_plugin_to_jsonc_config_with_comments(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_appends_tui_plugin_to_jsonc_config_with_comments( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: import powercontext.cli.opencode as opencode_cli checkout = tmp_path / "checkout" @@ -266,7 +270,7 @@ def test_setup_opencode_appends_tui_plugin_to_jsonc_config_with_comments(tmp_pat result = CliRunner().invoke(create_cli([setup_app]), ["setup", "opencode", "--source", str(checkout)]) assert result.exit_code == 0 - assert "PowerContext OpenCode setup complete." in result.output + assert "opencode: ok" in result.output assert (config / "plugins" / "powercontext-opencode.js").is_file() assert not stale_tui.exists() original = (config / "tui.jsonc").read_text(encoding="utf-8") @@ -279,7 +283,9 @@ def test_setup_opencode_appends_tui_plugin_to_jsonc_config_with_comments(tmp_pat assert str(stale_tui) not in tui_config["plugin"] -def test_setup_opencode_replaces_stale_checkout_tui_entries(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_replaces_stale_checkout_tui_entries( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "checkout" plugin = _write_plugin(checkout) config = tmp_path / "config" @@ -314,7 +320,7 @@ def test_setup_opencode_replaces_stale_checkout_tui_entries(tmp_path: Path, monk assert str((plugin / "lib" / "tui.js").resolve()) in tui_config["plugin"] -def test_setup_opencode_preserves_malformed_tui_entries(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_preserves_malformed_tui_entries(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: checkout = tmp_path / "checkout" plugin = _write_plugin(checkout) config = tmp_path / "config" @@ -334,7 +340,9 @@ def test_setup_opencode_preserves_malformed_tui_entries(tmp_path: Path, monkeypa assert str((plugin / "lib" / "tui.js").resolve()) in tui_config["plugin"] -def test_setup_opencode_keeps_tui_config_bytes_when_no_entry_changes(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_keeps_tui_config_bytes_when_no_entry_changes( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "checkout" plugin = _write_plugin(checkout) config = tmp_path / "config" @@ -351,7 +359,9 @@ def test_setup_opencode_keeps_tui_config_bytes_when_no_entry_changes(tmp_path: P assert (config / "tui.json").read_bytes() == before -def test_setup_opencode_keeps_previous_install_when_tui_config_is_invalid(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_keeps_previous_install_when_tui_config_is_invalid( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: checkout = tmp_path / "checkout" plugin = _write_plugin(checkout) config = tmp_path / "config" @@ -374,116 +384,52 @@ def test_setup_opencode_keeps_previous_install_when_tui_config_is_invalid(tmp_pa assert not (config / "skills" / "project-context").exists() -def test_remote_checkout_cache_is_scoped_by_source_and_resolved_commit(short_tmp_path: Path, monkeypatch) -> None: - import powercontext.cli.opencode as opencode_cli - - commits = iter(["a" * 40, "b" * 40, "a" * 40]) - - def clone(_source: str, _ref: str, target: Path) -> None: - _write_plugin(target) - - monkeypatch.setenv("POWERCONTEXT_HOME", str(short_tmp_path)) - monkeypatch.setattr(opencode_cli, "clone_github_source", clone) - monkeypatch.setattr(opencode_cli, "_checkout_commit", lambda _target: next(commits), raising=False) - - first = opencode_cli._materialize_remote_checkout("owner-a/repo", "master") - refreshed = opencode_cli._materialize_remote_checkout("owner-a/repo", "master") - other_source = opencode_cli._materialize_remote_checkout("owner-b/repo", "master") - - assert first != refreshed - assert first != other_source - assert first.name == "a" * 40 - assert refreshed.name == "b" * 40 - assert other_source.name == "a" * 40 - assert opencode_cli.checkout_target("owner-a/repo", "master", "a" * 40) == opencode_cli.checkout_target( - "git@github.com:OWNER-A/REPO.git", "master", "a" * 40 - ) - - -def test_remote_checkout_refresh_failure_keeps_previous_commit(short_tmp_path: Path, monkeypatch) -> None: - import powercontext.cli.opencode as opencode_cli - - attempts = 0 - - def clone(_source: str, _ref: str, target: Path) -> None: - nonlocal attempts - attempts += 1 - if attempts == 2: - raise SetupError.git_clone_failed() - _write_plugin(target) - - monkeypatch.setenv("POWERCONTEXT_HOME", str(short_tmp_path)) - monkeypatch.setattr(opencode_cli, "clone_github_source", clone) - monkeypatch.setattr(opencode_cli, "_checkout_commit", lambda _target: "a" * 40) - - current = opencode_cli._materialize_remote_checkout("owner/repo", "master") - with pytest.raises(SetupError): - opencode_cli._materialize_remote_checkout("owner/repo", "master") - - assert (current / "integrations" / "opencode" / "plugins" / "powercontext" / "package.json").is_file() - assert not list(current.parent.glob(".checkout-*")) - - -def test_opencode_skill_refresh_replaces_only_an_owned_installation(tmp_path: Path) -> None: - import powercontext.cli.opencode as opencode_cli +def test_opencode_skill_refresh_replaces_only_an_owned_installation(tmp_path: Path, monkeypatch) -> None: from powercontext.cli.guidance import END, RESOURCE_MANIFEST, START - first = tmp_path / "first" - second = tmp_path / "second" - first.mkdir() - second.mkdir() - (first / "SKILL.md").write_text(f"{START}\nfirst\n{END}\n", encoding="utf-8") - (second / "SKILL.md").write_text(f"{START}\nsecond\n{END}\n", encoding="utf-8") - for source in (first, second): - (source / RESOURCE_MANIFEST).write_text( - json.dumps({"schema_version": 1, "resources": ["SKILL.md"], "legacy_sha256": {}}), encoding="utf-8" + first, second = _write_plugin(tmp_path / "first"), _write_plugin(tmp_path / "second") + for package, value in ((first, "first"), (second, "second")): + skill = package / "skills/powercontext-project-context" + (skill / "SKILL.md").write_text(f"{START}\n{value}\n{END}\n", encoding="utf-8") + (skill / "references/memory.md").unlink() + (skill / RESOURCE_MANIFEST).write_text( + json.dumps({"schema_version": 1, "resources": ["SKILL.md"]}), encoding="utf-8" ) - target = tmp_path / "config" / "skills" / "powercontext-project-context" - - opencode_cli._install_skill(first, target) - opencode_cli._install_skill(second, target) - + config = tmp_path / "config" + configure_opencode(monkeypatch, config) + install_plugin("opencode", source=str(first)) + install_plugin("opencode", source=str(second)) + target = config / "skills/powercontext-project-context" assert (target / "SKILL.md").read_text(encoding="utf-8") == f"{START}\nsecond\n{END}\n" assert not list(target.parent.glob(".powercontext-project-context.*")) def test_interrupted_plugin_install_recovers_on_retry(tmp_path: Path, monkeypatch) -> None: - import powercontext.cli.opencode as opencode_cli - - source = tmp_path / "index.js" - source.write_text("export default {}\n", encoding="utf-8") - target = tmp_path / "config" / "plugins" / "powercontext-opencode.js" + package = _write_plugin(tmp_path / "source") + config = tmp_path / "config" + configure_opencode(monkeypatch, config) + target = config / "plugins/powercontext-opencode.js" manifest = target.parent / ".powercontext-opencode.json" real_replace = os.replace - replacements = 0 - - def interrupted_replace(first: Path, second: Path) -> None: - nonlocal replacements - replacements += 1 - if replacements == 2: - raise OSError - real_replace(first, second) - monkeypatch.setattr(opencode_cli.os, "replace", interrupted_replace) - with pytest.raises(SetupError): - opencode_cli._install_plugin(source, target) + def interrupted_replace(first, second): + if Path(second) == target: + raise OSError("publication failed") # noqa: TRY003 + return real_replace(first, second) - assert manifest.is_file() + with monkeypatch.context() as fault: + fault.setattr(os, "replace", interrupted_replace) + with pytest.raises(SetupError): + install_plugin("opencode", source=str(package)) assert not target.exists() - assert not list(target.parent.glob("*.tmp")) + assert not manifest.exists() + assert not list(config.rglob("*.tmp")) + install_plugin("opencode", source=str(package)) + assert target.read_bytes() == (package / "lib/index.js").read_bytes() + assert json.loads(manifest.read_text()) == {"schema": 1, "owner": "powercontext", "integration": "opencode-plugin"} - monkeypatch.setattr(opencode_cli.os, "replace", real_replace) - opencode_cli._install_plugin(source, target) - - assert target.read_text(encoding="utf-8") == "export default {}\n" - assert json.loads(manifest.read_text(encoding="utf-8")) == { - "schema": 1, - "owner": "powercontext", - "integration": "opencode-plugin", - } - -def test_setup_opencode_refuses_unowned_skill(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_refuses_unowned_skill(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: import powercontext.cli.opencode as opencode_cli checkout = tmp_path / "checkout" @@ -505,34 +451,6 @@ def test_setup_opencode_refuses_unowned_skill(tmp_path: Path, monkeypatch) -> No assert not (config / "plugins" / ".powercontext-opencode.json").exists() -def test_activation_probe_uses_headless_server_without_model(tmp_path: Path, monkeypatch) -> None: - import powercontext.cli.opencode as opencode_cli - - plugin = _write_plugin(tmp_path / "checkout") - config = tmp_path / "config" - observed: list[list[str]] = [] - - monkeypatch.setattr(opencode_cli, "which", lambda _name: "/usr/bin/opencode") - monkeypatch.setattr(opencode_cli, "_run_opencode", _fake_opencode(plugin, config)) - - def probe(command: list[str], env: dict[str, str]) -> None: - observed.append(command) - Path(env["POWERCONTEXT_OPENCODE_ACTIVATION_PROBE_PATH"]).write_text( - env["POWERCONTEXT_OPENCODE_ACTIVATION_PROBE_NONCE"], encoding="utf-8" - ) - - monkeypatch.setattr(opencode_cli, "_run_opencode_probe", probe) - - output, activated = opencode_cli._probe_plugin_activation() - - assert json.loads(output)["plugin"] - assert activated - assert len(observed) == 1 - assert observed[0][:5] == ["/usr/bin/opencode", "serve", "--hostname", "127.0.0.1", "--port"] - assert int(observed[0][5]) > 0 - assert "--model" not in observed[0] - - def test_run_opencode_probe_executes_request_waits_for_nonce_and_stops_process(tmp_path: Path) -> None: import powercontext.cli.opencode as opencode_cli @@ -568,7 +486,7 @@ def test_run_opencode_probe_times_out_and_stops_process(tmp_path: Path, monkeypa _assert_process_stopped(pid_path) -def test_setup_opencode_requires_built_bundle(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_requires_built_bundle(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: import powercontext.cli.opencode as opencode_cli checkout = tmp_path / "checkout" @@ -583,7 +501,7 @@ def test_setup_opencode_requires_built_bundle(tmp_path: Path, monkeypatch) -> No assert "lib/index.js" in result.output -def test_setup_opencode_rejects_unsupported_version(tmp_path: Path, monkeypatch) -> None: +def test_setup_opencode_rejects_unsupported_version(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: import powercontext.cli.opencode as opencode_cli checkout = tmp_path / "checkout" @@ -598,14 +516,15 @@ def test_setup_opencode_rejects_unsupported_version(tmp_path: Path, monkeypatch) assert "requires OpenCode v1.18.21" in result.output -def test_doctor_opencode_reports_plugin_when_another_plugin_writes_to_stdout(tmp_path: Path, monkeypatch) -> None: +def test_doctor_opencode_reports_plugin_when_another_plugin_writes_to_stdout( + tmp_path: Path, monkeypatch, server_checks +) -> None: import powercontext.cli.opencode as opencode_cli plugin = _write_plugin(tmp_path / "checkout") config = tmp_path / "config" - skill = config / "skills" / "powercontext-project-context" - opencode_cli._install_plugin(plugin / "lib" / "index.js", config / "plugins" / "powercontext-opencode.js") - opencode_cli._install_skill(plugin / "skills" / "powercontext-project-context", skill) + configure_opencode(monkeypatch, config) + install_plugin("opencode", source=str(plugin)) monkeypatch.setattr(opencode_cli, "which", lambda _name: "/usr/bin/opencode") fake_opencode = _fake_opencode(plugin, config) @@ -627,18 +546,18 @@ def noisy(*arguments: str, env: dict[str, str] | None = None) -> str: result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "opencode", "--json"]) assert result.exit_code == 0 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["opencode"] assert payload["checks"]["plugin"]["status"] == "ok" assert payload["checks"]["skill"]["status"] == "ok" -def test_doctor_opencode_rejects_configured_but_inactive_plugin(tmp_path: Path, monkeypatch) -> None: +def test_doctor_opencode_rejects_configured_but_inactive_plugin(tmp_path: Path, monkeypatch, server_checks) -> None: import powercontext.cli.opencode as opencode_cli plugin = _write_plugin(tmp_path / "checkout with spaces") config = tmp_path / "config" - skill = config / "skills" / "powercontext-project-context" - opencode_cli._install_skill(plugin / "skills" / "powercontext-project-context", skill) + configure_opencode(monkeypatch, config) + install_plugin("opencode", source=str(plugin)) monkeypatch.setattr(opencode_cli, "which", lambda _name: "/usr/bin/opencode") def inactive(*arguments: str, env: dict[str, str] | None = None) -> str: @@ -657,64 +576,6 @@ def inactive(*arguments: str, env: dict[str, str] | None = None) -> str: result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "opencode", "--json"]) assert result.exit_code == 1 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["opencode"] assert payload["checks"]["plugin"]["status"] == "failed" assert "did not activate" in payload["checks"]["plugin"]["detail"] - - -@pytest.mark.parametrize( - ("uri", "url_path", "expected"), - [ - ( - "file:///C:/Users/Alice/PowerContext%20Plugin", - "/C:/Users/Alice/PowerContext%20Plugin", - r"C:\Users\Alice\PowerContext Plugin", - ), - ( - "file://server/share/PowerContext%20Plugin", - "//server/share/PowerContext%20Plugin", - r"\\server\share\PowerContext Plugin", - ), - ], -) -def test_configured_plugin_converts_windows_file_uris(uri: str, url_path: str, expected: str, monkeypatch) -> None: - import powercontext.cli.opencode as opencode_cli - - def convert(path: str) -> str: - assert path == url_path - return expected - - def is_plugin(path: Path) -> bool: - return str(path) == expected - - monkeypatch.setattr(opencode_cli, "url2pathname", convert) - monkeypatch.setattr(opencode_cli, "_is_opencode_plugin", is_plugin) - - assert opencode_cli._configured_plugin(json.dumps({"plugin": [uri]})) - - -def test_configured_plugin_keeps_package_spec_out_of_file_uri_conversion(monkeypatch) -> None: - import powercontext.cli.opencode as opencode_cli - - spec = "@example/powercontext-opencode" - - def is_plugin(path: Path) -> bool: - return path == Path(spec) - - monkeypatch.setattr( - opencode_cli, - "url2pathname", - lambda _path: pytest.fail("package specifications must not be converted as file URIs"), - ) - monkeypatch.setattr(opencode_cli, "_is_opencode_plugin", is_plugin) - - assert opencode_cli._configured_plugin(json.dumps({"plugin": [spec]})) - - -def test_tui_entry_path_decodes_file_uri(tmp_path: Path) -> None: - import powercontext.cli.opencode as opencode_cli - - target = tmp_path / "PowerContext Plugin" - target.mkdir() - - assert opencode_cli._tui_entry_path(target.as_uri()) == target.resolve() diff --git a/tests/test_opencode_installation.py b/tests/test_opencode_installation.py index 2349b97bf2..5c8342fc58 100644 --- a/tests/test_opencode_installation.py +++ b/tests/test_opencode_installation.py @@ -25,6 +25,7 @@ from powercontext.cli import authorization, opencode from powercontext.cli.guidance import HOST_GUIDANCE, RESOURCE_MANIFEST +from powercontext.cli.installation import setup as install_plugin from powercontext.cli.system import SetupError @@ -35,7 +36,7 @@ def _package(root: Path, version: str) -> Path: for name in ("index.js", "tui.js"): (package / "lib" / name).write_text(f"export default {{version: '{version}'}};\n", encoding="utf-8") shutil.copytree( - Path(__file__).parents[1] / HOST_GUIDANCE["opencode"].skill, package / opencode.OPENCODE_SKILL.parent + Path(__file__).parents[1] / HOST_GUIDANCE["agent-plugin"].skill, package / opencode.OPENCODE_SKILL.parent ) entry = package / opencode.OPENCODE_SKILL entry.write_text( @@ -61,7 +62,7 @@ def installation(request, tmp_path: Path, monkeypatch): monkeypatch.setattr(authorization, "setup_authorization_value", lambda _host: None) if request.param: old = _package(tmp_path, "old") - opencode.install_opencode_plugin(source=str(old), ref="unused") + install_plugin("opencode", source=str(str(old)), ref="unused") skill = config / "skills/powercontext-project-context" (skill / "personal.md").write_bytes(b"My personal instructions\r\n") entry = skill / "SKILL.md" @@ -88,9 +89,6 @@ def _assert_success(config: Path, package: Path, upgrade: bool) -> None: assert (skill / "personal.md").read_bytes() == b"My personal instructions\r\n" assert (skill / "SKILL.md").read_bytes().endswith(b"\r\nMy appended notes\r\n") assert (config / "unrelated.json").read_bytes() == b'{"keep": true}\r\n' - assert not list(config.rglob("*.tmp")) - assert not list(config.glob(".powercontext-install-*")) - assert not list(skill.parent.glob(f".{skill.name}.*.bak")) @pytest.mark.parametrize("invalid", ["missing-reference", "invalid-manifest"]) @@ -105,13 +103,13 @@ def test_rejected_package_preserves_whole_installation_and_can_retry(installatio else: path.write_bytes(b"[]") with pytest.raises(SetupError): - opencode.install_opencode_plugin(source=str(package), ref="unused") + install_plugin("opencode", source=str(str(package)), ref="unused") assert _snapshot(config) == before path.write_bytes(original) - opencode.install_opencode_plugin(source=str(package), ref="unused") + install_plugin("opencode", source=str(str(package)), ref="unused") _assert_success(config, package, upgrade) successful = _snapshot(config) - opencode.install_opencode_plugin(source=str(package), ref="unused") + install_plugin("opencode", source=str(str(package)), ref="unused") assert _snapshot(config) == successful @@ -141,202 +139,8 @@ def fail_once(source, target): with monkeypatch.context() as fault: fault.setattr(os, "replace", fail_once) with pytest.raises(SetupError): - opencode.install_opencode_plugin(source=str(package), ref="unused") + install_plugin("opencode", source=str(str(package)), ref="unused") assert failed assert _snapshot(config) == before - opencode.install_opencode_plugin(source=str(package), ref="unused") + install_plugin("opencode", source=str(str(package)), ref="unused") _assert_success(config, package, upgrade) - - -@pytest.mark.parametrize("installation", [True], indirect=True) -@pytest.mark.parametrize( - "destination", - [ - "plugins/.powercontext-opencode.json", - "plugins/powercontext-opencode.js", - "tui.json", - "plugins/powercontext-opencode-tui.js", - ], -) -def test_failed_file_recovery_blocks_retry_until_restored(installation, destination: str, monkeypatch) -> None: - config, package, _upgrade = installation - before = _snapshot(config) - target = config / destination - old_content = target.read_bytes() - real_replace = os.replace - commit_failed = False - - def fail_commit_and_recovery(source, target): - nonlocal commit_failed - if Path(target) == config / "skills/powercontext-project-context" and not commit_failed: - commit_failed = True - raise OSError("commit") - if commit_failed and Path(target) == config / destination: - raise OSError("recovery") - return real_replace(source, target) - - with monkeypatch.context() as fault: - fault.setattr(os, "replace", fail_commit_and_recovery) - with pytest.raises(SetupError, match=r"recovery failed.*backups retained") as failure: - opencode.install_opencode_plugin(source=str(package), ref="unused") - backups = list(config.glob(".powercontext-install-*")) - assert len(backups) == 1 - assert target.name in str(failure.value) - assert str(backups[0]) in str(failure.value) - old_file = next(path for path in backups[0].iterdir() if path.is_file() and path.read_bytes() == old_content) - pending = _snapshot(config) - with pytest.raises(SetupError, match="recovery") as retry: - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert str(backups[0]) in str(retry.value) - assert _snapshot(config) == pending - - # Simulate the operator restoring the reported backup before trying again. - os.replace(old_file, target) - shutil.rmtree(backups[0]) - assert _snapshot(config) == before - opencode.install_opencode_plugin(source=str(package), ref="unused") - _assert_success(config, package, True) - successful = _snapshot(config) - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert _snapshot(config) == successful - - -@pytest.mark.parametrize("installation", [True], indirect=True) -@pytest.mark.parametrize("also_fail_plugin_recovery", [False, True], ids=["skill", "skill-and-plugin"]) -def test_failed_skill_recovery_reports_backup_and_blocks_retry( - installation, also_fail_plugin_recovery: bool, monkeypatch -) -> None: - config, package, _upgrade = installation - before = _snapshot(config) - skill = config / "skills/powercontext-project-context" - old_skill = _snapshot(skill) - plugin = config / "plugins/powercontext-opencode.js" - old_plugin = plugin.read_bytes() - real_replace = os.replace - skill_failed = False - - def fail_commit_and_recovery(source, target): - nonlocal skill_failed - if Path(target) == skill: - skill_failed = True - message = "Skill destination unavailable" - raise OSError(message) - if skill_failed and also_fail_plugin_recovery and Path(target) == plugin: - message = "plugin recovery unavailable" - raise OSError(message) - return real_replace(source, target) - - with monkeypatch.context() as fault: - fault.setattr(os, "replace", fail_commit_and_recovery) - with pytest.raises(SetupError, match="recovery failed") as failure: - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert not skill.exists() - backups = list(skill.parent.glob(f".{skill.name}.*.bak")) - assert len(backups) == 1 - assert _snapshot(backups[0]) == old_skill - assert str(backups[0]) in str(failure.value) - transactions = list(config.glob(".powercontext-install-*")) - if also_fail_plugin_recovery: - assert len(transactions) == 1 - assert str(transactions[0]) in str(failure.value) - else: - assert not transactions - assert plugin.read_bytes() == old_plugin - - pending = _snapshot(config) - with pytest.raises(SetupError, match="recovery") as retry: - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert str(backups[0]) in str(retry.value) - for transaction in transactions: - assert str(transaction) in str(retry.value) - assert not skill.exists() - assert _snapshot(config) == pending - - os.replace(backups[0], skill) - for transaction in transactions: - old_file = next(path for path in transaction.iterdir() if path.is_file() and path.read_bytes() == old_plugin) - os.replace(old_file, plugin) - shutil.rmtree(transaction) - assert _snapshot(config) == before - opencode.install_opencode_plugin(source=str(package), ref="unused") - _assert_success(config, package, True) - successful = _snapshot(config) - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert _snapshot(config) == successful - - -@pytest.mark.parametrize("installation", [False], indirect=True) -def test_failed_fresh_install_cleanup_blocks_retry_until_reconciled(installation, monkeypatch) -> None: - config, package, _upgrade = installation - before = _snapshot(config) - plugin = config / "plugins/powercontext-opencode.js" - real_replace, real_unlink = os.replace, Path.unlink - - def fail_skill_commit(source, target): - if Path(target) == config / "skills/powercontext-project-context": - raise OSError("commit") - return real_replace(source, target) - - def fail_plugin_cleanup(path, *args, **kwargs): - if path == plugin: - raise OSError("cleanup") - return real_unlink(path, *args, **kwargs) - - with monkeypatch.context() as fault: - fault.setattr(os, "replace", fail_skill_commit) - fault.setattr(Path, "unlink", fail_plugin_cleanup) - with pytest.raises(SetupError, match="recovery failed"): - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert plugin.exists() - transactions = list(config.glob(".powercontext-install-*")) - assert len(transactions) == 1 - pending = _snapshot(config) - with pytest.raises(SetupError, match="recovery") as retry: - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert str(transactions[0]) in str(retry.value) - assert _snapshot(config) == pending - plugin.unlink() - shutil.rmtree(transactions[0]) - assert _snapshot(config) == before - opencode.install_opencode_plugin(source=str(package), ref="unused") - _assert_success(config, package, False) - - -@pytest.mark.parametrize("pending", ["multiple", "unowned", "file"]) -def test_retry_never_selects_or_overwrites_unresolved_skill_backups(installation, pending: str) -> None: - config, package, _upgrade = installation - skill = config / "skills/powercontext-project-context" - skill.parent.mkdir(parents=True, exist_ok=True) - backups = [skill.with_name(f".{skill.name}.{index:032x}.bak") for index in range(2 if pending == "multiple" else 1)] - for backup in backups: - if pending == "file": - backup.write_bytes(b"unrecognized recovery data") - else: - backup.mkdir() - (backup / "personal.md").write_text(f"Unique notes from {backup.name}", encoding="utf-8") - before = _snapshot(config) - with pytest.raises(SetupError, match="recovery") as failure: - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert all(str(backup) in str(failure.value) for backup in backups) - assert _snapshot(config) == before - - -@pytest.mark.parametrize("location", ["config", "skills"]) -def test_recovery_inspection_error_aborts_before_writes(installation, location: str, monkeypatch) -> None: - config, package, _upgrade = installation - unreadable = config if location == "config" else config / "skills" - unreadable.mkdir(exist_ok=True) - before = _snapshot(config) - real_iterdir = Path.iterdir - - def cannot_list(path): - if path == unreadable: - message = "recovery directory unreadable" - raise PermissionError(message) - return real_iterdir(path) - - with monkeypatch.context() as fault: - fault.setattr(Path, "iterdir", cannot_list) - with pytest.raises(SetupError, match="recovery"): - opencode.install_opencode_plugin(source=str(package), ref="unused") - assert _snapshot(config) == before diff --git a/tests/test_pi_cli.py b/tests/test_pi_cli.py index 4fb955dbe8..e42dcc93c3 100644 --- a/tests/test_pi_cli.py +++ b/tests/test_pi_cli.py @@ -21,7 +21,9 @@ import pytest from typer.testing import CliRunner +from powercontext.cli import git_source from powercontext.cli.app import create_cli +from powercontext.cli.git_source import resolve_plugin_source from powercontext.cli.system import SetupError, doctor_app, setup_app @@ -40,7 +42,9 @@ def _write_pi_package(root: Path) -> Path: return package -def test_setup_pi_installs_the_native_package_and_reports_configuration(tmp_path: Path, monkeypatch) -> None: +def test_setup_pi_installs_the_native_package_and_reports_configuration( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: import powercontext.cli.pi as pi_cli checkout = tmp_path / "powercontext" @@ -53,13 +57,13 @@ def test_setup_pi_installs_the_native_package_and_reports_configuration(tmp_path result = CliRunner().invoke(create_cli([setup_app]), ["setup", "pi", "--source", str(checkout)]) assert result.exit_code == 0 - assert "PowerContext Pi setup complete." in result.output - assert str(package) in result.output + assert "pi: ok" in result.output + assert str(tmp_path / "data/plugins/pi") in result.output @pytest.mark.parametrize("endpoint", ["http://127.0.0.1:18000", "https://remote.example/proxy"]) def test_setup_pi_binds_runtime_and_credentials_to_selected_endpoint( - tmp_path: Path, monkeypatch, endpoint: str + tmp_path: Path, monkeypatch, endpoint: str, installed_agent_checks ) -> None: """A real installer persists one endpoint for runtime and URL-bound credentials.""" import powercontext.cli.pi as pi_cli @@ -83,7 +87,9 @@ def test_setup_pi_binds_runtime_and_credentials_to_selected_endpoint( assert read_stored_authorization(credential_path("pi"), server_url="https://other.example").status == "url_mismatch" -def test_setup_pi_refreshes_remote_source_and_replaces_previous_package(tmp_path: Path, monkeypatch) -> None: +def test_setup_pi_reuses_its_owned_path_and_preserves_other_registrations( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: import powercontext.cli.pi as pi_cli data_dir = tmp_path / "data" @@ -91,10 +97,13 @@ def test_setup_pi_refreshes_remote_source_and_replaces_previous_package(tmp_path monkeypatch.setattr(pi_cli, "which", lambda _name: "/usr/bin/pi") legacy_package = _write_pi_package(data_dir / "checkouts" / "pi" / "v0.0.1") - current_root = pi_cli.checkout_target("master") + current_root = data_dir / "checkouts/pi/current" current_package = _write_pi_package(current_root) (current_root / "source.txt").write_text("stale/source@master", encoding="utf-8") - installed_packages = {str(legacy_package), str(current_package)} + unrelated = _write_pi_package(tmp_path / "unrelated") + (unrelated / "package.json").write_text('{"name":"another-plugin"}', encoding="utf-8") + project_package = _write_pi_package(tmp_path / "project") + installed_packages = {str(legacy_package), str(current_package), str(unrelated)} def fake_pi(*arguments: str) -> str: command, *values = arguments @@ -102,8 +111,10 @@ def fake_pi(*arguments: str) -> str: lines = ["User packages:"] for package in sorted(installed_packages): lines.extend((f" {package}", f" {package}")) + lines.extend(("Project packages:", f" {project_package}", f" {project_package}")) return "\n".join(lines) if command == "remove": + assert values[0] != str(project_package) installed_packages.discard(values[0]) return "" if command == "install": @@ -116,20 +127,70 @@ def clone(source: str, ref: str, target: Path) -> None: (target / "source.txt").write_text(f"{source}@{ref}", encoding="utf-8") monkeypatch.setattr(pi_cli, "_run_pi", fake_pi) - monkeypatch.setattr(pi_cli, "_clone_github_source", clone) + monkeypatch.setattr(git_source, "clone_github_source", clone) result = CliRunner().invoke( create_cli([setup_app]), ["setup", "pi", "--source", "another/powercontext", "--ref", "master"], ) - refreshed_package = current_root / "integrations" / "pi" / "plugins" / "powercontext" - assert result.exit_code == 0 - assert installed_packages == {str(refreshed_package)} - assert (current_root / "source.txt").read_text(encoding="utf-8") == "another/powercontext@master" + assert result.exit_code == 0, result.output + refreshed_package = data_dir / "plugins/pi" + assert installed_packages == {str(unrelated), str(refreshed_package)} + result = CliRunner().invoke( + create_cli([setup_app]), ["setup", "pi", "--source", "another/powercontext", "--ref", "master"] + ) + assert result.exit_code == 0, result.output + assert installed_packages == {str(unrelated), str(refreshed_package)} + assert (refreshed_package / "extensions/powercontext.ts").is_file() + assert (legacy_package / "package.json").is_file() + assert (project_package / "package.json").is_file() -def test_setup_pi_keeps_the_existing_package_when_refresh_installation_fails(tmp_path: Path, monkeypatch) -> None: +@pytest.mark.parametrize("installed", [False, True]) +def test_setup_pi_rejects_filtered_legacy_migration_without_changing_host( + tmp_path: Path, monkeypatch, installed_agent_checks, installed: bool +) -> None: + import powercontext.cli.pi as pi_cli + + package = _write_pi_package(tmp_path / "checkout") + legacy = _write_pi_package(tmp_path / "legacy") + home = tmp_path / "home" + monkeypatch.setenv("POWERCONTEXT_HOME", str(home / "data")) + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(home / "agent")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(home / "clients.json")) + monkeypatch.setattr(pi_cli, "which", lambda _name: "pi") + destination = home / "data/plugins/pi" + if installed: + destination.mkdir(parents=True) + (destination / ".powercontext-owned").write_text("powercontext-pi\n", encoding="utf-8") + (destination / "release.txt").write_text("old release", encoding="utf-8") + registry = {"packages": [{"source": str(legacy), "extensions": [], "skills": []}]} + settings = home / "agent/settings.json" + settings.parent.mkdir(parents=True, exist_ok=True) + settings.write_text(json.dumps(registry), encoding="utf-8") + + def native_command(*arguments): + if arguments[0] == "list": + return f"User packages:\n {legacy} (filtered)\n {legacy}\n" + if arguments[0] == "install": + registry["packages"].append(arguments[1]) + settings.write_text(json.dumps(registry), encoding="utf-8") + return "" + + monkeypatch.setattr(pi_cli, "_run_pi", native_command) + before = {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} + result = CliRunner().invoke(create_cli([setup_app]), ["setup", "pi", "--source", str(package), "--json"]) + + assert result.exit_code == 1, result.output + assert "resource filters" in json.loads(result.output)["hosts"]["pi"]["error"] + assert {path: path.read_bytes() for path in home.rglob("*") if path.is_file()} == before + + +@pytest.mark.parametrize("owned", [False, True], ids=["checkout", "owned-package"]) +def test_setup_pi_keeps_the_existing_package_when_refresh_installation_fails( + tmp_path: Path, monkeypatch, installed_agent_checks, owned: bool +) -> None: import powercontext.cli.pi as pi_cli data_dir = tmp_path / "data" @@ -137,6 +198,8 @@ def test_setup_pi_keeps_the_existing_package_when_refresh_installation_fails(tmp _write_pi_package(checkout) existing = _write_pi_package(tmp_path / "existing") installed_packages = {str(existing)} + (existing / "extensions/powercontext.ts").write_text("// working extension\n", encoding="utf-8") + fail_install = False monkeypatch.setenv("POWERCONTEXT_HOME", str(data_dir)) monkeypatch.setattr(pi_cli, "which", lambda _name: "/usr/bin/pi") @@ -144,38 +207,46 @@ def test_setup_pi_keeps_the_existing_package_when_refresh_installation_fails(tmp def fake_pi(*arguments: str) -> str: command, *values = arguments if command == "list": - return "\n".join(["User packages:", f" {existing}", f" {existing}"]) + return "User packages:\n" + "".join(f" {path}\n {path}\n" for path in installed_packages) if command == "install": - raise SetupError.command_failed(["pi", "install"], "simulated Pi installation failure") + if fail_install: + message = "`pi install` failed: simulated Pi installation failure" + raise SetupError(message) + installed_packages.add(values[0]) + return "" if command == "remove": installed_packages.discard(values[0]) return "" raise AssertionError monkeypatch.setattr(pi_cli, "_run_pi", fake_pi) + cli = create_cli([setup_app]) + if owned: + result = CliRunner().invoke(cli, ["setup", "pi", "--source", str(existing)]) + assert result.exit_code == 0, result.output + previous = {path: (Path(path) / "extensions/powercontext.ts").read_bytes() for path in installed_packages} + fail_install = True - result = CliRunner().invoke(create_cli([setup_app]), ["setup", "pi", "--source", str(checkout)]) + result = CliRunner().invoke(cli, ["setup", "pi", "--source", str(checkout)]) assert result.exit_code == 1 assert "simulated Pi installation failure" in result.output - assert installed_packages == {str(existing)} + assert {path: (Path(path) / "extensions/powercontext.ts").read_bytes() for path in installed_packages} == previous -def test_setup_pi_does_not_echo_source_credentials(tmp_path: Path, monkeypatch) -> None: - import powercontext.cli.pi as pi_cli +def test_setup_pi_does_not_echo_source_credentials(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: marker = "redacted-value" source = f"https://{marker}@github.com/oceanbase/powercontext" monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) with pytest.raises(SetupError) as raised: - pi_cli.resolve_pi_plugin_dir(source=source, ref="master") + resolve_plugin_source(source=source, ref="master", host="pi") assert marker not in str(raised.value) -def test_setup_pi_does_not_echo_git_clone_output(tmp_path: Path, monkeypatch) -> None: - import powercontext.cli.pi as pi_cli +def test_setup_pi_does_not_echo_git_clone_output(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: marker = "redacted-value" monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) @@ -185,12 +256,12 @@ def test_setup_pi_does_not_echo_git_clone_output(tmp_path: Path, monkeypatch) -> ) with pytest.raises(SetupError) as raised: - pi_cli.resolve_pi_plugin_dir(source="oceanbase/powercontext", ref="master") + resolve_plugin_source(source="oceanbase/powercontext", ref="master", host="pi") assert marker not in str(raised.value) -def test_doctor_pi_reports_a_missing_cli(monkeypatch) -> None: +def test_doctor_pi_reports_a_missing_cli(monkeypatch, server_checks) -> None: import powercontext.cli.pi as pi_cli monkeypatch.setattr(pi_cli, "which", lambda _name: None) @@ -202,7 +273,7 @@ def test_doctor_pi_reports_a_missing_cli(monkeypatch) -> None: assert "package: skipped - not checked because Pi CLI is unavailable" in result.output -def test_doctor_pi_reports_an_installed_package_as_json(tmp_path: Path, monkeypatch) -> None: +def test_doctor_pi_reports_an_installed_package_as_json(tmp_path: Path, monkeypatch, server_checks) -> None: import powercontext.cli.pi as pi_cli package = _write_pi_package(tmp_path / "powercontext") @@ -213,7 +284,7 @@ def test_doctor_pi_reports_an_installed_package_as_json(tmp_path: Path, monkeypa result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "pi", "--json"]) assert result.exit_code == 0 - payload = json.loads(result.output) + payload = json.loads(result.output)["hosts"]["pi"] assert payload["ok"] is True assert payload["checks"]["package"] == { "ok": True, diff --git a/tests/test_setup_rollback.py b/tests/test_setup_rollback.py index 1421ea7d25..77b6b770c5 100644 --- a/tests/test_setup_rollback.py +++ b/tests/test_setup_rollback.py @@ -12,21 +12,79 @@ # See the License for the specific language governing permissions and # limitations under the License. + +"""Installation failure restores exact user configuration bytes.""" + +import json from pathlib import Path import pytest +from typer.testing import CliRunner -from powercontext.cli import system, workbuddy +from powercontext.cli import installation +from powercontext.cli.app import create_cli -@pytest.mark.parametrize("host", ["claude-code", "workbuddy"]) -def test_setup_rollback_restores_exact_configuration_bytes(tmp_path: Path, monkeypatch, host: str) -> None: +def test_failed_local_publication_restores_exact_configuration_bytes(tmp_path, monkeypatch): original = b'{\r\n "custom": true,\n "note": "keep original line endings"\r\n}\r\n' - settings = tmp_path / "settings.json" - settings.write_bytes(b"{}\n") - if host == "claude-code": - monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) - system._restore_claude_settings(original) - else: - workbuddy._restore_file(settings, original) + home = tmp_path / "workbuddy" + home.mkdir() + settings = home / "settings.json" + settings.write_bytes(original) + monkeypatch.setenv("WORKBUDDY_HOME", str(home)) + write = installation.write_bytes + + def fail_settings(path, content): + if path == settings: + raise OSError("settings write failed") # noqa: TRY003 + write(path, content) + + monkeypatch.setattr(installation, "write_bytes", fail_settings) + with pytest.raises(OSError, match="settings write failed"): + installation.installers()["workbuddy"]().install(installation.PluginSource(str(Path(__file__).parents[1]))) assert settings.read_bytes() == original + assert not (home / "hooks/workbuddy_powercontext_hook.py").exists() + + +def test_retry_preserves_personal_skill_files_after_failed_restore(tmp_path, monkeypatch, installed_agent_checks): + home = tmp_path / "workbuddy" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("WORKBUDDY_HOME", str(home)) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + cli, runner = create_cli(), CliRunner() + command = ["setup", "workbuddy", "--source", str(Path(__file__).parents[1]), "--json"] + result = runner.invoke(cli, command) + assert result.exit_code == 0, result.output + skill = home / "skills/powercontext-project-context" + (skill / "personal.md").write_text("Keep my own instructions.\n", encoding="utf-8") + settings = home / "settings.json" + write, replace = installation.write_bytes, installation.os.replace + recovery = None + + def fail_settings(path, content): + if path == settings: + raise OSError("settings write failed") # noqa: TRY003 + write(path, content) + + def fail_restore(source, target): + nonlocal recovery + if target == skill and source.suffix == ".bak": + recovery = source + raise OSError("Skill restore failed") # noqa: TRY003 + replace(source, target) + + with monkeypatch.context() as failure: + failure.setattr(installation, "write_bytes", fail_settings) + failure.setattr(installation.os, "replace", fail_restore) + result = runner.invoke(cli, command) + assert result.exit_code == 1 + assert recovery is not None + result = runner.invoke(cli, command) + assert result.exit_code == 1, result.output + assert str(recovery) in json.loads(result.output)["hosts"]["workbuddy"]["error"] + assert (recovery / "personal.md").read_text(encoding="utf-8") == "Keep my own instructions.\n" + recovery.rename(skill) + result = runner.invoke(cli, command) + assert result.exit_code == 0, result.output + assert (skill / "personal.md").read_text(encoding="utf-8") == "Keep my own instructions.\n" diff --git a/tests/test_setup_select.py b/tests/test_setup_select.py deleted file mode 100644 index dec05aafba..0000000000 --- a/tests/test_setup_select.py +++ /dev/null @@ -1,451 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import json -from unittest.mock import Mock - -import pytest -from typer.testing import CliRunner - -import powercontext.cli.dsh as dsh_cli -import powercontext.cli.hermes as hermes_cli -import powercontext.cli.hosts as hosts_cli -import powercontext.cli.openclaw as openclaw_cli -import powercontext.cli.opencode as opencode_cli -import powercontext.cli.pi as pi_cli -import powercontext.cli.system as system_cli -from powercontext.cli.app import create_cli -from powercontext.cli.hosts import parse_host_selection -from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError, setup_app - -FIRST_CLASS_HOSTS = ("codex", "claude-code", "dsh", "openclaw", "opencode", "pi", "hermes") - - -@pytest.fixture(autouse=True) -def isolate_setup_configuration(tmp_path, monkeypatch) -> None: - """Keep endpoint discovery independent of the developer's environment file.""" - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) - - -def _cli(): - return create_cli([setup_app]) - - -def _invoke(arguments: list[str], *, stdin_text: str | None = None): - return CliRunner().invoke(_cli(), arguments, input=stdin_text) - - -def _patch_installers(monkeypatch, **replacements: Mock) -> dict[str, Mock]: - _patch_diagnostics(monkeypatch) - installers = { - "codex": Mock(name="install_codex_plugin", return_value=object()), - "claude-code": Mock(name="install_claude_code_plugin", return_value=object()), - "dsh": Mock(name="install_dsh_plugin", return_value=object()), - "openclaw": Mock(name="install_openclaw_plugin", return_value=object()), - "opencode": Mock(name="install_opencode_plugin", return_value=object()), - "pi": Mock(name="install_pi_plugin", return_value=object()), - "hermes": Mock(name="install_hermes_plugin", return_value=object()), - } - installers.update(replacements) - monkeypatch.setattr(system_cli, "install_codex_plugin", installers["codex"]) - monkeypatch.setattr(system_cli, "install_claude_code_plugin", installers["claude-code"]) - monkeypatch.setattr(dsh_cli, "install_dsh_plugin", installers["dsh"]) - monkeypatch.setattr(openclaw_cli, "install_openclaw_plugin", installers["openclaw"]) - monkeypatch.setattr(opencode_cli, "install_opencode_plugin", installers["opencode"]) - monkeypatch.setattr(pi_cli, "install_pi_plugin", installers["pi"]) - monkeypatch.setattr(hermes_cli, "install_hermes_plugin", installers["hermes"]) - return installers - - -def _patch_diagnostics(monkeypatch, **replacements: Mock) -> dict[str, Mock]: - diagnostics = { - "codex": Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.OK, "installed")}), - "dsh": Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.OK, "installed")}), - "opencode": Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.OK, "installed")}), - "pi": Mock(return_value={"package": Diagnostic(DiagnosticStatus.OK, "installed")}), - "hermes": Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.OK, "installed")}), - } - diagnostics.update(replacements) - monkeypatch.setattr(system_cli, "run_codex_diagnostics", diagnostics["codex"]) - monkeypatch.setattr(dsh_cli, "run_dsh_diagnostics", diagnostics["dsh"]) - monkeypatch.setattr(opencode_cli, "run_opencode_diagnostics", diagnostics["opencode"]) - monkeypatch.setattr(pi_cli, "run_pi_diagnostics", diagnostics["pi"]) - monkeypatch.setattr(hermes_cli, "run_hermes_diagnostics", diagnostics["hermes"]) - return diagnostics - - -def _assert_not_called(*installers: Mock) -> None: - for installer in installers: - installer.assert_not_called() - - -def test_selected_agents_share_resolved_endpoint_and_persistence(tmp_path, monkeypatch) -> None: - """Multi-host orchestration forwards one endpoint to installers and native Hermes settings.""" - from powercontext.client.transport_policy import load_client_settings - - installers = _patch_installers(monkeypatch) - monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) - arguments = ["setup", "select", "--server-url", "http://127.0.0.1:18000", "--json"] - for host in FIRST_CLASS_HOSTS: - arguments.extend(["--host", host]) - result = _invoke(arguments) - assert result.exit_code == 0, result.output - for host, installer in installers.items(): - assert load_client_settings(host)["server_url"] == "http://127.0.0.1:18000" - if host != "hermes": - assert installer.call_args.kwargs["server_url"] == "http://127.0.0.1:18000" - native = json.loads((tmp_path / "hermes/powercontext/config.json").read_text()) - assert native["base_url"] == "http://127.0.0.1:18000" - - -def test_setup_without_subcommand_prints_help_and_installs_nothing(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - - result = _invoke(["setup"]) - - assert result.exit_code == 2 - assert "Usage" in result.output - assert "Install and configure PowerContext integrations." in result.output - _assert_not_called(*installers.values()) - - -def test_setup_select_json_without_host_does_not_install(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - - result = _invoke(["setup", "select", "--json"]) - - assert result.exit_code == 1 - assert "--host" in result.output - _assert_not_called(*installers.values()) - - -def test_setup_select_without_host_requires_host_on_a_non_tty(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: False) - - result = _invoke(["setup", "select"]) - - assert result.exit_code == 1 - assert "--host" in result.output - _assert_not_called(*installers.values()) - - -def test_setup_select_rejects_an_unknown_host_before_installing(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - - result = _invoke(["setup", "select", "--host", "unknown"]) - - assert result.exit_code == 1 - assert "unknown host: unknown" in result.output - assert "codex" in result.output - _assert_not_called(*installers.values()) - - -def test_setup_select_installs_only_the_requested_hosts(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - - result = _invoke(["setup", "select", "--host", "codex", "--host", "dsh", "--json"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == { - "hosts": [ - {"host": "codex", "status": "installed"}, - {"host": "claude-code", "status": "skipped"}, - {"host": "dsh", "status": "installed"}, - {"host": "openclaw", "status": "skipped"}, - {"host": "opencode", "status": "skipped"}, - {"host": "pi", "status": "skipped"}, - {"host": "hermes", "status": "skipped"}, - {"host": "workbuddy", "status": "skipped"}, - ] - } - installers["codex"].assert_called_once() - installers["dsh"].assert_called_once() - _assert_not_called( - installers["claude-code"], - installers["openclaw"], - installers["opencode"], - installers["pi"], - installers["hermes"], - ) - - -def test_setup_select_continues_after_a_selected_host_fails(monkeypatch) -> None: - installers = _patch_installers( - monkeypatch, - dsh=Mock(side_effect=SetupError.dsh_unavailable()), - ) - - result = _invoke(["setup", "select", "--host", "dsh", "--host", "hermes", "--json"]) - - assert result.exit_code == 1 - assert json.loads(result.output) == { - "hosts": [ - {"host": "codex", "status": "skipped"}, - {"host": "claude-code", "status": "skipped"}, - { - "host": "dsh", - "status": "failed", - "error": "DeepSeek Harness CLI is not installed or is not on PATH.", - }, - {"host": "openclaw", "status": "skipped"}, - {"host": "opencode", "status": "skipped"}, - {"host": "pi", "status": "skipped"}, - {"host": "hermes", "status": "installed"}, - {"host": "workbuddy", "status": "skipped"}, - ] - } - installers["dsh"].assert_called_once() - installers["hermes"].assert_called_once() - - -def test_setup_select_reports_an_unavailable_selected_host_and_continues(monkeypatch) -> None: - installers = _patch_installers( - monkeypatch, - codex=Mock(side_effect=SetupError.codex_unavailable()), - ) - - result = _invoke(["setup", "select", "--host", "codex", "--host", "pi"]) - - assert result.exit_code == 1 - assert "codex: failed - Codex CLI is not installed or is not on PATH." in result.output - assert "pi: installed" in result.output - installers["codex"].assert_called_once() - installers["pi"].assert_called_once() - - -def test_setup_select_reports_a_successful_rerun_as_installed(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - - first = _invoke(["setup", "select", "--host", "codex", "--json"]) - second = _invoke(["setup", "select", "--host", "codex", "--json"]) - - assert first.exit_code == 0 - assert second.exit_code == 0 - assert json.loads(second.output)["hosts"][0] == {"host": "codex", "status": "installed"} - assert installers["codex"].call_count == 2 - - -def test_setup_select_reads_a_tty_selection_by_number(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: True) - - result = _invoke(["setup", "select"], stdin_text="1,3\n") - - assert result.exit_code == 0 - assert "codex: installed" in result.output - assert "dsh: installed" in result.output - assert "claude-code: skipped" in result.output - installers["codex"].assert_called_once() - installers["dsh"].assert_called_once() - _assert_not_called( - installers["claude-code"], - installers["openclaw"], - installers["opencode"], - installers["pi"], - installers["hermes"], - ) - - -def test_setup_select_cancels_an_empty_tty_selection(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: True) - - result = _invoke(["setup", "select"], stdin_text="\n") - - assert result.exit_code == 0 - _assert_not_called(*installers.values()) - - -def test_setup_select_rejects_an_invalid_tty_token_without_installing(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: True) - - result = _invoke(["setup", "select"], stdin_text="nope\n") - - assert result.exit_code == 1 - assert "unknown host: nope" in result.output - _assert_not_called(*installers.values()) - - -def test_setup_select_deduplicates_repeated_host_flags(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - - result = _invoke(["setup", "select", "--host", "codex", "--host", "codex", "--json"]) - - assert result.exit_code == 0 - installers["codex"].assert_called_once() - - -def test_setup_select_json_writes_the_matrix_without_a_prompt(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: True) - - result = _invoke(["setup", "select", "--host", "codex", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["hosts"][0] == {"host": "codex", "status": "installed"} - assert "Select hosts" not in result.output - assert "Next:" not in result.output - installers["codex"].assert_called_once() - - -def test_setup_select_passes_source_ref_and_host_specific_defaults(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - - result = _invoke([ - "setup", - "select", - "--host", - "codex", - "--host", - "claude-code", - "--host", - "openclaw", - "--host", - "opencode", - "--source", - "oceanbase/powercontext", - "--ref", - "tested-ref", - ]) - - assert result.exit_code == 0 - installers["codex"].assert_called_once_with( - source="oceanbase/powercontext", ref="tested-ref", server_url="http://127.0.0.1:8000" - ) - installers["claude-code"].assert_called_once_with( - source="oceanbase/powercontext", - ref="tested-ref", - server_url="http://127.0.0.1:8000", - capture_prompts=True, - allow_insecure_http=False, - ) - installers["openclaw"].assert_called_once_with( - source="oceanbase/powercontext", - ref="tested-ref", - server_url="http://127.0.0.1:8000", - allow_insecure_http=False, - ) - installers["opencode"].assert_called_once_with( - source="oceanbase/powercontext", ref="tested-ref", server_url="http://127.0.0.1:8000" - ) - - -def test_setup_select_passes_server_override_to_openclaw(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - - result = _invoke([ - "setup", - "select", - "--host", - "openclaw", - "--server-url", - "https://memory.example", - ]) - - assert result.exit_code == 0 - installers["openclaw"].assert_called_once_with( - source="oceanbase/powercontext", - ref="master", - server_url="https://memory.example", - allow_insecure_http=False, - ) - - -@pytest.mark.parametrize( - ("host", "module", "attribute"), - [ - ("codex", system_cli, "run_codex_diagnostics"), - ("dsh", dsh_cli, "run_dsh_diagnostics"), - ("opencode", opencode_cli, "run_opencode_diagnostics"), - ("pi", pi_cli, "run_pi_diagnostics"), - ("hermes", hermes_cli, "run_hermes_diagnostics"), - ], -) -def test_setup_select_fails_a_row_when_post_install_verification_fails( - monkeypatch, - host: str, - module: object, - attribute: str, -) -> None: - installers = _patch_installers(monkeypatch) - verification = Mock( - return_value={"plugin": Diagnostic(DiagnosticStatus.FAILED, "PowerContext plugin is not loaded")} - ) - monkeypatch.setattr(module, attribute, verification) - - result = _invoke(["setup", "select", "--host", host, "--json"]) - - assert result.exit_code == 1 - row = next(row for row in json.loads(result.output)["hosts"] if row["host"] == host) - assert row == { - "host": host, - "status": "failed", - "error": "post-install verification failed: plugin: PowerContext plugin is not loaded", - } - installers[host].assert_called_once() - verification.assert_called_once_with() - - -def test_setup_select_continues_after_post_install_verification_fails(monkeypatch) -> None: - installers = _patch_installers(monkeypatch) - monkeypatch.setattr( - system_cli, - "run_codex_diagnostics", - Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.FAILED, "plugin is not loaded")}), - ) - - result = _invoke(["setup", "select", "--host", "codex", "--host", "openclaw", "--json"]) - - assert result.exit_code == 1 - rows = {row["host"]: row for row in json.loads(result.output)["hosts"]} - assert rows["codex"]["status"] == "failed" - assert rows["openclaw"] == {"host": "openclaw", "status": "installed"} - installers["openclaw"].assert_called_once() - - -def test_setup_select_prints_hermes_specific_next_step_only_when_installed(monkeypatch) -> None: - _patch_installers(monkeypatch) - - installed = _invoke(["setup", "select", "--host", "hermes"]) - skipped = _invoke(["setup", "select", "--host", "openclaw"]) - - assert installed.exit_code == 0 - assert "`hermes memory setup`" in installed.output - assert "`hermes memory setup`" not in skipped.output - - -def test_setup_dsh_still_fails_closed_when_the_cli_is_missing(monkeypatch) -> None: - monkeypatch.setattr(dsh_cli, "which", lambda _name: None) - - result = _invoke(["setup", "dsh"]) - - assert result.exit_code == 1 - assert "DeepSeek Harness CLI is not installed" in result.output - - -def test_parse_host_selection_accepts_names_and_reorders_to_the_catalog() -> None: - assert parse_host_selection("dsh,codex") == ("codex", "dsh") - assert parse_host_selection("4") == ("openclaw",) - assert parse_host_selection("5") == ("opencode",) - assert parse_host_selection("7") == ("hermes",) - assert parse_host_selection("") is None - assert parse_host_selection(" ") is None diff --git a/tests/test_setup_transport.py b/tests/test_setup_transport.py index a240668acd..a89df1f497 100644 --- a/tests/test_setup_transport.py +++ b/tests/test_setup_transport.py @@ -15,17 +15,16 @@ """Setup consent must be explicit, endpoint-bound, and usable in a new session.""" import json -import os -import subprocess -import sys from unittest.mock import Mock import pytest from typer.testing import CliRunner +from powercontext.cli import installation from powercontext.cli.app import create_cli -from powercontext.cli.hosts import HOST_NAMES +from powercontext.cli.installation import installers from powercontext.cli.system import setup_app +from tests.install_support import stub_installer @pytest.fixture(autouse=True) @@ -33,6 +32,7 @@ def isolate_client_config(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) monkeypatch.chdir(tmp_path) + monkeypatch.setattr("powercontext.cli.system._run_codex_json", lambda *_args: {"installed": []}) monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) monkeypatch.delenv("POWERCONTEXT_CLIENT_ALLOW_INSECURE_HTTP", raising=False) monkeypatch.delenv("POWERCONTEXT_CLIENT_SERVER_URL", raising=False) @@ -50,15 +50,15 @@ def test_every_setup_rejects_remote_http_without_consent_before_install(host, mo @pytest.mark.parametrize("host", ["codex", "claude-code", "dsh", "openclaw", "opencode", "pi", "hermes", "workbuddy"]) -def test_discovered_local_port_persists_for_every_agent(host, tmp_path): +def test_discovered_local_port_persists_for_every_agent(install_connection, host, tmp_path): """A new Agent session keeps the selected local listener, not the default port.""" - from powercontext.cli.transport import prepare_setup_transport, save_setup_transport + from powercontext.cli.transport import prepare_setup_transport from powercontext.client.transport_policy import resolve_client_transport (tmp_path / ".env").write_text("POWERCONTEXT_SERVER_HTTP_PORT=18000\n") selected = prepare_setup_transport(host) assert selected.server_url == "http://127.0.0.1:18000" - save_setup_transport(selected) + install_connection(selected) (tmp_path / ".env").unlink() assert resolve_client_transport(host)[0] == selected.server_url @@ -80,7 +80,6 @@ def test_explicit_remote_endpoint_overrides_file_and_local_port(tmp_path): @pytest.mark.parametrize("configuration", ["file", "process", "both", "explicit"]) def test_new_codex_endpoint_replaces_saved_and_native_settings(tmp_path, monkeypatch, configuration): """Refreshing setup persists a newly supplied URL despite stale installed settings.""" - from powercontext.cli.hosts import setup_host from powercontext.client.transport_policy import client_config_file, load_client_settings old_url = "http://127.0.0.1:8100" @@ -96,15 +95,14 @@ def test_new_codex_endpoint_replaces_saved_and_native_settings(tmp_path, monkeyp (tmp_path / ".env").write_text(f"POWERCONTEXT_CLIENT_SERVER_URL={new_url}\n") if configuration in {"process", "both"}: monkeypatch.setenv("POWERCONTEXT_CLIENT_SERVER_URL", new_url) - installer = Mock(return_value="installed") - monkeypatch.setattr("powercontext.cli.hosts.install_host", installer) + installer = Mock(return_value={}) + stub_installer(monkeypatch, "codex", installer) - result = setup_host( + result = installation.setup( "codex", source="local-source", ref="master", server_url=new_url if configuration == "explicit" else None ) - assert result.result == "installed" - assert installer.call_args.kwargs["server_url"] == new_url + assert result == {} assert load_client_settings("codex")["server_url"] == new_url assert json.loads(settings.read_text())["hosts"]["codex"] == { "server_url": new_url, @@ -113,7 +111,7 @@ def test_new_codex_endpoint_replaces_saved_and_native_settings(tmp_path, monkeyp } -def test_saved_endpoints_still_require_choice_without_new_configuration(tmp_path): +def test_saved_endpoints_still_require_choice_without_new_configuration(tmp_path, monkeypatch): """Conflicting persisted settings remain ambiguous when no new URL is supplied.""" from powercontext.cli.transport import client_config_file, prepare_setup_transport @@ -126,6 +124,19 @@ def test_saved_endpoints_still_require_choice_without_new_configuration(tmp_path json.dumps({"mcpServers": {"powercontext": {"url": "http://127.0.0.1:8000/mcp/"}}}) ) + monkeypatch.setattr( + "powercontext.cli.system._run_codex_json", + lambda *_args: { + "installed": [ + { + "name": "powercontext", + "installed": True, + "marketplaceName": "powercontext", + "version": "0.1.0", + } + ] + }, + ) with pytest.raises(RuntimeError, match=r"Conflicting.*saved client settings, native host settings"): prepare_setup_transport("codex") @@ -169,70 +180,55 @@ def test_saved_native_remote_endpoint_is_not_replaced_by_local_port(tmp_path, mo assert prepare_setup_transport("hermes").server_url == "https://proxy.example/prefix" -def test_explicit_environment_file_selects_installer_endpoint(tmp_path, monkeypatch): +def test_explicit_environment_file_selects_installer_endpoint(tmp_path, monkeypatch, installed_agent_checks): """The setup group loads --env-file safely before handing one URL to installers.""" import powercontext.cli.pi as pi from powercontext.cli.system import Diagnostic, DiagnosticStatus path = tmp_path / "chosen.env" path.write_text("POWERCONTEXT_CLIENT_SERVER_URL=https://proxy.example/path\n") - installer = Mock(return_value=pi.PiSetupResult("test", "test", "test")) - monkeypatch.setattr(pi, "install_pi_plugin", installer) - monkeypatch.setattr(pi, "run_pi_diagnostics", lambda: {"pi": Diagnostic(DiagnosticStatus.OK, "ok")}) + installer = Mock( + return_value={ + "package": "test", + "package_path": "test", + "data_dir": "test", + } + ) + stub_installer(monkeypatch, "pi", installer) + monkeypatch.setattr(pi.PiInstaller, "verify", staticmethod(lambda: {"pi": Diagnostic(DiagnosticStatus.OK, "ok")})) result = CliRunner().invoke(create_cli([setup_app]), ["setup", "--env-file", str(path), "pi", "--json"]) assert result.exit_code == 0, result.output - assert installer.call_args.kwargs["server_url"] == "https://proxy.example/path" + from powercontext.client.transport_policy import resolve_client_transport - -def test_setup_supports_cli_installation_without_server_dependencies(tmp_path): - """Resolve client configuration when the optional database dependency is absent.""" - (tmp_path / ".env").write_text("POWERCONTEXT_SERVER_HTTP_PORT=18000\n") - result = subprocess.run( - [ - sys.executable, - "-c", - "import sys; sys.modules['sqlalchemy'] = None; " - "from powercontext.cli.transport import prepare_setup_transport; " - "assert prepare_setup_transport('pi').server_url == 'http://127.0.0.1:18000'", - ], - cwd=tmp_path, - env=dict(os.environ), - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, result.stderr + assert resolve_client_transport("pi")[0] == "https://proxy.example/path" @pytest.mark.parametrize("content", [None, b"SECRET='private-value", b"\xff"]) def test_invalid_explicit_environment_file_fails_before_installation(tmp_path, monkeypatch, content): """Missing or malformed explicit files fail safely without installing an Agent.""" - from powercontext.cli import hosts path = tmp_path / "invalid.env" if content is not None: path.write_bytes(content) installer = Mock(side_effect=AssertionError("Invalid configuration must not install an Agent")) - monkeypatch.setattr(hosts, "install_host", installer) + stub_installer(monkeypatch, "pi", installer) result = CliRunner().invoke(create_cli([setup_app]), ["setup", "--env-file", str(path), "pi", "--json"]) assert result.exit_code == 1, result.output assert "Cannot read setup environment file" in result.output assert "private-value" not in result.output - installer.assert_not_called() assert not (tmp_path / "clients.json").exists() -@pytest.mark.parametrize("host", HOST_NAMES) -@pytest.mark.parametrize("bulk", [False, True], ids=["individual", "selected"]) +@pytest.mark.parametrize("host", [name for name in installers() if name != "kimi-code"]) @pytest.mark.parametrize("endpoint", ["http://127.0.0.1:18000", "https://proxy.example/prefix"]) -def test_all_setup_routes_persist_adapter_endpoint(host, bulk, endpoint, tmp_path, monkeypatch): - """Both CLI routes apply identical policy and preserve unrelated user preferences.""" - import importlib +def test_setup_persists_adapter_endpoint(host, endpoint, tmp_path, monkeypatch, installed_agent_checks): + """The setup CLI applies endpoint policy and preserve unrelated user preferences.""" - from powercontext.cli import hosts, system from powercontext.cli.transport import client_config_file + if host == "zcode": + endpoint = endpoint.removesuffix("/prefix") if host == "dsh": monkeypatch.setattr("powercontext.cli.dsh.which", lambda _name: None) path = client_config_file() @@ -240,18 +236,19 @@ def test_all_setup_routes_persist_adapter_endpoint(host, bulk, endpoint, tmp_pat monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) (tmp_path / ".env").write_text("POWERCONTEXT_SERVER_HTTP_PORT=18000\n") - def install(name, **options): + def install(request): """Stand in for an external Agent installer and check its effective endpoint.""" - assert name == host - assert options["server_url"] == endpoint - return system.CodexSetupResult("marketplace", "plugin", "1.0", "data") - - monkeypatch.setattr(hosts, "install_host", install) - monkeypatch.setattr(hosts, "verify_host", lambda _host: None) - if host not in {"claude-code", "openclaw"}: - module = system if host == "codex" else importlib.import_module(f"powercontext.cli.{host}") - monkeypatch.setattr(module, f"run_{host}_diagnostics", lambda **_options: {}) - arguments = ["setup", "select", "--host", host, "--json"] if bulk else ["setup", host, "--json"] + assert request.host == host + assert request.server_url == endpoint + return { + "marketplace": "marketplace", + "plugin": "plugin", + "plugin_version": "1.0", + "data_dir": "data", + } + + stub_installer(monkeypatch, host, install) + arguments = ["setup", host, "--json"] if endpoint.startswith("https:"): arguments.extend(["--server-url", endpoint]) result = CliRunner().invoke(create_cli([setup_app]), arguments) @@ -259,9 +256,6 @@ def install(name, **options): saved = json.loads(path.read_text())["hosts"] assert saved[host] == {"custom": "keep", "server_url": endpoint, "allow_insecure_http": False} assert saved["other"] == {"custom": True} - if host == "hermes": - native = json.loads((tmp_path / "hermes/powercontext/config.json").read_text()) - assert native["base_url"] == saved[host]["server_url"] @pytest.mark.parametrize("explicit", [False, True], ids=["discovered", "explicit"]) @@ -293,8 +287,17 @@ def test_equivalent_endpoint_spellings_do_not_conflict(tmp_path, monkeypatch, ex ], ids=["file-yes", "file-no", "process-no", "process-yes", "explicit-no", "explicit-yes"], ) -def test_setup_consent_precedence(tmp_path, monkeypatch, file_consent, process_consent, explicit_consent, allowed): - from powercontext.cli.transport import prepare_setup_transport, save_setup_transport +def test_setup_consent_precedence( + install_connection, + tmp_path, + monkeypatch, + file_consent, + process_consent, + explicit_consent, + allowed, + installed_agent_checks, +): + from powercontext.cli.transport import prepare_setup_transport from powercontext.client.transport_policy import resolve_client_transport monkeypatch.delenv("POWERCONTEXT_PI_ALLOW_INSECURE_HTTP", raising=False) @@ -311,28 +314,25 @@ def test_setup_consent_precedence(tmp_path, monkeypatch, file_consent, process_c return selected = prepare_setup_transport("pi", allow_insecure_http=explicit_consent, json_output=True) - save_setup_transport(selected) + install_connection(selected) path.unlink() monkeypatch.delenv("POWERCONTEXT_CLIENT_ALLOW_INSECURE_HTTP", raising=False) assert resolve_client_transport("pi") == ("http://192.0.2.10:18000", True) -@pytest.mark.parametrize("bulk", [False, True], ids=["individual", "selected"]) -def test_failed_setup_env_file_does_not_install_or_leak_into_next_invocation(tmp_path, monkeypatch, bulk): - from powercontext.cli import hosts +def test_failed_setup_env_file_does_not_install_or_leak_into_next_invocation(tmp_path, monkeypatch): from powercontext.cli.transport import resolve_setup_endpoint chosen = tmp_path / "chosen.env" chosen.write_text("POWERCONTEXT_CLIENT_SERVER_URL=http://192.0.2.10\n") (tmp_path / ".env").write_text("POWERCONTEXT_SERVER_HTTP_PORT=18000\n") installer = Mock(side_effect=AssertionError("Rejected configuration must not install an Agent")) - monkeypatch.setattr(hosts, "install_host", installer) - command = ["select", "--host", "pi"] if bulk else ["pi"] + stub_installer(monkeypatch, "pi", installer) + command = ["pi"] result = CliRunner().invoke(create_cli([setup_app]), ["setup", "--env-file", str(chosen), *command, "--json"]) assert result.exit_code == 1, result.output assert "--allow-insecure-http" in result.output - installer.assert_not_called() assert not (tmp_path / "clients.json").exists() assert resolve_setup_endpoint("pi") == "http://127.0.0.1:18000" @@ -347,42 +347,24 @@ def test_json_setup_never_prompts_even_with_tty(monkeypatch): transport.prepare_setup_transport("pi", server_url="http://192.0.2.10", json_output=True) -def test_interactive_consent_is_default_no_and_not_saved_until_completed(monkeypatch): - from powercontext.cli import transport - - monkeypatch.setattr(transport.sys.stdin, "isatty", lambda: True) - confirm = Mock(return_value=False) - monkeypatch.setattr(transport.typer, "confirm", confirm) - with pytest.raises(RuntimeError): - transport.prepare_setup_transport("pi", server_url="http://192.0.2.10") - assert confirm.call_args.kwargs["default"] is False - assert not transport.client_config_file().exists() - confirm.return_value = True - settings = transport.prepare_setup_transport("pi", server_url="http://192.0.2.10") - assert not transport.client_config_file().exists() - transport.save_setup_transport(settings) - assert json.loads(transport.client_config_file().read_text())["hosts"]["pi"] == { - "server_url": "http://192.0.2.10", - "allow_insecure_http": True, - } - - -def test_saved_consent_does_not_follow_changed_url(monkeypatch): +def test_saved_consent_does_not_follow_changed_url(install_connection, monkeypatch): from powercontext.cli import transport settings = transport.prepare_setup_transport("pi", server_url="http://192.0.2.10", allow_insecure_http=True) - transport.save_setup_transport(settings) + install_connection(settings) assert transport.prepare_setup_transport("pi", json_output=True).allow_insecure_http with pytest.raises(RuntimeError, match="--allow-insecure-http"): transport.prepare_setup_transport("pi", server_url="http://192.0.2.11", json_output=True) -def test_setup_saves_only_nonsecret_fields_and_preserves_other_hosts(monkeypatch): +def test_setup_saves_only_nonsecret_fields_and_preserves_other_hosts( + install_connection, monkeypatch, installed_agent_checks +): from powercontext.cli import transport monkeypatch.setenv("POWERCONTEXT_CLIENT_AUTHORIZATION", "Bearer test-secret") for host in ("pi", "dsh"): - transport.save_setup_transport(transport.prepare_setup_transport(host, server_url="https://example.test")) + install_connection(transport.prepare_setup_transport(host, server_url="https://example.test")) content = transport.client_config_file().read_text() assert "secret" not in content assert set(json.loads(content)["hosts"]) == {"pi", "dsh"} @@ -407,14 +389,14 @@ def test_environment_false_does_not_prompt_to_override_refusal(monkeypatch): transport.prepare_setup_transport("pi", server_url="http://192.0.2.10") -def test_hermes_setup_synchronizes_native_endpoint_and_preserves_preferences(tmp_path, monkeypatch): - from powercontext.cli.transport import SetupTransport, save_setup_transport +def test_hermes_setup_synchronizes_native_endpoint_and_preserves_preferences(install_connection, tmp_path, monkeypatch): + from powercontext.cli.transport import SetupTransport monkeypatch.setenv("HERMES_HOME", str(tmp_path)) path = tmp_path / "powercontext/config.json" path.parent.mkdir() path.write_text(json.dumps({"base_url": "https://old.example", "capture": False})) - save_setup_transport(SetupTransport("hermes", "http://192.0.2.10", True)) + install_connection(SetupTransport("hermes", "http://192.0.2.10", True)) assert json.loads(path.read_text()) == { "base_url": "http://192.0.2.10", "capture": False, @@ -423,31 +405,32 @@ def test_hermes_setup_synchronizes_native_endpoint_and_preserves_preferences(tmp @pytest.mark.parametrize("failed_file", ["shared", "native"]) -def test_failed_persistence_restores_native_settings(tmp_path, monkeypatch, failed_file): - import powercontext.cli.system as system - from powercontext.cli.transport import SetupTransport, client_config_file, save_setup_transport +def test_failed_persistence_restores_native_settings(install_connection, tmp_path, monkeypatch, failed_file): + from powercontext.cli.transport import SetupTransport, client_config_file monkeypatch.setenv("HERMES_HOME", str(tmp_path)) native = tmp_path / "powercontext/config.json" native.parent.mkdir() original = '{"base_url":"https://old.example","capture":false}' native.write_text(original) - write = system._write_bytes_atomically + write = installation.write_bytes def fail_shared(path, content): if path == (client_config_file() if failed_file == "shared" else native): raise OSError("simulated disk failure") # noqa: TRY003 write(path, content) - monkeypatch.setattr(system, "_write_bytes_atomically", fail_shared) - with pytest.raises(RuntimeError): - save_setup_transport(SetupTransport("hermes", "http://192.0.2.10", True)) + monkeypatch.setattr(installation, "write_bytes", fail_shared) + with pytest.raises(OSError): + install_connection(SetupTransport("hermes", "http://192.0.2.10", True)) assert native.read_text() == original assert not client_config_file().exists() def test_codex_setup_updates_native_mcp_endpoint_without_touching_headers(tmp_path, monkeypatch): - from powercontext.cli.system import _configure_codex_endpoint + + from powercontext.cli.system import CodexInstaller + from powercontext.cli.transport import SetupTransport monkeypatch.setenv("CODEX_HOME", str(tmp_path)) path = tmp_path / "plugins/cache/powercontext/powercontext/0.1.0/.mcp.json" @@ -462,7 +445,15 @@ def test_codex_setup_updates_native_mcp_endpoint_without_touching_headers(tmp_pa } } path.write_text(json.dumps(original)) - _configure_codex_endpoint("powercontext", "0.1.0", "http://192.0.2.10:8000") + monkeypatch.setattr( + "powercontext.cli.system._run_codex_json", + lambda *args: { + "installed": [ + {"name": "powercontext", "installed": True, "marketplaceName": "powercontext", "version": "0.1.0"} + ] + }, + ) + CodexInstaller().configure(SetupTransport("codex", "http://192.0.2.10:8000", True)) changed = json.loads(path.read_text())["mcpServers"]["powercontext"] assert changed["url"] == "http://192.0.2.10:8000/mcp/" assert changed["env_http_headers"] == original["mcpServers"]["powercontext"]["env_http_headers"] @@ -479,52 +470,25 @@ def test_workbuddy_setup_aligns_mcp_with_selected_endpoint(tmp_path): assert servers["other"] == {"url": "https://other.test"} -def test_doctor_reports_insecure_opt_in_as_degraded(): - from powercontext.cli.transport import prepare_setup_transport, save_setup_transport, transport_diagnostic - - save_setup_transport(prepare_setup_transport("pi", server_url="http://192.0.2.10", allow_insecure_http=True)) - diagnostic = transport_diagnostic("pi") - assert diagnostic.status == "degraded" - assert "unencrypted" in diagnostic.detail - - -def test_doctor_connects_only_after_explicit_opt_in(monkeypatch): - import powercontext.cli.system as system - - probe = Mock(return_value=system.Diagnostic(status=system.DiagnosticStatus.OK, detail="reachable")) - monkeypatch.setattr(system, "_server_liveness_diagnostic", probe) - monkeypatch.setattr(system, "_server_readiness_diagnostic", probe) - diagnostics = system.run_diagnostics(server_url="http://192.0.2.10") - assert diagnostics["server_liveness"].status == "failed" - probe.assert_not_called() - diagnostics = system.run_diagnostics(server_url="http://192.0.2.10", allow_insecure_http=True) - assert diagnostics["server_liveness"].ok - assert diagnostics["transport"].status == "degraded" - - -def test_doctor_reads_openclaw_native_endpoint_and_binds_consent(tmp_path, monkeypatch): - from powercontext.cli.transport import transport_diagnostic - - path = tmp_path / "openclaw.json" - monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(path)) - path.write_text( - json.dumps({ - "plugins": { - "entries": { - "memory-powercontext": {"config": {"endpoint": "http://192.0.2.10", "allowInsecureHttp": True}} - } - } - }) - ) - assert transport_diagnostic("openclaw").status == "degraded" - monkeypatch.setenv("POWERCONTEXT_OPENCLAW_BASE_URL", "http://192.0.2.11") - assert transport_diagnostic("openclaw").status == "failed" - - -def test_doctor_does_not_claim_safety_for_unreadable_native_configuration(tmp_path, monkeypatch): - from powercontext.cli.transport import transport_diagnostic - - path = tmp_path / "openclaw.json" - monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(path)) - path.write_text("{ this is not plain JSON }") - assert transport_diagnostic("openclaw").status != "ok" +@pytest.fixture +def install_connection(monkeypatch): + """Exercise persistence through the real operation with native commands substituted.""" + + def complete(settings): + if settings.host == "hermes": + from powercontext.cli.hermes import HermesInstaller, hermes_home + + marker = hermes_home() / "plugins/powercontext/__init__.py" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() + return HermesInstaller().configure(settings) + with monkeypatch.context() as native: + stub_installer(native, settings.host, lambda request: {}) + return installation.setup( + settings.host, + source="unused", + server_url=settings.server_url, + allow_insecure_http=settings.allow_insecure_http, + ) + + return complete diff --git a/tests/test_system_cli.py b/tests/test_system_cli.py index bd9bdec08c..fea8156f48 100644 --- a/tests/test_system_cli.py +++ b/tests/test_system_cli.py @@ -20,6 +20,7 @@ from email.message import Message from io import BytesIO, StringIO from pathlib import Path +from typing import Any from unittest.mock import Mock from urllib.error import HTTPError @@ -29,6 +30,7 @@ import powercontext.cli.authorization as authorization_cli import powercontext.cli.system as system_cli from powercontext.cli.app import create_cli +from powercontext.cli.installation import setup as install_plugin from powercontext.cli.system import Diagnostic, DiagnosticStatus, doctor_app, setup_app from powercontext.paths import default_scheduler_path from powercontext.server.settings import ServerSettings @@ -99,31 +101,34 @@ def test_server_defaults_to_persistent_user_storage( assert default_scheduler_path() == data_dir / "scheduler.db" -def test_setup_codex_installs_from_a_remote_ref_and_prepares_storage( - tmp_path: Path, - monkeypatch, +@pytest.mark.parametrize("already_disabled", [False, True]) +def test_setup_codex_installs_or_preserves_native_disablement( + tmp_path: Path, monkeypatch, installed_agent_checks, already_disabled ) -> None: data_dir = tmp_path / "data" monkeypatch.setenv("POWERCONTEXT_HOME", str(data_dir)) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/codex") - run_codex = Mock( - side_effect=[ - {"marketplaceName": "powercontext", "alreadyAdded": False}, - {"name": "powercontext", "version": "0.1.0"}, - { - "installed": [ - { - "name": "powercontext", - "pluginId": "powercontext@powercontext", - "installed": True, - "enabled": True, - } - ] - }, - ] - ) - monkeypatch.setattr(system_cli, "_run_codex_json", run_codex) - + record = { + "name": "powercontext", + "installed": True, + "enabled": not already_disabled, + "marketplaceName": "powercontext", + "version": "0.1.0", + } + installed = [record] if already_disabled else [] + commands = [] + + def native(*arguments): + commands.append(arguments) + if arguments[:2] == ("plugin", "marketplace"): + return {"marketplaceName": "powercontext"} + if arguments[:2] == ("plugin", "add"): + record["enabled"] = True + installed[:] = [record] + return record + return {"installed": installed} + + monkeypatch.setattr(system_cli, "_run_codex_json", native) result = CliRunner().invoke( create_cli([setup_app]), [ @@ -136,30 +141,17 @@ def test_setup_codex_installs_from_a_remote_ref_and_prepares_storage( "--json", ], ) - assert result.exit_code == 0, result.output - assert json.loads(result.output) == { + assert json.loads(result.output)["hosts"]["codex"]["result"] == { "marketplace": "powercontext", "plugin": "powercontext", "plugin_version": "0.1.0", "data_dir": str(data_dir), "authorization_state": "not_configured", } - assert data_dir.is_dir() - assert run_codex.call_args_list[0].args == ( - "plugin", - "marketplace", - "add", - "oceanbase/powercontext", - "--ref", - "tested-ref", - ) - assert run_codex.call_args_list[1].args == ( - "plugin", - "add", - "powercontext@powercontext", - ) - assert run_codex.call_args_list[2].args == ("plugin", "list") + assert record["installed"] is True + assert record["enabled"] is not already_disabled + assert ("plugin", "marketplace", "add", "oceanbase/powercontext", "--ref", "tested-ref") in commands def test_codex_diagnostics_verify_native_mcp_tools_without_process_authorization(monkeypatch) -> None: @@ -174,13 +166,15 @@ def test_codex_diagnostics_verify_native_mcp_tools_without_process_authorization "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, ) monkeypatch.delenv("POWERCONTEXT_CODEX_AUTHORIZATION", raising=False) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["mcp_configuration"].status is DiagnosticStatus.OK assert diagnostics["authorization"].status is DiagnosticStatus.OK @@ -208,13 +202,23 @@ async def catalog(): monkeypatch.setattr( system_cli, "_run_codex_json", - lambda *_args: {"installed": [{"name": "powercontext", "installed": True, "enabled": True}]}, + lambda *_args: { + "installed": [ + { + "name": "powercontext", + "installed": True, + "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", + } + ] + }, ) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", lambda **_kwargs: {"tools": tools}) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["mcp_full_profile"].status is DiagnosticStatus.OK tools.pop("resolve_external_skill") - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["mcp_tools"].status is DiagnosticStatus.OK assert diagnostics["mcp_full_profile"].status is DiagnosticStatus.DEGRADED assert "resolve_external_skill" in diagnostics["mcp_full_profile"].detail @@ -234,7 +238,7 @@ def _declared_codex_mcp_tools() -> set[str]: @pytest.mark.parametrize("missing", [None, *sorted(_declared_codex_mcp_tools())]) -def test_doctor_codex_requires_every_declared_capability_tool(monkeypatch, missing) -> None: +def test_doctor_codex_requires_every_declared_capability_tool(monkeypatch, missing, server_checks) -> None: tools = {name: {} for name in _declared_codex_mcp_tools()} if missing is not None: tools.pop(missing) @@ -242,12 +246,22 @@ def test_doctor_codex_requires_every_declared_capability_tool(monkeypatch, missi monkeypatch.setattr( system_cli, "_run_codex_json", - lambda *_args: {"installed": [{"name": "powercontext", "installed": True, "enabled": True}]}, + lambda *_args: { + "installed": [ + { + "name": "powercontext", + "installed": True, + "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", + } + ] + }, ) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", lambda **_kwargs: {"tools": tools}) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "codex", "--json"]) - full = json.loads(result.output)["checks"]["mcp_full_profile"] + full = json.loads(result.output)["hosts"]["codex"]["checks"]["mcp_full_profile"] assert result.exit_code == (0 if missing is None else 1) assert full["status"] == ("ok" if missing is None else "degraded") if missing is not None: @@ -266,54 +280,21 @@ def test_codex_diagnostics_fail_when_required_native_tools_are_missing(monkeypat "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, ) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", lambda **_kwargs: {"name": "powercontext", "tools": {}}) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["mcp_tools"].status is DiagnosticStatus.FAILED assert "remember_memory, search_memory" in diagnostics["mcp_tools"].detail assert "POWERCONTEXT_CODEX_AUTHORIZATION" in diagnostics["mcp_tools"].detail -@pytest.mark.parametrize("selected", [False, True], ids=["setup-codex", "setup-select"]) -@pytest.mark.parametrize("basic_available", [False, True]) -def test_codex_setup_entrypoints_accept_basic_memory_only(monkeypatch, tmp_path, selected, basic_available) -> None: - monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) - monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/codex") - monkeypatch.setattr( - system_cli, - "install_codex_plugin", - lambda **_kwargs: system_cli.CodexSetupResult( - marketplace="powercontext", - plugin="powercontext", - plugin_version="1.1.0", - data_dir=str(tmp_path / "data"), - authorization_state="not_configured", - ), - ) - monkeypatch.setattr( - system_cli, - "_run_codex_json", - lambda *_args: {"installed": [{"name": "powercontext", "installed": True, "enabled": True}]}, - ) - tools = {"remember_memory": {}, "search_memory": {}} if basic_available else {} - monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", lambda **_kwargs: {"tools": tools}) - arguments = ["setup", "select", "--host", "codex"] if selected else ["setup", "codex"] - - result = CliRunner().invoke(create_cli([setup_app]), [*arguments, "--json"]) - - assert result.exit_code == (0 if basic_available else 1), result.output - if selected: - codex = next(host for host in json.loads(result.output)["hosts"] if host["host"] == "codex") - assert codex["status"] == ("installed" if basic_available else "failed") - if not basic_available: - assert "remember_memory, search_memory" in result.output - - def test_codex_diagnostics_reject_native_mcp_without_authorization_environment(monkeypatch) -> None: monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/codex") monkeypatch.setattr( @@ -326,6 +307,8 @@ def test_codex_diagnostics_reject_native_mcp_without_authorization_environment(m "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -344,7 +327,7 @@ def test_codex_diagnostics_reject_native_mcp_without_authorization_environment(m probe = Mock() monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["mcp_configuration"].status is DiagnosticStatus.FAILED assert "reinstall the current plugin" in diagnostics["mcp_configuration"].detail @@ -369,6 +352,8 @@ def test_codex_diagnostics_probe_without_url_mismatched_stored_authorization( "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -392,7 +377,7 @@ def test_codex_diagnostics_probe_without_url_mismatched_stored_authorization( ) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].status is DiagnosticStatus.OK assert "url_mismatch" in diagnostics["authorization"].detail @@ -488,8 +473,7 @@ def wait(self, timeout: float) -> int: def test_setup_codex_persists_setup_only_token_in_codex_owned_storage( - tmp_path: Path, - monkeypatch, + tmp_path: Path, monkeypatch, installed_agent_checks ) -> None: monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) @@ -501,16 +485,29 @@ def test_setup_codex_persists_setup_only_token_in_codex_owned_storage( system_cli, "_run_codex_json", Mock( - side_effect=[ - {"marketplaceName": "powercontext"}, - {"name": "powercontext", "version": "0.1.0"}, - ] + side_effect=lambda *args: ( + {"marketplaceName": "powercontext"} + if args[:2] == ("plugin", "marketplace") + else {"name": "powercontext", "version": "0.1.0"} + if args[:2] == ("plugin", "add") + else { + "installed": [ + { + "name": "powercontext", + "installed": True, + "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", + } + ] + } + ) ), ) - result = system_cli.install_codex_plugin(source="oceanbase/powercontext", ref="master") + result = install_plugin("codex", source="oceanbase/powercontext", ref="master") - assert result.authorization_state == "configured" + assert result["authorization_state"] == "configured" assert ( json.loads((tmp_path / "codex" / "powercontext" / "credentials.json").read_text())["authorization"] == "Bearer setup-token" @@ -535,6 +532,8 @@ def test_codex_diagnostics_use_matching_windows_user_authorization(tmp_path: Pat "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -549,7 +548,7 @@ def test_codex_diagnostics_use_matching_windows_user_authorization(tmp_path: Pat probe = Mock(return_value={"name": "powercontext", "tools": {"remember_memory": {}, "search_memory": {}}}) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].status is DiagnosticStatus.OK assert "Windows user authorization" in diagnostics["authorization"].detail @@ -575,6 +574,8 @@ def test_codex_diagnostics_prefer_process_authorization_over_stale_stored_creden "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -588,7 +589,7 @@ def test_codex_diagnostics_prefer_process_authorization_over_stale_stored_creden probe = Mock(return_value={"name": "powercontext", "tools": {"remember_memory": {}, "search_memory": {}}}) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].status is DiagnosticStatus.OK assert diagnostics["authorization"].checks == { @@ -616,6 +617,8 @@ def test_codex_diagnostics_reject_bare_process_token_without_repairing_it(tmp_pa "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -634,7 +637,7 @@ def test_codex_diagnostics_reject_bare_process_token_without_repairing_it(tmp_pa probe = Mock() monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].status is DiagnosticStatus.FAILED assert diagnostics["authorization"].checks == { @@ -660,6 +663,8 @@ def test_codex_diagnostics_probe_lowercase_bearer_header_without_rewriting_it(tm "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -678,7 +683,7 @@ def test_codex_diagnostics_probe_lowercase_bearer_header_without_rewriting_it(tm probe = Mock(return_value={"name": "powercontext", "tools": {"remember_memory": {}, "search_memory": {}}}) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].status is DiagnosticStatus.OK assert diagnostics["authorization"].checks == { @@ -704,6 +709,8 @@ def test_codex_diagnostics_do_not_replace_process_authorization_with_windows_use "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -718,7 +725,7 @@ def test_codex_diagnostics_do_not_replace_process_authorization_with_windows_use probe = Mock(return_value={"name": "powercontext", "tools": {"remember_memory": {}, "search_memory": {}}}) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].status is DiagnosticStatus.OK assert diagnostics["authorization"].checks == { @@ -744,6 +751,8 @@ def test_codex_diagnostics_report_matching_desktop_override_separately_from_stal "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -758,7 +767,7 @@ def test_codex_diagnostics_report_matching_desktop_override_separately_from_stal probe = Mock(return_value={"name": "powercontext", "tools": {"remember_memory": {}, "search_memory": {}}}) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].status is DiagnosticStatus.OK assert diagnostics["authorization"].checks == { @@ -779,7 +788,14 @@ def test_codex_diagnostics_probe_the_native_credential_helper_without_injecting_ "_run_codex_json", lambda *_args: { "installed": [ - {"name": "powercontext", "pluginId": "powercontext@powercontext", "installed": True, "enabled": True} + { + "name": "powercontext", + "pluginId": "powercontext@powercontext", + "installed": True, + "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", + } ] }, ) @@ -796,7 +812,7 @@ def test_codex_diagnostics_probe_the_native_credential_helper_without_injecting_ probe = Mock(return_value={"name": "powercontext", "tools": {"remember_memory": {}, "search_memory": {}}}) monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].ok assert diagnostics["mcp_tools"].ok @@ -823,6 +839,8 @@ def test_codex_diagnostics_fail_when_setup_credential_is_unavailable_to_host(tmp "pluginId": "powercontext@powercontext", "installed": True, "enabled": True, + "marketplaceName": "powercontext", + "version": "0.1.0", } ] }, @@ -836,7 +854,7 @@ def test_codex_diagnostics_fail_when_setup_credential_is_unavailable_to_host(tmp probe = Mock() monkeypatch.setattr(system_cli, "_probe_codex_mcp_status", probe) - diagnostics = system_cli.run_codex_diagnostics() + diagnostics = system_cli.CodexInstaller().verify() assert diagnostics["authorization"].status is DiagnosticStatus.FAILED assert "not available to the Codex host" in diagnostics["authorization"].detail @@ -845,28 +863,30 @@ def test_codex_diagnostics_fail_when_setup_credential_is_unavailable_to_host(tmp def test_setup_codex_uses_an_absolute_local_marketplace_without_a_ref( - tmp_path: Path, - monkeypatch, + tmp_path: Path, monkeypatch, installed_agent_checks ) -> None: marketplace = tmp_path / "marketplace" marketplace.mkdir() monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/codex") run_codex = Mock( - side_effect=[ - {"marketplaceName": "powercontext-local"}, - {"name": "powercontext", "version": "0.1.0"}, - { + side_effect=lambda *args: ( + {"marketplaceName": "powercontext-local"} + if args[:2] == ("plugin", "marketplace") + else {"name": "powercontext", "version": "0.1.0"} + if args[:2] == ("plugin", "add") + else { "installed": [ { "name": "powercontext", - "pluginId": "powercontext@powercontext-local", "installed": True, "enabled": True, + "marketplaceName": "powercontext-local", + "version": "0.1.0", } ] - }, - ] + } + ) ) monkeypatch.setattr(system_cli, "_run_codex_json", run_codex) @@ -876,37 +896,29 @@ def test_setup_codex_uses_an_absolute_local_marketplace_without_a_ref( ) assert result.exit_code == 0 - assert "Authorization: not_configured" in result.output - assert run_codex.call_args_list[0].args == ( + assert "authorization_state: not_configured" in result.output + assert ( "plugin", "marketplace", "add", str(marketplace), - ) + ) in [call.args for call in run_codex.call_args_list] -def test_setup_claude_code_reports_mutations_then_installs_and_verifies( - tmp_path: Path, - monkeypatch, +def test_setup_claude_code_installs_and_reports_connection_settings( + tmp_path: Path, monkeypatch, installed_agent_checks ) -> None: config_dir = tmp_path / "claude" monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda name: "/usr/bin/claude" if name == "claude" else None) - run_claude_json = Mock( - side_effect=[ - [], - [], - [ - { - "id": "powercontext@powercontext", - "scope": "user", - "version": "0.1.0", - "enabled": True, - } - ], - ] - ) - run_claude = Mock() + installed = [] + run_claude_json = Mock(side_effect=lambda *args: [] if args[:3] == ("plugin", "marketplace", "list") else installed) + + def native_command(*arguments): + if arguments[:2] == ("plugin", "install"): + installed.append({"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}) + + run_claude = Mock(side_effect=native_command) monkeypatch.setattr(system_cli, "_run_claude_json", run_claude_json) monkeypatch.setattr(system_cli, "_run_claude", run_claude) @@ -927,23 +939,17 @@ def test_setup_claude_code_reports_mutations_then_installs_and_verifies( ) assert result.exit_code == 0 - assert json.loads(result.stdout) == { + assert json.loads(result.stdout)["hosts"]["claude-code"]["result"] == { "marketplace": "powercontext", "plugin": "powercontext", "plugin_version": "0.1.0", "settings_file": str(config_dir / "settings.json"), - "cache_dir": str(config_dir / "plugins" / "cache" / "powercontext" / "powercontext" / ""), + "cache_dir": str(config_dir / "plugins" / "cache" / "powercontext" / "powercontext" / "0.1.0"), "data_dir": str(config_dir / "plugins" / "data" / "powercontext-powercontext"), "authorization_state": "not_configured", } - assert "no changes made yet" in result.stderr - assert str(config_dir / "settings.json") in result.stderr - assert "read/write access" in result.stderr - assert "claude plugin uninstall powercontext@powercontext --scope user" in result.stderr - assert "claude plugin marketplace remove powercontext" in result.stderr - assert run_claude_json.call_args_list[0].args == ("plugin", "marketplace", "list") - assert run_claude_json.call_args_list[1].args == ("plugin", "list") - assert run_claude.call_args_list[0].args == ( + + run_claude.assert_any_call( "plugin", "marketplace", "add", @@ -951,14 +957,13 @@ def test_setup_claude_code_reports_mutations_then_installs_and_verifies( "--scope", "user", ) - assert run_claude.call_args_list[1].args == ( + run_claude.assert_any_call( "plugin", "install", "powercontext@powercontext", "--scope", "user", ) - assert run_claude_json.call_args_list[2].args == ("plugin", "list") assert json.loads((config_dir / "settings.json").read_text(encoding="utf-8"))["pluginConfigs"] == { "powercontext@powercontext": { "options": { @@ -975,111 +980,104 @@ def test_setup_claude_code_reports_mutations_then_installs_and_verifies( assert "--server-url http://127.0.0.1:9000" in statusline["command"] -def test_setup_claude_code_refreshes_the_marketplace_and_plugin_before_installing( - tmp_path: Path, - monkeypatch, +def test_setup_claude_code_upgrades_and_preserves_disablement_and_capture_preference( + tmp_path: Path, monkeypatch, installed_agent_checks ) -> None: config_dir = tmp_path / "claude" monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda name: "/usr/bin/claude" if name == "claude" else None) - installed = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.1", "enabled": True}] - refreshed = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.2", "enabled": True}] + config_dir.mkdir() + settings: dict[str, Any] = { + "enabledPlugins": {"powercontext@powercontext": False}, + "pluginConfigs": {"powercontext@powercontext": {"options": {"capture_prompts": False}}}, + } + (config_dir / "settings.json").write_text(json.dumps(settings)) + installed = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.1", "enabled": False}] monkeypatch.setattr( system_cli, "_run_claude_json", Mock( - side_effect=[ - [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}], - installed, - refreshed, - ] + side_effect=lambda *args: ( + [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}] + if args[:3] == ("plugin", "marketplace", "list") + else installed + ) ), ) - run_claude = Mock() + refreshed = False + + def native_command(*arguments): + nonlocal refreshed + if arguments[:3] == ("plugin", "marketplace", "update"): + refreshed = True + if arguments[:2] == ("plugin", "update") and refreshed: + installed[0]["version"] = "0.2.0" + if arguments[:2] == ("plugin", "install"): + settings["enabledPlugins"]["powercontext@powercontext"] = True + (config_dir / "settings.json").write_text(json.dumps(settings)) + + run_claude = Mock(side_effect=native_command) monkeypatch.setattr(system_cli, "_run_claude", run_claude) - result = system_cli.install_claude_code_plugin( + result = install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url="http://127.0.0.1:9000", - capture_prompts=True, ) - assert result.plugin_version == "0.1.2" - assert [call.args for call in run_claude.call_args_list] == [ - ("plugin", "marketplace", "update", "powercontext"), - ("plugin", "update", "powercontext@powercontext", "--scope", "user"), - ("plugin", "install", "powercontext@powercontext", "--scope", "user"), - ] + assert result["plugin_version"] == "0.2.0" + assert Path(result["cache_dir"]).name == "0.2.0" + + persisted = json.loads((config_dir / "settings.json").read_text()) + assert "0.2.0" in persisted["statusLine"]["command"] + assert persisted["enabledPlugins"]["powercontext@powercontext"] is False + assert persisted["pluginConfigs"]["powercontext@powercontext"]["options"]["capture_prompts"] is False def test_setup_claude_code_ignores_a_project_scoped_plugin_when_updating_user_scope( - tmp_path: Path, - monkeypatch, + tmp_path: Path, monkeypatch, installed_agent_checks ) -> None: config_dir = tmp_path / "claude" monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") project_plugin = {"id": "powercontext@powercontext", "scope": "project", "version": "0.1.1", "enabled": True} user_plugin = {"id": "powercontext@powercontext", "scope": "user", "version": "0.1.2", "enabled": True} + installed = [project_plugin] monkeypatch.setattr( system_cli, "_run_claude_json", Mock( - side_effect=[ - [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}], - [project_plugin], - [project_plugin, user_plugin], - ] + side_effect=lambda *args: ( + [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}] + if args[:3] == ("plugin", "marketplace", "list") + else installed + ) ), ) - run_claude = Mock() + + def native_command(*arguments): + if arguments == ("plugin", "install", "powercontext@powercontext", "--scope", "user"): + installed.append(user_plugin) + + run_claude = Mock(side_effect=native_command) monkeypatch.setattr(system_cli, "_run_claude", run_claude) - result = system_cli.install_claude_code_plugin( + result = install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url="http://127.0.0.1:9000", capture_prompts=True, ) - assert result.plugin_version == "0.1.2" - assert [call.args for call in run_claude.call_args_list] == [ - ("plugin", "marketplace", "update", "powercontext"), - ("plugin", "install", "powercontext@powercontext", "--scope", "user"), - ] - + assert result["plugin_version"] == "0.1.2" + assert project_plugin["version"] == "0.1.1" -def test_setup_claude_code_rolls_back_only_new_objects_after_verification_failure(monkeypatch) -> None: - monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") - monkeypatch.setattr(system_cli, "_run_claude_json", Mock(side_effect=[[], [], []])) - run_claude = Mock() - monkeypatch.setattr(system_cli, "_run_claude", run_claude) - with pytest.raises(system_cli.SetupError): - system_cli.install_claude_code_plugin( - source="https://github.com/oceanbase/powercontext.git", - ref="tested-ref", - server_url="http://127.0.0.1:8000", - capture_prompts=True, - ) - - assert run_claude.call_args_list[-2].args == ( - "plugin", - "uninstall", - "powercontext@powercontext", - "--scope", - "user", - ) - assert run_claude.call_args_list[-1].args == ( - "plugin", - "marketplace", - "remove", - "powercontext", - ) - - -def test_setup_claude_code_preserves_preexisting_objects_on_failure(tmp_path: Path, monkeypatch) -> None: +def test_setup_claude_code_preserves_preexisting_objects_on_failure( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: config_dir = tmp_path / "claude" config_dir.mkdir() settings_file = config_dir / "settings.json" @@ -1090,98 +1088,31 @@ def test_setup_claude_code_preserves_preexisting_objects_on_failure(tmp_path: Pa }, } settings_file.write_text(json.dumps(previous_settings), encoding="utf-8") - installed = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}] monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") monkeypatch.setattr( system_cli, "_run_claude_json", Mock( - side_effect=[ - [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}], - installed, - [], - ] + side_effect=lambda *args: ( + [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}] + if args[:3] == ("plugin", "marketplace", "list") + else [] + ) ), ) run_claude = Mock() monkeypatch.setattr(system_cli, "_run_claude", run_claude) with pytest.raises(system_cli.SetupError): - system_cli.install_claude_code_plugin( - source="oceanbase/powercontext", - ref="master", - server_url="http://127.0.0.1:8000", - capture_prompts=True, - ) - - assert [call.args[:2] for call in run_claude.call_args_list] == [ - ("plugin", "marketplace"), - ("plugin", "update"), - ("plugin", "install"), - ] - assert json.loads(settings_file.read_text(encoding="utf-8")) == previous_settings - - -def test_setup_claude_code_restores_a_preexisting_disabled_plugin_after_failure( - tmp_path: Path, - monkeypatch, -) -> None: - config_dir = tmp_path / "claude" - config_dir.mkdir() - settings_file = config_dir / "settings.json" - previous_settings = { - "enabledPlugins": {"powercontext@powercontext": False}, - "pluginConfigs": { - "powercontext@powercontext": {"options": {"server_url": "http://127.0.0.1:7000", "capture_prompts": False}} - }, - "unrelated": {"preserved": True}, - } - settings_file.write_text(json.dumps(previous_settings), encoding="utf-8") - disabled = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": False}] - monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) - monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") - monkeypatch.setattr( - system_cli, - "_run_claude_json", - Mock( - side_effect=[ - [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}], - disabled, - [], - ] - ), - ) - - def run_claude(*arguments: str) -> None: - if arguments[:2] == ("plugin", "install"): - changed = { - **previous_settings, - "enabledPlugins": {"powercontext@powercontext": True}, - "pluginConfigs": { - "powercontext@powercontext": { - "options": {"server_url": "http://127.0.0.1:8000", "capture_prompts": True} - } - }, - } - settings_file.write_text(json.dumps(changed), encoding="utf-8") - - run_claude_mock = Mock(side_effect=run_claude) - monkeypatch.setattr(system_cli, "_run_claude", run_claude_mock) - - with pytest.raises(system_cli.SetupError): - system_cli.install_claude_code_plugin( + install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url="http://127.0.0.1:8000", capture_prompts=True, ) - assert [call.args[:2] for call in run_claude_mock.call_args_list] == [ - ("plugin", "marketplace"), - ("plugin", "update"), - ("plugin", "install"), - ] assert json.loads(settings_file.read_text(encoding="utf-8")) == previous_settings @@ -1194,8 +1125,7 @@ def run_claude(*arguments: str) -> None: ids=["different-repository", "different-ref"], ) def test_setup_claude_code_rejects_a_conflicting_existing_marketplace_before_mutation( - existing_marketplace: dict[str, object], - monkeypatch, + existing_marketplace: dict[str, object], monkeypatch, installed_agent_checks ) -> None: monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") run_claude_json = Mock(return_value=[existing_marketplace]) @@ -1204,40 +1134,20 @@ def test_setup_claude_code_rejects_a_conflicting_existing_marketplace_before_mut monkeypatch.setattr(system_cli, "_run_claude", run_claude) with pytest.raises(system_cli.SetupError, match="marketplace remove powercontext"): - system_cli.install_claude_code_plugin( + install_plugin( + "claude-code", source="oceanbase/powercontext", ref="tested-ref", server_url="http://127.0.0.1:8000", capture_prompts=True, ) - run_claude_json.assert_called_once_with("plugin", "marketplace", "list") run_claude.assert_not_called() -@pytest.mark.parametrize( - ("source", "ref", "expected"), - [ - ("oceanbase/powercontext", "feature", "oceanbase/powercontext@feature"), - ( - "https://github.com/oceanbase/powercontext.git", - "feature", - "https://github.com/oceanbase/powercontext.git#feature", - ), - ], -) -def test_claude_marketplace_remote_ref_syntax(source: str, ref: str, expected: str) -> None: - assert system_cli._normalize_claude_marketplace_source(source, ref=ref) == expected - - -def test_claude_marketplace_accepts_json_that_omits_the_configured_ref() -> None: - assert system_cli._claude_marketplace_matches( - {"source": "github", "repo": "oceanbase/powercontext"}, - "oceanbase/powercontext@master", - ) - - -def test_setup_claude_code_normalizes_an_mcp_url_before_installing(tmp_path: Path, monkeypatch) -> None: +def test_setup_claude_code_normalizes_an_mcp_url_before_installing( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: config_dir = tmp_path / "claude" monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") @@ -1245,17 +1155,18 @@ def test_setup_claude_code_normalizes_an_mcp_url_before_installing(tmp_path: Pat system_cli, "_run_claude_json", Mock( - side_effect=[ - [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}], - [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}], - [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}], - ] + side_effect=lambda *args: ( + [{"name": "powercontext", "source": "github", "repo": "oceanbase/powercontext", "ref": "master"}] + if args[:3] == ("plugin", "marketplace", "list") + else [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}] + ) ), ) run_claude = Mock() monkeypatch.setattr(system_cli, "_run_claude", run_claude) - system_cli.install_claude_code_plugin( + install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url="https://memory.example/api/mcp/", @@ -1266,10 +1177,11 @@ def test_setup_claude_code_normalizes_an_mcp_url_before_installing(tmp_path: Pat "powercontext@powercontext" ]["options"] assert options["server_url"] == "https://memory.example/api" - assert "--config" not in run_claude.call_args.args -def test_setup_claude_code_preserves_an_unrelated_statusline(tmp_path: Path, monkeypatch) -> None: +def test_setup_claude_code_preserves_an_unrelated_statusline( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: config_dir = tmp_path / "claude" config_dir.mkdir() settings_file = config_dir / "settings.json" @@ -1278,10 +1190,15 @@ def test_setup_claude_code_preserves_an_unrelated_statusline(tmp_path: Path, mon installed = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}] monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") - monkeypatch.setattr(system_cli, "_run_claude_json", Mock(side_effect=[[], installed, installed])) + monkeypatch.setattr( + system_cli, + "_run_claude_json", + Mock(side_effect=lambda *args: [] if args[:3] == ("plugin", "marketplace", "list") else installed), + ) monkeypatch.setattr(system_cli, "_run_claude", Mock()) - system_cli.install_claude_code_plugin( + install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url="http://127.0.0.1:8000", @@ -1291,7 +1208,9 @@ def test_setup_claude_code_preserves_an_unrelated_statusline(tmp_path: Path, mon assert json.loads(settings_file.read_text(encoding="utf-8"))["statusLine"] == custom_statusline -def test_setup_claude_code_preserves_a_command_that_merely_mentions_powercontext(tmp_path: Path, monkeypatch) -> None: +def test_setup_claude_code_preserves_a_command_that_merely_mentions_powercontext( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: config_dir = tmp_path / "claude" config_dir.mkdir() settings_file = config_dir / "settings.json" @@ -1304,10 +1223,15 @@ def test_setup_claude_code_preserves_a_command_that_merely_mentions_powercontext installed = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}] monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") - monkeypatch.setattr(system_cli, "_run_claude_json", Mock(side_effect=[[], installed, installed])) + monkeypatch.setattr( + system_cli, + "_run_claude_json", + Mock(side_effect=lambda *args: [] if args[:3] == ("plugin", "marketplace", "list") else installed), + ) monkeypatch.setattr(system_cli, "_run_claude", Mock()) - system_cli.install_claude_code_plugin( + install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url="http://127.0.0.1:8000", @@ -1317,7 +1241,9 @@ def test_setup_claude_code_preserves_a_command_that_merely_mentions_powercontext assert json.loads(settings_file.read_text(encoding="utf-8"))["statusLine"] == custom_statusline -def test_setup_claude_code_refreshes_an_existing_powercontext_statusline(tmp_path: Path, monkeypatch) -> None: +def test_setup_claude_code_refreshes_an_existing_powercontext_statusline( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: config_dir = tmp_path / "claude" config_dir.mkdir() settings_file = config_dir / "settings.json" @@ -1332,10 +1258,15 @@ def test_setup_claude_code_refreshes_an_existing_powercontext_statusline(tmp_pat installed = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}] monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") - monkeypatch.setattr(system_cli, "_run_claude_json", Mock(side_effect=[[], installed, installed])) + monkeypatch.setattr( + system_cli, + "_run_claude_json", + Mock(side_effect=lambda *args: [] if args[:3] == ("plugin", "marketplace", "list") else installed), + ) monkeypatch.setattr(system_cli, "_run_claude", Mock()) - system_cli.install_claude_code_plugin( + install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url="http://127.0.0.1:9000", @@ -1349,7 +1280,9 @@ def test_setup_claude_code_refreshes_an_existing_powercontext_statusline(tmp_pat assert "--server-url http://127.0.0.1:9000" in statusline["command"] -def test_setup_claude_code_preserves_unrelated_settings_when_updating_options(tmp_path: Path, monkeypatch) -> None: +def test_setup_claude_code_preserves_unrelated_settings_when_updating_options( + tmp_path: Path, monkeypatch, installed_agent_checks +) -> None: config_dir = tmp_path / "claude" config_dir.mkdir() settings_file = config_dir / "settings.json" @@ -1368,10 +1301,15 @@ def test_setup_claude_code_preserves_unrelated_settings_when_updating_options(tm installed = [{"id": "powercontext@powercontext", "scope": "user", "version": "0.1.0", "enabled": True}] monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config_dir)) monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") - monkeypatch.setattr(system_cli, "_run_claude_json", Mock(side_effect=[[], installed, installed])) + monkeypatch.setattr( + system_cli, + "_run_claude_json", + Mock(side_effect=lambda *args: [] if args[:3] == ("plugin", "marketplace", "list") else installed), + ) monkeypatch.setattr(system_cli, "_run_claude", Mock()) - system_cli.install_claude_code_plugin( + install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url="http://127.0.0.1:8000", @@ -1402,8 +1340,7 @@ def test_setup_claude_code_preserves_unrelated_settings_when_updating_options(tm ], ) def test_setup_claude_code_rejects_unsafe_server_urls_before_cli_writes( - monkeypatch, - server_url: str, + monkeypatch, server_url: str, installed_agent_checks ) -> None: monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") run_claude = Mock() @@ -1412,7 +1349,8 @@ def test_setup_claude_code_rejects_unsafe_server_urls_before_cli_writes( monkeypatch.setattr(system_cli, "_run_claude_json", run_claude_json) with pytest.raises(system_cli.SetupError): - system_cli.install_claude_code_plugin( + install_plugin( + "claude-code", source="oceanbase/powercontext", ref="master", server_url=server_url, @@ -1499,15 +1437,11 @@ def test_doctor_matching_loopback_endpoint_includes_service_diagnostics( monkeypatch: pytest.MonkeyPatch, target: str, ) -> None: - calls: list[str] = [] - class Controller: def registration_status(self) -> ServiceStatus: - calls.append("registration") return _service_status() def status(self) -> ServiceStatus: - calls.append("manager") return _service_status( manager=ManagerState.ACTIVE, ownership=ManagerOwnershipState.OWNED, @@ -1517,7 +1451,6 @@ def status(self) -> ServiceStatus: diagnostics = system_cli._local_service_diagnostics(target) - assert calls == ["registration", "manager"] assert diagnostics["service_registration"].status is DiagnosticStatus.OK assert diagnostics["service_manager"].status is DiagnosticStatus.OK @@ -1544,9 +1477,9 @@ def getcode(self) -> int: def test_default_doctor_checks_server_without_inspecting_codex(monkeypatch) -> None: _mock_optional_personal_service(monkeypatch) monkeypatch.setattr( - system_cli, - "run_codex_diagnostics", - Mock(side_effect=AssertionError("default doctor must not inspect Codex")), + system_cli.CodexInstaller, + "verify", + staticmethod(Mock(side_effect=AssertionError("default doctor must not inspect Codex"))), ) monkeypatch.setattr( system_cli, @@ -1773,88 +1706,70 @@ def _mock_optional_personal_service(monkeypatch: pytest.MonkeyPatch) -> None: ) -def test_doctor_codex_reports_missing_cli_and_skipped_plugin(monkeypatch) -> None: +def test_doctor_codex_reports_missing_cli_and_skipped_plugin(tmp_path, monkeypatch, server_checks) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex")) monkeypatch.setattr(system_cli, "which", lambda _name: None) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "codex", "--json"]) assert result.exit_code == 1 - assert json.loads(result.output) == { - "ok": False, - "status": "failed", - "checks": { - "codex": { - "ok": False, - "status": "failed", - "detail": "Codex CLI is not installed or is not on PATH", - }, - "plugin": { - "ok": False, - "status": "skipped", - "detail": "not checked because Codex CLI is unavailable", - }, - }, - } + checks = json.loads(result.output)["hosts"]["codex"]["checks"] + assert checks["codex"]["status"] == "failed" + assert checks["plugin"]["status"] == "skipped" -def test_doctor_codex_requires_an_enabled_powercontext_plugin(monkeypatch) -> None: +def test_doctor_codex_requires_an_enabled_powercontext_plugin(monkeypatch, server_checks) -> None: monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/codex") monkeypatch.setattr(system_cli, "_run_codex_json", lambda *_args: {"installed": []}) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "codex"]) assert result.exit_code == 1 - assert "codex: ok - /usr/bin/codex" in result.output assert "plugin: failed - PowerContext plugin is not installed" in result.output -def test_doctor_claude_code_reports_missing_cli_and_skipped_plugin(monkeypatch) -> None: +def test_doctor_claude_code_reports_missing_cli_and_skipped_plugin(monkeypatch, server_checks) -> None: monkeypatch.setattr(system_cli, "which", lambda _name: None) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "claude-code", "--json"]) assert result.exit_code == 1 - assert json.loads(result.output) == { - "ok": False, - "status": "failed", - "checks": { - "claude_code": { - "ok": False, - "status": "failed", - "detail": "Claude Code CLI is not installed or is not on PATH", - }, - "plugin": { - "ok": False, - "status": "skipped", - "detail": "not checked because Claude Code CLI is unavailable", - }, - }, - } + checks = json.loads(result.output)["hosts"]["claude-code"]["checks"] + assert checks["claude_code"]["status"] == "failed" + assert checks["plugin"]["status"] == "skipped" -def test_doctor_claude_code_requires_an_enabled_powercontext_plugin(monkeypatch) -> None: +def test_doctor_claude_code_requires_an_enabled_powercontext_plugin(monkeypatch, server_checks) -> None: monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") monkeypatch.setattr(system_cli, "_run_claude_json", lambda *_args: []) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "claude-code"]) assert result.exit_code == 1 - assert "claude code: ok - /usr/bin/claude" in result.output assert "plugin: failed - PowerContext plugin is not installed" in result.output -def test_claude_runner_uses_the_resolved_executable(monkeypatch) -> None: - monkeypatch.setattr(system_cli, "which", lambda _name: "/resolved/bin/claude") - run = Mock(return_value=system_cli.subprocess.CompletedProcess([], 0, stdout="[]", stderr="")) - monkeypatch.setattr(system_cli.subprocess, "run", run) - - assert system_cli._run_claude_json("plugin", "list") == [] - assert run.call_args.args[0] == ["/resolved/bin/claude", "plugin", "list", "--json"] - assert run.call_args.kwargs["encoding"] == "utf-8" - assert run.call_args.kwargs["errors"] == "replace" +def test_doctor_claude_reports_enabled_plugin_load_errors(monkeypatch, server_checks): + monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/claude") + monkeypatch.setattr( + system_cli, + "_run_claude_json", + lambda *_args: [ + { + "id": "powercontext@powercontext", + "enabled": True, + "errors": ["Failed to load hooks: invalid hooks.json"], + } + ], + ) + result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "claude-code", "--json"]) + assert result.exit_code == 1 + plugin = json.loads(result.output)["hosts"]["claude-code"]["checks"]["plugin"] + assert plugin["status"] == "failed" + assert "Failed to load hooks" in plugin["detail"] -def test_setup_dsh_adds_plugin_from_a_local_checkout(tmp_path: Path, monkeypatch) -> None: +def test_setup_dsh_adds_plugin_from_a_local_checkout(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: import powercontext.cli.dsh as dsh_cli import powercontext.cli.dsh_transport as dsh_transport @@ -1876,7 +1791,7 @@ def test_setup_dsh_adds_plugin_from_a_local_checkout(tmp_path: Path, monkeypatch ) assert result.exit_code == 0 - assert json.loads(result.output) == { + assert json.loads(result.output)["hosts"]["dsh"]["result"] == { "plugin": "powercontext-dsh", "plugin_path": str(plugin), "data_dir": str(tmp_path / "data"), @@ -1893,7 +1808,7 @@ def test_setup_dsh_adds_plugin_from_a_local_checkout(tmp_path: Path, monkeypatch ) -def test_setup_dsh_fails_when_dsh_cli_is_missing(tmp_path: Path, monkeypatch) -> None: +def test_setup_dsh_fails_when_dsh_cli_is_missing(tmp_path: Path, monkeypatch, installed_agent_checks) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: None) @@ -1907,7 +1822,7 @@ def test_setup_dsh_fails_when_dsh_cli_is_missing(tmp_path: Path, monkeypatch) -> assert "DeepSeek Harness CLI is not installed" in result.output -def test_doctor_dsh_reports_missing_cli_and_skipped_plugin(monkeypatch) -> None: +def test_doctor_dsh_reports_missing_cli_and_skipped_plugin(monkeypatch, server_checks) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: None) @@ -1915,25 +1830,12 @@ def test_doctor_dsh_reports_missing_cli_and_skipped_plugin(monkeypatch) -> None: result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh", "--json"]) assert result.exit_code == 1 - assert json.loads(result.output) == { - "ok": False, - "status": "failed", - "checks": { - "dsh": { - "ok": False, - "status": "failed", - "detail": "DeepSeek Harness CLI is not installed or is not on PATH", - }, - "plugin": { - "ok": False, - "status": "skipped", - "detail": "not checked because DeepSeek Harness CLI is unavailable", - }, - }, - } + checks = json.loads(result.output)["hosts"]["dsh"]["checks"] + assert checks["dsh"]["status"] == "failed" + assert checks["plugin"]["status"] == "skipped" -def test_doctor_dsh_requires_the_installed_plugin(monkeypatch) -> None: +def test_doctor_dsh_requires_the_installed_plugin(monkeypatch, server_checks) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: "/usr/bin/dsh") @@ -1942,5 +1844,52 @@ def test_doctor_dsh_requires_the_installed_plugin(monkeypatch) -> None: result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh"]) assert result.exit_code == 1 - assert "dsh: ok - /usr/bin/dsh" in result.output assert "plugin: failed - PowerContext DSH plugin is not registered in the Web profile" in result.output + + +def test_codex_configuration_and_doctor_follow_the_native_installation_not_old_cache(tmp_path, monkeypatch): + monkeypatch.setattr(system_cli, "which", lambda _name: "/usr/bin/codex") + monkeypatch.setattr( + system_cli, + "_run_codex_json", + lambda *_args: { + "installed": [ + { + "name": "powercontext", + "installed": True, + "enabled": True, + "pluginId": "powercontext@powercontext", + "marketplaceName": "powercontext", + "version": "0.1.0", + } + ] + }, + ) + monkeypatch.setattr( + system_cli, + "_probe_codex_mcp_status", + lambda **_kwargs: { + "tools": {name: {} for name in system_cli._CODEX_FULL_MCP_TOOLS}, + }, + ) + monkeypatch.setattr( + system_cli, + "server_diagnostics", + lambda **options: { + "server_readiness": Diagnostic(DiagnosticStatus.OK, options["server_url"]), + }, + ) + obsolete = tmp_path / "codex/plugins/cache/powercontext/powercontext/0.0.0/.mcp.json" + obsolete.parent.mkdir(parents=True) + original = '{"mcpServers":{"powercontext":{"type":"http","url":"https://old.example/mcp/"}}}' + obsolete.write_text(original) + runner = CliRunner() + configured = runner.invoke( + create_cli(), ["config", "agent", "codex", "--server-url", "https://new.example", "--json"] + ) + assert configured.exit_code == 0, configured.output + diagnosed = runner.invoke(create_cli(), ["doctor", "codex", "--json"]) + assert diagnosed.exit_code == 0, diagnosed.output + report = json.loads(diagnosed.output)["hosts"]["codex"] + assert report["checks"]["server_readiness"]["detail"] == "https://new.example" + assert obsolete.read_text() == original