From 6c486a53a95b237f56a4e566a91f3d8b3a8ac7f0 Mon Sep 17 00:00:00 2001 From: XiaoLrrr Date: Wed, 14 Oct 2026 10:16:02 +0800 Subject: [PATCH 1/6] feat: add Pi coding agent adapter (earendil-works/pi + pi-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi is the coding agent at github.com/earendil-works/pi with its config root at ~/.pi/agent ($PI_CODING_AGENT_DIR). pi-web (agegr/pi-web) is the local browser UI for the same agent and shares this config root, so one adapter covers both. - adapter/pi.rs: skills (~/.pi/agent/skills + ~/.agents/skills universal + npm package skills/ dirs; project .pi/skills + .agents/skills), MCP (~/.pi/agent/mcp.json, .pi/mcp.json — JSON mcpServers with native per-entry enabled), plugins (settings.json `packages` npm packages + ~/.pi/agent/extensions local TS modules), context files (AGENTS.md / CLAUDE.md variants), settings.json - deployer: set_pi_mcp_enabled (native MCP enabled flag in place) and set_pi_package_enabled (add/remove npm package declaration in `packages`) - manager: toggle dispatch for pi MCP + npm-package vs local-TS plugin toggle - RemoteMcpSchema::PlainUrlHttpOnly (Pi is Streamable HTTP only, no SSE) - frontend: AGENT_ORDER + display name "Pi" + onboarding scatter coords - README: agent matrix + supported-agent count --- README.md | 3 +- README.zh-CN.md | 3 +- crates/hk-core/src/adapter/mod.rs | 20 +- crates/hk-core/src/adapter/pi.rs | 589 +++++++++++++++++++++++ crates/hk-core/src/deployer.rs | 103 +++- crates/hk-core/src/manager.rs | 27 +- crates/hk-core/src/scanner.rs | 95 ++++ src/components/onboarding/onboarding.tsx | 2 + src/lib/types.ts | 2 + 9 files changed, 832 insertions(+), 12 deletions(-) create mode 100644 crates/hk-core/src/adapter/pi.rs diff --git a/README.md b/README.md index 196f7030..4580e4e7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ HarnessKit manages **all five extension types** from a unified interface — **S | **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ | | **OpenClaw** | ✓ | ✓ | — | — | ✓ | | **PenguinHarness** | ✓ | — | — | — | — | +| **Pi** | ✓ | ✓ | ✓ | — | ✓ | * "—" means HarnessKit does not manage this extension type for the agent yet. @@ -92,7 +93,7 @@ https://github.com/user-attachments/assets/a2a74fd1-f3f2-4525-9d64-ba00378d6eef ### 🤖 Agent Configs, Memory & Rules -HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **16 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, **DeepSeek Harness**, **Grok Build**, **Qoder CN**, **OpenClaw**, and **PenguinHarness**. +HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **17 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, **DeepSeek Harness**, **Grok Build**, **Qoder CN**, **OpenClaw**, **PenguinHarness**, and **Pi**. - **Config file tracking** — Automatically discovers every agent's config files — both global and per-project. Add your project directories or custom paths and HarnessKit picks them up alongside the global ones. - **Per-agent dashboard** — Each agent gets its own page with all files organized by category, showing scope, path, file size, and a summary of installed extensions. Expand any file to preview its content right in the app. diff --git a/README.zh-CN.md b/README.zh-CN.md index bbc34b92..617938e8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -74,6 +74,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill* | **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ | | **OpenClaw** | ✓ | ✓ | — | — | ✓ | | **PenguinHarness** | ✓ | — | — | — | — | +| **Pi** | ✓ | ✓ | ✓ | — | ✓ | * "—" 表示 HarnessKit 目前还不管理该 Agent 的这类扩展。 @@ -92,7 +93,7 @@ https://github.com/user-attachments/assets/a2a74fd1-f3f2-4525-9d64-ba00378d6eef ### 🤖 Agent 配置、记忆与规则 -HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **16 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi**、**DeepSeek Harness**、**Grok Build**、**Qoder CN**、**OpenClaw** 与 **PenguinHarness**。 +HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **17 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi**、**DeepSeek Harness**、**Grok Build**、**Qoder CN**、**OpenClaw**、**PenguinHarness** 与 **Pi**。 - **配置文件跟踪** —— 自动发现每个 Agent 的全局与项目级配置文件。添加项目目录或自定义路径后,HarnessKit 会将它们与全局配置一同纳入管理。 - **Agent 专属面板** —— 每个 Agent 拥有独立页面,文件按类别组织,列出范围、路径、文件大小以及已安装扩展的概览。展开任意文件即可在应用内预览。 diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index a6286bc9..0c8d0380 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -13,6 +13,7 @@ pub mod omp; pub mod openclaw; pub mod opencode; pub mod penguin; +pub mod pi; pub mod qoder_cn; pub mod windsurf; @@ -456,6 +457,10 @@ pub enum RemoteMcpSchema { TypeAndUrl, /// `{url, headers}`, protocol auto-detected by the agent — Cursor, Kiro. PlainUrl, + /// `{url, headers}`, Streamable HTTP only — Pi. The file shape matches + /// Cursor/Kiro's PlainUrl, but Pi rejects the legacy SSE transport + /// (docs/mcp.md), so remote entries are HTTP-only. + PlainUrlHttpOnly, /// `{httpUrl}` for Streamable HTTP, `{url}` for SSE, `headers` for both — Gemini. GeminiSplit, /// `{serverUrl, headers}`, protocol auto-detected — Windsurf, Antigravity. @@ -838,6 +843,7 @@ impl crate::models::AgentCapabilities { RemoteMcpSchema::Unsupported | RemoteMcpSchema::Toml | RemoteMcpSchema::DshTransport + | RemoteMcpSchema::PlainUrlHttpOnly ), }, } @@ -875,6 +881,7 @@ pub fn all_adapters() -> Vec> { Box::new(qoder_cn::QoderCnAdapter::new()), Box::new(openclaw::OpenClawAdapter::new()), Box::new(penguin::PenguinAdapter::new()), + Box::new(pi::PiAdapter::new()), ] } @@ -921,7 +928,7 @@ mod tests { for a in all_adapters() { let caps = crate::models::AgentCapabilities::from_adapter(a.as_ref()); match a.name() { - "codex" | "dsh" => { + "codex" | "dsh" | "pi" => { assert!(caps.mcp_remote.http); assert!(!caps.mcp_remote.sse); } @@ -958,9 +965,9 @@ mod tests { } #[test] - fn test_all_adapters_returns_sixteen() { + fn test_all_adapters_returns_seventeen() { let adapters = all_adapters(); - assert_eq!(adapters.len(), 16); + assert_eq!(adapters.len(), 17); let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect(); assert_eq!( names, @@ -981,6 +988,7 @@ mod tests { "qoder-cn", "openclaw", "penguin", + "pi", ] ); } @@ -1005,7 +1013,7 @@ mod tests { for name in [ "claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes", "kiro", "omp", "dsh", "grok", - "penguin", + "penguin", "pi", ] { assert!( !by_name[name].needs_path_injection(), @@ -1022,7 +1030,7 @@ mod tests { for a in &adapters { let expected = matches!( a.name(), - "hermes" | "kiro" | "omp" | "dsh" | "grok" | "openclaw" + "hermes" | "kiro" | "omp" | "dsh" | "grok" | "openclaw" | "pi" ); assert_eq!( a.supports_native_mcp_toggle(), @@ -1061,6 +1069,7 @@ mod tests { ("qoder-cn", true, false, true, true, true), // project MCP merge pending ("openclaw", false, false, false, false, true), // global-only: gateway state dir; hooks are JS handlers ("penguin", false, false, false, false, true), // read-only discovery; no hook format, like dsh + ("pi", true, true, false, false, true), // TS/JS extensions; MCP has native per-entry enabled; no shell hooks ]; let adapters = all_adapters(); @@ -1204,6 +1213,7 @@ mod tests { ("dsh", ".dsh/skills"), ("grok", ".grok/skills"), ("qoder-cn", ".agents/skills"), // Universal standard alias, confirmed in app.asar + ("pi", ".pi/skills"), // hermes is global-only — no project skill dir (hermes-agent#4667). ] .into_iter() diff --git a/crates/hk-core/src/adapter/pi.rs b/crates/hk-core/src/adapter/pi.rs new file mode 100644 index 00000000..3f7ceebc --- /dev/null +++ b/crates/hk-core/src/adapter/pi.rs @@ -0,0 +1,589 @@ +// Pi (earendil-works/pi coding agent) — verified against upstream docs +// (github.com/earendil-works/pi, packages/coding-agent/docs): +// - Agent dir: `$PI_CODING_AGENT_DIR` or `~/.pi/agent` +// (configuration.md). Same root serves the pi CLI and pi-web +// (github.com/agegr/pi-web), the local browser UI — pi-web reuses the +// pi agent's own configuration, so there is no separate config root. +// - Skills: `~/.pi/agent/skills/` (user) + `.pi/skills/` (project), with +// recursive SKILL.md discovery. Pi also reads the universal Agent Skills +// locations `~/.agents/skills` / `.agents/skills` (skills.md), so those +// are declared here too. +// - MCP: JSON `{"mcpServers": {name: {...}}}` — stdio `{command, args, env, +// cwd}` or streamable HTTP `{url, headers}` with a native per-entry +// `enabled` flag (mcp.md). User `~/.pi/agent/mcp.json`, project +// `.pi/mcp.json`. +// - Extensions: TypeScript/JavaScript modules — `~/.pi/agent/extensions/` + +// `.pi/extensions/` (extensions.md). Same shape as omp. +// - Context/rules: `AGENTS.override.md` / `AGENTS.md` / `AGENTS.MD` / +// `CLAUDE.md` / `CLAUDE.MD` from the agent dir and each ancestor of the +// working directory (configuration.md "Context files"). +// - No shell-command hooks → HookFormat::None (extensions are TS/JS modules). + +use super::{ + AgentAdapter, HookEntry, HookFormat, McpFormat, McpServerEntry, PluginEntry, ProjectMarker, + RemoteMcpSchema, +}; +use std::path::{Path, PathBuf}; + +/// Context-file fallback list, in Pi's documented precedence order. +const CONTEXT_FILENAMES: &[&str] = &[ + "AGENTS.override.md", + "AGENTS.md", + "AGENTS.MD", + "CLAUDE.md", + "CLAUDE.MD", +]; + +pub struct PiAdapter { + home: PathBuf, + agent_dir: PathBuf, +} + +impl Default for PiAdapter { + fn default() -> Self { + Self::new() + } +} + +impl PiAdapter { + pub fn new() -> Self { + let home = dirs::home_dir().unwrap_or_default(); + let agent_dir = std::env::var_os("PI_CODING_AGENT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".pi").join("agent")); + Self { home, agent_dir } + } + + #[cfg(test)] + pub fn with_home(home: PathBuf) -> Self { + Self { + agent_dir: home.join(".pi").join("agent"), + home, + } + } + + fn parse_json(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() + } + + /// `npm:[@version]` declarations from `settings.json` → package + /// directory under `~/.pi/agent/npm/node_modules`. Only npm sources are + /// returned (git/local packages are not modeled as plugins yet). + fn npm_package_dirs(&self) -> Vec<(String, String, PathBuf)> { + let Some(settings) = Self::parse_json(&self.agent_dir.join("settings.json")) else { + return vec![]; + }; + let Some(packages) = settings.get("packages").and_then(|v| v.as_array()) else { + return vec![]; + }; + let npm_root = self.agent_dir.join("npm").join("node_modules"); + let mut out = vec![]; + for pkg in packages { + let source = match pkg { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Object(o) => o + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + _ => continue, + }; + let Some(spec) = source.strip_prefix("npm:") else { + continue; + }; + // Strip a trailing `@version` only when it is not the scope of a + // scoped package (`@scope/name`): the version separator never + // contains a `/`. + let pkg_name = match spec.rsplit_once('@') { + Some((name, ver)) if !ver.contains('/') && !name.is_empty() => name.to_string(), + _ => spec.to_string(), + }; + let dir = npm_root.join(&pkg_name); + if dir.is_dir() { + out.push((source, pkg_name, dir)); + } + } + out + } + + /// Read an installed npm package's `repository.url` and derive its + /// lowercased `owner/repo` pack. `None` for packages without a repository + /// (e.g. unpublished local packages). + fn npm_package_repo(pkg_dir: &Path) -> Option<(String, String)> { + let content = std::fs::read_to_string(pkg_dir.join("package.json")).ok()?; + let json: serde_json::Value = serde_json::from_str(&content).ok()?; + crate::scanner::repository_pack_from_package_json(&json) + } + + /// Strip a `.disabled` suffix so a toggled-off `hello.ts.disabled` keeps + /// the stable name of its enabled sibling `hello.ts` (same as omp). + fn plugin_name(path: &Path) -> String { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); + Path::new(base) + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_else(|| base.to_string()) + } + + /// Pi loads direct `.ts`/`.js` files and `index.ts`/`index.js` entry + /// points (extensions.md). A `.disabled` suffix is HarnessKit's own + /// in-place rename, which hides the file from the loader. + fn is_extension_file(path: &Path) -> bool { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); + matches!( + Path::new(base).extension().and_then(|ext| ext.to_str()), + Some("ts" | "js") + ) + } +} + +impl AgentAdapter for PiAdapter { + fn name(&self) -> &str { + "pi" + } + + fn base_dir(&self) -> PathBuf { + self.home.join(".pi") + } + + fn detect(&self) -> bool { + // Pi creates the agent dir on first run (`~/.pi/agent`), including + // `~/.pi` for pi-web session state. Presence of the agent dir is the + // reliable install signal. + self.agent_dir.exists() + } + + fn skill_dirs(&self) -> Vec { + // Native Pi user skills: ~/.pi/agent/skills, plus the universal + // Agent Skills location ~/.agents/skills (skills.md: "Pi also + // supports the Agent Skills locations ~/.agents/skills/ and + // .agents/skills/"). npm packages can ship skills under their own + // `skills/` directory (packages.md: conventional resource dir). + let mut dirs = vec![ + self.agent_dir.join("skills"), + self.home.join(".agents").join("skills"), + ]; + for (_, _, pkg_dir) in self.npm_package_dirs() { + let skills = pkg_dir.join("skills"); + if skills.is_dir() { + dirs.push(skills); + } + } + dirs + } + + fn mcp_config_path(&self) -> PathBuf { + // ~/.pi/agent/mcp.json is the canonical user-level file. + self.agent_dir.join("mcp.json") + } + + fn hook_config_path(&self) -> PathBuf { + // Pi has no shell-command hooks file. Reuse the MCP path so the + // default plugin_config_path() lands on a real file (mirrors omp). + self.mcp_config_path() + } + + fn plugin_config_path(&self) -> PathBuf { + // npm package plugins are enabled/disabled by editing the `packages` + // array in settings.json (`pi install` / `pi remove`). + self.agent_dir.join("settings.json") + } + + fn plugin_dirs(&self) -> Vec { + // TypeScript/JavaScript extension modules: ~/.pi/agent/extensions/*.ts + vec![self.agent_dir.join("extensions")] + } + + fn hook_format(&self) -> HookFormat { + HookFormat::None + } + + fn mcp_format(&self) -> McpFormat { + McpFormat::McpServers + } + + fn supports_native_mcp_toggle(&self) -> bool { + // Toggle flips the entry's own `enabled` flag in place (see + // deployer::set_pi_mcp_enabled), keeping `description`, `timeout`, + // `exposure`, `cwd`, `oauth`, `env`, `headers`, and all other keys + // untouched — exactly how `pi mcp` / `/mcp` behaves. + true + } + + fn remote_mcp_schema(&self) -> RemoteMcpSchema { + // HTTP entries are `{url, headers}` with the transport auto-detected; + // the legacy SSE transport is rejected upstream (docs/mcp.md), so + // remote entries are Streamable HTTP only. + RemoteMcpSchema::PlainUrlHttpOnly + } + + fn read_mcp_servers(&self) -> Vec { + self.read_mcp_servers_from(&self.mcp_config_path()) + } + + fn read_mcp_servers_from(&self, path: &Path) -> Vec { + let Some(config) = Self::parse_json(path) else { + return vec![]; + }; + let Some(servers) = config.get("mcpServers").and_then(|v| v.as_object()) else { + return vec![]; + }; + servers + .iter() + .map(|(name, val)| { + // `command` selects stdio, `url` selects streamable HTTP; + // an optional `type` (stdio/http/streamable-http) also counts. + let (transport, url) = super::parse_type_url(val); + McpServerEntry { + name: name.clone(), + command: val + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .into(), + args: super::json_string_vec(val, "args"), + env: super::json_string_map(val, "env"), + transport, + url, + headers: super::json_string_map(val, "headers"), + // Native per-server flag: absent means enabled. + enabled: val + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + } + }) + .collect() + } + + fn read_hooks(&self) -> Vec { + // Pi hooks are TS/JS event-handler extensions, not shell commands. + vec![] + } + + fn read_plugins(&self) -> Vec { + let mut entries = Vec::new(); + // npm packages are Pi's primary plugin distribution (settings.json + // `packages` → ~/.pi/agent/npm/node_modules). Each installed package + // is one plugin; the `uri` carries the original `npm:…` declaration + // so the toggle can remove/re-add it in settings.json. + for (source, pkg_name, pkg_dir) in self.npm_package_dirs() { + let repo = Self::npm_package_repo(&pkg_dir); + entries.push(PluginEntry { + name: pkg_name, + source: "npm".into(), + enabled: true, + path: Some(pkg_dir), + source_url: repo.as_ref().map(|(url, _)| url.clone()), + uri: Some(source), + installed_at: None, + updated_at: None, + base_layers: vec![], + pack: repo.map(|(_, pack)| pack), + }); + } + // Local single-file / directory TypeScript extensions. + for plugin_dir in self.plugin_dirs() { + let Ok(files) = std::fs::read_dir(plugin_dir) else { + continue; + }; + for file in files.flatten() { + let path = file.path(); + if path.is_file() { + if !Self::is_extension_file(&path) { + continue; + } + let enabled = path.extension().is_none_or(|ext| ext != "disabled"); + entries.push(PluginEntry { + name: Self::plugin_name(&path), + source: "local".into(), + enabled, + path: Some(path), + source_url: None, + uri: None, + installed_at: None, + updated_at: None, + base_layers: vec![], + pack: None, + }); + } else if path.is_dir() { + let Some(dir_name) = + path.file_name().map(|n| n.to_string_lossy().to_string()) + else { + continue; + }; + let candidates = [ + ("index.ts", true), + ("index.js", true), + ("index.ts.disabled", false), + ("index.js.disabled", false), + ]; + if let Some((entry_file, enabled)) = candidates + .iter() + .map(|(f, e)| (path.join(f), *e)) + .find(|(p, _)| p.is_file()) + { + entries.push(PluginEntry { + name: dir_name, + source: "local".into(), + enabled, + path: Some(entry_file), + source_url: None, + uri: None, + installed_at: None, + updated_at: None, + base_layers: vec![], + pack: None, + }); + } + } + } + } + entries + } + + fn global_rules_files(&self) -> Vec { + // Context files live directly in the agent dir (configuration.md). + CONTEXT_FILENAMES + .iter() + .map(|name| self.agent_dir.join(name)) + .collect() + } + + fn global_settings_files(&self) -> Vec { + // settings.json is the primary user-level settings file; models.json + // holds compatible endpoints; mcp.json is listed so the servers file + // is browsable from the agent page (matches the omp/kiro adapters). + vec![ + self.agent_dir.join("settings.json"), + self.agent_dir.join("models.json"), + self.mcp_config_path(), + ] + } + + fn project_markers(&self) -> Vec { + // Dir(".pi") alone suffices: every project-level Pi file lives inside + // `.pi/`. + vec![ProjectMarker::Dir(".pi")] + } + + fn project_rules_patterns(&self) -> Vec { + // Context files are discovered from the working directory and its + // ancestors (configuration.md "Context files"), not from `.pi/`. + CONTEXT_FILENAMES.iter().map(|s| s.to_string()).collect() + } + + fn project_settings_patterns(&self) -> Vec { + vec![".pi/settings.json".into()] + } + + fn project_skill_dirs(&self) -> Vec { + // Pi-native project skills plus the universal Agent Skills location + // (skills.md). + vec![".pi/skills".into(), ".agents/skills".into()] + } + + fn project_mcp_config_relpath(&self) -> Option { + // Pi reads `.pi/mcp.json`; project config is loaded after project + // trust is granted. + Some(".pi/mcp.json".into()) + } + + fn project_plugin_dirs(&self) -> Vec { + // Project-level extension modules: /.pi/extensions/*.ts + vec![".pi/extensions".into()] + } +} + +#[cfg(test)] +mod tests { + use super::super::AgentAdapter; + use super::*; + + #[test] + fn detect_requires_agent_dir() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + assert!(!adapter.detect()); + std::fs::create_dir_all(tmp.path().join(".pi/agent")).unwrap(); + assert!(adapter.detect()); + } + + #[test] + fn read_mcp_servers_handles_stdio_and_remote() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + let cfg = tmp.path().join(".pi/agent/mcp.json"); + std::fs::create_dir_all(cfg.parent().unwrap()).unwrap(); + std::fs::write( + &cfg, + r#"{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + }, + "docs": { + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer t" } + }, + "off": { "command": "echo", "enabled": false } + } + }"#, + ) + .unwrap(); + + let servers = adapter.read_mcp_servers(); + assert_eq!(servers.len(), 3); + let by_name: std::collections::HashMap<&str, &McpServerEntry> = + servers.iter().map(|s| (s.name.as_str(), s)).collect(); + + assert_eq!(by_name["filesystem"].command, "npx"); + assert!(by_name["filesystem"].enabled); + + let docs = by_name["docs"]; + assert_eq!(docs.transport, super::super::McpTransport::Http); + assert_eq!(docs.url.as_deref(), Some("https://example.com/mcp")); + assert_eq!(docs.headers["Authorization"], "Bearer t"); + + assert!(!by_name["off"].enabled); + } + + #[test] + fn read_plugins_lists_ts_extensions() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + let ext_dir = tmp.path().join(".pi/agent/extensions"); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write(ext_dir.join("hello.ts"), "// ext\n").unwrap(); + std::fs::write(ext_dir.join("off.ts.disabled"), "// ext\n").unwrap(); + std::fs::write(ext_dir.join("README.md"), "# readme\n").unwrap(); + + let plugins = adapter.read_plugins(); + assert_eq!(plugins.len(), 2); + let by_name: std::collections::HashMap<&str, &PluginEntry> = + plugins.iter().map(|p| (p.name.as_str(), p)).collect(); + assert!(by_name["hello"].enabled); + assert!(!by_name["off"].enabled); + } + + #[test] + fn read_plugins_lists_npm_packages() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + let agent = tmp.path().join(".pi/agent"); + std::fs::create_dir_all(&agent).unwrap(); + std::fs::write( + agent.join("settings.json"), + r#"{"packages": ["npm:@scope/rpiv-todo", "npm:pi-lens", {"source":"npm:ponytail","skills":[]}]}"#, + ) + .unwrap(); + let npm = agent.join("npm").join("node_modules"); + std::fs::create_dir_all(npm.join("@scope/rpiv-todo")).unwrap(); + std::fs::create_dir_all(npm.join("pi-lens")).unwrap(); + std::fs::create_dir_all(npm.join("ponytail")).unwrap(); + // Each declared package carries its upstream repo; a package without + // a repository reports no pack/source_url. + std::fs::write( + npm.join("@scope/rpiv-todo/package.json"), + r#"{"repository":{"url":"git+https://github.com/juicesharp/rpiv-mono.git"}}"#, + ) + .unwrap(); + std::fs::write( + npm.join("pi-lens/package.json"), + r#"{"repository":{"url":"git+https://github.com/apmantza/pi-lens.git"}}"#, + ) + .unwrap(); + std::fs::write( + npm.join("ponytail/package.json"), + r#"{"repository":{"url":"git+https://github.com/DietrichGebert/ponytail.git"}}"#, + ) + .unwrap(); + // A transitive dependency that is NOT declared in `packages` must + // not be surfaced as a plugin. + std::fs::create_dir_all(npm.join("express")).unwrap(); + + let plugins = adapter.read_plugins(); + let names: Vec<&str> = plugins.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["@scope/rpiv-todo", "pi-lens", "ponytail"]); + assert!(plugins.iter().all(|p| p.source == "npm")); + assert!(plugins.iter().all(|p| p.uri.is_some())); + let by_name: std::collections::HashMap<&str, &PluginEntry> = + plugins.iter().map(|p| (p.name.as_str(), p)).collect(); + assert_eq!( + by_name["pi-lens"].pack.as_deref(), + Some("apmantza/pi-lens") + ); + assert_eq!( + by_name["ponytail"].pack.as_deref(), + Some("dietrichgebert/ponytail"), + "GitHub owner/repo case must be normalized to lowercase" + ); + assert_eq!( + by_name["@scope/rpiv-todo"].pack.as_deref(), + Some("juicesharp/rpiv-mono") + ); + assert!(by_name["@scope/rpiv-todo"].source_url.is_some()); + } + + #[test] + fn skill_dirs_include_npm_package_skills() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + let agent = tmp.path().join(".pi/agent"); + std::fs::create_dir_all(&agent).unwrap(); + std::fs::write( + agent.join("settings.json"), + r#"{"packages": ["npm:with-skills", "npm:no-skills"]}"#, + ) + .unwrap(); + std::fs::create_dir_all(agent.join("npm/node_modules/with-skills/skills")).unwrap(); + std::fs::create_dir_all(agent.join("npm/node_modules/no-skills")).unwrap(); + + let dirs = adapter.skill_dirs(); + assert_eq!(dirs.len(), 3); + assert_eq!(dirs[0], agent.join("skills")); + assert_eq!(dirs[1], tmp.path().join(".agents/skills")); + assert_eq!(dirs[2], agent.join("npm/node_modules/with-skills/skills")); + } + + #[test] + fn paths_match_pi_native_conventions() { + let adapter = PiAdapter::with_home(PathBuf::from("/h")); + assert_eq!(adapter.base_dir(), PathBuf::from("/h/.pi")); + assert_eq!( + adapter.skill_dirs(), + vec![ + PathBuf::from("/h/.pi/agent/skills"), + PathBuf::from("/h/.agents/skills"), + ] + ); + assert_eq!( + adapter.mcp_config_path(), + PathBuf::from("/h/.pi/agent/mcp.json") + ); + assert_eq!( + adapter.plugin_dirs(), + vec![PathBuf::from("/h/.pi/agent/extensions")] + ); + assert_eq!( + adapter.project_skill_dirs(), + vec![".pi/skills".to_string(), ".agents/skills".to_string()] + ); + assert_eq!( + adapter.project_mcp_config_relpath(), + Some(".pi/mcp.json".into()) + ); + assert_eq!(adapter.hook_format(), HookFormat::None); + assert_eq!(adapter.mcp_format(), McpFormat::McpServers); + } +} diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index 02ac497f..7c5d99cc 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -224,7 +224,9 @@ fn validate_remote_mcp_target( RemoteMcpSchema::Unsupported => Err(HkError::Validation(format!( "{agent_name} does not support remote (HTTP/SSE) MCP servers" ))), - RemoteMcpSchema::Toml | RemoteMcpSchema::DshTransport + RemoteMcpSchema::Toml + | RemoteMcpSchema::DshTransport + | RemoteMcpSchema::PlainUrlHttpOnly if entry.transport == McpTransport::Sse => { Err(HkError::Validation(format!( @@ -259,7 +261,7 @@ fn build_mcp_json_value( obj.insert("type".into(), type_str.into()); obj.insert("url".into(), url.into()); } - RemoteMcpSchema::PlainUrl => { + RemoteMcpSchema::PlainUrl | RemoteMcpSchema::PlainUrlHttpOnly => { obj.insert("url".into(), url.into()); } RemoteMcpSchema::GeminiSplit => { @@ -1039,6 +1041,69 @@ pub fn set_omp_mcp_enabled( }) } +/// Flip a Pi MCP server via its native per-entry `enabled` flag in place. +/// Pi's mcp.json has no denylist/allowlist lists — `enabled: false` is the +/// whole mechanism (mcp.md: "keep the entry without connecting to it"). +/// Every other key (`description`, `timeout`, `exposure`, `toolExposure`, +/// `cwd`, `oauth`, `env`, `headers`) stays untouched, matching `pi mcp` / +/// `/mcp` enable/disable behavior. +pub fn set_pi_mcp_enabled( + config_path: &Path, + server_name: &str, + enabled: bool, +) -> Result<(), HkError> { + locked_modify_json(config_path, |config| { + let servers = config + .get_mut("mcpServers") + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| HkError::NotFound("No mcpServers block found".into()))?; + let server = servers + .get_mut(server_name) + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| HkError::NotFound(format!("MCP server '{server_name}' not found")))?; + if enabled { + // Absent means enabled (mcp.md: "skip when false"). + server.remove("enabled"); + } else { + server.insert("enabled".into(), serde_json::Value::Bool(false)); + } + Ok(()) + }) +} + +/// Toggle a Pi npm package plugin by removing/re-adding its declaration in +/// `settings.json` `packages` (personal scope) — the same operation as +/// `pi remove ` / `pi install `. The package's node_modules +/// directory stays on disk; only the declaration is touched, so any object +/// form (filters, `autoload`) and every other settings key are preserved. +pub fn set_pi_package_enabled( + settings_path: &Path, + package_source: &str, + enabled: bool, +) -> Result<(), HkError> { + locked_modify_json(settings_path, |config| { + let packages = config + .get_mut("packages") + .and_then(|v| v.as_array_mut()) + .ok_or_else(|| HkError::NotFound("No packages array found".into()))?; + let matches_source = |p: &serde_json::Value| match p { + serde_json::Value::String(s) => s == package_source, + serde_json::Value::Object(o) => { + o.get("source").and_then(|v| v.as_str()) == Some(package_source) + } + _ => false, + }; + if enabled { + if !packages.iter().any(matches_source) { + packages.push(serde_json::Value::String(package_source.to_string())); + } + } else { + packages.retain(|p| !matches_source(p)); + } + Ok(()) + }) +} + const DSH_BLOCK_BEGIN: &str = "# >>> managed by HarnessKit — do not edit this block >>>"; const DSH_BLOCK_END: &str = "# <<< managed by HarnessKit <<<"; @@ -4735,6 +4800,40 @@ mod tests { assert!(!user.exists()); } + #[test] + fn test_set_pi_package_enabled_removes_and_readds() { + let dir = TempDir::new().unwrap(); + let config = dir.path().join("settings.json"); + std::fs::write( + &config, + r#"{"packages": ["npm:a", {"source": "npm:b", "skills": []}], "defaultModel": "x"}"#, + ) + .unwrap(); + + // Disable a string-form declaration. + set_pi_package_enabled(&config, "npm:a", false).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + let arr = v["packages"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["source"], "npm:b"); // object form survives + assert_eq!(v["defaultModel"], "x"); // unrelated keys preserved + + // Re-enable it. + set_pi_package_enabled(&config, "npm:a", true).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + assert_eq!(v["packages"].as_array().unwrap().len(), 2); + + // Disable the object-form declaration by its source. + set_pi_package_enabled(&config, "npm:b", false).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + let arr = v["packages"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0], "npm:a"); + } + #[test] fn test_set_kiro_hook_enabled_flips_in_place() { let dir = TempDir::new().unwrap(); diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index 927c8618..fa3d63a2 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -223,6 +223,11 @@ fn toggle_mcp( &ext.name, enabled, )?; + } else if a.name() == "pi" { + // Per-server `enabled` field flipped in place in mcp.json — + // keeps `description`, `timeout`, `exposure`, `cwd`, `oauth`, + // `env`, and `headers` (mirrors `pi mcp` / `/mcp`). + deployer::set_pi_mcp_enabled(&config_path, &ext.name, enabled)?; } else if a.name() == "dsh" { // Official mechanism: id-targeted `disabled` override in the // home-level cordis.patch.yml (HK-managed block). Hot-reloaded @@ -630,6 +635,22 @@ fn toggle_plugin( })?; deployer::set_grok_plugin_enabled(&a.plugin_config_path(), plugin_id, enabled)?; store.set_disabled_config(&ext.id, None)?; + } else if a.name() == "pi" { + // npm package plugins toggle by editing the `packages` array in + // settings.json (`pi install` / `pi remove`); local .ts/.js + // extensions fall through to the generic `.disabled` rename. + // Must run before the generic fallback, which would rename the + // package's node_modules directory and break npm resolution. + let plugins = a.read_plugins(); + let plugin = find_plugin_for_ext(&plugins, ext, a.name()).ok_or_else(|| { + HkError::NotFound(format!("Pi plugin '{}' not found on disk", ext.name)) + })?; + if let Some(uri) = &plugin.uri { + deployer::set_pi_package_enabled(&a.plugin_config_path(), uri, enabled)?; + store.set_disabled_config(&ext.id, None)?; + } else { + toggle_plugin_manifest(ext, enabled, store, a.as_ref(), Some(plugins))?; + } } else if a.name() == "dsh" { // Explicit branch BEFORE the generic manifest-rename fallback — // mandatory ordering, not style: the fallback probes package dirs @@ -738,10 +759,10 @@ fn find_disabled_plugin_path(adapter: &dyn adapter::AgentAdapter, ext_id: &str) if !file_name.ends_with(".disabled") { continue; } - // opencode and omp report single-file plugins with + // opencode, omp, and pi report single-file plugins with // source "local" (see their read_plugins), so the ID must // be rebuilt with that label, not the directory name. - let source = if matches!(adapter.name(), "opencode" | "omp") { + let source = if matches!(adapter.name(), "opencode" | "omp" | "pi") { "local".to_string() } else { plugin_dir @@ -762,7 +783,7 @@ fn find_disabled_plugin_path(adapter: &dyn adapter::AgentAdapter, ext_id: &str) // Directory-form single-entry plugins (omp: /index.{ts,js}) // — the disabled marker is the renamed index file inside the // directory, and the plugin is named after the directory. - if adapter.name() == "omp" { + if matches!(adapter.name(), "omp" | "pi") { for index_name in ["index.ts.disabled", "index.js.disabled"] { let disabled = entry.path().join(index_name); if !disabled.exists() { diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 43ad3908..6f3adb5e 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -149,6 +149,47 @@ fn cli_stable_id(binary_name: &str) -> String { /// `standalone_md` mirrors `AgentAdapter::standalone_md_skills`: when false a /// loose `*.md` in `dir` is ordinary payload (a skill's own `reference.md`, a /// folder `README.md`) rather than a skill of its own. +/// +/// Source precedence for a skill: `skills`-CLI lockfile > enclosing installed +/// package's `package.json` repository > `.git` walk. Skills shipped inside an +/// installed package (pi/omp `npm/node_modules//skills/`) carry neither a +/// `.git` nor a lockfile, so the package's own repository is the only reliable +/// origin and keeps plugin-shipped skills grouped with the plugin that ships +/// them. +fn package_json_source(path: &Path) -> Option<(Source, String)> { + // Walk up from the skill to the nearest `package.json` with a repository. + // Depth-limited so a `package.json` in the user's home cannot mis-attribute + // standalone skills under `~/.agents/skills` (npm package layout puts the + // owning `package.json` two levels above `/skills/`). + let mut dir = if path.is_dir() { + path.to_path_buf() + } else { + path.parent()?.to_path_buf() + }; + for _ in 0..6 { + let pkg = dir.join("package.json"); + if let Ok(content) = std::fs::read_to_string(&pkg) + && let Ok(json) = serde_json::from_str::(&content) + && let Some((url, pack)) = repository_pack_from_package_json(&json) + { + return Some(( + Source { + origin: SourceOrigin::Git, + url: Some(url), + version: None, + commit_hash: None, + from_manifest: true, + }, + pack, + )); + } + if !dir.pop() { + break; + } + } + None +} + pub fn scan_skill_dir(dir: &Path, agent_name: &str, standalone_md: bool) -> Vec { let mut extensions = Vec::new(); let Ok(entries) = std::fs::read_dir(dir) else { @@ -243,6 +284,15 @@ pub fn scan_skill_dir(dir: &Path, agent_name: &str, standalone_md: bool) -> Vec< from_manifest: true, }; } + // Fallback for skills shipped inside an installed package: no `.git` + // and no `skills`-CLI lockfile, but the enclosing package.json names + // the upstream repo. Keeps plugin-shipped skills in the plugin's pack. + if source.url.is_none() { + if let Some((pkg_source, pkg_pack)) = package_json_source(&resolved) { + source = pkg_source; + pack = Some(pkg_pack); + } + } extensions.push(Extension { id: stable_id(&name, "skill", agent_name), kind: ExtensionKind::Skill, @@ -1823,6 +1873,22 @@ pub fn extract_pack_from_url(url: &str) -> Option { None } +/// Derive `(repository.url, pack)` from an installed package's `package.json` +/// `repository` field. The pack is lowercased — GitHub treats owner/repo +/// case-insensitively, and pi's own package registry lowercases too, so a +/// single canonical grouping key is used everywhere (plugin rows and the +/// skills a plugin ships must land in the same "pack" group). +pub fn repository_pack_from_package_json( + package_json: &serde_json::Value, +) -> Option<(String, String)> { + let url = package_json + .get("repository")? + .get("url")? + .as_str()?; + let pack = extract_pack_from_url(url)?.to_lowercase(); + Some((url.to_string(), pack)) +} + fn read_git_remote(repo_dir: &Path) -> Option { let config = std::fs::read_to_string(repo_dir.join(".git/config")).ok()?; for line in config.lines() { @@ -2332,6 +2398,35 @@ mod tests { assert_eq!(extensions[0].kind, ExtensionKind::Skill); } + #[test] + fn test_skill_in_npm_package_gets_pack_from_package_json() { + // Skills shipped inside an installed npm package carry no `.git` and no + // lockfile; the enclosing package.json repository must attribute them + // to the same pack as the plugin that ships them. + let dir = TempDir::new().unwrap(); + let pkg = dir.path().join("node_modules").join("pi-lens"); + std::fs::create_dir_all(pkg.join("skills").join("pi-lens-ast-grep")).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"pi-lens","repository":{"url":"git+https://github.com/apmantza/pi-lens.git"}}"#, + ) + .unwrap(); + std::fs::write( + pkg.join("skills").join("pi-lens-ast-grep").join("SKILL.md"), + "---\nname: pi-lens-ast-grep\ndescription: semantic search\n---\n", + ) + .unwrap(); + + let exts = scan_skill_dir(&pkg.join("skills"), "pi", true); + assert_eq!(exts.len(), 1); + assert_eq!(exts[0].pack.as_deref(), Some("apmantza/pi-lens")); + assert_eq!( + exts[0].source.url.as_deref(), + Some("git+https://github.com/apmantza/pi-lens.git") + ); + assert_eq!(exts[0].source.origin, SourceOrigin::Git); + } + #[cfg(unix)] #[test] fn test_symlinked_skill_attributed_to_real_source_not_enclosing_repo() { diff --git a/src/components/onboarding/onboarding.tsx b/src/components/onboarding/onboarding.tsx index 7673a4b5..1ce3c900 100644 --- a/src/components/onboarding/onboarding.tsx +++ b/src/components/onboarding/onboarding.tsx @@ -271,6 +271,7 @@ const FLOAT_DELAYS: Record<(typeof AGENT_ORDER)[number], number> = { "qoder-cn": 0.5, openclaw: 1.2, penguin: 2.5, + pi: 2.3, }; const SCATTER_POSITIONS: Record< (typeof AGENT_ORDER)[number], @@ -292,6 +293,7 @@ const SCATTER_POSITIONS: Record< "qoder-cn": { x: 180, y: 100, r: -14 }, openclaw: { x: -170, y: -110, r: 16 }, penguin: { x: -130, y: 145, r: 16 }, + pi: { x: 70, y: 155, r: -18 }, }; function HandAnnotation({ diff --git a/src/lib/types.ts b/src/lib/types.ts index 0c60cdbc..d9eae702 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -436,6 +436,7 @@ export const AGENT_ORDER = [ "qoder-cn", "openclaw", "penguin", + "pi", ] as const; /** Sort an array of agents (or agent-like objects with a `name` field) by a given order. */ @@ -467,6 +468,7 @@ const AGENT_DISPLAY_NAMES: Record = { "qoder-cn": "Qoder CN", openclaw: "OpenClaw", penguin: "Penguin", + pi: "Pi", }; /** Get the display name for an agent (e.g. "claude" → "Claude Code"). */ From c535f434b15bfa38727394c56afa93a6788a4518 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 17 Oct 2026 22:26:03 +0800 Subject: [PATCH 2/6] test: count Pi in AGENT_ORDER The frontend test still asserted 16 agents with penguin last, which failed CI before the Rust suite ran. Also fold the adapter test's name list to rustfmt's layout. Co-Authored-By: Claude Fable 5.1 --- crates/hk-core/src/adapter/mod.rs | 3 +-- src/lib/__tests__/types.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 0c8d0380..bffb64de 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -1012,8 +1012,7 @@ mod tests { // hurting cross-machine portability. for name in [ "claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes", "kiro", "omp", - "dsh", "grok", - "penguin", "pi", + "dsh", "grok", "penguin", "pi", ] { assert!( !by_name[name].needs_path_injection(), diff --git a/src/lib/__tests__/types.test.ts b/src/lib/__tests__/types.test.ts index b4cd4079..f507694a 100644 --- a/src/lib/__tests__/types.test.ts +++ b/src/lib/__tests__/types.test.ts @@ -334,9 +334,9 @@ describe("sortAgentNames", () => { }); describe("AGENT_ORDER", () => { - it("lists 16 agents with penguin last", () => { - expect(AGENT_ORDER).toHaveLength(16); - expect(AGENT_ORDER[AGENT_ORDER.length - 1]).toBe("penguin"); + it("lists 17 agents with pi last", () => { + expect(AGENT_ORDER).toHaveLength(17); + expect(AGENT_ORDER[AGENT_ORDER.length - 1]).toBe("pi"); }); }); From 035690fedd6ac9acd8ae565913eea778746a4b2e Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 17 Oct 2026 22:35:53 +0800 Subject: [PATCH 3/6] refactor(pi): drop npm package plugin support Packages declared in settings.json `packages` were listed as plugins, but the toggle removed the declaration itself, so a disabled package vanished from the list and could not be re-enabled; delete left the declaration behind. The package.json walk added to scan_skill_dir also ran for every agent and could attribute any skill under a non-git project to that project's repository. Pi now covers skills, MCP, local .ts/.js extensions and rules; `pi install` packages are left for a follow-up. Co-Authored-By: Claude Fable 5.1 --- crates/hk-core/src/adapter/pi.rs | 171 +------------------------------ crates/hk-core/src/deployer.rs | 67 ------------ crates/hk-core/src/manager.rs | 16 --- crates/hk-core/src/scanner.rs | 95 ----------------- 4 files changed, 5 insertions(+), 344 deletions(-) diff --git a/crates/hk-core/src/adapter/pi.rs b/crates/hk-core/src/adapter/pi.rs index 3f7ceebc..9f77c77f 100644 --- a/crates/hk-core/src/adapter/pi.rs +++ b/crates/hk-core/src/adapter/pi.rs @@ -67,55 +67,6 @@ impl PiAdapter { serde_json::from_str(&content).ok() } - /// `npm:[@version]` declarations from `settings.json` → package - /// directory under `~/.pi/agent/npm/node_modules`. Only npm sources are - /// returned (git/local packages are not modeled as plugins yet). - fn npm_package_dirs(&self) -> Vec<(String, String, PathBuf)> { - let Some(settings) = Self::parse_json(&self.agent_dir.join("settings.json")) else { - return vec![]; - }; - let Some(packages) = settings.get("packages").and_then(|v| v.as_array()) else { - return vec![]; - }; - let npm_root = self.agent_dir.join("npm").join("node_modules"); - let mut out = vec![]; - for pkg in packages { - let source = match pkg { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Object(o) => o - .get("source") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - _ => continue, - }; - let Some(spec) = source.strip_prefix("npm:") else { - continue; - }; - // Strip a trailing `@version` only when it is not the scope of a - // scoped package (`@scope/name`): the version separator never - // contains a `/`. - let pkg_name = match spec.rsplit_once('@') { - Some((name, ver)) if !ver.contains('/') && !name.is_empty() => name.to_string(), - _ => spec.to_string(), - }; - let dir = npm_root.join(&pkg_name); - if dir.is_dir() { - out.push((source, pkg_name, dir)); - } - } - out - } - - /// Read an installed npm package's `repository.url` and derive its - /// lowercased `owner/repo` pack. `None` for packages without a repository - /// (e.g. unpublished local packages). - fn npm_package_repo(pkg_dir: &Path) -> Option<(String, String)> { - let content = std::fs::read_to_string(pkg_dir.join("package.json")).ok()?; - let json: serde_json::Value = serde_json::from_str(&content).ok()?; - crate::scanner::repository_pack_from_package_json(&json) - } - /// Strip a `.disabled` suffix so a toggled-off `hello.ts.disabled` keeps /// the stable name of its enabled sibling `hello.ts` (same as omp). fn plugin_name(path: &Path) -> String { @@ -166,19 +117,11 @@ impl AgentAdapter for PiAdapter { // Native Pi user skills: ~/.pi/agent/skills, plus the universal // Agent Skills location ~/.agents/skills (skills.md: "Pi also // supports the Agent Skills locations ~/.agents/skills/ and - // .agents/skills/"). npm packages can ship skills under their own - // `skills/` directory (packages.md: conventional resource dir). - let mut dirs = vec![ + // .agents/skills/"). + vec![ self.agent_dir.join("skills"), self.home.join(".agents").join("skills"), - ]; - for (_, _, pkg_dir) in self.npm_package_dirs() { - let skills = pkg_dir.join("skills"); - if skills.is_dir() { - dirs.push(skills); - } - } - dirs + ] } fn mcp_config_path(&self) -> PathBuf { @@ -192,12 +135,6 @@ impl AgentAdapter for PiAdapter { self.mcp_config_path() } - fn plugin_config_path(&self) -> PathBuf { - // npm package plugins are enabled/disabled by editing the `packages` - // array in settings.json (`pi install` / `pi remove`). - self.agent_dir.join("settings.json") - } - fn plugin_dirs(&self) -> Vec { // TypeScript/JavaScript extension modules: ~/.pi/agent/extensions/*.ts vec![self.agent_dir.join("extensions")] @@ -271,27 +208,9 @@ impl AgentAdapter for PiAdapter { } fn read_plugins(&self) -> Vec { + // Single-file / directory TypeScript extensions. Packages installed + // with `pi install` (settings.json `packages`) are not modeled yet. let mut entries = Vec::new(); - // npm packages are Pi's primary plugin distribution (settings.json - // `packages` → ~/.pi/agent/npm/node_modules). Each installed package - // is one plugin; the `uri` carries the original `npm:…` declaration - // so the toggle can remove/re-add it in settings.json. - for (source, pkg_name, pkg_dir) in self.npm_package_dirs() { - let repo = Self::npm_package_repo(&pkg_dir); - entries.push(PluginEntry { - name: pkg_name, - source: "npm".into(), - enabled: true, - path: Some(pkg_dir), - source_url: repo.as_ref().map(|(url, _)| url.clone()), - uri: Some(source), - installed_at: None, - updated_at: None, - base_layers: vec![], - pack: repo.map(|(_, pack)| pack), - }); - } - // Local single-file / directory TypeScript extensions. for plugin_dir in self.plugin_dirs() { let Ok(files) = std::fs::read_dir(plugin_dir) else { continue; @@ -476,86 +395,6 @@ mod tests { assert!(!by_name["off"].enabled); } - #[test] - fn read_plugins_lists_npm_packages() { - let tmp = tempfile::tempdir().unwrap(); - let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); - let agent = tmp.path().join(".pi/agent"); - std::fs::create_dir_all(&agent).unwrap(); - std::fs::write( - agent.join("settings.json"), - r#"{"packages": ["npm:@scope/rpiv-todo", "npm:pi-lens", {"source":"npm:ponytail","skills":[]}]}"#, - ) - .unwrap(); - let npm = agent.join("npm").join("node_modules"); - std::fs::create_dir_all(npm.join("@scope/rpiv-todo")).unwrap(); - std::fs::create_dir_all(npm.join("pi-lens")).unwrap(); - std::fs::create_dir_all(npm.join("ponytail")).unwrap(); - // Each declared package carries its upstream repo; a package without - // a repository reports no pack/source_url. - std::fs::write( - npm.join("@scope/rpiv-todo/package.json"), - r#"{"repository":{"url":"git+https://github.com/juicesharp/rpiv-mono.git"}}"#, - ) - .unwrap(); - std::fs::write( - npm.join("pi-lens/package.json"), - r#"{"repository":{"url":"git+https://github.com/apmantza/pi-lens.git"}}"#, - ) - .unwrap(); - std::fs::write( - npm.join("ponytail/package.json"), - r#"{"repository":{"url":"git+https://github.com/DietrichGebert/ponytail.git"}}"#, - ) - .unwrap(); - // A transitive dependency that is NOT declared in `packages` must - // not be surfaced as a plugin. - std::fs::create_dir_all(npm.join("express")).unwrap(); - - let plugins = adapter.read_plugins(); - let names: Vec<&str> = plugins.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, vec!["@scope/rpiv-todo", "pi-lens", "ponytail"]); - assert!(plugins.iter().all(|p| p.source == "npm")); - assert!(plugins.iter().all(|p| p.uri.is_some())); - let by_name: std::collections::HashMap<&str, &PluginEntry> = - plugins.iter().map(|p| (p.name.as_str(), p)).collect(); - assert_eq!( - by_name["pi-lens"].pack.as_deref(), - Some("apmantza/pi-lens") - ); - assert_eq!( - by_name["ponytail"].pack.as_deref(), - Some("dietrichgebert/ponytail"), - "GitHub owner/repo case must be normalized to lowercase" - ); - assert_eq!( - by_name["@scope/rpiv-todo"].pack.as_deref(), - Some("juicesharp/rpiv-mono") - ); - assert!(by_name["@scope/rpiv-todo"].source_url.is_some()); - } - - #[test] - fn skill_dirs_include_npm_package_skills() { - let tmp = tempfile::tempdir().unwrap(); - let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); - let agent = tmp.path().join(".pi/agent"); - std::fs::create_dir_all(&agent).unwrap(); - std::fs::write( - agent.join("settings.json"), - r#"{"packages": ["npm:with-skills", "npm:no-skills"]}"#, - ) - .unwrap(); - std::fs::create_dir_all(agent.join("npm/node_modules/with-skills/skills")).unwrap(); - std::fs::create_dir_all(agent.join("npm/node_modules/no-skills")).unwrap(); - - let dirs = adapter.skill_dirs(); - assert_eq!(dirs.len(), 3); - assert_eq!(dirs[0], agent.join("skills")); - assert_eq!(dirs[1], tmp.path().join(".agents/skills")); - assert_eq!(dirs[2], agent.join("npm/node_modules/with-skills/skills")); - } - #[test] fn paths_match_pi_native_conventions() { let adapter = PiAdapter::with_home(PathBuf::from("/h")); diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index 7c5d99cc..421ccc93 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -1071,39 +1071,6 @@ pub fn set_pi_mcp_enabled( }) } -/// Toggle a Pi npm package plugin by removing/re-adding its declaration in -/// `settings.json` `packages` (personal scope) — the same operation as -/// `pi remove ` / `pi install `. The package's node_modules -/// directory stays on disk; only the declaration is touched, so any object -/// form (filters, `autoload`) and every other settings key are preserved. -pub fn set_pi_package_enabled( - settings_path: &Path, - package_source: &str, - enabled: bool, -) -> Result<(), HkError> { - locked_modify_json(settings_path, |config| { - let packages = config - .get_mut("packages") - .and_then(|v| v.as_array_mut()) - .ok_or_else(|| HkError::NotFound("No packages array found".into()))?; - let matches_source = |p: &serde_json::Value| match p { - serde_json::Value::String(s) => s == package_source, - serde_json::Value::Object(o) => { - o.get("source").and_then(|v| v.as_str()) == Some(package_source) - } - _ => false, - }; - if enabled { - if !packages.iter().any(matches_source) { - packages.push(serde_json::Value::String(package_source.to_string())); - } - } else { - packages.retain(|p| !matches_source(p)); - } - Ok(()) - }) -} - const DSH_BLOCK_BEGIN: &str = "# >>> managed by HarnessKit — do not edit this block >>>"; const DSH_BLOCK_END: &str = "# <<< managed by HarnessKit <<<"; @@ -4800,40 +4767,6 @@ mod tests { assert!(!user.exists()); } - #[test] - fn test_set_pi_package_enabled_removes_and_readds() { - let dir = TempDir::new().unwrap(); - let config = dir.path().join("settings.json"); - std::fs::write( - &config, - r#"{"packages": ["npm:a", {"source": "npm:b", "skills": []}], "defaultModel": "x"}"#, - ) - .unwrap(); - - // Disable a string-form declaration. - set_pi_package_enabled(&config, "npm:a", false).unwrap(); - let v: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); - let arr = v["packages"].as_array().unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["source"], "npm:b"); // object form survives - assert_eq!(v["defaultModel"], "x"); // unrelated keys preserved - - // Re-enable it. - set_pi_package_enabled(&config, "npm:a", true).unwrap(); - let v: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); - assert_eq!(v["packages"].as_array().unwrap().len(), 2); - - // Disable the object-form declaration by its source. - set_pi_package_enabled(&config, "npm:b", false).unwrap(); - let v: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); - let arr = v["packages"].as_array().unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0], "npm:a"); - } - #[test] fn test_set_kiro_hook_enabled_flips_in_place() { let dir = TempDir::new().unwrap(); diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index fa3d63a2..b143d5db 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -635,22 +635,6 @@ fn toggle_plugin( })?; deployer::set_grok_plugin_enabled(&a.plugin_config_path(), plugin_id, enabled)?; store.set_disabled_config(&ext.id, None)?; - } else if a.name() == "pi" { - // npm package plugins toggle by editing the `packages` array in - // settings.json (`pi install` / `pi remove`); local .ts/.js - // extensions fall through to the generic `.disabled` rename. - // Must run before the generic fallback, which would rename the - // package's node_modules directory and break npm resolution. - let plugins = a.read_plugins(); - let plugin = find_plugin_for_ext(&plugins, ext, a.name()).ok_or_else(|| { - HkError::NotFound(format!("Pi plugin '{}' not found on disk", ext.name)) - })?; - if let Some(uri) = &plugin.uri { - deployer::set_pi_package_enabled(&a.plugin_config_path(), uri, enabled)?; - store.set_disabled_config(&ext.id, None)?; - } else { - toggle_plugin_manifest(ext, enabled, store, a.as_ref(), Some(plugins))?; - } } else if a.name() == "dsh" { // Explicit branch BEFORE the generic manifest-rename fallback — // mandatory ordering, not style: the fallback probes package dirs diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 6f3adb5e..43ad3908 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -149,47 +149,6 @@ fn cli_stable_id(binary_name: &str) -> String { /// `standalone_md` mirrors `AgentAdapter::standalone_md_skills`: when false a /// loose `*.md` in `dir` is ordinary payload (a skill's own `reference.md`, a /// folder `README.md`) rather than a skill of its own. -/// -/// Source precedence for a skill: `skills`-CLI lockfile > enclosing installed -/// package's `package.json` repository > `.git` walk. Skills shipped inside an -/// installed package (pi/omp `npm/node_modules//skills/`) carry neither a -/// `.git` nor a lockfile, so the package's own repository is the only reliable -/// origin and keeps plugin-shipped skills grouped with the plugin that ships -/// them. -fn package_json_source(path: &Path) -> Option<(Source, String)> { - // Walk up from the skill to the nearest `package.json` with a repository. - // Depth-limited so a `package.json` in the user's home cannot mis-attribute - // standalone skills under `~/.agents/skills` (npm package layout puts the - // owning `package.json` two levels above `/skills/`). - let mut dir = if path.is_dir() { - path.to_path_buf() - } else { - path.parent()?.to_path_buf() - }; - for _ in 0..6 { - let pkg = dir.join("package.json"); - if let Ok(content) = std::fs::read_to_string(&pkg) - && let Ok(json) = serde_json::from_str::(&content) - && let Some((url, pack)) = repository_pack_from_package_json(&json) - { - return Some(( - Source { - origin: SourceOrigin::Git, - url: Some(url), - version: None, - commit_hash: None, - from_manifest: true, - }, - pack, - )); - } - if !dir.pop() { - break; - } - } - None -} - pub fn scan_skill_dir(dir: &Path, agent_name: &str, standalone_md: bool) -> Vec { let mut extensions = Vec::new(); let Ok(entries) = std::fs::read_dir(dir) else { @@ -284,15 +243,6 @@ pub fn scan_skill_dir(dir: &Path, agent_name: &str, standalone_md: bool) -> Vec< from_manifest: true, }; } - // Fallback for skills shipped inside an installed package: no `.git` - // and no `skills`-CLI lockfile, but the enclosing package.json names - // the upstream repo. Keeps plugin-shipped skills in the plugin's pack. - if source.url.is_none() { - if let Some((pkg_source, pkg_pack)) = package_json_source(&resolved) { - source = pkg_source; - pack = Some(pkg_pack); - } - } extensions.push(Extension { id: stable_id(&name, "skill", agent_name), kind: ExtensionKind::Skill, @@ -1873,22 +1823,6 @@ pub fn extract_pack_from_url(url: &str) -> Option { None } -/// Derive `(repository.url, pack)` from an installed package's `package.json` -/// `repository` field. The pack is lowercased — GitHub treats owner/repo -/// case-insensitively, and pi's own package registry lowercases too, so a -/// single canonical grouping key is used everywhere (plugin rows and the -/// skills a plugin ships must land in the same "pack" group). -pub fn repository_pack_from_package_json( - package_json: &serde_json::Value, -) -> Option<(String, String)> { - let url = package_json - .get("repository")? - .get("url")? - .as_str()?; - let pack = extract_pack_from_url(url)?.to_lowercase(); - Some((url.to_string(), pack)) -} - fn read_git_remote(repo_dir: &Path) -> Option { let config = std::fs::read_to_string(repo_dir.join(".git/config")).ok()?; for line in config.lines() { @@ -2398,35 +2332,6 @@ mod tests { assert_eq!(extensions[0].kind, ExtensionKind::Skill); } - #[test] - fn test_skill_in_npm_package_gets_pack_from_package_json() { - // Skills shipped inside an installed npm package carry no `.git` and no - // lockfile; the enclosing package.json repository must attribute them - // to the same pack as the plugin that ships them. - let dir = TempDir::new().unwrap(); - let pkg = dir.path().join("node_modules").join("pi-lens"); - std::fs::create_dir_all(pkg.join("skills").join("pi-lens-ast-grep")).unwrap(); - std::fs::write( - pkg.join("package.json"), - r#"{"name":"pi-lens","repository":{"url":"git+https://github.com/apmantza/pi-lens.git"}}"#, - ) - .unwrap(); - std::fs::write( - pkg.join("skills").join("pi-lens-ast-grep").join("SKILL.md"), - "---\nname: pi-lens-ast-grep\ndescription: semantic search\n---\n", - ) - .unwrap(); - - let exts = scan_skill_dir(&pkg.join("skills"), "pi", true); - assert_eq!(exts.len(), 1); - assert_eq!(exts[0].pack.as_deref(), Some("apmantza/pi-lens")); - assert_eq!( - exts[0].source.url.as_deref(), - Some("git+https://github.com/apmantza/pi-lens.git") - ); - assert_eq!(exts[0].source.origin, SourceOrigin::Git); - } - #[cfg(unix)] #[test] fn test_symlinked_skill_attributed_to_real_source_not_enclosing_repo() { From 64be39e93ed2036840695713e70a59b903aa4b9a Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 17 Oct 2026 22:59:48 +0800 Subject: [PATCH 4/6] fix(pi): skip override-only entries in project mcp.json A project entry with none of `command`, `url` or `type` only overrides `enabled` / `exposure` / `toolExposure` of the user-level server of the same name (mcp.md); it was read as a stdio server with an empty command. The per-entry `enabled` flip is now `set_mcp_server_enabled_flag`, shared with omp, replacing the copy Pi carried. Co-Authored-By: Claude Fable 5.1 --- crates/hk-core/src/adapter/mod.rs | 14 ++--- crates/hk-core/src/adapter/pi.rs | 47 ++++++++++------ crates/hk-core/src/deployer.rs | 91 +++++++++++++++---------------- crates/hk-core/src/manager.rs | 8 +-- 4 files changed, 83 insertions(+), 77 deletions(-) diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index bffb64de..02815a5e 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -446,11 +446,11 @@ pub enum McpFormat { /// How an agent's config spells a remote (HTTP/SSE) MCP entry. /// /// This is the single source of truth for "which transports can this agent -/// receive": the deployer's JSON writer dispatches on the four JSON-family +/// receive": the deployer's JSON writer dispatches on the JSON-family /// variants, and `AgentCapabilities::from_adapter` derives UI install-gating -/// from it (`Toml` and `DshTransport` are the HTTP-only variants — Codex -/// and dsh have no SSE support; `GrokToml` accepts both; -/// `Unsupported` receives no remote entries at all). +/// from it (`Toml`, `DshTransport` and `PlainUrlHttpOnly` are the HTTP-only +/// variants — Codex, dsh and Pi have no SSE support; `GrokToml` accepts +/// both; `Unsupported` receives no remote entries at all). #[derive(Debug, Clone, Copy, PartialEq)] pub enum RemoteMcpSchema { /// `{type: "http"|"sse", url, headers}` — Claude, Copilot, omp. @@ -833,9 +833,9 @@ impl crate::models::AgentCapabilities { .is_some(), global_hook_install: a.supports_global_hook_install(), vendor_baseline_packs: a.vendor_baseline_packs(), - // Codex (`Toml`) and dsh (`DshTransport`) speak Streamable HTTP - // but not SSE; GrokToml and every other non-Unsupported schema - // take both. + // Codex (`Toml`), dsh (`DshTransport`) and Pi (`PlainUrlHttpOnly`) + // speak Streamable HTTP but not SSE; GrokToml and every other + // non-Unsupported schema take both. mcp_remote: crate::models::RemoteTransportFlags { http: remote_schema != RemoteMcpSchema::Unsupported, sse: !matches!( diff --git a/crates/hk-core/src/adapter/pi.rs b/crates/hk-core/src/adapter/pi.rs index 9f77c77f..d0e7cffb 100644 --- a/crates/hk-core/src/adapter/pi.rs +++ b/crates/hk-core/src/adapter/pi.rs @@ -4,10 +4,11 @@ // (configuration.md). Same root serves the pi CLI and pi-web // (github.com/agegr/pi-web), the local browser UI — pi-web reuses the // pi agent's own configuration, so there is no separate config root. -// - Skills: `~/.pi/agent/skills/` (user) + `.pi/skills/` (project), with -// recursive SKILL.md discovery. Pi also reads the universal Agent Skills -// locations `~/.agents/skills` / `.agents/skills` (skills.md), so those -// are declared here too. +// - Skills: `~/.pi/agent/skills/` (user) + `.pi/skills/` (project). Pi also +// reads the universal Agent Skills locations `~/.agents/skills` / +// `.agents/skills` (skills.md), so those are declared here too. Pi walks +// nested directories for SKILL.md; HarnessKit lists the top level only, +// as for omp. // - MCP: JSON `{"mcpServers": {name: {...}}}` — stdio `{command, args, env, // cwd}` or streamable HTTP `{url, headers}` with a native per-entry // `enabled` flag (mcp.md). User `~/.pi/agent/mcp.json`, project @@ -130,8 +131,8 @@ impl AgentAdapter for PiAdapter { } fn hook_config_path(&self) -> PathBuf { - // Pi has no shell-command hooks file. Reuse the MCP path so the - // default plugin_config_path() lands on a real file (mirrors omp). + // Pi has no shell-command hooks file; the trait requires a path, so + // point at mcp.json like omp does. self.mcp_config_path() } @@ -149,17 +150,16 @@ impl AgentAdapter for PiAdapter { } fn supports_native_mcp_toggle(&self) -> bool { - // Toggle flips the entry's own `enabled` flag in place (see - // deployer::set_pi_mcp_enabled), keeping `description`, `timeout`, - // `exposure`, `cwd`, `oauth`, `env`, `headers`, and all other keys - // untouched — exactly how `pi mcp` / `/mcp` behaves. + // Toggle flips the entry's own `enabled` flag in place + // (deployer::set_mcp_server_enabled_flag), keeping `description`, + // `timeout`, `exposure`, `cwd`, `oauth`, `env`, `headers`, and all + // other keys untouched — exactly how `pi mcp` / `/mcp` behaves. true } fn remote_mcp_schema(&self) -> RemoteMcpSchema { - // HTTP entries are `{url, headers}` with the transport auto-detected; - // the legacy SSE transport is rejected upstream (docs/mcp.md), so - // remote entries are Streamable HTTP only. + // Remote entries are `{url, headers}` and always Streamable HTTP: + // Pi rejects `type: "sse"` outright (docs/mcp.md). RemoteMcpSchema::PlainUrlHttpOnly } @@ -176,6 +176,16 @@ impl AgentAdapter for PiAdapter { }; servers .iter() + // A project entry with none of `command`, `url`, `type` is not a + // server: it only overrides `enabled` / `exposure` / + // `toolExposure` of the user-level server of the same name + // (mcp.md). Reading it as a server would show an empty stdio + // entry; the override itself is not modeled yet. + .filter(|(_, val)| { + ["command", "url", "type"] + .iter() + .any(|k| val.get(k).is_some()) + }) .map(|(name, val)| { // `command` selects stdio, `url` selects streamable HTTP; // an optional `type` (stdio/http/streamable-http) also counts. @@ -193,10 +203,7 @@ impl AgentAdapter for PiAdapter { url, headers: super::json_string_map(val, "headers"), // Native per-server flag: absent means enabled. - enabled: val - .get("enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(true), + enabled: val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), } }) .collect() @@ -355,14 +362,18 @@ mod tests { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer t" } }, - "off": { "command": "echo", "enabled": false } + "off": { "command": "echo", "enabled": false }, + "internal-tools": { "enabled": false } } }"#, ) .unwrap(); + // `internal-tools` has no command/url/type: a project-style override + // of a server defined elsewhere, not a server of its own. let servers = adapter.read_mcp_servers(); assert_eq!(servers.len(), 3); + assert!(servers.iter().all(|s| s.name != "internal-tools")); let by_name: std::collections::HashMap<&str, &McpServerEntry> = servers.iter().map(|s| (s.name.as_str(), s)).collect(); diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index 421ccc93..e5ebd8e8 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -306,7 +306,7 @@ fn build_mcp_json_value( } /// JSON-based MCP deploy (Claude, Gemini, Cursor, Antigravity, Copilot, -/// Windsurf, Kiro, omp). `top_key` is "mcpServers" or "servers" depending +/// Windsurf, Kiro, omp, Pi). `top_key` is "mcpServers" or "servers" depending /// on the agent; `remote` picks the agent's remote-entry spelling. fn deploy_mcp_server_json( config_path: &Path, @@ -992,6 +992,34 @@ pub fn set_kiro_mcp_enabled( }) } +/// Flip a `mcpServers..enabled` flag in place, leaving every other key +/// of the entry untouched. Absent means enabled (omp mcp-config.md, pi +/// mcp.md: "skip when false"), so enabling removes the key rather than +/// writing `true`. For agents whose mcp.json carries this native per-entry +/// flag (omp, pi). +pub fn set_mcp_server_enabled_flag( + config_path: &Path, + server_name: &str, + enabled: bool, +) -> Result<(), HkError> { + locked_modify_json(config_path, |config| { + let servers = config + .get_mut("mcpServers") + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| HkError::NotFound("No mcpServers block found".into()))?; + let server = servers + .get_mut(server_name) + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| HkError::NotFound(format!("MCP server '{server_name}' not found")))?; + if enabled { + server.remove("enabled"); + } else { + server.insert("enabled".into(), serde_json::Value::Bool(false)); + } + Ok(()) + }) +} + /// Flip an omp MCP server's native per-entry `enabled` flag in place, then /// scrub the user-level name list that would override the flag: on disable /// the name is removed from `enabledServers` (the force-enable allowlist @@ -1010,23 +1038,7 @@ pub fn set_omp_mcp_enabled( server_name: &str, enabled: bool, ) -> Result<(), HkError> { - locked_modify_json(entry_config_path, |config| { - let servers = config - .get_mut("mcpServers") - .and_then(|v| v.as_object_mut()) - .ok_or_else(|| HkError::NotFound("No mcpServers block found".into()))?; - let server = servers - .get_mut(server_name) - .and_then(|v| v.as_object_mut()) - .ok_or_else(|| HkError::NotFound(format!("MCP server '{server_name}' not found")))?; - if enabled { - // Absent means enabled (mcp-config.md: "skip when false"). - server.remove("enabled"); - } else { - server.insert("enabled".into(), serde_json::Value::Bool(false)); - } - Ok(()) - })?; + set_mcp_server_enabled_flag(entry_config_path, server_name, enabled)?; // A missing user file can't contain the name — don't create one just to // scrub it. if !user_config_path.exists() { @@ -1041,36 +1053,6 @@ pub fn set_omp_mcp_enabled( }) } -/// Flip a Pi MCP server via its native per-entry `enabled` flag in place. -/// Pi's mcp.json has no denylist/allowlist lists — `enabled: false` is the -/// whole mechanism (mcp.md: "keep the entry without connecting to it"). -/// Every other key (`description`, `timeout`, `exposure`, `toolExposure`, -/// `cwd`, `oauth`, `env`, `headers`) stays untouched, matching `pi mcp` / -/// `/mcp` enable/disable behavior. -pub fn set_pi_mcp_enabled( - config_path: &Path, - server_name: &str, - enabled: bool, -) -> Result<(), HkError> { - locked_modify_json(config_path, |config| { - let servers = config - .get_mut("mcpServers") - .and_then(|v| v.as_object_mut()) - .ok_or_else(|| HkError::NotFound("No mcpServers block found".into()))?; - let server = servers - .get_mut(server_name) - .and_then(|v| v.as_object_mut()) - .ok_or_else(|| HkError::NotFound(format!("MCP server '{server_name}' not found")))?; - if enabled { - // Absent means enabled (mcp.md: "skip when false"). - server.remove("enabled"); - } else { - server.insert("enabled".into(), serde_json::Value::Bool(false)); - } - Ok(()) - }) -} - const DSH_BLOCK_BEGIN: &str = "# >>> managed by HarnessKit — do not edit this block >>>"; const DSH_BLOCK_END: &str = "# <<< managed by HarnessKit <<<"; @@ -3239,6 +3221,13 @@ mod tests { assert_eq!(v["url"], "https://mcp.linear.app/mcp"); assert!(v.get("type").is_none()); + // Pi spells remote entries like PlainUrl; the HTTP-only part lives in + // the validator. + let v = build_mcp_json_value(&http, RemoteMcpSchema::PlainUrlHttpOnly).unwrap(); + assert_eq!(v["url"], "https://mcp.linear.app/mcp"); + assert_eq!(v["headers"]["Authorization"], "Bearer tok"); + assert!(v.get("type").is_none()); + // Gemini spells the transport through the key itself. let v = build_mcp_json_value(&http, RemoteMcpSchema::GeminiSplit).unwrap(); assert_eq!(v["httpUrl"], "https://mcp.linear.app/mcp"); @@ -3271,6 +3260,12 @@ mod tests { assert!(matches!(&err, HkError::Validation(m) if m.contains("not SSE"))); assert!(validate_remote_mcp_target(&http, "dsh", RemoteMcpSchema::DshTransport).is_ok()); + // Pi (PlainUrlHttpOnly) rejects SSE upstream. + let err = validate_remote_mcp_target(&sse, "pi", RemoteMcpSchema::PlainUrlHttpOnly) + .unwrap_err(); + assert!(matches!(&err, HkError::Validation(m) if m.contains("not SSE"))); + assert!(validate_remote_mcp_target(&http, "pi", RemoteMcpSchema::PlainUrlHttpOnly).is_ok()); + // Remote without url is corrupt regardless of target. let mut broken = remote_entry(McpTransport::Http); broken.url = None; diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index b143d5db..e5aa796b 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -224,10 +224,10 @@ fn toggle_mcp( enabled, )?; } else if a.name() == "pi" { - // Per-server `enabled` field flipped in place in mcp.json — - // keeps `description`, `timeout`, `exposure`, `cwd`, `oauth`, - // `env`, and `headers` (mirrors `pi mcp` / `/mcp`). - deployer::set_pi_mcp_enabled(&config_path, &ext.name, enabled)?; + // Native per-entry flag with no overriding name lists, so the + // in-place flip is the whole operation (mirrors `pi mcp` / + // `/mcp`). + deployer::set_mcp_server_enabled_flag(&config_path, &ext.name, enabled)?; } else if a.name() == "dsh" { // Official mechanism: id-targeted `disabled` override in the // home-level cordis.patch.yml (HK-managed block). Hot-reloaded From 27431886c19786587537133cdd5123e24c1f3d44 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 17 Oct 2026 23:03:15 +0800 Subject: [PATCH 5/6] refactor(adapter): share .ts extension discovery between omp and pi Both adapters carried the same 90 lines for listing *.ts/*.js modules and /index.{ts,js} directories. They also declared project_plugin_dirs without implementing read_plugins_from, so project-level extensions were never scanned; both now delegate to one helper for either scope. Co-Authored-By: Claude Fable 5.1 --- crates/hk-core/src/adapter/mod.rs | 66 +++++++++++++++ crates/hk-core/src/adapter/omp.rs | 128 +++++++----------------------- crates/hk-core/src/adapter/pi.rs | 112 ++++++-------------------- 3 files changed, 117 insertions(+), 189 deletions(-) diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 02815a5e..27f01fc2 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -238,6 +238,72 @@ pub(crate) fn json_string_vec(val: &serde_json::Value, key: &str) -> Vec .unwrap_or_default() } +/// Local TypeScript/JavaScript extension modules in `dir`, as omp and Pi +/// load them: a direct `*.ts`/`*.js` file, or a `/index.{ts,js}` +/// directory with TypeScript preferred. The loaders glob only those two +/// extensions, so `.mjs`/`.cjs` and any other file are never listed. +/// package.json-manifest extensions are not modeled. +/// +/// A `.disabled` suffix is HarnessKit's in-place disable rename; the loader +/// glob no longer matches it. The name is taken with the suffix stripped so +/// it stays stable across the toggle. For a directory extension the entry's +/// path is the index file, which the toggle renames, keeping the directory +/// (and so the name) stable; active index files are probed first so an +/// `index.ts.disabled` next to a live `index.js` reports what the agent +/// actually loads. +pub(crate) fn read_ts_extensions(dir: &Path) -> Vec { + fn entry(name: String, path: PathBuf, enabled: bool) -> PluginEntry { + PluginEntry { + name, + source: "local".into(), + enabled, + path: Some(path), + source_url: None, + uri: None, + installed_at: None, + updated_at: None, + base_layers: vec![], + pack: None, + } + } + let Ok(files) = std::fs::read_dir(dir) else { + return vec![]; + }; + let mut entries = Vec::new(); + for path in files.flatten().map(|f| f.path()) { + let Some(file_name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { + continue; + }; + if path.is_file() { + let enabled = !file_name.ends_with(".disabled"); + let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); + let base = Path::new(base); + if !matches!(base.extension().and_then(|e| e.to_str()), Some("ts" | "js")) { + continue; + } + let Some(name) = base.file_stem().map(|s| s.to_string_lossy().to_string()) else { + continue; + }; + entries.push(entry(name, path, enabled)); + } else if path.is_dir() { + let candidates = [ + ("index.ts", true), + ("index.js", true), + ("index.ts.disabled", false), + ("index.js.disabled", false), + ]; + if let Some((index, enabled)) = candidates + .iter() + .map(|(f, e)| (path.join(f), *e)) + .find(|(p, _)| p.is_file()) + { + entries.push(entry(file_name, index, enabled)); + } + } + } + entries +} + /// Represents a hook entry parsed from an agent's config #[derive(Debug, Clone)] pub struct HookEntry { diff --git a/crates/hk-core/src/adapter/omp.rs b/crates/hk-core/src/adapter/omp.rs index 8f9e4a65..eca765af 100644 --- a/crates/hk-core/src/adapter/omp.rs +++ b/crates/hk-core/src/adapter/omp.rs @@ -53,42 +53,6 @@ impl OmpAdapter { serde_json::from_str(&content).ok() } - fn plugin_name(path: &Path) -> String { - // Strip `.disabled` first so a toggled-off `orca-spin.ts.disabled` - // shares the name of its enabled sibling `orca-spin.ts` — the name - // must be stable across enable/disable or HK's toggle model breaks. - // Same approach as the opencode adapter. - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(); - let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); - Path::new(base) - .file_stem() - .map(|stem| stem.to_string_lossy().to_string()) - .unwrap_or_else(|| base.to_string()) - } - - /// omp extensions are .ts/.js modules exporting a default factory. The - /// loader globs only `*.{ts,js}` (discovery/helpers.ts, extension-loading.md) - /// — no .mjs/.cjs — so matching more here would list files omp never loads. - /// - /// A `.disabled` suffix is HK's own in-place disable rename (same model as - /// opencode plugins). omp's native disable mechanism is the - /// `disabledExtensions` id list in config.yml, which HK doesn't write; the - /// rename works because the loader's glob won't match a `.disabled` file. - fn is_extension_file(path: &Path) -> bool { - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(); - let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); - matches!( - Path::new(base).extension().and_then(|ext| ext.to_str()), - Some("ts" | "js") - ) - } - /// Read a string-array field (e.g. `disabledServers`) from the *user* /// mcp.json. Both the denylist and the force-enable allowlist live only in /// the user file but apply to servers from every source, including project @@ -225,71 +189,19 @@ impl AgentAdapter for OmpAdapter { } fn read_plugins(&self) -> Vec { - let mut entries = Vec::new(); - for plugin_dir in self.plugin_dirs() { - let Ok(files) = std::fs::read_dir(plugin_dir) else { - continue; - }; - for file in files.flatten() { - let path = file.path(); - if path.is_file() { - if !Self::is_extension_file(&path) { - continue; - } - let enabled = path.extension().is_none_or(|ext| ext != "disabled"); - entries.push(PluginEntry { - name: Self::plugin_name(&path), - source: "local".into(), - enabled, - path: Some(path), - source_url: None, - uri: None, - installed_at: None, - updated_at: None, - base_layers: vec![], - pack: None, - }); - } else if path.is_dir() { - // Directory-form extension: /index.{ts,js}, TypeScript - // preferred (extension-loading.md resolution order). Active - // entry files first so a `index.ts.disabled` next to a live - // `index.js` reports what omp actually loads. The entry's - // path is the index file — HK's toggle renames that file, - // keeping the directory (and thus the plugin name) stable. - // package.json-manifest extensions (`omp.extensions`) are - // not modeled. - let Some(dir_name) = path.file_name().map(|n| n.to_string_lossy().to_string()) - else { - continue; - }; - let candidates = [ - ("index.ts", true), - ("index.js", true), - ("index.ts.disabled", false), - ("index.js.disabled", false), - ]; - if let Some((entry_file, enabled)) = candidates - .iter() - .map(|(f, e)| (path.join(f), *e)) - .find(|(p, _)| p.is_file()) - { - entries.push(PluginEntry { - name: dir_name, - source: "local".into(), - enabled, - path: Some(entry_file), - source_url: None, - uri: None, - installed_at: None, - updated_at: None, - base_layers: vec![], - pack: None, - }); - } - } - } - } - entries + // .ts/.js modules exporting a default factory; the loader globs only + // `*.{ts,js}` (discovery/helpers.ts, extension-loading.md). omp's + // native disable is the `disabledExtensions` id list in config.yml, + // which HK doesn't write; the `.disabled` rename works because the + // glob won't match it. + self.plugin_dirs() + .iter() + .flat_map(|dir| super::read_ts_extensions(dir)) + .collect() + } + + fn read_plugins_from(&self, dir: &Path) -> Vec { + super::read_ts_extensions(dir) } fn global_rules_files(&self) -> Vec { @@ -516,6 +428,20 @@ mod tests { assert!(!by_name["orca-dock"].enabled); } + #[test] + fn read_plugins_from_reads_project_extensions() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = OmpAdapter::with_home(tmp.path().to_path_buf()); + let ext_dir = tmp.path().join("repo/.omp/extensions"); + std::fs::create_dir_all(&ext_dir).unwrap(); + std::fs::write(ext_dir.join("repo-tool.ts"), "// ext\n").unwrap(); + + let plugins = adapter.read_plugins_from(&ext_dir); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0].name, "repo-tool"); + assert!(adapter.read_plugins().is_empty()); + } + #[test] fn paths_match_omp_native_conventions() { let adapter = OmpAdapter::with_home(PathBuf::from("/h")); diff --git a/crates/hk-core/src/adapter/pi.rs b/crates/hk-core/src/adapter/pi.rs index d0e7cffb..36559d2d 100644 --- a/crates/hk-core/src/adapter/pi.rs +++ b/crates/hk-core/src/adapter/pi.rs @@ -67,35 +67,6 @@ impl PiAdapter { let content = std::fs::read_to_string(path).ok()?; serde_json::from_str(&content).ok() } - - /// Strip a `.disabled` suffix so a toggled-off `hello.ts.disabled` keeps - /// the stable name of its enabled sibling `hello.ts` (same as omp). - fn plugin_name(path: &Path) -> String { - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(); - let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); - Path::new(base) - .file_stem() - .map(|stem| stem.to_string_lossy().to_string()) - .unwrap_or_else(|| base.to_string()) - } - - /// Pi loads direct `.ts`/`.js` files and `index.ts`/`index.js` entry - /// points (extensions.md). A `.disabled` suffix is HarnessKit's own - /// in-place rename, which hides the file from the loader. - fn is_extension_file(path: &Path) -> bool { - let file_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(); - let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name); - matches!( - Path::new(base).extension().and_then(|ext| ext.to_str()), - Some("ts" | "js") - ) - } } impl AgentAdapter for PiAdapter { @@ -215,66 +186,16 @@ impl AgentAdapter for PiAdapter { } fn read_plugins(&self) -> Vec { - // Single-file / directory TypeScript extensions. Packages installed + // Local .ts/.js extension modules (extensions.md). Packages installed // with `pi install` (settings.json `packages`) are not modeled yet. - let mut entries = Vec::new(); - for plugin_dir in self.plugin_dirs() { - let Ok(files) = std::fs::read_dir(plugin_dir) else { - continue; - }; - for file in files.flatten() { - let path = file.path(); - if path.is_file() { - if !Self::is_extension_file(&path) { - continue; - } - let enabled = path.extension().is_none_or(|ext| ext != "disabled"); - entries.push(PluginEntry { - name: Self::plugin_name(&path), - source: "local".into(), - enabled, - path: Some(path), - source_url: None, - uri: None, - installed_at: None, - updated_at: None, - base_layers: vec![], - pack: None, - }); - } else if path.is_dir() { - let Some(dir_name) = - path.file_name().map(|n| n.to_string_lossy().to_string()) - else { - continue; - }; - let candidates = [ - ("index.ts", true), - ("index.js", true), - ("index.ts.disabled", false), - ("index.js.disabled", false), - ]; - if let Some((entry_file, enabled)) = candidates - .iter() - .map(|(f, e)| (path.join(f), *e)) - .find(|(p, _)| p.is_file()) - { - entries.push(PluginEntry { - name: dir_name, - source: "local".into(), - enabled, - path: Some(entry_file), - source_url: None, - uri: None, - installed_at: None, - updated_at: None, - base_layers: vec![], - pack: None, - }); - } - } - } - } - entries + self.plugin_dirs() + .iter() + .flat_map(|dir| super::read_ts_extensions(dir)) + .collect() + } + + fn read_plugins_from(&self, dir: &Path) -> Vec { + super::read_ts_extensions(dir) } fn global_rules_files(&self) -> Vec { @@ -406,6 +327,21 @@ mod tests { assert!(!by_name["off"].enabled); } + #[test] + fn read_plugins_from_reads_project_extensions() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = PiAdapter::with_home(tmp.path().to_path_buf()); + let ext_dir = tmp.path().join("repo/.pi/extensions"); + std::fs::create_dir_all(ext_dir.join("repo-panel")).unwrap(); + std::fs::write(ext_dir.join("repo-panel/index.ts"), "// ext\n").unwrap(); + + let plugins = adapter.read_plugins_from(&ext_dir); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0].name, "repo-panel"); + assert_eq!(plugins[0].path, Some(ext_dir.join("repo-panel/index.ts"))); + assert!(adapter.read_plugins().is_empty()); + } + #[test] fn paths_match_pi_native_conventions() { let adapter = PiAdapter::with_home(PathBuf::from("/h")); From 35fbe251fa1313524dbb6f6e5d5ed0bc4fa0134a Mon Sep 17 00:00:00 2001 From: RealZST Date: Sun, 18 Oct 2026 00:23:38 +0800 Subject: [PATCH 6/6] feat(pi): add mascot, agent color and filter styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mascot is the Pi logo's three-block π with no tile. Hover picks the blocks up one by one, each growing a side face; click slides them into a 3×3 square, which puffs away in smoke before the π fades back in. The agent color is the warm grey of pi.dev's page background, darkened in the light theme for contrast the way Cursor's neutral is; the same token drives the filter chip. The onboarding icon's scatter start now stays inside a 900px window. Co-Authored-By: Claude Fable 5.1 --- .../extensions/extension-filters.tsx | 1 + src/components/onboarding/onboarding.tsx | 2 +- src/components/shared/agent-card.tsx | 2 + .../shared/agent-mascot/agent-mascot.tsx | 2 + src/components/shared/agent-mascot/mascot.css | 129 ++++++++++++++++++ .../shared/agent-mascot/pi-mascot.tsx | 86 ++++++++++++ src/index.css | 7 + 7 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/components/shared/agent-mascot/pi-mascot.tsx diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx index aee5b11c..10afb7cb 100644 --- a/src/components/extensions/extension-filters.tsx +++ b/src/components/extensions/extension-filters.tsx @@ -52,6 +52,7 @@ const AGENT_FILTER_COLORS: Record = { "bg-agent-qoder-cn/10 text-agent-qoder-cn border-agent-qoder-cn/30", openclaw: "bg-agent-openclaw/10 text-agent-openclaw border-agent-openclaw/30", penguin: "bg-agent-penguin/10 text-agent-penguin border-agent-penguin/30", + pi: "bg-agent-pi/10 text-agent-pi border-agent-pi/30", }; export function ExtensionFilters() { diff --git a/src/components/onboarding/onboarding.tsx b/src/components/onboarding/onboarding.tsx index 1ce3c900..42caa84e 100644 --- a/src/components/onboarding/onboarding.tsx +++ b/src/components/onboarding/onboarding.tsx @@ -293,7 +293,7 @@ const SCATTER_POSITIONS: Record< "qoder-cn": { x: 180, y: 100, r: -14 }, openclaw: { x: -170, y: -110, r: 16 }, penguin: { x: -130, y: 145, r: 16 }, - pi: { x: 70, y: 155, r: -18 }, + pi: { x: -80, y: 160, r: -18 }, }; function HandAnnotation({ diff --git a/src/components/shared/agent-card.tsx b/src/components/shared/agent-card.tsx index 0da65e7c..982f1535 100644 --- a/src/components/shared/agent-card.tsx +++ b/src/components/shared/agent-card.tsx @@ -24,6 +24,8 @@ const CLICK_DURATIONS: Partial> = { openclaw: 3050, // shake and wake scatter share one 1.6s track penguin: 1650, + // slide together, hold, puff away, pause and fade back: 3s plus the last leg's delay + pi: 3200, }; export function AgentCard({ agent }: AgentCardProps) { diff --git a/src/components/shared/agent-mascot/agent-mascot.tsx b/src/components/shared/agent-mascot/agent-mascot.tsx index f2d9fb95..b32c19aa 100644 --- a/src/components/shared/agent-mascot/agent-mascot.tsx +++ b/src/components/shared/agent-mascot/agent-mascot.tsx @@ -15,6 +15,7 @@ import { OmpMascot } from "./omp-mascot"; import { OpenClawMascot } from "./openclaw-mascot"; import { OpencodeMascot } from "./opencode-mascot"; import { PenguinMascot } from "./penguin-mascot"; +import { PiMascot } from "./pi-mascot"; import { QoderCnMascot } from "./qoder-cn-mascot"; interface AgentMascotProps { @@ -106,6 +107,7 @@ const MASCOT_MAP: Record< className: "mascot-penguin", scale: 1.1, }, + pi: { component: PiMascot, className: "mascot-pi", scale: 0.9 }, }; export function AgentMascot({ diff --git a/src/components/shared/agent-mascot/mascot.css b/src/components/shared/agent-mascot/mascot.css index de88262f..db64c474 100644 --- a/src/components/shared/agent-mascot/mascot.css +++ b/src/components/shared/agent-mascot/mascot.css @@ -2370,6 +2370,133 @@ } /* === Reduced Motion === */ +/* ===== Pi ===== */ +/* Each block moves about its own centre. --tx/--ty is where it goes on + click: the three slide into a 3×3 square with the blue leg's foot left + over below it, the yellow bar travelling furthest. */ +.mascot-pi path, +.mascot-pi .pi-puff { + transform-box: fill-box; + transform-origin: center; +} +.mascot-pi path { + stroke-width: 0.6; + stroke-linejoin: miter; +} +.mascot-pi .pi-bar { + --tx: 5px; + --ty: 0; +} +.mascot-pi .pi-left { + --tx: 15px; + --ty: 0; +} +.mascot-pi .pi-right { + --tx: -25px; + --ty: -10px; +} +.mascot-pi .pi-puff { + fill: #bdbdbd; + opacity: 0; + filter: blur(0.8px); +} + +/* Hover: the blocks get picked up one after another. Each darker twin + slides out towards the bottom right as the block's side face while the + block itself lifts a little, so the flat mark reads as blocks with + thickness; leaving the card presses them flat again. Transitions rather + than a loop, so the state simply holds while the pointer stays. */ +.mascot-pi .pi-depth, +.mascot-pi .pi-piece { + --rx: 0; + --ry: 0; + transform: translate(var(--rx), var(--ry)); + transition: transform 0.4s cubic-bezier(0.34, 1.3, 0.64, 1); +} +.mascot-pi .pi-left { + transition-delay: 0.12s; +} +.mascot-pi .pi-right { + transition-delay: 0.24s; +} +.mascot-pi.is-animated .pi-depth { + --rx: 2.2px; + --ry: 2.2px; +} +.mascot-pi.is-animated .pi-piece { + --rx: -0.6px; + --ry: -0.6px; +} + +/* Click: the blocks slide into the square, which holds, then swells, blurs + and is gone in a puff of smoke, and the π fades back in; the legs start + a beat after the bar. Each side face runs the same animation as its + block so it stays hidden underneath. After the hover rules so a click + mid-hover takes the blocks over. The last frame ends on the hover + resting offset: removing an animation never starts a transition, so + this is what lets the blocks come back picked up, gradually, when the + pointer is still over the card. */ +.mascot-pi.is-clicked .pi-depth, +.mascot-pi.is-clicked .pi-piece { + animation: pi-assemble 3s both; +} +.mascot-pi.is-clicked .pi-left { + animation-delay: 0.08s; +} +.mascot-pi.is-clicked .pi-right { + animation-delay: 0.16s; +} +.mascot-pi.is-clicked .pi-puff { + /* no fill mode: a backwards fill would show the first frame through the + whole delay, as a dot at the centre of the square */ + animation: pi-puff 0.9s ease-out calc(1.35s + var(--delay)); +} + +@keyframes pi-assemble { + 0% { + transform: none; + opacity: 1; + filter: blur(0); + animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1); + } + 30%, + 45% { + transform: translate(var(--tx), var(--ty)); + opacity: 1; + filter: blur(0); + animation-timing-function: ease-out; + } + 62% { + transform: translate(var(--tx), var(--ty)) scale(1.35); + opacity: 0; + filter: blur(1.8px); + } + /* half a second of nothing before the π comes back */ + 63%, + 80% { + transform: none; + opacity: 0; + filter: blur(0); + animation-timing-function: ease-out; + } + 100% { + transform: translate(var(--rx), var(--ry)); + opacity: 1; + filter: blur(0); + } +} + +@keyframes pi-puff { + 0% { + transform: scale(0.4); + opacity: 0.8; + } + 100% { + transform: translate(var(--px), var(--py)) scale(2.8); + opacity: 0; + } +} + @media (prefers-reduced-motion: reduce) { /* The bare `.is-animated` selectors below are not redundant with the `*` ones: `*` only matches descendants, so a mascot that animates its own @@ -2395,6 +2522,7 @@ .mascot-qoder-cn.is-animated *, .mascot-openclaw.is-animated *, .mascot-penguin.is-animated *, + .mascot-pi.is-animated *, .mascot-fallback.is-animated *, .mascot-claude.is-clicked, .mascot-claude.is-clicked *, @@ -2423,6 +2551,7 @@ .mascot-qoder-cn.is-clicked *, .mascot-openclaw.is-clicked *, .mascot-penguin.is-clicked *, + .mascot-pi.is-clicked *, .mascot-fallback.is-clicked * { animation: none; transition: none; diff --git a/src/components/shared/agent-mascot/pi-mascot.tsx b/src/components/shared/agent-mascot/pi-mascot.tsx new file mode 100644 index 00000000..8b0bfd1c --- /dev/null +++ b/src/components/shared/agent-mascot/pi-mascot.tsx @@ -0,0 +1,86 @@ +// Pi mascot — the upstream brand mark (pi.dev/logo-auto.svg): a pixel π +// built from three blocks on a 4×4 grid, a coral bar, a blue left leg and +// a yellow right leg. Drawn at 10 units per cell without a tile, which also +// keeps it apart from omp's gradient π on a dark tile. Each block has a +// darker twin behind it (.pi-depth) that sits exactly under it at rest and +// slides out on hover as the block's side face, so the flat mark turns into +// blocks with thickness. Blocks paint a same-colour stroke 0.3 units past +// their edges to cover the anti-aliased seam where two of them meet. +// Motion lives on the .mascot-pi wrapper in mascot.css: hover lifts the +// blocks one by one; click slides them together into a 3×3 square with one +// cell left over, holds, then the square puffs away in a burst of smoke +// and the π fades back in. + +interface MascotSvgProps { + size: number; +} + +const BLOCKS = [ + { cls: "pi-bar", d: "M0 0h30v20H20V10H0z", fill: "#F09082", side: "#B5665B" }, + { + cls: "pi-left", + d: "M0 10h10v10h10v10H10v10H0z", + fill: "#4D9ABF", + side: "#33708C", + }, + { cls: "pi-right", d: "M30 20h10v20H30z", fill: "#F1BE58", side: "#B58A3A" }, +]; + +// Smoke puffs for the vanish, in viewBox units around the square's centre. +// Each drifts along its own vector so the burst reads as a puff rather +// than a ring; fixed so every click looks the same. +const PUFFS = [ + { px: -16, py: -13, delay: 0 }, + { px: 15, py: -15, delay: 0.04 }, + { px: 18, py: 6, delay: 0.02 }, + { px: -14, py: 13, delay: 0.06 }, + { px: 0, py: -19, delay: 0.08 }, + { px: 12, py: 17, delay: 0.05 }, +]; + +export function PiMascot({ size }: MascotSvgProps) { + return ( + + {BLOCKS.map((b) => ( + + ))} + {BLOCKS.map((b) => ( + + ))} + {PUFFS.map((p) => ( + + ))} + + ); +} diff --git a/src/index.css b/src/index.css index 0db42703..24098c4a 100644 --- a/src/index.css +++ b/src/index.css @@ -104,6 +104,7 @@ --agent-qoder-cn: oklch(0.52 0.11 195); --agent-openclaw: oklch(0.62 0.19 35); --agent-penguin: oklch(0.5 0.2 262); + --agent-pi: oklch(0.52 0.06 82); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.72 0.16 155); --toast-success-text: oklch(0.4 0.1 155); @@ -214,6 +215,7 @@ --agent-qoder-cn: oklch(0.76 0.1 195); --agent-openclaw: oklch(0.7 0.17 35); --agent-penguin: oklch(0.74 0.14 262); + --agent-pi: oklch(0.9 0.045 85); --toast-success-bg: oklch(0.23 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -318,6 +320,7 @@ --agent-qoder-cn: oklch(0.52 0.11 195); --agent-openclaw: oklch(0.62 0.19 35); --agent-penguin: oklch(0.5 0.2 262); + --agent-pi: oklch(0.52 0.06 82); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.65 0.15 155); --toast-success-text: oklch(0.4 0.1 155); @@ -416,6 +419,7 @@ --agent-qoder-cn: oklch(0.76 0.1 195); --agent-openclaw: oklch(0.7 0.17 35); --agent-penguin: oklch(0.74 0.14 262); + --agent-pi: oklch(0.9 0.045 85); --toast-success-bg: oklch(0.26 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -526,6 +530,7 @@ --agent-qoder-cn: oklch(0.52 0.11 195); --agent-openclaw: oklch(0.62 0.19 35); --agent-penguin: oklch(0.5 0.2 262); + --agent-pi: oklch(0.52 0.06 82); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.72 0.16 155); --toast-success-text: oklch(0.4 0.1 155); @@ -630,6 +635,7 @@ --agent-qoder-cn: oklch(0.76 0.1 195); --agent-openclaw: oklch(0.7 0.17 35); --agent-penguin: oklch(0.74 0.14 262); + --agent-pi: oklch(0.9 0.045 85); --toast-success-bg: oklch(0.23 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -712,6 +718,7 @@ html.dark[data-theme="tiesen"][data-web="true"] { --color-agent-qoder-cn: var(--agent-qoder-cn); --color-agent-openclaw: var(--agent-openclaw); --color-agent-penguin: var(--agent-penguin); + --color-agent-pi: var(--agent-pi); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary);