diff --git a/README.md b/README.md index 8e961a7..78dbe1d 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/cmd/dotagents/agents.go b/cmd/dotagents/agents.go index abff7d1..95528aa 100644 --- a/cmd/dotagents/agents.go +++ b/cmd/dotagents/agents.go @@ -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 { @@ -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 { @@ -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"}, diff --git a/cmd/dotagents/doctor.go b/cmd/dotagents/doctor.go index eed6f28..04fd5da 100644 --- a/cmd/dotagents/doctor.go +++ b/cmd/dotagents/doctor.go @@ -21,6 +21,7 @@ const ( agentCodex = "codex" agentDroid = "droid" agentHermes = "hermes" + agentOpenCode = "opencode" agentPi = "pi" agentOMP = "omp" dotagentsSkillsPathValue = "~/.agents/skills" diff --git a/cmd/dotagents/harness.go b/cmd/dotagents/harness.go index ab0de33..ff29a31 100644 --- a/cmd/dotagents/harness.go +++ b/cmd/dotagents/harness.go @@ -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 @@ -204,6 +209,24 @@ func initHarnesses() { TrailerExample: "Co-Authored-By: hermes[bot] ", }, + 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}, + 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, diff --git a/cmd/dotagents/inspect.go b/cmd/dotagents/inspect.go index 2937ea9..64cb3ce 100644 --- a/cmd/dotagents/inspect.go +++ b/cmd/dotagents/inspect.go @@ -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) } } diff --git a/cmd/dotagents/opencode.go b/cmd/dotagents/opencode.go new file mode 100644 index 0000000..1c004af --- /dev/null +++ b/cmd/dotagents/opencode.go @@ -0,0 +1,277 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// openCodeConfigDir resolves the OpenCode global config directory, honoring +// $XDG_CONFIG_HOME when set (per the OpenCode config docs) and falling back to +// ~/.config/opencode otherwise. +func openCodeConfigDir(home string) string { + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + return filepath.Join(xdg, "opencode") + } + return filepath.Join(home, ".config", "opencode") +} + +// openCodeConfigPath returns the shared OpenCode config file that also holds the +// managed `mcp` block. +func openCodeConfigPath(home string) string { + return filepath.Join(openCodeConfigDir(home), "opencode.json") +} + +// openCodeReadsAgentsSkills reports whether OpenCode already reads dotagents +// skills directly from the config root. OpenCode natively loads +// ~/.agents/skills, so when the dotagents config root IS ~/.agents there is no +// need to mirror skills into ~/.config/opencode/skills (doing so would +// double-list every skill). +func openCodeReadsAgentsSkills(repoRoot string, home string) bool { + agentsRoot := filepath.Join(home, ".agents") + if filepath.Clean(repoRoot) == filepath.Clean(agentsRoot) { + return true + } + return sameResolvedPath(repoRoot, agentsRoot) +} + +func renderOpenCodeAgentRole(role agentRole) string { + mode := strings.TrimSpace(role.Opencode.Mode) + if mode == "" { + mode = "subagent" + } + + var b strings.Builder + b.WriteString("---\n") + writeYAMLScalar(&b, "description", role.Description) + b.WriteString("mode: ") + b.WriteString(mode) + b.WriteString("\n") + if model := strings.TrimSpace(role.Opencode.Model); model != "" { + writeYAMLScalar(&b, "model", model) + } + if temperature := strings.TrimSpace(role.Opencode.Temperature); temperature != "" { + b.WriteString("temperature: ") + b.WriteString(temperature) + b.WriteString("\n") + } + b.WriteString("---\n\n") + b.WriteString("\n\n") + b.WriteString(role.Instructions) + b.WriteString("\n") + return b.String() +} + +// openCodeCommandArray folds the canonical command + args into the single +// command array OpenCode expects for a local (stdio) MCP server. +func openCodeCommandArray(server mcpServerConfig) []string { + out := make([]string, 0, len(server.Args)+1) + out = append(out, server.Command) + out = append(out, server.Args...) + return out +} + +func inspectOpenCodeMCPServer(target mcpTarget, server mcpServerConfig, home string) (string, error) { + configPath := target.configPath(home) + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return stateMissing, nil + } + return stateMissing, fmt.Errorf("read %s: %w", configPath, err) + } + var raw map[string]interface{} + if err := parseJSONConfig(configPath, data, &raw); err != nil { + return stateMissing, fmt.Errorf("parse %s: %w", configPath, err) + } + servers, ok := asMap(raw[target.rootKey]) + if !ok { + return stateMissing, nil + } + entryRaw, ok := servers[server.Name] + if !ok { + return stateMissing, nil + } + entry, ok := asMap(entryRaw) + if !ok { + return stateDrifted, nil + } + if openCodeMCPEntryMatches(entry, server) { + return stateSynced, nil + } + return stateDrifted, nil +} + +func openCodeMCPEntryMatches(entry map[string]interface{}, server mcpServerConfig) bool { + if kind, _ := entry["type"].(string); kind != "local" { + return false + } + command, ok := toStringSlice(entry["command"]) + if !ok || !stringSlicesEqual(command, openCodeCommandArray(server)) { + return false + } + if enabled, ok := entry["enabled"].(bool); !ok || !enabled { + return false + } + if len(server.Env) > 0 { + envMap, ok := asMap(entry["environment"]) + if !ok { + return false + } + for key, expected := range server.Env { + actual, ok := envMap[key].(string) + if !ok || actual != expected { + return false + } + } + } + return true +} + +func patchOpenCodeMCPServer(target mcpTarget, server mcpServerConfig, home string) error { + configPath := target.configPath(home) + data, err := os.ReadFile(configPath) + raw := map[string]interface{}{} + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", configPath, err) + } + } else if err := parseJSONConfig(configPath, data, &raw); err != nil { + return fmt.Errorf("parse %s: %w", configPath, err) + } + + servers, _ := asMap(raw[target.rootKey]) + if servers == nil { + servers = map[string]interface{}{} + } + entry, _ := asMap(servers[server.Name]) + if entry == nil { + entry = map[string]interface{}{} + } + entry["type"] = "local" + command := make([]interface{}, 0, len(server.Args)+1) + for _, part := range openCodeCommandArray(server) { + command = append(command, part) + } + entry["command"] = command + if len(server.Env) > 0 { + envMap, _ := asMap(entry["environment"]) + if envMap == nil { + envMap = map[string]interface{}{} + } + for key, value := range server.Env { + envMap[key] = value + } + entry["environment"] = envMap + } + entry["enabled"] = true + servers[server.Name] = entry + raw[target.rootKey] = servers + + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return fmt.Errorf("marshal %s: %w", configPath, err) + } + out = append(out, '\n') + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(configPath), err) + } + if err := os.WriteFile(configPath, out, 0o644); err != nil { + return fmt.Errorf("write %s: %w", configPath, err) + } + return nil +} + +func readOpenCodeMCPServer(target mcpTarget, name string, home string) (mcpServerConfig, error) { + configPath := target.configPath(home) + data, err := os.ReadFile(configPath) + if err != nil { + return mcpServerConfig{}, fmt.Errorf("read %s: %w", configPath, err) + } + var raw map[string]interface{} + if err := parseJSONConfig(configPath, data, &raw); err != nil { + return mcpServerConfig{}, fmt.Errorf("parse %s: %w", configPath, err) + } + servers, ok := asMap(raw[target.rootKey]) + if !ok { + return mcpServerConfig{}, fmt.Errorf("MCP server %q not found in %s", name, target.agentName) + } + entry, ok := asMap(servers[name]) + if !ok { + return mcpServerConfig{}, fmt.Errorf("MCP server %q not found in %s", name, target.agentName) + } + if kind, _ := entry["type"].(string); kind != "" && kind != "local" { + return mcpServerConfig{}, fmt.Errorf("MCP server %q in %s is not a local stdio server", name, target.agentName) + } + command, ok := toStringSlice(entry["command"]) + if !ok || len(command) == 0 || strings.TrimSpace(command[0]) == "" { + return mcpServerConfig{}, fmt.Errorf("MCP server %q in %s has no stdio command", name, target.agentName) + } + var args []string + if len(command) > 1 { + args = append([]string{}, command[1:]...) + } + var env map[string]string + if envRaw, ok := entry["environment"]; ok { + envMap, ok := asMap(envRaw) + if !ok { + return mcpServerConfig{}, fmt.Errorf("MCP server %q environment is not a map", name) + } + env = make(map[string]string, len(envMap)) + for key, value := range envMap { + str, ok := value.(string) + if !ok { + return mcpServerConfig{}, fmt.Errorf("MCP server %q environment %s is not a string", name, key) + } + env[key] = str + } + } + return mcpServerConfig{Name: name, Enabled: true, Command: command[0], Args: args, Env: env}, nil +} + +// checkOpenCodeDuplicateSkills warns when a skill of the same name exists in +// both ~/.agents/skills and ~/.config/opencode/skills, which OpenCode would list +// twice because it reads both roots. +func checkOpenCodeDuplicateSkills(_ string, home string, cfg config) checkResult { + const name = "opencode duplicate skills" + if !isAgentDetected(cfg, agentOpenCode) { + return checkResult{name, checkStatusPass, agentOpenCode + " not detected, skipped"} + } + agentsSkills := openCodeSkillDirNames(filepath.Join(home, ".agents", "skills")) + mirrorSkills := openCodeSkillDirNames(filepath.Join(openCodeConfigDir(home), "skills")) + var dups []string + for skill := range mirrorSkills { + if agentsSkills[skill] { + dups = append(dups, skill) + } + } + if len(dups) > 0 { + sort.Strings(dups) + return checkResult{name, checkStatusWarn, fmt.Sprintf("%s listed in both ~/.agents/skills and %s", strings.Join(dups, ", "), filepath.Join(openCodeConfigDir(home), "skills"))} + } + return checkResult{name, checkStatusPass, "no duplicate skill listings"} +} + +func openCodeSkillDirNames(root string) map[string]bool { + names := map[string]bool{} + entries, err := os.ReadDir(root) + if err != nil { + return names + } + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + if hasFile(filepath.Join(root, entry.Name(), "SKILL.md")) { + names[entry.Name()] = true + } + } + return names +} diff --git a/cmd/dotagents/opencode_test.go b/cmd/dotagents/opencode_test.go new file mode 100644 index 0000000..ad2caaf --- /dev/null +++ b/cmd/dotagents/opencode_test.go @@ -0,0 +1,389 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestOpenCodeHarnessCapabilitiesAndDefaults(t *testing.T) { + h := harnessFor(agentOpenCode) + if h == nil { + t.Fatal("OpenCode harness is not registered") + } + if h.Skills != SkillsSymlink { + t.Fatalf("OpenCode skills capability = %v, want symlink", h.Skills) + } + if h.SkillsNativeRoot == nil { + t.Fatal("OpenCode must declare a native skill root predicate") + } + if h.MCP == nil { + t.Fatal("OpenCode does not expose MCP support") + } + if h.Roles == nil || h.Roles.Extension != ".md" { + t.Fatalf("OpenCode roles capability = %#v, want Markdown roles", h.Roles) + } + if h.Hooks != nil { + t.Fatal("OpenCode must not declare hook support (JS plugin surface only)") + } + + var found *agentConfig + for _, agent := range defaultAgentConfigs() { + if agent.Name == agentOpenCode { + a := agent + found = &a + } + } + if found == nil { + t.Fatal("opencode missing from defaultAgentConfigs") + } + if found.SkillRoot != "~/.config/opencode/skills" || found.AgentRoot != "~/.config/opencode/agents" || found.Detect != "opencode" { + t.Fatalf("opencode default config = %#v", *found) + } +} + +func TestOpenCodeDetectionFromPATH(t *testing.T) { + fakePath(t, "opencode") + detected, err := detectDefaultAgents("") + if err != nil { + t.Fatal(err) + } + var names []string + for _, agent := range detected { + names = append(names, agent.Name) + } + if strings.Join(names, ",") != agentOpenCode { + t.Fatalf("detected agents = %v, want opencode", names) + } +} + +func TestOpenCodeRoleRenderDefaultMode(t *testing.T) { + role := agentRole{ + Name: "researcher", + Description: "Find reliable evidence", + Model: "opus", + Effort: "high", + Instructions: "Compare the sources.", + Source: "agents/researcher.md", + } + content := renderOpenCodeAgentRole(role) + + if strings.Contains(content, "name:") { + t.Fatalf("opencode role should not emit a name field (filename is the name):\n%s", content) + } + if !strings.Contains(content, "description: \"Find reliable evidence\"") { + t.Fatalf("opencode role missing description:\n%s", content) + } + if !strings.Contains(content, "mode: subagent") { + t.Fatalf("opencode role missing default mode:\n%s", content) + } + if strings.Contains(content, "model:") || strings.Contains(content, "temperature:") { + t.Fatalf("opencode role should omit model/temperature without an override:\n%s", content) + } + if !strings.Contains(content, generatedAgentMarker) { + t.Fatalf("opencode role missing managed marker:\n%s", content) + } + if !strings.Contains(content, "Compare the sources.") { + t.Fatalf("opencode role dropped instructions:\n%s", content) + } +} + +func TestOpenCodeRoleRenderHonorsOverride(t *testing.T) { + role := agentRole{ + Name: "reviewer", + Description: "Reviews code", + Instructions: "Review carefully.", + Opencode: opencodeRoleOptions{Model: "anthropic/claude-sonnet-4", Temperature: "0.1", Mode: "primary"}, + } + content := renderOpenCodeAgentRole(role) + if !strings.Contains(content, "mode: primary") { + t.Fatalf("opencode override mode not applied:\n%s", content) + } + if !strings.Contains(content, "model: \"anthropic/claude-sonnet-4\"") { + t.Fatalf("opencode override model not applied:\n%s", content) + } + if !strings.Contains(content, "temperature: 0.1") { + t.Fatalf("opencode override temperature not applied:\n%s", content) + } +} + +func TestOpenCodeRoleRenderFromCanonicalMarkdown(t *testing.T) { + repoRoot := t.TempDir() + writeSyncTestFile(t, filepath.Join(repoRoot, "agents", "planner.md"), []byte(`--- +name: planner +description: Plans work +opencode: + mode: all + temperature: 0.2 +--- + +Plan the work. +`)) + agent := agentConfig{Name: agentOpenCode, AgentRoot: filepath.Join(t.TempDir(), "agents")} + expected, err := expectedAgentRoles(repoRoot, agent) + if err != nil { + t.Fatal(err) + } + rendered, ok := expected["planner"] + if !ok { + t.Fatalf("planner role not rendered: %#v", expected) + } + if !strings.Contains(rendered.Content, "mode: all") || !strings.Contains(rendered.Content, "temperature: 0.2") { + t.Fatalf("canonical opencode override not honored:\n%s", rendered.Content) + } +} + +func TestOpenCodeMCPFreshFilePatchAndInspect(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home := t.TempDir() + server := testMCPServer() + server.Agents = []string{agentOpenCode} + + if err := patchMCPServer(agentOpenCode, server, home); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(home, ".config", "opencode", "opencode.json") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("opencode.json not written: %v", err) + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + mcp, _ := raw["mcp"].(map[string]interface{}) + entry, _ := mcp["linkedin"].(map[string]interface{}) + if entry["type"] != "local" || entry["enabled"] != true { + t.Fatalf("opencode MCP entry shape = %#v", entry) + } + command, _ := toStringSlice(entry["command"]) + if !stringSlicesEqual(command, []string{"uvx", "linkedin-scraper-mcp==4.13.2"}) { + t.Fatalf("opencode command array = %#v, want folded command+args", entry["command"]) + } + envMap, _ := asMap(entry["environment"]) + if envMap["UV_HTTP_TIMEOUT"] != "300" { + t.Fatalf("opencode environment = %#v", entry["environment"]) + } + if _, ok := entry["env"]; ok { + t.Fatalf("opencode must use environment, not env: %#v", entry) + } + + state, err := inspectMCPServer(agentOpenCode, server, home) + if err != nil { + t.Fatal(err) + } + if state != stateSynced { + t.Fatalf("opencode MCP state after patch = %q, want synced", state) + } +} + +func TestOpenCodeMCPPreservesUnmanagedKeysAndServers(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home := t.TempDir() + configPath := filepath.Join(home, ".config", "opencode", "opencode.json") + writeSyncTestFile(t, configPath, []byte(`{ + "$schema": "https://opencode.ai/config.json", + "theme": "opencode", + "mcp": { + "existing-remote": {"type": "remote", "url": "https://example.test/mcp", "enabled": true}, + "existing-local": {"type": "local", "command": ["node", "server.js"], "enabled": true} + } +}`)) + + server := testMCPServer() + server.Agents = []string{agentOpenCode} + if err := patchMCPServer(agentOpenCode, server, home); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if raw["theme"] != "opencode" || raw["$schema"] != "https://opencode.ai/config.json" { + t.Fatalf("unmanaged top-level keys not preserved: %#v", raw) + } + mcp, _ := raw["mcp"].(map[string]interface{}) + if _, ok := mcp["existing-remote"]; !ok { + t.Fatalf("unmanaged remote server dropped: %#v", mcp) + } + if _, ok := mcp["existing-local"]; !ok { + t.Fatalf("unmanaged local server dropped: %#v", mcp) + } + if _, ok := mcp["linkedin"]; !ok { + t.Fatalf("managed server not added: %#v", mcp) + } +} + +func TestOpenCodeMCPImportReverseTranslation(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home := t.TempDir() + configPath := filepath.Join(home, ".config", "opencode", "opencode.json") + writeSyncTestFile(t, configPath, []byte(`{ + "mcp": { + "search": {"type": "local", "command": ["uvx", "search-mcp", "--fast"], "environment": {"TOKEN": "${TOKEN}"}, "enabled": true}, + "remote-only": {"type": "remote", "url": "https://example.test/mcp"} + } +}`)) + + agent := agentConfig{Name: agentOpenCode, Enabled: true} + candidates, err := scanNativeMCP(agent, config{Version: 1}, home) + if err != nil { + t.Fatal(err) + } + if len(candidates) != 1 { + t.Fatalf("import candidates = %#v, want only the local server", candidates) + } + got := candidates[0].Server + if got.Name != "search" || got.Command != "uvx" || !stringSlicesEqual(got.Args, []string{"search-mcp", "--fast"}) { + t.Fatalf("reverse translation = %#v", got) + } + if got.Env["TOKEN"] != "${TOKEN}" { + t.Fatalf("environment not reverse-translated to env: %#v", got.Env) + } +} + +func TestOpenCodeConfigPathHonorsXDG(t *testing.T) { + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + home := t.TempDir() + if got, want := openCodeConfigPath(home), filepath.Join(xdg, "opencode", "opencode.json"); got != want { + t.Fatalf("opencode config path = %q, want %q", got, want) + } + t.Setenv("XDG_CONFIG_HOME", "") + if got, want := openCodeConfigPath(home), filepath.Join(home, ".config", "opencode", "opencode.json"); got != want { + t.Fatalf("opencode config path without XDG = %q, want %q", got, want) + } +} + +func TestOpenCodeSkillsNativeReadDoesNotMirror(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home := t.TempDir() + repoRoot := filepath.Join(home, ".agents") + writeSyncTestFile(t, filepath.Join(repoRoot, "skills", "grilling", "SKILL.md"), []byte("---\nname: grilling\n---\n")) + + agent := agentConfig{Name: agentOpenCode, Enabled: true, SkillRoot: filepath.Join(home, ".config", "opencode", "skills")} + expected := map[string]string{"grilling": filepath.Join(repoRoot, "skills", "grilling")} + cfg := config{Version: 1, Agents: []agentConfig{agent}} + + report, err := inspectAgent(agent, expected, repoRoot, filepath.Join(repoRoot, "skills"), cfg, home) + if err != nil { + t.Fatal(err) + } + if len(report.Adds) != 0 || len(report.Missing) != 0 { + t.Fatalf("native read must have no skill adds: %#v", report) + } + if !stringInSlice("grilling", report.Managed) { + t.Fatalf("native read must report skill as managed: %#v", report.Managed) + } + + if err := applyAgentSync([]agentReport{report}, cfg, repoRoot, home); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(agent.SkillRoot); !os.IsNotExist(err) { + t.Fatalf("native read must not create a skill mirror at %s (err=%v)", agent.SkillRoot, err) + } +} + +func TestOpenCodeSkillsMirrorWhenConfigRootDiffers(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home := t.TempDir() + repoRoot := filepath.Join(home, "custom-agents") + writeSyncTestFile(t, filepath.Join(repoRoot, "skills", "grilling", "SKILL.md"), []byte("---\nname: grilling\n---\n")) + + agent := agentConfig{Name: agentOpenCode, Enabled: true, SkillRoot: filepath.Join(home, ".config", "opencode", "skills")} + expected := map[string]string{"grilling": filepath.Join(repoRoot, "skills", "grilling")} + cfg := config{Version: 1, Agents: []agentConfig{agent}} + + report, err := inspectAgent(agent, expected, repoRoot, filepath.Join(repoRoot, "skills"), cfg, home) + if err != nil { + t.Fatal(err) + } + if !stringInSlice("grilling", report.Adds) { + t.Fatalf("custom root must mirror skills: %#v", report.Adds) + } + if err := applyAgentSync([]agentReport{report}, cfg, repoRoot, home); err != nil { + t.Fatal(err) + } + linkPath := filepath.Join(agent.SkillRoot, "grilling") + if !sameResolvedPath(linkPath, filepath.Join(repoRoot, "skills", "grilling")) { + rawTarget, _ := os.Readlink(linkPath) + t.Fatalf("custom-root skill link %s -> %q, want %s", linkPath, rawTarget, filepath.Join(repoRoot, "skills", "grilling")) + } +} + +func TestOpenCodeDoctorWarnsOnDuplicateSkills(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home := t.TempDir() + dir := fakePath(t, "opencode") + _ = dir + cfg := config{Version: 1, Agents: []agentConfig{{Name: agentOpenCode, Enabled: true, Detect: "opencode", SkillRoot: filepath.Join(home, ".config", "opencode", "skills")}}} + + // no duplicates yet + res := checkOpenCodeDuplicateSkills("", home, cfg) + if res.status != checkStatusPass { + t.Fatalf("expected pass with no duplicates, got %#v", res) + } + + writeSyncTestFile(t, filepath.Join(home, ".agents", "skills", "grilling", "SKILL.md"), []byte("---\nname: grilling\n---\n")) + writeSyncTestFile(t, filepath.Join(home, ".config", "opencode", "skills", "grilling", "SKILL.md"), []byte("---\nname: grilling\n---\n")) + + res = checkOpenCodeDuplicateSkills("", home, cfg) + if res.status != checkStatusWarn || !strings.Contains(res.detail, "grilling") { + t.Fatalf("expected duplicate warning, got %#v", res) + } +} + +func TestOpenCodeSetupImportScansAgentsAndMCP(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home := t.TempDir() + root := t.TempDir() + writeSyncTestFile(t, filepath.Join(home, ".config", "opencode", "agents", "helper.md"), []byte(`--- +description: Helps out +mode: subagent +--- + +Be helpful. +`)) + writeSyncTestFile(t, filepath.Join(home, ".config", "opencode", "opencode.json"), []byte(`{ + "mcp": { + "search": {"type": "local", "command": ["uvx", "search-mcp"], "enabled": true} + } +}`)) + + detected := []agentConfig{{Name: agentOpenCode, Enabled: true, SkillRoot: filepath.Join(home, ".config", "opencode", "skills"), AgentRoot: filepath.Join(home, ".config", "opencode", "agents")}} + skills, roles, mcps, err := scanNativeImports(config{Version: 1, Agents: detected}, detected, root, home) + if err != nil { + t.Fatal(err) + } + if len(skills) != 0 { + t.Fatalf("unexpected skill candidates: %#v", skills) + } + if len(roles) != 1 || roles[0].TargetName != "helper" { + t.Fatalf("opencode role import candidates = %#v", roles) + } + if len(mcps) != 1 || mcps[0].Name != "search" { + t.Fatalf("opencode MCP import candidates = %#v", mcps) + } + data, err := roles[0].Convert() + if err != nil { + t.Fatal(err) + } + var fm struct { + Description string `yaml:"description"` + } + parts := strings.SplitN(string(data), "---\n", 3) + if len(parts) == 3 { + _ = yaml.Unmarshal([]byte(parts[1]), &fm) + } + if !strings.Contains(string(data), "Be helpful.") { + t.Fatalf("converted opencode role dropped body:\n%s", data) + } +} diff --git a/cmd/dotagents/setup_scaffold.go b/cmd/dotagents/setup_scaffold.go index 76798ad..d3ac1b3 100644 --- a/cmd/dotagents/setup_scaffold.go +++ b/cmd/dotagents/setup_scaffold.go @@ -151,6 +151,7 @@ func defaultAgentConfigs() []agentConfig { {Name: agentDroid, Enabled: true, SkillRoot: "~/.factory/skills", AgentRoot: "~/.factory/droids", Detect: "droid"}, {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"}, } } @@ -655,15 +656,16 @@ func renderCanonicalRoleMarkdown(role agentRole) ([]byte, error) { return nil, err } front := struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Model string `yaml:"model,omitempty"` - Effort string `yaml:"effort,omitempty"` - Tools []string `yaml:"tools,omitempty"` - Color string `yaml:"color,omitempty"` - Codex codexRoleOptions `yaml:"codex,omitempty"` - Droid droidRoleOptions `yaml:"droid,omitempty"` - }{Name: role.Name, Description: role.Description, Model: role.Model, Effort: role.Effort, Tools: role.Tools, Color: role.Color, Codex: role.Codex, Droid: role.Droid} + Name string `yaml:"name"` + Description string `yaml:"description"` + Model string `yaml:"model,omitempty"` + Effort string `yaml:"effort,omitempty"` + Tools []string `yaml:"tools,omitempty"` + Color string `yaml:"color,omitempty"` + Codex codexRoleOptions `yaml:"codex,omitempty"` + Droid droidRoleOptions `yaml:"droid,omitempty"` + Opencode opencodeRoleOptions `yaml:"opencode,omitempty"` + }{Name: role.Name, Description: role.Description, Model: role.Model, Effort: role.Effort, Tools: role.Tools, Color: role.Color, Codex: role.Codex, Droid: role.Droid, Opencode: role.Opencode} meta, err := yaml.Marshal(front) if err != nil { return nil, err diff --git a/cmd/dotagents/sync.go b/cmd/dotagents/sync.go index 945b29d..323677c 100644 --- a/cmd/dotagents/sync.go +++ b/cmd/dotagents/sync.go @@ -182,6 +182,10 @@ func applyAgentSync(reports []agentReport, cfg config, repoRoot string, home str } continue } + if h != nil && h.SkillsNativeRoot != nil && h.SkillsNativeRoot(repoRoot, home) { + // Skills are read directly from the config root; no mirror to write. + continue + } if err := os.MkdirAll(report.SkillRoot, 0o755); err != nil { return fmt.Errorf("create %s: %w", report.SkillRoot, err) diff --git a/docs/harness-map.html b/docs/harness-map.html index 083d6e3..fefd2f1 100644 --- a/docs/harness-map.html +++ b/docs/harness-map.html @@ -310,6 +310,11 @@

