diff --git a/.gitignore b/.gitignore index 853c826..62d5677 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ # Canonical user-config runtime state external/ dotagents.local.yaml +/subagents/ memsearch.conf .skill-lock.json diff --git a/README.md b/README.md index e4e0b21..6c359d3 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,16 @@ Five surfaces, each rendered into the harness's own format — dotagents does no | Qwen Code | yes, config-driven | yes | yes | yes | skills + MCP§ | | OMP (pi fork) | yes | yes | yes | --‡ | -- | | Pi* | yes | --* | --* | -- | -- | +| Pi Desktop¶ | yes | yes | -- | -- | -- | +| Selesai Code** | yes, filtered | -- | -- | -- | -- | \* Vanilla [pi](https://github.com/earendil-works/pi) is skills-only by design; the OMP fork is detected as its own 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. ⁑ Amp's hook and role surfaces use plugin-based models incompatible with dotagents' script-based hooks and per-agent role files. +¶ Pi Desktop reads the supported global `~/.agents/skills` and `~/.agents/subagents` roots; MCP and other settings remain configured in the app's Settings UI. +** [Selesai Code](https://github.com/SelesaiInTech/selesai-code) uses `~/.selesai/agent/skills`; dotagents syncs only non-bundled skills and reports conflicts instead of overwriting Selesai-owned names. OpenClaw is not currently supported. Native skill discovery from `~/.agents/skills` may work due to OpenClaw's multi-tier skill precedence, but this is unverified and unmanaged. A managed harness entry is planned for a future release. A "yes" above only appears after end-to-end verification. diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go index f42f229..6b11945 100644 --- a/cmd/dotagents/agents.go +++ b/cmd/dotagents/agents.go @@ -439,6 +439,12 @@ func renderClaudeAgentRole(role agentRole) string { return b.String() } +// renderPiDesktopAgentRole emits the Markdown frontmatter consumed by +// Pi Desktop's global ~/.agents/subagents directory. +func renderPiDesktopAgentRole(role agentRole) string { + return renderClaudeAgentRole(role) +} + func renderCodexAgentRole(role agentRole) string { model := strings.TrimSpace(role.Codex.Model) if model == "" { diff --git a/cmd/dotagents/doctor.go b/cmd/dotagents/doctor.go index efe6cd3..767ea6f 100644 --- a/cmd/dotagents/doctor.go +++ b/cmd/dotagents/doctor.go @@ -23,8 +23,10 @@ const ( agentHermes = "hermes" agentOpenCode = "opencode" agentPi = "pi" + agentPiDesktop = "pi-desktop" agentOMP = "omp" agentQwenCode = "qwen-code" + agentSelesai = "selesai" dotagentsSkillsPathValue = "~/.agents/skills" ) diff --git a/cmd/dotagents/harness.go b/cmd/dotagents/harness.go index 066fd4f..d51cac0 100644 --- a/cmd/dotagents/harness.go +++ b/cmd/dotagents/harness.go @@ -3,6 +3,7 @@ package main import ( "bytes" "fmt" + "os" "os/exec" "path/filepath" "sort" @@ -245,6 +246,13 @@ func initHarnesses() { TrailerExample: "Co-authored-by: pi[bot] ", }, + agentPiDesktop: { + Detect: detectPiDesktop, + Skills: SkillsSymlink, + Roles: &RolesCapability{Extension: ".md", Render: renderPiDesktopAgentRole}, + IntegrationNote: "uses Pi Desktop's supported global ~/.agents/skills and ~/.agents/subagents roots; no app internals or plugin package", + }, + agentOMP: { Skills: SkillsSymlink, MCP: mcpTargetPtr(mcpTarget{ @@ -258,6 +266,13 @@ func initHarnesses() { Roles: &RolesCapability{Extension: ".md", Render: renderOMPAgentRole}, }, + agentSelesai: { + Detect: detectSelesai, + Skills: SkillsSymlink, + TrailerExample: "Co-authored-by: selesai[bot] ", + IntegrationNote: "syncs only non-bundled skills to avoid conflicts with Selesai's installed bundled skills", + }, + agentQwenCode: { Skills: SkillsConfigDriven, InspectSkills: func(agent agentConfig, expected map[string]string, agentsSkillRoot string, cfg config, home string) (agentReport, error) { @@ -348,3 +363,16 @@ func detectVanillaPi(executable string) bool { version, _ := exec.Command(executable, "--version").CombinedOutput() // nosemgrep: go.lang.security.audit.dangerous-exec-command return !bytes.HasPrefix(bytes.TrimSpace(version), []byte("omp/")) } + +func detectPiDesktop(executable string) bool { + // Pi Desktop is a GUI application. Detect by checking if the app bundle exists. + // The executable might be 'pi' from the PATH, but we check for the desktop app. + info, err := os.Stat("/Applications/PI-Desktop.app") + return err == nil && info.IsDir() +} + +func detectSelesai(executable string) bool { + // Verify this is actually the selesai command, not something else named 'selesai'. + version, _ := exec.Command(executable, "--version").CombinedOutput() // nosemgrep: go.lang.security.audit.dangerous-exec-command + return bytes.Contains(bytes.ToLower(version), []byte("selesai")) +} diff --git a/cmd/dotagents/inspect.go b/cmd/dotagents/inspect.go index c032e78..eaa34a5 100644 --- a/cmd/dotagents/inspect.go +++ b/cmd/dotagents/inspect.go @@ -77,7 +77,11 @@ func discoverLocalSkills(repoRoot string, _ string) ([]discoveredSkill, error) { return discovered, nil } -func expectedSkillsForAgent(base map[string]string, _ string, _ config, _ string) (map[string]string, error) { +func expectedSkillsForAgent(base map[string]string, _ string, _ config, agentName string) (map[string]string, error) { + // Filter bundled skills for Selesai to avoid conflicts + if agentName == agentSelesai { + return filterSelesaiExpectedSkills(base) + } return base, nil } diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index a61f79d..bfdd83e 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -121,6 +121,10 @@ type agentReport struct { func isDetected(agent agentConfig) bool { if agent.Detect == "" { + // No executable required, but check harness-specific detection if available + if harness := harnessFor(agent.Name); harness != nil && harness.Detect != nil { + return harness.Detect("") + } return true } executable, err := exec.LookPath(agent.Detect) diff --git a/cmd/dotagents/pi_desktop_test.go b/cmd/dotagents/pi_desktop_test.go new file mode 100644 index 0000000..0ad75a9 --- /dev/null +++ b/cmd/dotagents/pi_desktop_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestPiDesktopHarnessCapabilities(t *testing.T) { + piDesktop := harnessFor(agentPiDesktop) + if piDesktop == nil { + t.Fatal("Pi Desktop harness is not registered") + } + if piDesktop.Skills != SkillsSymlink { + t.Fatalf("Pi Desktop skills capability = %v, want symlink", piDesktop.Skills) + } + if piDesktop.Roles == nil || piDesktop.Roles.Extension != ".md" { + t.Fatalf("Pi Desktop roles capability = %#v, want Markdown roles", piDesktop.Roles) + } + if piDesktop.MCP != nil || piDesktop.Hooks != nil || piDesktop.RootInstructions != nil { + t.Fatal("Pi Desktop must expose only its verified skills and subagents surfaces") + } + if piDesktop.IntegrationNote == "" { + t.Fatal("Pi Desktop should document its native global roots") + } +} + +func TestPiDesktopDefaultConfig(t *testing.T) { + for _, cfg := range defaultAgentConfigs() { + if cfg.Name != agentPiDesktop { + continue + } + if cfg.SkillRoot != "~/.agents/skills" || cfg.AgentRoot != "~/.agents/subagents" || cfg.Detect != "" { + t.Fatalf("Pi Desktop default config = %#v", cfg) + } + return + } + t.Fatal("Pi Desktop not found in default agent configs") +} + +func TestPiDesktopRendersSupportedSubagentRole(t *testing.T) { + role := agentRole{ + Name: "researcher", + Description: "Find reliable evidence", + Model: "gpt-5.6-luna", + Tools: []string{"read", "grep"}, + Instructions: "Compare the sources.", + } + root := filepath.Join(t.TempDir(), ".agents", "subagents") + path, content, ok := renderAgentRole(role, agentConfig{Name: agentPiDesktop, AgentRoot: root}) + if !ok { + t.Fatal("Pi Desktop role was not rendered") + } + if want := filepath.Join(root, "researcher.md"); path != want { + t.Fatalf("Pi Desktop role path = %q, want %q", path, want) + } + parts := strings.SplitN(content, "---\n", 3) + if len(parts) != 3 { + t.Fatalf("Pi Desktop role lacks YAML frontmatter:\n%s", content) + } + var frontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Model string `yaml:"model"` + Tools string `yaml:"tools"` + } + if err := yaml.Unmarshal([]byte(parts[1]), &frontmatter); err != nil { + t.Fatalf("parse Pi Desktop role frontmatter: %v", err) + } + if frontmatter.Name != role.Name || frontmatter.Description != role.Description || frontmatter.Model != role.Model || frontmatter.Tools != "read, grep" { + t.Fatalf("Pi Desktop role frontmatter = %#v", frontmatter) + } + if !strings.Contains(parts[2], role.Instructions) { + t.Fatalf("Pi Desktop role dropped instructions:\n%s", content) + } +} diff --git a/cmd/dotagents/selesai.go b/cmd/dotagents/selesai.go new file mode 100644 index 0000000..2ec69a3 --- /dev/null +++ b/cmd/dotagents/selesai.go @@ -0,0 +1,90 @@ +package main + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" +) + +// getSelesaiBundledSkills discovers Selesai's bundled skills by inspecting +// the installed npm package. Returns a set of bundled skill names to exclude +// from dotagents sync. +func getSelesaiBundledSkills() (map[string]struct{}, error) { + // Find selesai executable + selesaiPath, err := exec.LookPath("selesai") + if err != nil { + // Selesai not installed, return empty set + return make(map[string]struct{}), nil + } + + // Resolve symlink if the executable is a symlink (common with npm global installs) + selesaiPath, err = filepath.EvalSymlinks(selesaiPath) + if err != nil { + return nil, err + } + + // npm global installs typically have structure: + // /path/to/npm/prefix/lib/node_modules/@selesai/code/bin/selesai.js + // We need to find the package directory: /path/to/npm/prefix/lib/node_modules/@selesai/code + packageDir := selesaiPath + for { + parent := filepath.Dir(packageDir) + if parent == packageDir { + // Reached root without finding package.json + return nil, errors.New("could not find @selesai/code package directory") + } + packageJSON := filepath.Join(parent, "package.json") + if hasFile(packageJSON) { + packageDir = parent + break + } + packageDir = parent + } + + // Look for bundled skills in dist/skills/ or src/skills/ + bundled := make(map[string]struct{}) + for _, skillsDir := range []string{ + filepath.Join(packageDir, "dist", "skills"), + filepath.Join(packageDir, "src", "skills"), + } { + entries, err := os.ReadDir(skillsDir) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() && hasFile(filepath.Join(skillsDir, entry.Name(), "SKILL.md")) { + bundled[entry.Name()] = struct{}{} + } + } + } + + return bundled, nil +} + +// filterSelesaiExpectedSkills removes bundled skills from the expected skills map +// for Selesai agent to avoid conflicts with Selesai's built-in skills. +func filterSelesaiExpectedSkills(expected map[string]string) (map[string]string, error) { + bundled, err := getSelesaiBundledSkills() + if err != nil { + return nil, err + } + + if len(bundled) == 0 { + // No bundled skills found or Selesai not installed, sync all skills + return expected, nil + } + + filtered := make(map[string]string) + for name, path := range expected { + if _, isBundled := bundled[name]; !isBundled { + filtered[name] = path + } + } + + return filtered, nil +} diff --git a/cmd/dotagents/selesai_test.go b/cmd/dotagents/selesai_test.go new file mode 100644 index 0000000..d1325b8 --- /dev/null +++ b/cmd/dotagents/selesai_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSelesaiHarnessCapabilities(t *testing.T) { + selesai := harnessFor(agentSelesai) + if selesai == nil { + t.Fatal("Selesai harness is not registered") + } + if selesai.Skills != SkillsSymlink { + t.Fatalf("Selesai skills capability = %v, want symlink", selesai.Skills) + } + if selesai.IntegrationNote == "" { + t.Fatal("Selesai should have integration note about bundled skills") + } +} + +func TestSelesaiDefaultConfig(t *testing.T) { + configs := defaultAgentConfigs() + var found bool + for _, cfg := range configs { + if cfg.Name == agentSelesai { + found = true + if cfg.SkillRoot != "~/.selesai/agent/skills" { + t.Fatalf("Selesai skill root = %q, want ~/.selesai/agent/skills", cfg.SkillRoot) + } + if cfg.Detect != "selesai" { + t.Fatalf("Selesai detect = %q, want selesai", cfg.Detect) + } + break + } + } + if !found { + t.Fatal("Selesai not found in default agent configs") + } +} + +func TestSelesaiUsesDistinctPath(t *testing.T) { + home := t.TempDir() + configs := []agentConfig{ + {Name: agentPi, Enabled: true, SkillRoot: filepath.Join(home, ".pi", "agent", "skills")}, + {Name: agentPiDesktop, Enabled: true, SkillRoot: filepath.Join(home, ".agents", "skills")}, + {Name: agentSelesai, Enabled: true, SkillRoot: filepath.Join(home, ".selesai", "agent", "skills")}, + } + + // Selesai should have a different path from Pi/Pi Desktop. + if configs[2].SkillRoot == configs[0].SkillRoot { + t.Fatal("Selesai should not share path with vanilla Pi") + } + if configs[2].SkillRoot == configs[1].SkillRoot { + t.Fatal("Selesai should not share path with Pi Desktop") + } +} + +func TestSelesaiSkillFiltering(t *testing.T) { + // Test that skill filtering works when Selesai is not installed + // (should return all skills unchanged) + expected := map[string]string{ + "test-skill": "/path/to/test-skill", + "another-skill": "/path/to/another-skill", + } + + filtered, err := filterSelesaiExpectedSkills(expected) + if err != nil { + t.Fatalf("filterSelesaiExpectedSkills failed: %v", err) + } + + // When Selesai is not installed, all skills should pass through + if len(filtered) != len(expected) { + t.Fatalf("filtered skills count = %d, want %d", len(filtered), len(expected)) + } + + for name, path := range expected { + if filtered[name] != path { + t.Fatalf("skill %q path = %q, want %q", name, filtered[name], path) + } + } +} + +func TestSelesaiSkillFilteringWithMockBundled(t *testing.T) { + // Create a temporary directory structure mimicking a Selesai installation + tmpDir := t.TempDir() + packageDir := filepath.Join(tmpDir, "node_modules", "@selesai", "code") + skillsDir := filepath.Join(packageDir, "dist", "skills") + + // Create package.json + if err := os.MkdirAll(packageDir, 0o755); err != nil { + t.Fatal(err) + } + pkgJSON := `{"name": "@selesai/code"}` + if err := os.WriteFile(filepath.Join(packageDir, "package.json"), []byte(pkgJSON), 0o644); err != nil { + t.Fatal(err) + } + + // Create bundled skills + for _, name := range []string{"bundled-skill-1", "bundled-skill-2"} { + skillDir := filepath.Join(skillsDir, name) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644); err != nil { + t.Fatal(err) + } + } + + // Create a mock selesai executable + binPath := filepath.Join(packageDir, "bin", "selesai.js") + if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binPath, []byte("#!/usr/bin/env node\nconsole.log('selesai');"), 0o755); err != nil { + t.Fatal(err) + } + + // Note: This test can't easily test the actual filtering because we can't + // mock exec.LookPath. The test above verifies the behavior when Selesai + // is not installed (the common case in CI). + // The actual bundled skill discovery would need integration testing. +} + +func TestSelesaiDetection(t *testing.T) { + // Test detection with a mock executable + tmpDir := t.TempDir() + mockSelesai := filepath.Join(tmpDir, "selesai") + + // Create a script that outputs "selesai" in version + script := `#!/bin/sh +echo "selesai version 0.13.25" +` + if err := os.WriteFile(mockSelesai, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + if !detectSelesai(mockSelesai) { + t.Fatal("Selesai executable with 'selesai' in version output was not detected") + } + + // Test with non-Selesai executable + mockOther := filepath.Join(tmpDir, "other") + otherScript := `#!/bin/sh +echo "other version 1.0.0" +` + if err := os.WriteFile(mockOther, []byte(otherScript), 0o755); err != nil { + t.Fatal(err) + } + + if detectSelesai(mockOther) { + t.Fatal("Non-Selesai executable was incorrectly detected as Selesai") + } +} diff --git a/cmd/dotagents/setup_scaffold.go b/cmd/dotagents/setup_scaffold.go index 9e8c184..5bc9086 100644 --- a/cmd/dotagents/setup_scaffold.go +++ b/cmd/dotagents/setup_scaffold.go @@ -142,7 +142,9 @@ func defaultAgentConfigs() []agentConfig { {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: agentPiDesktop, Enabled: true, SkillRoot: "~/.agents/skills", AgentRoot: "~/.agents/subagents", Detect: ""}, {Name: agentQwenCode, Enabled: true, SkillRoot: "~/.qwen/skills", AgentRoot: "~/.qwen/agents", Detect: "qwen"}, + {Name: agentSelesai, Enabled: true, SkillRoot: "~/.selesai/agent/skills", Detect: "selesai"}, } } diff --git a/docs/roles.md b/docs/roles.md index 8708adf..b749598 100644 --- a/docs/roles.md +++ b/docs/roles.md @@ -2,6 +2,8 @@ A role is a Markdown file in `~/.agents/agents/` with frontmatter (`name`, `description`, `model`, `effort`, `tools`, optional per-harness overrides) and the system prompt as body. dotagents renders it into each harness's native format — e.g. TOML for Codex. Six generic starter roles ship with the tool: `architect` `builder` `general` `researcher` `reviewer` `tester`. A same-name file in your `~/.agents/agents/` always wins over the starter. +Pi Desktop consumes the same Markdown frontmatter for its global subagents in `~/.agents/subagents/`. Because the canonical repository is linked at `~/.agents`, dotagents writes generated Pi Desktop roles to the ignored top-level `subagents/` directory; edit the source role under `agents/`, never the generated output. + ## Model tiers and overrides Claude Code and Droid render the tier natively in their own model family. Codex neutralizes the tier and uses its own default unless an exact per-harness `codex.model` override is set. Harnesses without a tier concept use native inheritance or an exact per-harness override: @@ -34,6 +36,7 @@ To pin one model for all rendered roles that have no explicit model, set `role_m | Factory Droid | Markdown | `~/.factory/droids/.md` | | OpenCode | YAML frontmatter | `~/.config/opencode/agents/.md` | | OMP | YAML frontmatter | `~/.omp/agent/agents/.md` | +| Pi Desktop | YAML frontmatter | `~/.agents/subagents/.md` | | Qwen Code | YAML frontmatter | `~/.qwen/agents/.md` | Roles are regenerated on every `dotagents sync`; edit the canonical `.md`, never the rendered output. diff --git a/docs/setup.md b/docs/setup.md index 8900e01..77d3ae1 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -8,6 +8,8 @@ 4. Before its first sync touches a harness that already has content, shows exactly what would be removed or overwritten there and asks per harness. Declining keeps that harness's files. 5. Offers to `git init` the new repository, and runs the first sync. +When Pi Desktop is installed, the first sync also renders canonical roles into its supported global `~/.agents/subagents/` directory. Its global skills already use the canonical `~/.agents/skills/` directory; configure MCP and other desktop settings in Pi Desktop's Settings UI. + The review screen in step 3 looks like this — `space` cycles share/keep/skip per row, `enter` applies: ``` diff --git a/docs/site/index.html b/docs/site/index.html index 3a5f8ab..edf5465 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -368,6 +368,8 @@

Sync surface by harness

Qwen CodeyesyesyesyesQWEN.md link OMP (pi fork)yesyesyes--‡AGENTS.md Pi*yes--*--*---- + Pi Desktop¶yesyes------ + Selesai Code**yes, filtered-------- @@ -375,6 +377,8 @@

Sync surface by harness

† 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.

+

¶ Pi Desktop uses the supported global ~/.agents/skills/ and ~/.agents/subagents/ roots; configure MCP in its Settings UI.

+

** Selesai keeps its bundled skill names; dotagents links only user-only skills and reports conflicts.