Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions cmd/dotagents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand Down
20 changes: 17 additions & 3 deletions cmd/dotagents/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,23 @@ func initHarnesses() {
},

agentPi: {
Detect: detectVanillaPi,
Skills: SkillsSymlink,
TrailerExample: "Co-authored-by: pi[bot] <pi[bot]@users.noreply.github.com>",
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] <pi[bot]@users.noreply.github.com>",
},

agentOMP: {
Expand Down
48 changes: 32 additions & 16 deletions cmd/dotagents/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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")},
Expand All @@ -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{
Expand All @@ -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}
Expand All @@ -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")})
Expand Down
66 changes: 66 additions & 0 deletions cmd/dotagents/pi_agent.go
Original file line number Diff line number Diff line change
@@ -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("<!-- ")
b.WriteString(generatedAgentMarker)
b.WriteString(" from ")
b.WriteString(agentRoleSourceLabel(role))
b.WriteString("; do not edit directly. -->\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
}
3 changes: 3 additions & 0 deletions cmd/dotagents/pluginmcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions cmd/dotagents/setup_scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile imported Pi roles before first sync

When setup detects Pi with an existing third-party pi-subagents role, this newly configured agent root makes scanNativeRoles offer the role for import. After the user accepts, the canonical copy is created but the source remains unchanged; the first runSync renders a version containing the dotagents marker, classifies the original source as a non-managed conflict, and aborts setup. Accepted Pi role imports therefore cannot complete their required first sync and need to be recognized or otherwise reconciled without modifying the source.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

{Name: agentQwenCode, Enabled: true, SkillRoot: "~/.qwen/skills", AgentRoot: "~/.qwen/agents", Detect: "qwen"},
}
}
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions cmd/dotagents/setup_separation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -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"},
Expand All @@ -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)
}
}

Expand Down
50 changes: 50 additions & 0 deletions cmd/dotagents/sync_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions docs/roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ To pin one model for all rendered roles that have no explicit model, set `role_m
| Codex | TOML | `~/.codex/agents/<name>.toml` |
| Factory Droid | Markdown | `~/.factory/droids/<name>.md` |
| OpenCode | YAML frontmatter | `~/.config/opencode/agents/<name>.md` |
| Pi (`pi-subagents`) | YAML frontmatter | `~/.pi/agent/agents/<name>.md` |
| OMP | YAML frontmatter | `~/.omp/agent/agents/<name>.md` |
| Qwen Code | YAML frontmatter | `~/.qwen/agents/<name>.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.
Loading
Loading