Factory Droid

~/.factory/{skills,droids}

Pi

~/.pi/agent/skills
skillsskills-only by designOMP fork: +agents +MCP*
+
+
primary
+

OpenCode

~/.config/opencode/{agents,opencode.json}
+
skills (native ~/.agents)agentsMCPno hooks
+
@@ -323,6 +328,7 @@

Managed support matrix

Codexmanaged mirrorTOMLTOMLhooks.jsonAGENTS.md Hermesexternal_dirsnot managedYAMLYAMLnative context Factory Droidmanaged mirror.mdJSONsettingsAGENTS link + OpenCodenative ~/.agents.mdJSONJS plugin onlynative AGENTS.md Pi*managed mirrornot supported*not supported*not managednot managed @@ -335,7 +341,6 @@

Compatibility-only queue

Amp

CLI support remains for migration and one-off local configs. Not a canonical default target.

OpenClaw

Needs an owner and verified config surface before dotagents treats it as managed.

-
{ }OpenCode

Compatibility research only. Add support when there is a real daily workflow to preserve.

?New harnesses

Help wanted: document skills root, MCP config, hooks, roles, and failure mode first.

@@ -349,7 +354,7 @@

Compatibility-only queue

Source of truth: dotagents.yaml + cmd/dotagents/harness.go - Primary stack: Claude Code · Codex · Hermes · Factory Droid · Pi + Primary stack: Claude Code · Codex · Hermes · Factory Droid · OpenCode · Pi
diff --git a/docs/harness-map.png b/docs/harness-map.png index f5f1837..bfa74c1 100644 Binary files a/docs/harness-map.png and b/docs/harness-map.png differ diff --git a/docs/plans/launch-2026-07-14/08-opencode-research.md b/docs/plans/launch-2026-07-14/08-opencode-research.md new file mode 100644 index 0000000..d3e3173 --- /dev/null +++ b/docs/plans/launch-2026-07-14/08-opencode-research.md @@ -0,0 +1,267 @@ +# OpenCode Config Surfaces — Fact Sheet (for sync adapter) + +Researched: 2026-07-17. Target: `github.com/sst/opencode`, docs `opencode.ai/docs`. +All facts below verified from official docs (`opencode.ai/docs/*`) and the repo source +(`packages/web/src/content/docs/*.mdx`, `packages/plugin/src/index.ts`) on the `dev` branch. + +Latest release at research time: **v1.18.3**, published **2026-07-16T15:34:33Z** +(source: `gh api repos/sst/opencode/releases/latest`). Project is highly active +(near-daily releases). + +--- + +## 0. Config root, binary, and precedence + +- **Global config dir:** `~/.config/opencode/` (respects `$XDG_CONFIG_HOME`, so + `$XDG_CONFIG_HOME/opencode/` when set). Custom override via `OPENCODE_CONFIG` env var. +- **Global config file:** `~/.config/opencode/opencode.json` (also `opencode.jsonc` — JSONC + with comments is supported). +- **Project config file:** `opencode.json` (or `.jsonc`) in the project root. +- **Binary name to detect on PATH:** `opencode`. +- **JSON schema URL:** `https://opencode.ai/config.json` (put in `$schema`). TUI settings use a + separate `tui.json` file with schema `https://opencode.ai/tui.json`. +- **Merge semantics:** configs are merged (not replaced); later overrides earlier for + conflicting keys. Load order (later wins): remote config -> global config -> + `OPENCODE_CONFIG` custom path -> project config -> managed/enterprise settings. +- Top-level config keys include: `model`, `provider`, `agent`, `permission`, `tools`, + `mcp`, `plugin`, `instructions`, `formatter`, `lsp`, `theme`, `keybinds`, `server`, `shell`. +- Source: + +--- + +## 1. Skills (SKILL.md) + +**Supported — yes.** OpenCode natively loads `SKILL.md`-style skills and explicitly reads the +Claude and `.agents` conventions as compatible locations. Source: + (repo: `packages/web/src/content/docs/skills.mdx`). + +Layout: one folder per skill, `SKILL.md` inside it (`/SKILL.md`). + +**Global (user) skill locations (all three are read):** +- `~/.config/opencode/skills//SKILL.md` (native) +- `~/.claude/skills//SKILL.md` (Claude-compatible) +- `~/.agents/skills//SKILL.md` (agent-compatible) + +**Project-level skill locations (all three are read):** +- `.opencode/skills//SKILL.md` +- `.claude/skills//SKILL.md` +- `.agents/skills//SKILL.md` + +> Adapter note: because OpenCode reads `~/.agents/skills/*/SKILL.md` and `.agents/skills/...` +> directly, and this repo's canonical store is `~/.agents`, OpenCode may pick up dotagents +> skills with **no sync at all** for the global case. Verify this is desired vs. duplicating +> into `~/.config/opencode/skills/`. + +**Frontmatter (only these fields are recognized; unknown fields ignored):** +- `name` (required) — must match the containing directory name; 1–64 chars; pattern + `^[a-z0-9]+(-[a-z0-9]+)*$`. +- `description` (required) — 1–1024 chars. +- `license` (optional) +- `compatibility` (optional) +- `metadata` (optional, string-to-string map) + +This matches the agentskills.io / Anthropic Agent Skills convention (name + description +frontmatter, directory-named skill, `SKILL.md` all-caps). Permissions for skill invocation are +configurable in `opencode.json` (allow/ask/deny). + +**Symlinks:** the docs do **not** document symlinked skill directory behavior either way — treat +as UNVERIFIED. Loading is by glob (`skills/*/SKILL.md`); whether the glob resolves symlinked dirs +was not confirmed in docs and would need a live test. + +--- + +## 2. Agents / subagents (roles) + +**Supported — yes.** Two definition styles: (a) inline under the `agent` key in `opencode.json`, +or (b) per-agent markdown files. Source: +(repo: `packages/web/src/content/docs/agents.mdx`). + +**Markdown agent directories (verified plural `agents/` from repo source):** +- Global: `~/.config/opencode/agents/.md` +- Project: `.opencode/agents/.md` + +The markdown filename becomes the agent name (`review.md` -> agent `review`). The markdown body +is the system prompt; frontmatter carries the config. + +**Fields (YAML frontmatter, or JSON object under `agent.`):** +- `description` (required) — what the agent is for / when to use it. +- `mode` — `primary` | `subagent` | `all`. +- `model` — override model, format `provider/model-id` + (e.g. `anthropic/claude-sonnet-4-20250514`). +- `prompt` — path to an external system-prompt file (alternative to inline markdown body). +- `temperature` — 0.0–1.0. +- `permission` — per-tool access map, e.g. `{ edit: deny, bash: deny }` (values allow/ask/deny). +- `tools` — enable/disable specific tools. +- `steps` — max agentic iterations. +- `color` — TUI display color. +- Provider-specific params can be passed through. + +Example frontmatter (from docs): +```yaml +--- +description: Reviews code for quality and best practices +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +temperature: 0.1 +permission: + edit: deny + bash: deny +--- +``` + +--- + +## 3. MCP servers + +**Declared under the `mcp` key in `opencode.json`** (global or project). Each key is the server +name. Source: . + +**Local (stdio) server:** +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-local-mcp": { + "type": "local", + "command": ["npx", "-y", "@modelcontextprotocol/server-everything"], + "environment": { "MY_VAR": "value" }, + "cwd": "/optional/working/dir", + "enabled": true, + "timeout": 5000 + } + } +} +``` +- `type`: `"local"` (required) +- `command`: **array of strings** (required) — NOTE: this differs from the Claude/Codex shape + that splits `command` (string) + `args` (array). OpenCode folds the whole invocation into one + `command` array. +- `environment`: object of env vars (NOTE: key is `environment`, not `env`). +- `cwd`, `enabled` (bool), `timeout` (ms, default 5000): optional. + +**Remote server:** +```json +{ + "mcp": { + "my-remote": { + "type": "remote", + "url": "https://my-mcp-server.com", + "headers": { "Authorization": "Bearer API_KEY" }, + "enabled": true, + "timeout": 5000 + } + } +} +``` +- `type`: `"remote"` (required), `url` (required). +- `headers` (optional), `oauth` (object or `false`), `enabled`, `timeout`. + +> Adapter gotchas: (1) `command` is a single array, not command+args; (2) env key is +> `environment`; (3) MCP lives inside the shared `opencode.json`, so an adapter must +> merge into existing JSON rather than write a dedicated file. + +--- + +## 4. Hooks / plugins + +**Plugin system — yes.** Plugins are JS/TS modules; no separate "hooks" config file. Source: + and repo `packages/plugin/src/index.ts` (`Hooks` interface, +line ~222). + +**Plugin locations (verified plural `plugins/`):** +- Global: `~/.config/opencode/plugins/*.{js,ts}` +- Project: `.opencode/plugins/*.{js,ts}` +- Or npm packages listed in config: `"plugin": ["pkg-name", ["pkg", {opts}]]`. +- External deps for local plugins: add `.opencode/package.json` (or in the config dir). +- Types: `import type { Plugin } from "@opencode-ai/plugin"`. + +**Plugin shape:** an exported async function `(input, options?) => Promise`. The input +context provides `{ project, client, $, directory, worktree }` (`$` is a shell helper; `client` +is the OpenCode SDK client). + +**Hook surface (from `Hooks` interface):** +- `event` — catch-all: receives every emitted event `{ event: Event }`. +- `config` — mutate/inspect resolved config on load. +- `auth`, `provider` — auth loaders and provider registration. +- `tool` — register custom tools; `tool.definition` to rewrite a tool's schema. +- `chat.message`, `chat.params`, `chat.headers` — intercept outgoing chat. +- `permission.ask` — approve/deny permission prompts. +- `command.execute.before`. +- `tool.execute.before`, `tool.execute.after`. +- `shell.env` — inject env for shell calls. +- `dispose` — cleanup on shutdown. +- Experimental: `experimental.chat.messages.transform`, + `experimental.chat.system.transform`, `experimental.provider.small_model`, + `experimental.session.compacting`, `experimental.compaction.autocontinue`, + `experimental.text.complete`. + +**Event names available via the `event` hook** (from docs "Events" list): +- Session: `session.created`, `session.updated`, `session.compacted`, `session.deleted`, + `session.diff`, `session.error`, `session.idle`, `session.status`. +- Tool: `tool.execute.before`, `tool.execute.after`. +- Message: `message.updated`, `message.part.updated`, `message.removed`. +- File: `file.edited`, `file.watcher.updated`. +- LSP: `lsp.updated`, `lsp.client.diagnostics`. +- Command: `command.executed`. Permission: `permission.asked`. +- Server/Shell: `server.connected`, `shell.env`. Installation: `installation.updated`. +- Todo: `todo.updated`. TUI: `tui.prompt.append`, `tui.command.execute`, `tui.toast.show`. + +> **Memory-integration answer:** there is **no dedicated `session-start` / `session-end` hook** +> like Claude Code's. The equivalent is a plugin subscribing via the `event` hook: +> `session.created` ~ session start; `session.idle` / `session.deleted` ~ end; +> `session.compacted` for context-compaction moments. A dotagents memory bridge would ship as a +> plugin (JS/TS) dropped in `~/.config/opencode/plugins/` that watches these events and calls out +> to the memory store. This is a code artifact, not a declarative config entry. + +--- + +## 5. Instructions files (AGENTS.md etc.) + +Source: . +- **Reads `AGENTS.md`** — yes. Project root `AGENTS.md` applies in that dir/subdirs. +- **Global user instructions:** `~/.config/opencode/AGENTS.md`. +- **`instructions` config key** in `opencode.json`: array of extra instruction files — supports + local paths, glob patterns (e.g. `packages/*/AGENTS.md`), and remote URLs. These combine with + the `AGENTS.md` files. +- **Claude Code fallbacks:** project `CLAUDE.md` used if no `AGENTS.md`; `~/.claude/CLAUDE.md` + used if no `~/.config/opencode/AGENTS.md`. Disable via `OPENCODE_DISABLE_CLAUDE_CODE=1`. +- Precedence: local `AGENTS.md` > global config instructions > Claude Code fallbacks. + +--- + +## 6. Recent state, providers, Kimi K3 + +- **Version:** v1.18.3 (2026-07-16). Actively maintained, frequent releases. +- **Breaking config changes:** none surfaced in current docs for the config surfaces above; the + `agent`/`mcp`/`plugin`/`instructions`/`skills` keys are the current stable shape. (No changelog + diff was pulled — flag as "not exhaustively verified" if a specific version boundary matters.) +- **Providers:** OpenCode uses the Vercel AI SDK + Models.dev catalog (75+ providers, plus local + models). Custom providers go under the `provider` key with: + - `npm` — the AI SDK package (e.g. `@ai-sdk/openai-compatible` for any OpenAI-compatible API), + - `options.baseURL` — custom endpoint, + - `models` — map/array of model IDs shown in the `/models` picker. + Source: . +- **Kimi K3:** Moonshot released **Kimi K3 on 2026-07-16** (MoE, ~2.8T params, ~1M context). + Moonshot's platform ships an **official "Use Kimi Models in OpenCode" guide** + (); OpenCode is listed among first-class + supported agents. Simplest path: `opencode auth login` -> select Moonshot AI -> paste Kimi + Open Platform API key. Custom/proxy path: define a provider with + `npm: "@ai-sdk/openai-compatible"` + `options.baseURL`. OpenCode is a commonly-recommended CLI + for running Kimi models (it publishes usage/rank data at `opencode.ai/data/moonshot/kimi-k3`). + Note: the model is Kimi **K3** (K2 is the prior generation still referenced in some docs pages). + +--- + +## Sources +- Config: +- Agents: +- MCP: +- Skills: +- Plugins: +- Rules/instructions: +- Providers: +- Repo source: `github.com/sst/opencode` — `packages/web/src/content/docs/*.mdx`, + `packages/plugin/src/index.ts` +- Release check: `gh api repos/sst/opencode/releases/latest` -> v1.18.3 (2026-07-16) +- Kimi in OpenCode: , + diff --git a/docs/plans/launch-2026-07-14/09-opencode-spec.md b/docs/plans/launch-2026-07-14/09-opencode-spec.md new file mode 100644 index 0000000..8f4e46e --- /dev/null +++ b/docs/plans/launch-2026-07-14/09-opencode-spec.md @@ -0,0 +1,52 @@ +# Spec: OpenCode harness support + +Implementer: builder agent (claude-opus-4-6). Worktree: `.worktrees/opencode-support`, branch `feature/opencode-support` from latest `origin/main` (after PR #118 merges). Open a PR when done; the session lead reviews and merges. + +Motivation: Kimi K3 (released 2026-07-16) made OpenCode the go-to open harness alongside Pi and Claude Code; Moonshot ships an official OpenCode guide. Users arriving from that wave should get first-class dotagents support. + +## Verified facts (source: docs/plans/launch-2026-07-14/08-opencode-research.md) + +All verified against opencode.ai/docs and repo source on 2026-07-17. OpenCode v1.18.3 (2026-07-16), very active. + +- Binary on PATH: `opencode`. Config root: `~/.config/opencode/` (honors `$XDG_CONFIG_HOME`). Main config: `opencode.json` (JSONC variant possible; schema `https://opencode.ai/config.json`). +- **Skills**: native `SKILL.md` support, agentskills.io-style frontmatter (`name` required, must match dir name, pattern `^[a-z0-9]+(-[a-z0-9]+)*$`; `description` required). Reads global skills from THREE roots: `~/.config/opencode/skills/`, `~/.claude/skills/`, and `~/.agents/skills/`. +- **Agents (subagent roles)**: Markdown files at `~/.config/opencode/agents/.md`. Filename = agent name; body = system prompt. Frontmatter fields: `description` (required), `mode` (primary|subagent|all), `model` (`provider/model-id` form), `temperature`, `tools`, `color`. +- **MCP**: `mcp` key inside `opencode.json`. Local server shape: `{"type":"local","command":["cmd","arg1",...],"environment":{...},"enabled":true}`. Remote: `{"type":"remote","url":...}`. NOTE: `command` is one array including the binary; env key is `environment`, NOT `env`. +- **Hooks**: no declarative lifecycle hooks. Plugin system = JS/TS modules. Out of scope (see below). +- Instructions: reads project `AGENTS.md` and `~/.config/opencode/AGENTS.md`. + +## Design decisions (already made — do not relitigate) + +1. **Skills = native read, not mirror.** OpenCode already reads `~/.agents/skills/` directly. When the dotagents config root IS `~/.agents` (the default), sync must NOT copy skills into `~/.config/opencode/skills/` — that would double-list every skill (dual-delivery bug class). The adapter reports skills as natively consumed. When the config root is elsewhere (`DOTAGENTS_HOME`/`--config`), mirror skills into `~/.config/opencode/skills/` like other harnesses. +2. **Doctor duplicate check.** `doctor` warns when a skill exists both in `~/.agents/skills/` and `~/.config/opencode/skills/` under the same name (double listing in OpenCode). +3. **Roles**: render canonical `agents/*.md` into `~/.config/opencode/agents/.md`. Map frontmatter: `description` → `description`; `model`/`effort` have no direct equivalent — omit unless a per-harness override block `opencode:` is present in the canonical role (mirror of the existing `codex:` override pattern; support `model`, `temperature`, `mode`; default `mode: subagent`). Instructions body passes through. Managed-marker convention same as other harnesses so sync can detect drift and setup can skip unmanaged files. +4. **MCP**: merge managed entries into `~/.config/opencode/opencode.json` under `mcp`, translating `command` + `args` → single `command` array and `env` → `environment`. Surgical JSON merge — never touch unmanaged keys or servers. Respect `agents:` targeting (server applies to opencode only when listed). +5. **Hooks: unsupported.** OpenCode's hook surface is a JS plugin API, not declarative config. Per repo invariant ("do not invent unsupported surfaces"), hooks stay `--` for OpenCode in v1. Do not generate plugin files. +6. **Detection**: `detect: opencode`. Default agent entry added to `defaultAgentConfigs()` and `setupSelectableAgentConfigs()`: name `opencode`, skill_root `~/.config/opencode/skills`, agent_root `~/.config/opencode/agents`. +7. **Setup import**: scan existing `~/.config/opencode/agents/*.md` (convert = mostly pass-through with frontmatter normalization) and `opencode.json` MCP servers (reverse-translate `command` array → command+args, `environment` → env) as import candidates, same prompts as other harnesses. Copy-only. +8. **Respect XDG**: resolve config root as `$XDG_CONFIG_HOME/opencode` when set, else `~/.config/opencode`. + +## Deliverables + +1. Harness adapter in `cmd/dotagents/harness.go` (+ any new opencode-specific file) implementing the above. +2. Setup/import/scan support; prune-guard integration comes free via existing report flow — verify it triggers for opencode. +3. Tests, following existing per-harness test patterns: + - default config entry + detection + - role render (frontmatter mapping incl. `opencode:` override + default mode) + - MCP merge: fresh file, existing unmanaged servers preserved, command/env translation both directions (import) + - skills native-read path: no mirror when root == ~/.agents; mirror when custom root + - doctor duplicate-skill warning + - setup import scan for agents + MCP +4. Docs: README harness table row `OpenCode | yes | yes | yes | -- `(with a note that skills are read natively from `~/.agents`); `docs/harness-map.html` — move OpenCode from compatibility queue to a primary card + matrix row, regenerate `docs/harness-map.png` if a headless browser is available (Brave at `/Applications/Brave Browser.app/Contents/MacOS/Brave Browser` on this machine; window 1400x1560), otherwise note it in the PR; landing `docs/site/index.html` harness grid + table row. +5. `dotagents.yaml` template: nothing to add (agents are detected, not templated). + +## Acceptance + +- `go build ./cmd/dotagents` and full `go test ./...` pass. +- `dotagents status`/`sync`/`doctor` behave correctly on a machine with opencode binary faked on PATH (tests cover this; no live install required). +- No changes to unrelated harness behavior (existing tests untouched except where a shared fixture legitimately grows). +- Single PR from `feature/opencode-support`, short single-line commits, no bot trailers. Do NOT merge — open the PR and stop. + +## Out of scope + +- OpenCode plugins/hooks, project-level `.opencode/`, remote MCP servers (only local stdio in v1; remote entries in opencode.json must be preserved untouched), model/provider config (Kimi K3 setup is the user's business), Amp/OpenClaw. diff --git a/docs/site/index.html b/docs/site/index.html index f258e5d..3a04159 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -4,7 +4,7 @@ dotagents - public CLI for your private agent config - +