Skip to content

Commit 0b0d7e0

Browse files
Add OpenCode harness support (#120)
* docs: add opencode research fact sheet and implementation spec * feat: add OpenCode harness adapter with native skills, roles, and MCP * test: cover OpenCode adapter roles, MCP translation, native skills, and doctor * docs: OpenCode as managed harness in README, harness map, and landing
1 parent 9620df5 commit 0b0d7e0

14 files changed

Lines changed: 1146 additions & 105 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Dotfiles for your AI agents.
44

5-
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.
5+
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.
66

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

@@ -75,11 +75,14 @@ Exactly four surfaces, each rendered into the harness's own format — dotagents
7575
| Codex | yes | yes | yes | yes |
7676
| Factory Droid | yes | yes | yes | yes |
7777
| Hermes | yes | -- | yes | yes |
78+
| OpenCode | yes† | yes | yes | -- |
7879
| Pi* | yes | --* | --* | -- |
7980

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

82-
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.
83+
† 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.
84+
85+
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.
8386

8487
## Working with skills
8588

cmd/dotagents/agents.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,17 @@ const (
2626
const agentRoleMarkdownExt = ".md"
2727

2828
type agentRole struct {
29-
Name string `yaml:"name"`
30-
Description string `yaml:"description"`
31-
Model string `yaml:"model"`
32-
Effort string `yaml:"effort"`
33-
Tools []string `yaml:"tools"`
34-
Color string `yaml:"color"`
35-
Instructions string `yaml:"-"`
36-
Source string `yaml:"-"`
37-
Codex codexRoleOptions `yaml:"codex"`
38-
Droid droidRoleOptions `yaml:"droid"`
29+
Name string `yaml:"name"`
30+
Description string `yaml:"description"`
31+
Model string `yaml:"model"`
32+
Effort string `yaml:"effort"`
33+
Tools []string `yaml:"tools"`
34+
Color string `yaml:"color"`
35+
Instructions string `yaml:"-"`
36+
Source string `yaml:"-"`
37+
Codex codexRoleOptions `yaml:"codex"`
38+
Droid droidRoleOptions `yaml:"droid"`
39+
Opencode opencodeRoleOptions `yaml:"opencode"`
3940
}
4041

4142
func (role *agentRole) UnmarshalYAML(value *yaml.Node) error {
@@ -74,6 +75,10 @@ func (role *agentRole) UnmarshalYAML(value *yaml.Node) error {
7475
if err := node.Decode(&role.Droid); err != nil {
7576
return err
7677
}
78+
case "opencode":
79+
if err := node.Decode(&role.Opencode); err != nil {
80+
return err
81+
}
7782
case "tools":
7883
tools, err := decodeRoleTools(node)
7984
if err != nil {
@@ -117,6 +122,12 @@ type droidRoleOptions struct {
117122
Tools []string `yaml:"tools"`
118123
}
119124

125+
type opencodeRoleOptions struct {
126+
Model string `yaml:"model"`
127+
Temperature string `yaml:"temperature"`
128+
Mode string `yaml:"mode"`
129+
}
130+
120131
var droidToolMapping = map[string][]string{
121132
"bash": {"Execute"},
122133
"edit": {"Edit"},

cmd/dotagents/doctor.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const (
2121
agentCodex = "codex"
2222
agentDroid = "droid"
2323
agentHermes = "hermes"
24+
agentOpenCode = "opencode"
2425
agentPi = "pi"
2526
agentOMP = "omp"
2627
dotagentsSkillsPathValue = "~/.agents/skills"

cmd/dotagents/harness.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ type Harness struct {
5858
// InspectSkills is called instead of the generic symlink inspector
5959
// when Skills == SkillsConfigDriven.
6060
InspectSkills InspectSkillsFunc
61+
// SkillsNativeRoot, when non-nil and returning true, marks that this
62+
// harness reads dotagents skills directly from the config root, so no
63+
// per-harness skill mirror is created. Only consulted for SkillsSymlink
64+
// harnesses.
65+
SkillsNativeRoot func(repoRoot string, home string) bool
6166
// Setup patches the agent's config during `dotagents setup`.
6267
// nil means no patching needed.
6368
Setup SetupFunc
@@ -204,6 +209,24 @@ func initHarnesses() {
204209
TrailerExample: "Co-Authored-By: hermes[bot] <hermes[bot]@users.noreply.github.com>",
205210
},
206211

212+
agentOpenCode: {
213+
Skills: SkillsSymlink,
214+
SkillsNativeRoot: openCodeReadsAgentsSkills,
215+
MCP: mcpTargetPtr(mcpTarget{
216+
agentName: agentOpenCode,
217+
configPath: openCodeConfigPath,
218+
inspect: inspectOpenCodeMCPServer,
219+
patch: patchOpenCodeMCPServer,
220+
read: readOpenCodeMCPServer,
221+
rootKey: "mcp",
222+
}),
223+
Roles: &RolesCapability{Extension: ".md", Render: renderOpenCodeAgentRole},
224+
IntegrationNote: "skills read natively from ~/.agents/skills (mirrored into the skill root only when the config root differs)",
225+
DoctorChecks: []DoctorCheck{
226+
{Name: "opencode duplicate skills", Run: checkOpenCodeDuplicateSkills},
227+
},
228+
},
229+
207230
agentPi: {
208231
Detect: detectVanillaPi,
209232
Skills: SkillsSymlink,

cmd/dotagents/inspect.go

Lines changed: 73 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -196,88 +196,94 @@ func inspectAgent(agent agentConfig, expected map[string]string, repoRoot string
196196
return h.InspectSkills(agent, expected, agentsSkillRoot, cfg, home)
197197
}
198198

199-
expectedNames := sortedKeys(expected)
200-
rootInfo, err := os.Stat(agent.SkillRoot)
201-
rootMissing := false
202-
switch {
203-
case errors.Is(err, fs.ErrNotExist):
204-
rootMissing = true
205-
case err != nil:
206-
return agentReport{}, fmt.Errorf("stat %s: %w", agent.SkillRoot, err)
207-
case !rootInfo.IsDir():
208-
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but is not a directory", agent.SkillRoot))
209-
report.Missing = append(report.Missing, expectedNames...)
210-
report.Adds = append(report.Adds, expectedNames...)
211-
sortReportLists(&report)
212-
report.Synced = false
213-
return report, nil
214-
}
215-
216-
entryMap := make(map[string]fs.DirEntry)
217-
if !rootMissing {
218-
entries, err := os.ReadDir(agent.SkillRoot)
219-
if err != nil {
220-
return agentReport{}, fmt.Errorf("read %s: %w", agent.SkillRoot, err)
221-
}
222-
for _, entry := range entries {
223-
entryMap[entry.Name()] = entry
199+
if h != nil && h.SkillsNativeRoot != nil && h.SkillsNativeRoot(repoRoot, home) {
200+
// Skills are consumed directly from the config root; no per-harness
201+
// mirror is created, so every expected skill is already managed.
202+
report.Managed = append(report.Managed, sortedKeys(expected)...)
203+
} else {
204+
expectedNames := sortedKeys(expected)
205+
rootInfo, err := os.Stat(agent.SkillRoot)
206+
rootMissing := false
207+
switch {
208+
case errors.Is(err, fs.ErrNotExist):
209+
rootMissing = true
210+
case err != nil:
211+
return agentReport{}, fmt.Errorf("stat %s: %w", agent.SkillRoot, err)
212+
case !rootInfo.IsDir():
213+
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but is not a directory", agent.SkillRoot))
214+
report.Missing = append(report.Missing, expectedNames...)
215+
report.Adds = append(report.Adds, expectedNames...)
216+
sortReportLists(&report)
217+
report.Synced = false
218+
return report, nil
224219
}
225-
}
226220

227-
for _, name := range expectedNames {
228-
linkPath := filepath.Join(agent.SkillRoot, name)
229-
entry, ok := entryMap[name]
230-
if !ok || rootMissing {
231-
report.Missing = append(report.Missing, name)
232-
report.Adds = append(report.Adds, name)
233-
continue
221+
entryMap := make(map[string]fs.DirEntry)
222+
if !rootMissing {
223+
entries, err := os.ReadDir(agent.SkillRoot)
224+
if err != nil {
225+
return agentReport{}, fmt.Errorf("read %s: %w", agent.SkillRoot, err)
226+
}
227+
for _, entry := range entries {
228+
entryMap[entry.Name()] = entry
229+
}
234230
}
235231

236-
mode := entry.Type()
237-
if mode&os.ModeSymlink == 0 {
238-
matches, err := treesEqual(linkPath, expected[name])
232+
for _, name := range expectedNames {
233+
linkPath := filepath.Join(agent.SkillRoot, name)
234+
entry, ok := entryMap[name]
235+
if !ok || rootMissing {
236+
report.Missing = append(report.Missing, name)
237+
report.Adds = append(report.Adds, name)
238+
continue
239+
}
240+
241+
mode := entry.Type()
242+
if mode&os.ModeSymlink == 0 {
243+
matches, err := treesEqual(linkPath, expected[name])
244+
if err != nil {
245+
return agentReport{}, fmt.Errorf("compare %s with %s: %w", linkPath, expected[name], err)
246+
}
247+
if matches {
248+
report.Managed = append(report.Managed, name)
249+
continue
250+
}
251+
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but differs from canonical content and is not a symlink", linkPath))
252+
continue
253+
}
254+
255+
rawTarget, err := os.Readlink(linkPath)
239256
if err != nil {
240-
return agentReport{}, fmt.Errorf("compare %s with %s: %w", linkPath, expected[name], err)
257+
return agentReport{}, fmt.Errorf("readlink %s: %w", linkPath, err)
241258
}
242-
if matches {
259+
if linkMatches(linkPath, rawTarget, expected[name]) {
243260
report.Managed = append(report.Managed, name)
244261
continue
245262
}
246-
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s exists but differs from canonical content and is not a symlink", linkPath))
247-
continue
248-
}
249263

250-
rawTarget, err := os.Readlink(linkPath)
251-
if err != nil {
252-
return agentReport{}, fmt.Errorf("readlink %s: %w", linkPath, err)
253-
}
254-
if linkMatches(linkPath, rawTarget, expected[name]) {
255-
report.Managed = append(report.Managed, name)
256-
continue
264+
report.Drifted = append(report.Drifted, name)
265+
report.Updates = append(report.Updates, name)
257266
}
258267

259-
report.Drifted = append(report.Drifted, name)
260-
report.Updates = append(report.Updates, name)
261-
}
262-
263-
if !rootMissing {
264-
for name, entry := range entryMap {
265-
if _, ok := expected[name]; ok {
266-
continue
267-
}
268-
path := filepath.Join(agent.SkillRoot, name)
269-
if entry.Type()&os.ModeSymlink != 0 {
270-
rawTarget, err := os.Readlink(path)
271-
if err != nil {
272-
return agentReport{}, fmt.Errorf("readlink %s: %w", path, err)
273-
}
274-
if isManagedSkillLink(path, rawTarget, repoRoot, agentsSkillRoot) || isExternalSkillLink(path, rawTarget, home) {
275-
report.StaleManaged = append(report.StaleManaged, name)
276-
report.Removes = append(report.Removes, name)
268+
if !rootMissing {
269+
for name, entry := range entryMap {
270+
if _, ok := expected[name]; ok {
277271
continue
278272
}
273+
path := filepath.Join(agent.SkillRoot, name)
274+
if entry.Type()&os.ModeSymlink != 0 {
275+
rawTarget, err := os.Readlink(path)
276+
if err != nil {
277+
return agentReport{}, fmt.Errorf("readlink %s: %w", path, err)
278+
}
279+
if isManagedSkillLink(path, rawTarget, repoRoot, agentsSkillRoot) || isExternalSkillLink(path, rawTarget, home) {
280+
report.StaleManaged = append(report.StaleManaged, name)
281+
report.Removes = append(report.Removes, name)
282+
continue
283+
}
284+
}
285+
report.External = append(report.External, name)
279286
}
280-
report.External = append(report.External, name)
281287
}
282288
}
283289

0 commit comments

Comments
 (0)