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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Dotfiles for your AI agents.

One private `~/.agents` git repository holds your skills, MCP servers, hooks, and agent roles. The `dotagents` CLI syncs each of those into the native format of every coding agent you use — Claude Code, Codex, Factory Droid, Hermes, Pi — and follows you across machines the way dotfiles do. External skills are commit-pinned and audited before any agent loads them.
One private `~/.agents` git repository holds your skills, MCP servers, hooks, and agent roles. The `dotagents` CLI syncs each of those into the native format of every coding agent you use — Claude Code, Codex, Factory Droid, Hermes, OpenCode, Pi — and follows you across machines the way dotfiles do. External skills are commit-pinned and audited before any agent loads them.

**[Overview & comparison →](https://yourconscience.github.io/dotagents/)** · [Releases](https://github.com/yourconscience/dotagents/releases)

Expand Down Expand Up @@ -75,11 +75,14 @@ Exactly four surfaces, each rendered into the harness's own format — dotagents
| Codex | yes | yes | yes | yes |
| Factory Droid | yes | yes | yes | yes |
| Hermes | yes | -- | yes | yes |
| OpenCode | yes† | yes | yes | -- |
| Pi* | yes | --* | --* | -- |

\* Vanilla [pi](https://github.com/earendil-works/pi) is skills-only by design. If you run the OMP fork instead, dotagents detects it separately and additionally manages roles and MCP servers there — the two never conflict.

Amp, OpenCode, and OpenClaw can read the repo's skills through standard conventions but are not managed; a surface gets a "yes" above only after its native behavior is verified end to end.
† OpenCode reads `~/.agents/skills/` natively, so dotagents delivers skills without a mirror when the config root is `~/.agents`; a custom config root mirrors into `~/.config/opencode/skills/` like other harnesses. OpenCode's only hook surface is a JS plugin API, so hooks stay unsupported.

Amp and OpenClaw can read the repo's skills through standard conventions but are not managed; a surface gets a "yes" above only after its native behavior is verified end to end.

## Working with skills

Expand Down
31 changes: 21 additions & 10 deletions cmd/dotagents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,17 @@ const (
const agentRoleMarkdownExt = ".md"

type agentRole struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Model string `yaml:"model"`
Effort string `yaml:"effort"`
Tools []string `yaml:"tools"`
Color string `yaml:"color"`
Instructions string `yaml:"-"`
Source string `yaml:"-"`
Codex codexRoleOptions `yaml:"codex"`
Droid droidRoleOptions `yaml:"droid"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Model string `yaml:"model"`
Effort string `yaml:"effort"`
Tools []string `yaml:"tools"`
Color string `yaml:"color"`
Instructions string `yaml:"-"`
Source string `yaml:"-"`
Codex codexRoleOptions `yaml:"codex"`
Droid droidRoleOptions `yaml:"droid"`
Opencode opencodeRoleOptions `yaml:"opencode"`
}

func (role *agentRole) UnmarshalYAML(value *yaml.Node) error {
Expand Down Expand Up @@ -74,6 +75,10 @@ func (role *agentRole) UnmarshalYAML(value *yaml.Node) error {
if err := node.Decode(&role.Droid); err != nil {
return err
}
case "opencode":
if err := node.Decode(&role.Opencode); err != nil {
return err
}
case "tools":
tools, err := decodeRoleTools(node)
if err != nil {
Expand Down Expand Up @@ -117,6 +122,12 @@ type droidRoleOptions struct {
Tools []string `yaml:"tools"`
}

type opencodeRoleOptions struct {
Model string `yaml:"model"`
Temperature string `yaml:"temperature"`
Mode string `yaml:"mode"`
}

var droidToolMapping = map[string][]string{
"bash": {"Execute"},
"edit": {"Edit"},
Expand Down
1 change: 1 addition & 0 deletions cmd/dotagents/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const (
agentCodex = "codex"
agentDroid = "droid"
agentHermes = "hermes"
agentOpenCode = "opencode"
agentPi = "pi"
agentOMP = "omp"
dotagentsSkillsPathValue = "~/.agents/skills"
Expand Down
23 changes: 23 additions & 0 deletions cmd/dotagents/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ type Harness struct {
// InspectSkills is called instead of the generic symlink inspector
// when Skills == SkillsConfigDriven.
InspectSkills InspectSkillsFunc
// SkillsNativeRoot, when non-nil and returning true, marks that this
// harness reads dotagents skills directly from the config root, so no
// per-harness skill mirror is created. Only consulted for SkillsSymlink
// harnesses.
SkillsNativeRoot func(repoRoot string, home string) bool
// Setup patches the agent's config during `dotagents setup`.
// nil means no patching needed.
Setup SetupFunc
Expand Down Expand Up @@ -204,6 +209,24 @@ func initHarnesses() {
TrailerExample: "Co-Authored-By: hermes[bot] <hermes[bot]@users.noreply.github.com>",
},

agentOpenCode: {
Skills: SkillsSymlink,
SkillsNativeRoot: openCodeReadsAgentsSkills,
MCP: mcpTargetPtr(mcpTarget{
agentName: agentOpenCode,
configPath: openCodeConfigPath,
inspect: inspectOpenCodeMCPServer,
patch: patchOpenCodeMCPServer,
read: readOpenCodeMCPServer,
rootKey: "mcp",
}),
Roles: &RolesCapability{Extension: ".md", Render: renderOpenCodeAgentRole},

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 Preserve OpenCode agent frontmatter on import

With OpenCode roles enabled here, setup scans ~/.config/opencode/agents/*.md, but OpenCode markdown agents use the filename as the agent name and their frontmatter normally lacks the canonical name field. The current generic Markdown importer therefore fails canonical parsing, falls back to wrapping the entire original file (including --- frontmatter) into the instructions, and replaces fields like description, mode, and model; accepting such an import loses the native role metadata instead of normalizing it.

Useful? React with 👍 / 👎.

IntegrationNote: "skills read natively from ~/.agents/skills (mirrored into the skill root only when the config root differs)",
DoctorChecks: []DoctorCheck{
{Name: "opencode duplicate skills", Run: checkOpenCodeDuplicateSkills},
},
},

agentPi: {
Detect: detectVanillaPi,
Skills: SkillsSymlink,
Expand Down
140 changes: 73 additions & 67 deletions cmd/dotagents/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,88 +196,94 @@ func inspectAgent(agent agentConfig, expected map[string]string, repoRoot string
return h.InspectSkills(agent, expected, agentsSkillRoot, cfg, home)
}

expectedNames := sortedKeys(expected)
rootInfo, err := os.Stat(agent.SkillRoot)
rootMissing := false
switch {
case errors.Is(err, fs.ErrNotExist):
rootMissing = true
case err != nil:
return agentReport{}, fmt.Errorf("stat %s: %w", agent.SkillRoot, err)
case !rootInfo.IsDir():
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but is not a directory", agent.SkillRoot))
report.Missing = append(report.Missing, expectedNames...)
report.Adds = append(report.Adds, expectedNames...)
sortReportLists(&report)
report.Synced = false
return report, nil
}

entryMap := make(map[string]fs.DirEntry)
if !rootMissing {
entries, err := os.ReadDir(agent.SkillRoot)
if err != nil {
return agentReport{}, fmt.Errorf("read %s: %w", agent.SkillRoot, err)
}
for _, entry := range entries {
entryMap[entry.Name()] = entry
if h != nil && h.SkillsNativeRoot != nil && h.SkillsNativeRoot(repoRoot, home) {
// Skills are consumed directly from the config root; no per-harness
// mirror is created, so every expected skill is already managed.
report.Managed = append(report.Managed, sortedKeys(expected)...)
} else {
expectedNames := sortedKeys(expected)
rootInfo, err := os.Stat(agent.SkillRoot)
rootMissing := false
switch {
case errors.Is(err, fs.ErrNotExist):
rootMissing = true
case err != nil:
return agentReport{}, fmt.Errorf("stat %s: %w", agent.SkillRoot, err)
case !rootInfo.IsDir():
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but is not a directory", agent.SkillRoot))
report.Missing = append(report.Missing, expectedNames...)
report.Adds = append(report.Adds, expectedNames...)
sortReportLists(&report)
report.Synced = false
return report, nil
}
}

for _, name := range expectedNames {
linkPath := filepath.Join(agent.SkillRoot, name)
entry, ok := entryMap[name]
if !ok || rootMissing {
report.Missing = append(report.Missing, name)
report.Adds = append(report.Adds, name)
continue
entryMap := make(map[string]fs.DirEntry)
if !rootMissing {
entries, err := os.ReadDir(agent.SkillRoot)
if err != nil {
return agentReport{}, fmt.Errorf("read %s: %w", agent.SkillRoot, err)
}
for _, entry := range entries {
entryMap[entry.Name()] = entry
}
}

mode := entry.Type()
if mode&os.ModeSymlink == 0 {
matches, err := treesEqual(linkPath, expected[name])
for _, name := range expectedNames {
linkPath := filepath.Join(agent.SkillRoot, name)
entry, ok := entryMap[name]
if !ok || rootMissing {
report.Missing = append(report.Missing, name)
report.Adds = append(report.Adds, name)
continue
}

mode := entry.Type()
if mode&os.ModeSymlink == 0 {
matches, err := treesEqual(linkPath, expected[name])
if err != nil {
return agentReport{}, fmt.Errorf("compare %s with %s: %w", linkPath, expected[name], err)
}
if matches {
report.Managed = append(report.Managed, name)
continue
}
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but differs from canonical content and is not a symlink", linkPath))
continue
}

rawTarget, err := os.Readlink(linkPath)
if err != nil {
return agentReport{}, fmt.Errorf("compare %s with %s: %w", linkPath, expected[name], err)
return agentReport{}, fmt.Errorf("readlink %s: %w", linkPath, err)
}
if matches {
if linkMatches(linkPath, rawTarget, expected[name]) {
report.Managed = append(report.Managed, name)
continue
}
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but differs from canonical content and is not a symlink", linkPath))
continue
}

rawTarget, err := os.Readlink(linkPath)
if err != nil {
return agentReport{}, fmt.Errorf("readlink %s: %w", linkPath, err)
}
if linkMatches(linkPath, rawTarget, expected[name]) {
report.Managed = append(report.Managed, name)
continue
report.Drifted = append(report.Drifted, name)
report.Updates = append(report.Updates, name)
}

report.Drifted = append(report.Drifted, name)
report.Updates = append(report.Updates, name)
}

if !rootMissing {
for name, entry := range entryMap {
if _, ok := expected[name]; ok {
continue
}
path := filepath.Join(agent.SkillRoot, name)
if entry.Type()&os.ModeSymlink != 0 {
rawTarget, err := os.Readlink(path)
if err != nil {
return agentReport{}, fmt.Errorf("readlink %s: %w", path, err)
}
if isManagedSkillLink(path, rawTarget, repoRoot, agentsSkillRoot) || isExternalSkillLink(path, rawTarget, home) {
report.StaleManaged = append(report.StaleManaged, name)
report.Removes = append(report.Removes, name)
if !rootMissing {
for name, entry := range entryMap {
if _, ok := expected[name]; ok {
continue
}
path := filepath.Join(agent.SkillRoot, name)
if entry.Type()&os.ModeSymlink != 0 {
rawTarget, err := os.Readlink(path)
if err != nil {
return agentReport{}, fmt.Errorf("readlink %s: %w", path, err)
}
if isManagedSkillLink(path, rawTarget, repoRoot, agentsSkillRoot) || isExternalSkillLink(path, rawTarget, home) {
report.StaleManaged = append(report.StaleManaged, name)
report.Removes = append(report.Removes, name)
continue
}
}
report.External = append(report.External, name)
}
report.External = append(report.External, name)
}
}

Expand Down
Loading
Loading