diff --git a/README.md b/README.md index bc33f94..d442991 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ Five surfaces, each rendered into the harness's own format — dotagents does no | OpenCode | yes† | yes | yes | -- | -- | | Qwen Code | yes, config-driven | yes | yes | yes | skills + MCP§ | | OMP (pi fork) | yes | yes | yes | --‡ | -- | -| Pi* | yes | --* | --* | -- | -- | +| Pi* | yes | yes* | yes* | -- | skills + MCP* | -\* Vanilla [pi](https://github.com/earendil-works/pi) is skills-only by design; the OMP fork is detected as its own target. +\* Vanilla [pi](https://github.com/earendil-works/pi) gains managed roles through `pi-subagents` and managed MCP/Agent Plugin projection through `pi-mcp-adapter`. Install those Pi packages before using the corresponding surfaces. The OMP fork remains a separate target. † OpenCode reads `~/.agents/skills/` natively; its only hook surface is a JS plugin API. ‡ OMP has no managed hook surface yet; register memory hooks manually if needed. § Qwen Code natively loads Agent Plugins v1 skills and MCP servers; dotagents manages those same surfaces without rewriting the plugin. diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go index f42f229..357642e 100644 --- a/cmd/dotagents/agents.go +++ b/cmd/dotagents/agents.go @@ -37,6 +37,7 @@ type agentRole struct { Claude claudeRoleOptions `yaml:"claude"` Codex codexRoleOptions `yaml:"codex"` OMP ompRoleOptions `yaml:"omp"` + Pi piRoleOptions `yaml:"pi"` Droid droidRoleOptions `yaml:"droid"` Opencode opencodeRoleOptions `yaml:"opencode"` Qwen qwenRoleOptions `yaml:"qwen"` @@ -82,6 +83,10 @@ func (role *agentRole) UnmarshalYAML(value *yaml.Node) error { if err := node.Decode(&role.OMP); err != nil { return err } + case "pi": + if err := node.Decode(&role.Pi); err != nil { + return err + } case "droid": if err := node.Decode(&role.Droid); err != nil { return err @@ -140,6 +145,11 @@ type ompRoleOptions struct { ThinkingLevel string `yaml:"thinking-level"` } +type piRoleOptions struct { + Model string `yaml:"model"` + Thinking string `yaml:"thinking"` +} + type droidRoleOptions struct { Model string `yaml:"model"` ReasoningEffort string `yaml:"reasoning_effort"` diff --git a/cmd/dotagents/harness.go b/cmd/dotagents/harness.go index 066fd4f..f157eac 100644 --- a/cmd/dotagents/harness.go +++ b/cmd/dotagents/harness.go @@ -240,9 +240,23 @@ func initHarnesses() { }, agentPi: { - Detect: detectVanillaPi, - Skills: SkillsSymlink, - TrailerExample: "Co-authored-by: pi[bot] ", + Detect: detectVanillaPi, + Skills: SkillsSymlink, + MCP: mcpTargetPtr(mcpTarget{ + agentName: agentPi, + configPath: func(home string) string { return filepath.Join(home, ".pi", "agent", "mcp.json") }, + inspect: inspectJSONMCPServer, + patch: patchJSONMCPServer, + read: readJSONMCPServer, + rootKey: "mcpServers", + }), + Roles: &RolesCapability{Extension: ".md", Render: renderPiAgentRole}, + RootInstructions: &RootInstructionsCapability{ + Path: func(home string) string { return filepath.Join(home, ".pi", "agent", "AGENTS.md") }, + Expected: func(repoRoot string) string { return filepath.Join(repoRoot, "AGENTS.md") }, + }, + IntegrationNote: "MCP and roles require pi-mcp-adapter and pi-subagents; Agent Plugins project through managed skills and MCP", + TrailerExample: "Co-authored-by: pi[bot] ", }, agentOMP: { diff --git a/cmd/dotagents/harness_test.go b/cmd/dotagents/harness_test.go index 25096b0..f16b581 100644 --- a/cmd/dotagents/harness_test.go +++ b/cmd/dotagents/harness_test.go @@ -18,11 +18,14 @@ func TestPiAndOMPHarnessCapabilities(t *testing.T) { if pi.Skills != SkillsSymlink { t.Fatalf("Pi skills capability = %v, want symlink", pi.Skills) } - if pi.MCP != nil { - t.Fatal("Pi unexpectedly exposes MCP support") + if pi.MCP == nil { + t.Fatal("Pi does not expose MCP adapter support") } - if pi.Roles != nil { - t.Fatal("Pi unexpectedly exposes agent-role support") + if pi.Roles == nil || pi.Roles.Extension != ".md" { + t.Fatalf("Pi roles capability = %#v, want pi-subagents Markdown roles", pi.Roles) + } + if pi.RootInstructions == nil { + t.Fatal("Pi does not expose root-instruction support") } omp := harnessFor(agentOMP) @@ -45,15 +48,19 @@ func TestPublicTemplateStartsWithoutConfiguredAgents(t *testing.T) { if err != nil { t.Fatal(err) } - home := t.TempDir() - cfg, err := loadConfig(repoRoot, home, filepath.Join(repoRoot, "dotagents.yaml")) + data, err := os.ReadFile(filepath.Join(repoRoot, "dotagents.yaml")) if err != nil { t.Fatal(err) } + var cfg config + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } if len(cfg.Agents) != 0 { t.Fatalf("public template agents = %#v, want none before setup detection", cfg.Agents) } + home := t.TempDir() target, err := mcpTargetForHarness(agentOMP) if err != nil { t.Fatal(err) @@ -63,7 +70,7 @@ func TestPublicTemplateStartsWithoutConfiguredAgents(t *testing.T) { } } -func TestConfigRejectsPiMCPButAcceptsOMP(t *testing.T) { +func TestConfigAcceptsPiAndOMPMCP(t *testing.T) { home := t.TempDir() baseAgents := []agentConfig{ {Name: agentPi, Enabled: true, SkillRoot: filepath.Join(home, ".pi", "agent", "skills")}, @@ -78,10 +85,10 @@ func TestConfigRejectsPiMCPButAcceptsOMP(t *testing.T) { }}, } if err := validateConfig(&piConfig, home, true); err != nil { - t.Fatalf("Pi MCP target must degrade with a warning, not fail: %v", err) + t.Fatalf("Pi MCP config rejected: %v", err) } - if len(piConfig.MCPServers[0].Agents) != 0 { - t.Fatalf("pi MCP target must be dropped: %#v", piConfig.MCPServers[0].Agents) + if !reflect.DeepEqual(piConfig.MCPServers[0].Agents, []string{agentPi}) { + t.Fatalf("Pi MCP target changed: %#v", piConfig.MCPServers[0].Agents) } ompConfig := config{ @@ -96,7 +103,7 @@ func TestConfigRejectsPiMCPButAcceptsOMP(t *testing.T) { } } -func TestOMPMCPPatchUsesOMPConfig(t *testing.T) { +func TestPiAndOMPMCPPatchesUseSeparateConfigs(t *testing.T) { home := t.TempDir() server := testMCPServer() server.Agents = []string{agentOMP} @@ -114,24 +121,33 @@ func TestOMPMCPPatchUsesOMPConfig(t *testing.T) { if _, err := os.Stat(filepath.Join(home, ".omp", "agent", "mcp.json")); err != nil { t.Fatalf("OMP MCP config was not written to native path: %v", err) } - if err := patchMCPServer(agentPi, server, home); err == nil || !strings.Contains(err.Error(), "no MCP support") { - t.Fatalf("Pi MCP patch error = %v, want unsupported-agent error", err) + server.Agents = []string{agentPi} + if err := patchMCPServer(agentPi, server, home); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, ".pi", "agent", "mcp.json")); err != nil { + t.Fatalf("Pi MCP adapter config was not written to native path: %v", err) } } -func TestOMPRendererProducesNativeRoleAndPiSkipsRoles(t *testing.T) { +func TestPiAndOMPRenderersProduceNativeRoles(t *testing.T) { role := agentRole{ Name: "researcher", Description: "Find reliable evidence", Model: "opus", + Effort: "high", OMP: ompRoleOptions{Model: "gpt-5.6-luna-high"}, Tools: []string{"Read", "WebSearch", "Write", "NotebookEdit", "read"}, Instructions: "Compare the sources.", } home := t.TempDir() - if path, content, ok := renderAgentRole(role, agentConfig{Name: agentPi, AgentRoot: filepath.Join(home, ".pi", "agent", "agents")}); ok || path != "" || content != "" { - t.Fatalf("Pi rendered unsupported role: path=%q ok=%v content=%q", path, ok, content) + piPath, piContent, ok := renderAgentRole(role, agentConfig{Name: agentPi, AgentRoot: filepath.Join(home, ".pi", "agent", "agents")}) + if !ok || piPath != filepath.Join(home, ".pi", "agent", "agents", "researcher.md") { + t.Fatalf("Pi role path=%q ok=%v", piPath, ok) + } + if !strings.Contains(piContent, `thinking: "high"`) || strings.Contains(piContent, `model: "opus"`) { + t.Fatalf("Pi role should map effort and omit legacy model tier:\n%s", piContent) } path, content, ok := renderAgentRole(role, agentConfig{Name: agentOMP, AgentRoot: filepath.Join(home, ".omp", "agent", "agents")}) diff --git a/cmd/dotagents/pi_agent.go b/cmd/dotagents/pi_agent.go new file mode 100644 index 0000000..70bf3ad --- /dev/null +++ b/cmd/dotagents/pi_agent.go @@ -0,0 +1,66 @@ +package main + +import "strings" + +var piToolMapping = map[string]string{ + "bash": "bash", + "edit": "edit", + "glob": "find", + "grep": "grep", + "read": "read", + "webfetch": "fetch_content", + "websearch": "web_search", + "write": "write", +} + +// renderPiAgentRole emits the user-agent format consumed by pi-subagents. +// Vanilla Pi itself ignores this directory when the extension is absent. +func renderPiAgentRole(role agentRole) string { + model := strings.TrimSpace(role.Pi.Model) + if model == "" && !canonicalModelTier(role.Model) { + model = strings.TrimSpace(role.Model) + } + thinking := strings.TrimSpace(role.Pi.Thinking) + if thinking == "" { + thinking = strings.TrimSpace(role.Effort) + } + + var b strings.Builder + b.WriteString("---\n") + writeYAMLScalar(&b, "name", role.Name) + writeYAMLScalar(&b, "description", role.Description) + writeYAMLScalar(&b, "model", model) + writeYAMLScalar(&b, "thinking", thinking) + if tools := piToolsFor(role.Tools); len(tools) > 0 { + b.WriteString("tools:\n") + for _, tool := range tools { + writeYAMLListItem(&b, tool) + } + } + b.WriteString("---\n\n") + b.WriteString("\n\n") + b.WriteString(role.Instructions) + b.WriteString("\n") + return b.String() +} + +func piToolsFor(tools []string) []string { + out := make([]string, 0, len(tools)) + seen := make(map[string]struct{}, len(tools)) + for _, tool := range tools { + mapped := piToolMapping[strings.ToLower(strings.TrimSpace(tool))] + if mapped == "" { + continue + } + if _, ok := seen[mapped]; ok { + continue + } + seen[mapped] = struct{}{} + out = append(out, mapped) + } + return out +} diff --git a/cmd/dotagents/pluginmcp_test.go b/cmd/dotagents/pluginmcp_test.go index 8b8bbc3..57e6c1a 100644 --- a/cmd/dotagents/pluginmcp_test.go +++ b/cmd/dotagents/pluginmcp_test.go @@ -340,6 +340,9 @@ func TestInjectPluginMCPServersWithOptIn(t *testing.T) { if cfg.MCPServers[0].Name != "test-server" { t.Errorf("server name = %q", cfg.MCPServers[0].Name) } + if got := desiredMCPServersForAgent(cfg, agentPi); len(got) != 1 || got[0].Name != "test-server" { + t.Fatalf("Pi Agent Plugin projection = %#v, want test-server", got) + } } func TestInjectPluginMCPServersUserWins(t *testing.T) { diff --git a/cmd/dotagents/setup_scaffold.go b/cmd/dotagents/setup_scaffold.go index 9e8c184..b381458 100644 --- a/cmd/dotagents/setup_scaffold.go +++ b/cmd/dotagents/setup_scaffold.go @@ -141,7 +141,7 @@ func defaultAgentConfigs() []agentConfig { {Name: agentHermes, Enabled: true, SkillRoot: "~/.hermes/skills", Detect: "hermes"}, {Name: agentOMP, Enabled: true, SkillRoot: "~/.omp/agent/skills", AgentRoot: "~/.omp/agent/agents", Detect: "omp"}, {Name: agentOpenCode, Enabled: true, SkillRoot: "~/.config/opencode/skills", AgentRoot: "~/.config/opencode/agents", Detect: "opencode"}, - {Name: agentPi, Enabled: true, SkillRoot: "~/.pi/agent/skills", Detect: "pi"}, + {Name: agentPi, Enabled: true, SkillRoot: "~/.pi/agent/skills", AgentRoot: "~/.pi/agent/agents", Detect: "pi"}, {Name: agentQwenCode, Enabled: true, SkillRoot: "~/.qwen/skills", AgentRoot: "~/.qwen/agents", Detect: "qwen"}, } } @@ -688,10 +688,11 @@ func renderCanonicalRoleMarkdown(role agentRole) ([]byte, error) { Droid droidRoleOptions `yaml:"droid,omitempty"` Opencode opencodeRoleOptions `yaml:"opencode,omitempty"` OMP ompRoleOptions `yaml:"omp,omitempty"` + Pi piRoleOptions `yaml:"pi,omitempty"` }{ Name: role.Name, Description: role.Description, Model: role.Model, Effort: role.Effort, Tools: role.Tools, Color: role.Color, Claude: role.Claude, Codex: role.Codex, - Droid: role.Droid, Opencode: role.Opencode, OMP: role.OMP, + Droid: role.Droid, Opencode: role.Opencode, OMP: role.OMP, Pi: role.Pi, } meta, err := yaml.Marshal(front) if err != nil { diff --git a/cmd/dotagents/setup_separation_test.go b/cmd/dotagents/setup_separation_test.go index 6a754d7..938c5e9 100644 --- a/cmd/dotagents/setup_separation_test.go +++ b/cmd/dotagents/setup_separation_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" @@ -656,7 +657,7 @@ func TestPromptYesNoEOFAnnouncesSkip(t *testing.T) { } } -func TestValidateConfigLegacyPiMCPTargetDegrades(t *testing.T) { +func TestValidateConfigPiMCPTargetIsPreserved(t *testing.T) { cfg := config{ Agents: []agentConfig{ {Name: "pi", Enabled: true, SkillRoot: "~/.pi/agent/skills"}, @@ -667,10 +668,10 @@ func TestValidateConfigLegacyPiMCPTargetDegrades(t *testing.T) { }, } if err := validateConfig(&cfg, "/home/u", false); err != nil { - t.Fatalf("legacy pi MCP target must degrade, not fail: %v", err) + t.Fatalf("Pi MCP target rejected: %v", err) } - if len(cfg.MCPServers[0].Agents) != 1 || cfg.MCPServers[0].Agents[0] != "claude-code" { - t.Fatalf("pi target must be dropped, keeping supported targets: %#v", cfg.MCPServers[0].Agents) + if !reflect.DeepEqual(cfg.MCPServers[0].Agents, []string{"pi", "claude-code"}) { + t.Fatalf("Pi MCP target changed: %#v", cfg.MCPServers[0].Agents) } } diff --git a/cmd/dotagents/sync_test.go b/cmd/dotagents/sync_test.go index ddebe66..200db3f 100644 --- a/cmd/dotagents/sync_test.go +++ b/cmd/dotagents/sync_test.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" "gopkg.in/yaml.v3" @@ -52,6 +54,54 @@ agents: } } +func TestRunSyncProjectsPiSkillsRolesMCPAndInstructions(t *testing.T) { + home := t.TempDir() + repoRoot := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("DOTAGENTS_HOME", repoRoot) + + writeSyncTestFile(t, filepath.Join(repoRoot, "dotagents.yaml"), []byte(`version: 1 +agents: + - name: pi + enabled: true + skill_root: ~/.pi/agent/skills + agent_root: ~/.pi/agent/agents +mcp_servers: + - name: local + enabled: true + command: local-mcp + agents: [pi] +`)) + writeSyncTestFile(t, filepath.Join(repoRoot, "AGENTS.md"), []byte("# Shared instructions\n")) + writeSyncTestFile(t, filepath.Join(repoRoot, "skills", "sample", "SKILL.md"), []byte("---\nname: sample\ndescription: sample\n---\n")) + writeSyncTestFile(t, filepath.Join(repoRoot, "agents", "reviewer.md"), []byte("---\nname: reviewer\ndescription: Review changes\neffort: high\ntools: [Read, Grep]\n---\n\nReview carefully.\n")) + + if err := runSync(runOptions{Agents: agentPi}); err != nil { + t.Fatal(err) + } + + piRoot := filepath.Join(home, ".pi", "agent") + if target, err := os.Readlink(filepath.Join(piRoot, "skills", "sample")); err != nil || target != filepath.Join(repoRoot, "skills", "sample") { + t.Fatalf("Pi skill link target=%q err=%v", target, err) + } + role, err := os.ReadFile(filepath.Join(piRoot, "agents", "reviewer.md")) + if err != nil || !strings.Contains(string(role), `thinking: "high"`) || !strings.Contains(string(role), `- "grep"`) { + t.Fatalf("Pi subagent role err=%v:\n%s", err, role) + } + var mcp map[string]interface{} + data, err := os.ReadFile(filepath.Join(piRoot, "mcp.json")) + if err != nil || json.Unmarshal(data, &mcp) != nil { + t.Fatalf("Pi MCP adapter config err=%v: %s", err, data) + } + servers, _ := mcp["mcpServers"].(map[string]interface{}) + if _, ok := servers["local"]; !ok { + t.Fatalf("Pi MCP server missing: %#v", mcp) + } + if target, err := os.Readlink(filepath.Join(piRoot, "AGENTS.md")); err != nil || target != filepath.Join(repoRoot, "AGENTS.md") { + t.Fatalf("Pi instructions target=%q err=%v", target, err) + } +} + func writeSyncTestFile(t *testing.T, path string, data []byte) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/docs/roles.md b/docs/roles.md index 8708adf..b78e300 100644 --- a/docs/roles.md +++ b/docs/roles.md @@ -33,7 +33,10 @@ To pin one model for all rendered roles that have no explicit model, set `role_m | Codex | TOML | `~/.codex/agents/.toml` | | Factory Droid | Markdown | `~/.factory/droids/.md` | | OpenCode | YAML frontmatter | `~/.config/opencode/agents/.md` | +| Pi (`pi-subagents`) | YAML frontmatter | `~/.pi/agent/agents/.md` | | OMP | YAML frontmatter | `~/.omp/agent/agents/.md` | | Qwen Code | YAML frontmatter | `~/.qwen/agents/.md` | +Pi role files are inert unless the `pi-subagents` package is installed. Legacy model tiers are omitted so Pi inherits its configured model; use a `pi.model` exact override when needed. + Roles are regenerated on every `dotagents sync`; edit the canonical `.md`, never the rendered output. diff --git a/docs/site/index.html b/docs/site/index.html index 254f5c1..1fac26b 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -367,11 +367,11 @@

