Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ __pycache__/
# Canonical user-config runtime state
external/
dotagents.local.yaml
/subagents/
memsearch.conf
.skill-lock.json

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | -- | -- | -- | -- |
Comment on lines +50 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add the new harnesses to the dotagents skill documentation

These new capability rows leave skills/dotagents/SKILL.md inconsistent: its description and capability matrix still enumerate only the previous harnesses, its role targets omit Pi Desktop, and it still states that Pi has skills only without distinguishing Desktop. Agents using the bundled dotagents skill will consequently give stale guidance about the newly advertised integrations.

AGENTS.md reference: AGENTS.md:L49-L51

Useful? React with 👍 / 👎.


\* 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.

Expand Down
6 changes: 6 additions & 0 deletions cmd/dotagents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render Pi roles without Claude-specific overrides

When a canonical role defines claude.model, this delegates to renderClaudeAgentRole, which prioritizes that Claude-only override over the generic model. Pi Desktop therefore receives a model selection intended exclusively for Claude Code, potentially pinning the wrong provider/model or making the subagent unusable; render Pi frontmatter independently from role.Claude.

AGENTS.md reference: AGENTS.md:L21-L21

Useful? React with 👍 / 👎.

}

func renderCodexAgentRole(role agentRole) string {
model := strings.TrimSpace(role.Codex.Model)
if model == "" {
Expand Down
2 changes: 2 additions & 0 deletions cmd/dotagents/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ const (
agentHermes = "hermes"
agentOpenCode = "opencode"
agentPi = "pi"
agentPiDesktop = "pi-desktop"
agentOMP = "omp"
agentQwenCode = "qwen-code"
agentSelesai = "selesai"
dotagentsSkillsPathValue = "~/.agents/skills"
)

Expand Down
28 changes: 28 additions & 0 deletions cmd/dotagents/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
Expand Down Expand Up @@ -245,6 +246,13 @@ func initHarnesses() {
TrailerExample: "Co-authored-by: pi[bot] <pi[bot]@users.noreply.github.com>",
},

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{
Expand All @@ -258,6 +266,13 @@ func initHarnesses() {
Roles: &RolesCapability{Extension: ".md", Render: renderOMPAgentRole},
},

agentSelesai: {
Detect: detectSelesai,
Skills: SkillsSymlink,
TrailerExample: "Co-authored-by: selesai[bot] <selesai[bot]@users.noreply.github.com>",
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) {
Expand Down Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect Pi Desktop in user-local installations

On macOS, applications may be installed under ~/Applications, but this detector checks only the system-wide /Applications directory. In that installation context both automatic setup and explicit --agents pi-desktop selection reject the installed harness because both paths still call isDetected, so the new skills and subagent integration cannot be configured.

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

Useful? React with 👍 / 👎.

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"))
}
6 changes: 5 additions & 1 deletion cmd/dotagents/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions cmd/dotagents/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions cmd/dotagents/pi_desktop_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
90 changes: 90 additions & 0 deletions cmd/dotagents/selesai.go
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +16 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor the configured Selesai executable when filtering skills

When a Selesai agent uses a custom detect command or absolute path, isDetected validates that executable but this helper independently searches for the literal selesai. If that name is absent—or resolves to a different installation—the helper returns an empty bundled set, so canonical skills that collide with Selesai's bundled names are no longer filtered and sync can abort on those conflicts. Pass the detected executable through to bundled-skill discovery instead of performing a second hard-coded lookup.

Useful? React with 👍 / 👎.

}

// 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
}
Loading
Loading