Sync surface by harness

OpenCodeyes†yesyes--native AGENTS.md Qwen CodeyesyesyesyesQWEN.md link OMP (pi fork)yesyesyes--‡AGENTS.md - Pi*yes--*--*---- + Pi*yesyes*yes*--skills + MCP* -

* Vanilla pi is skills-only by design; the OMP fork is detected as its own target.

+

* Vanilla Pi uses pi-subagents for managed roles and pi-mcp-adapter for MCP and Agent Plugin projection. OMP remains a separate target.

† OpenCode reads ~/.agents/skills/ natively — skills need no mirror when the config root is ~/.agents. Its only hook surface is a JS plugin API.

‡ OMP has no managed hook surface in dotagents.yaml yet; register memory hooks manually if needed.

⁑ Amp's hook and role surfaces use plugin-based models incompatible with dotagents' script-based hooks and per-agent role files.

diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d781367..b727ce3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,7 +1,7 @@ # Troubleshooting - **`dotagents doctor`** is the first stop: it validates skill frontmatter, role definitions, lock pins, materialized copies, hook registration, and audits external sources. -- **Upgrading from an old config** that targets agent `pi` with MCP: vanilla Pi has no MCP surface, so the target is ignored with a warning — rename `pi` to `omp` in `dotagents.yaml` if you meant the OMP fork. +- **Pi MCP entries do not appear:** install `pi-mcp-adapter`, keep the canonical server targeted at `pi`, run `dotagents sync`, then restart Pi or run `/reload`. dotagents writes only its named entries under `~/.pi/agent/mcp.json` and preserves adapter-specific settings. - **A sync proposed removals you didn't expect:** setup-driven syncs always preview removals per harness and default to keeping your files; answer `n` and inspect with `dotagents status`. - **"exists but is not a symlink" conflict on a root instruction file:** a real file occupies the harness's memory path (e.g. `~/.claude/CLAUDE.md`). dotagents never overwrites it silently — migrate the content into `~/.agents/AGENTS.md`, delete the real file, and rerun `dotagents sync`. - **Memory tools skipped during sync:** no Go toolchain on PATH. Install Go, or copy prebuilt binaries to `$GOBIN`/`~/.local/bin` manually; sync will manage them from then on (rebuilds only when sources change). diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 2bb0d9b..40a7336 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -102,10 +102,11 @@ Agent roles are canonical Markdown files under `~/.agents/agents/` and render to - Codex: `~/.codex/agents/.toml` - Factory Droid: `~/.factory/droids/.md` - OpenCode: `~/.config/opencode/agents/.md` +- Pi with `pi-subagents`: `~/.pi/agent/agents/.md` - OMP: `~/.omp/agent/agents/.md` - Qwen Code: `~/.qwen/agents/.md` -Pi has a managed skill root only. OMP is a separate target with skills, roles, and MCP support. +Pi always has managed skills. With `pi-subagents` installed, dotagents renders canonical roles into Pi's user agent directory. With `pi-mcp-adapter` installed, it patches canonical and Agent Plugin MCP entries into `~/.pi/agent/mcp.json`. The files remain inert when those packages are absent. OMP is a separate target. For MCP servers, sync patches only named canonical entries and preserves unrelated native servers. Import redacts literal environment values to `${KEY}` references; list output never prints values. @@ -147,7 +148,7 @@ publish_targets: ```bash dotagents mcp list dotagents mcp add local --command uvx --arg pkg@1.2.3 --env KEY=value -dotagents mcp import claude-code local --agents=codex,hermes,droid,omp +dotagents mcp import claude-code local --agents=codex,hermes,droid,pi,omp dotagents sync dotagents mcp remove local ``` @@ -197,7 +198,7 @@ dotagents inspect --ssh-host me@box --host 0.0.0.0 # remote: print an ssh -L tun | Factory Droid | yes | yes | yes | yes | | Hermes | yes, config-driven | no | yes | yes | | OpenCode | yes | yes | yes | no | -| Pi | yes | no | no | no | +| Pi | yes | yes, via `pi-subagents` | yes, via `pi-mcp-adapter` | no | | OMP | yes | yes | yes | no | | Qwen Code | yes, config-driven | yes | yes | yes |