diff --git a/README.md b/README.md index 37967ea..7113634 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Kanon moves your settings between three states, plus a git remote for sharing: - **Source state** — `kanon.yaml` plus `instructions/`, `skills/`, and `hooks/` in the Kanon home. The single source of truth, tracked in git. - **Target state** — the agent-native files **computed** from the source by the - per-agent adapters (`codex`, `claude`). Never stored; recomputed on demand. + per-agent adapters (`codex`, `claude`, `opencode`). Never stored; recomputed on + demand. - **Destination state** — the real files on this machine. ### Set up kanon on your current machine @@ -101,7 +102,7 @@ The source repository defaults to `~/.config/kanon`; set `KANON_HOME` or pass `--home` to point elsewhere. On another machine, `kanon update` pulls and applies in one step; use `kanon pull` / `kanon push` for explicit git sync. -If this machine already has Codex or Claude settings, start with +If this machine already has Codex, Claude, or OpenCode settings, start with `kanon import --ui`. It lets you review discovered instructions, skills, MCP servers, and hooks one item at a time before adding them to the Kanon source. @@ -117,7 +118,7 @@ in a repository that is shared across machines. From the source state, Kanon renders: - instructions into `AGENTS.md` and `CLAUDE.md` -- skills into Codex and Claude skill directories +- skills into Codex, Claude, and OpenCode skill directories - MCP server definitions - hooks @@ -189,11 +190,16 @@ entry or remove a stale entry for a removed provider. Use `kanon lock update --all` to refresh every enabled git skill provider. Co-owned config files that the agent also writes are merged instead of replaced. -For Codex `config.toml` and Claude `.claude.json` / project `.mcp.json`, Kanon -updates the MCP server entries named in the source and preserves other fields -and server entries. For Claude `settings.json`, Kanon updates the rendered -`hooks` section and preserves other settings. If an existing co-owned config -cannot be parsed, the merge stops with an error and the file is left untouched. +For Codex `config.toml`, Claude `.claude.json` / project `.mcp.json`, and +OpenCode `opencode.json`, Kanon updates the MCP server entries named in the +source and preserves other fields and server entries. For Claude +`settings.json`, Kanon updates the rendered `hooks` section and preserves other +settings. If an existing co-owned config cannot be parsed, the merge stops with +an error and the file is left untouched. + +OpenCode's native MCP `enabled` field is represented as `opencode_enabled` in +`kanon.yaml`. The top-level `enabled` field remains Kanon's source-level switch +for all agents. ## Importing existing settings @@ -204,8 +210,9 @@ kanon import --agent all --write kanon import --agent all --write --force ``` -`import` runs the pipeline in reverse: it reads existing Codex and Claude files -(the destination state) and normalizes them back into the neutral source state. +`import` runs the pipeline in reverse: it reads existing Codex, Claude, and +OpenCode files (the destination state) and normalizes them back into the neutral +source state. For interactive use, prefer `kanon import --ui`: it reviews discovered instructions, skills, MCP servers, and hooks as selectable import units before writing them into the source. The same import review is available from @@ -226,7 +233,7 @@ preserved and reported with warnings so you can move them to environment references or another secret manager manually. Future policies for env refs, omission, password managers, and encrypted secrets are tracked in code TODOs. -If both `AGENTS.md` and `CLAUDE.md` exist and differ, import stops by default. -Re-run with `--instruction-policy codex`, `claude`, `merge`, or `skip` to choose -how to create neutral instructions. `--write` refuses to replace an existing -`kanon.yaml`; use `--force` when intentionally re-importing. +If discovered agent instruction files exist and differ, import stops by default. +Re-run with `--instruction-policy codex`, `claude`, `opencode`, `merge`, or +`skip` to choose how to create neutral instructions. `--write` refuses to +replace an existing `kanon.yaml`; use `--force` when intentionally re-importing. diff --git a/internal/cli/root.go b/internal/cli/root.go index c36d27d..b2802ba 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -43,9 +43,9 @@ It works in three states, mirroring chezmoi: source state kanon.yaml plus instructions/ skills/ hooks/ — the single source of truth, tracked in git target state the agent-native files compiled from the source by the - per-agent adapters (codex, claude) + per-agent adapters (codex, claude, opencode) destination state the real files on this machine (AGENTS.md, CLAUDE.md, - ~/.codex, ~/.claude, and project directories) + ~/.codex, ~/.claude, ~/.config/opencode, and project directories) Commands move data between these states: render (source to target), diff and apply (target to destination), import (destination back to source), lock git @@ -56,7 +56,7 @@ skill provider pins, and pull/push/update to sync the source with a remote.`, cmd.PersistentFlags().StringVar(&opts.home, "home", "", "Kanon source repository path (defaults to KANON_HOME or ~/.config/kanon)") cmd.PersistentFlags().StringVar(&opts.configPath, "config", "", "config file path (defaults to /kanon.yaml)") cmd.PersistentFlags().StringVar(&opts.project, "project", "", "render project-scoped agent settings into this repository") - cmd.PersistentFlags().StringVar(&opts.agent, "agent", core.AgentAll, "agent to manage: all, codex, or claude") + cmd.PersistentFlags().StringVar(&opts.agent, "agent", core.AgentAll, "agent to manage: all, codex, claude, or opencode") cmd.AddCommand(initCommand(opts)) cmd.AddCommand(validateCommand(opts)) @@ -392,7 +392,7 @@ func importCommand(opts *options) *cobra.Command { cmd.Flags().BoolVar(&opts.write, "write", false, "write imported config and files into the Kanon home") cmd.Flags().BoolVar(&opts.force, "force", false, "overwrite an existing kanon.yaml during import") cmd.Flags().StringVar(&opts.secretPolicy, "secret-policy", string(core.SecretPolicyKeep), "secret handling during import: keep") - cmd.Flags().StringVar(&opts.instructionPolicy, "instruction-policy", string(core.InstructionPolicyAuto), "instruction handling during import: auto, codex, claude, merge, or skip") + cmd.Flags().StringVar(&opts.instructionPolicy, "instruction-policy", string(core.InstructionPolicyAuto), "instruction handling during import: auto, codex, claude, opencode, merge, or skip") return cmd } @@ -554,7 +554,7 @@ func (opts *options) targetOptions() (core.TargetOptions, error) { return core.TargetOptions{}, err } } - if opts.agent != core.AgentAll && opts.agent != core.AgentCodex && opts.agent != core.AgentClaude { + if opts.agent != core.AgentAll && opts.agent != core.AgentCodex && opts.agent != core.AgentClaude && opts.agent != core.AgentOpenCode { return core.TargetOptions{}, fmt.Errorf("unsupported agent %q", opts.agent) } return core.TargetOptions{ diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 46bba3c..73f4850 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -47,7 +47,7 @@ func TestRenderPrintsTargetState(t *testing.T) { } got := out.String() - for _, want := range []string{"==> [claude]", "CLAUDE.md", "==> [codex]", "AGENTS.md", "Shared Agent Instructions"} { + for _, want := range []string{"==> [claude]", "CLAUDE.md", "==> [codex]", "AGENTS.md", "==> [opencode]", "Shared Agent Instructions"} { if !strings.Contains(got, want) { t.Fatalf("render output missing %q\n%s", want, got) } @@ -82,7 +82,7 @@ func TestUpdatePullsAndApplies(t *testing.T) { } // The apply step should have written the rendered instruction files. - for _, rel := range []string{".codex/AGENTS.md", ".claude/CLAUDE.md"} { + for _, rel := range []string{".codex/AGENTS.md", ".claude/CLAUDE.md", ".config/opencode/AGENTS.md"} { if _, err := os.Stat(filepath.Join(userHome, rel)); err != nil { t.Fatalf("update did not apply %s: %v\n%s", rel, err, out.String()) } @@ -119,7 +119,7 @@ func TestApplyDryRunWritesNothing(t *testing.T) { t.Fatalf("dry run output missing %q; plan diff body not printed?\n%s", want, out.String()) } } - for _, rel := range []string{".codex/AGENTS.md", ".claude/CLAUDE.md"} { + for _, rel := range []string{".codex/AGENTS.md", ".claude/CLAUDE.md", ".config/opencode/AGENTS.md"} { if _, err := os.Stat(filepath.Join(userHome, rel)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("dry run wrote %s (err=%v)\n%s", rel, err, out.String()) } @@ -177,7 +177,7 @@ func TestUpdateDryRunPullsButWritesNothing(t *testing.T) { t.Fatalf("update -n did not pull: home HEAD=%s want=%s\n%s", got, want, out.String()) } // But the destination is left untouched and no state is recorded. - for _, rel := range []string{".codex/AGENTS.md", ".claude/CLAUDE.md"} { + for _, rel := range []string{".codex/AGENTS.md", ".claude/CLAUDE.md", ".config/opencode/AGENTS.md"} { if _, err := os.Stat(filepath.Join(userHome, rel)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("dry run wrote %s (err=%v)\n%s", rel, err, out.String()) } diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 4070679..ee3ee79 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -23,7 +23,7 @@ func uiCommand(opts *options) *cobra.Command { cmd.Flags().BoolVarP(&opts.dryRun, "dry-run", "n", false, "start in dry-run mode (preview applies without writing)") cmd.Flags().StringVar(&opts.uiMode, "mode", string(tui.ModeApply), "initial UI mode: apply or import") cmd.Flags().StringVar(&opts.secretPolicy, "secret-policy", string(core.SecretPolicyKeep), "secret handling during import mode: keep") - cmd.Flags().StringVar(&opts.instructionPolicy, "instruction-policy", string(core.InstructionPolicyAuto), "instruction handling during import mode: auto, codex, claude, merge, or skip") + cmd.Flags().StringVar(&opts.instructionPolicy, "instruction-policy", string(core.InstructionPolicyAuto), "instruction handling during import mode: auto, codex, claude, opencode, merge, or skip") return cmd } diff --git a/internal/core/apply_test.go b/internal/core/apply_test.go index 8213b35..9537790 100644 --- a/internal/core/apply_test.go +++ b/internal/core/apply_test.go @@ -411,6 +411,98 @@ func TestClaudeMCPMergePreservesExistingFieldsAndServers(t *testing.T) { } } +func TestOpenCodeConfigMergePreservesExistingFieldsAndServers(t *testing.T) { + kanonHome := t.TempDir() + userHome := t.TempDir() + configPath := filepath.Join(userHome, ".config", "opencode", "opencode.json") + writeTestFile(t, configPath, []byte(`{ + "model": "anthropic/claude-sonnet-4-5", + "mcp": { + "github": {"type":"local","command":["old-github"]}, + "private": {"type":"local","command":["private-mcp"]} + } +}`)) + cfg := &Config{ + Version: 1, + MCP: MCPConfig{Servers: map[string]MCPServer{ + "github": {Command: "github-mcp", Args: []string{"stdio"}, Targets: []string{AgentOpenCode}}, + }}, + } + + files, err := RenderAll(cfg, TargetOptions{KanonHome: kanonHome, UserHome: userHome, Agent: AgentOpenCode}) + if err != nil { + t.Fatal(err) + } + plan, err := PlanFiles(files, ApplyOptions{KanonHome: kanonHome}) + if err != nil { + t.Fatal(err) + } + if err := ApplyFiles(plan, ApplyOptions{KanonHome: kanonHome}); err != nil { + t.Fatal(err) + } + var merged map[string]any + if err := json.Unmarshal(readTestFile(t, configPath), &merged); err != nil { + t.Fatal(err) + } + if merged["model"] != "anthropic/claude-sonnet-4-5" { + t.Fatalf("merged OpenCode config dropped model: %#v", merged) + } + servers := merged["mcp"].(map[string]any) + if _, ok := servers["private"]; !ok { + t.Fatalf("merged OpenCode config dropped private server: %#v", servers) + } + github := servers["github"].(map[string]any) + command := github["command"].([]any) + if command[0] != "github-mcp" || command[1] != "stdio" { + t.Fatalf("merged OpenCode config did not replace github server: %#v", github) + } +} + +func TestOpenCodeConfigMergeAcceptsJSONC(t *testing.T) { + kanonHome := t.TempDir() + userHome := t.TempDir() + configPath := filepath.Join(userHome, ".config", "opencode", "opencode.json") + writeTestFile(t, configPath, []byte(`{ + // OpenCode accepts comments in config files. + "model": "anthropic/claude-sonnet-4-5", + "mcp": { + "github": {"type":"local","command":["old-github"]}, + "private": {"type":"local","command":["private-mcp"]}, + }, +}`)) + cfg := &Config{ + Version: 1, + MCP: MCPConfig{Servers: map[string]MCPServer{ + "github": {Command: "github-mcp", Args: []string{"stdio"}, Targets: []string{AgentOpenCode}}, + }}, + } + + files, err := RenderAll(cfg, TargetOptions{KanonHome: kanonHome, UserHome: userHome, Agent: AgentOpenCode}) + if err != nil { + t.Fatal(err) + } + plan, err := PlanFiles(files, ApplyOptions{KanonHome: kanonHome}) + if err != nil { + t.Fatal(err) + } + if err := ApplyFiles(plan, ApplyOptions{KanonHome: kanonHome}); err != nil { + t.Fatal(err) + } + var merged map[string]any + if err := json.Unmarshal(readTestFile(t, configPath), &merged); err != nil { + t.Fatal(err) + } + servers := merged["mcp"].(map[string]any) + if _, ok := servers["private"]; !ok { + t.Fatalf("merged OpenCode JSONC config dropped private server: %#v", servers) + } + github := servers["github"].(map[string]any) + command := github["command"].([]any) + if command[0] != "github-mcp" || command[1] != "stdio" { + t.Fatalf("merged OpenCode JSONC config did not replace github server: %#v", github) + } +} + func TestCoOwnedConfigMergeRejectsInvalidExistingFile(t *testing.T) { kanonHome := t.TempDir() userHome := t.TempDir() diff --git a/internal/core/config.go b/internal/core/config.go index 1900ac5..4079e3e 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -283,7 +283,7 @@ func HasTarget(list []string, agent string) bool { func validateTargets(label string, targets []string, errs *[]error) { for _, target := range targets { - if target != AgentAll && target != AgentCodex && target != AgentClaude { + if target != AgentAll && target != AgentCodex && target != AgentClaude && target != AgentOpenCode { *errs = append(*errs, fmt.Errorf("%s has unsupported target %q", label, target)) } } diff --git a/internal/core/import.go b/internal/core/import.go index 300aa56..fd8216d 100644 --- a/internal/core/import.go +++ b/internal/core/import.go @@ -60,7 +60,7 @@ func ValidateSecretPolicy(policy SecretPolicy) error { func ValidateInstructionPolicy(policy InstructionPolicy) error { switch policy { - case InstructionPolicyAuto, InstructionPolicyCodex, InstructionPolicyClaude, InstructionPolicyMerge, InstructionPolicySkip: + case InstructionPolicyAuto, InstructionPolicyCodex, InstructionPolicyClaude, InstructionPolicyOpenCode, InstructionPolicyMerge, InstructionPolicySkip: return nil default: return fmt.Errorf("unsupported instruction policy %q", policy) @@ -236,6 +236,7 @@ func looksSecret(key, value string) bool { func isSecretReference(value string) bool { return envRefPattern.MatchString(value) || + openCodeEnvRefPattern.MatchString(value) || value == redactedSecret || value == legacyRedactedSecret || strings.HasPrefix(value, "op://") diff --git a/internal/core/import_assets.go b/internal/core/import_assets.go index e901179..49869ca 100644 --- a/internal/core/import_assets.go +++ b/internal/core/import_assets.go @@ -11,31 +11,36 @@ import ( "slices" ) +type instructionSource struct { + agent string + path string + fallback bool + content []byte +} + func importInstructions(result *ImportResult, opts ImportOptions) error { if opts.InstructionPolicy == InstructionPolicySkip { return nil } - codexPath := filepath.Join(opts.UserHome, ".codex", "AGENTS.md") - claudePath := filepath.Join(opts.UserHome, ".claude", "CLAUDE.md") - if opts.Project != "" { - codexPath = filepath.Join(opts.Project, "AGENTS.md") - claudePath = filepath.Join(opts.Project, "CLAUDE.md") - } - var codex, claude []byte - var err error + var sources []instructionSource if opts.Agent == AgentAll || opts.Agent == AgentCodex { - codex, err = readIfExists(codexPath) - if err != nil { - return err - } + sources = append(sources, instructionSource{agent: AgentCodex, path: codexInstructionPath(opts)}) } if opts.Agent == AgentAll || opts.Agent == AgentClaude { - claude, err = readIfExists(claudePath) + sources = append(sources, instructionSource{agent: AgentClaude, path: claudeInstructionPath(opts)}) + } + if opts.Agent == AgentAll || opts.Agent == AgentOpenCode { + sources = append(sources, openCodeInstructionSources(opts)...) + } + for i := range sources { + data, err := readIfExists(sources[i].path) if err != nil { return err } + sources[i].content = data } - content, err := chooseInstructionContent(codex, claude, opts.InstructionPolicy) + sources = effectiveInstructionSources(sources) + content, err := chooseInstructionContentFromSources(sources, opts.InstructionPolicy) if err != nil { return err } @@ -49,54 +54,126 @@ func importInstructions(result *ImportResult, opts ImportOptions) error { } func chooseInstructionContent(codex, claude []byte, policy InstructionPolicy) ([]byte, error) { - codex = bytes.TrimSpace(codex) - claude = bytes.TrimSpace(claude) + return chooseInstructionContentFromSources([]instructionSource{ + {agent: AgentCodex, content: codex}, + {agent: AgentClaude, content: claude}, + }, policy) +} + +func effectiveInstructionSources(sources []instructionSource) []instructionSource { + hasPrimary := map[string]bool{} + for _, source := range sources { + if !source.fallback && len(bytes.TrimSpace(source.content)) > 0 { + hasPrimary[source.agent] = true + } + } + out := sources[:0] + for _, source := range sources { + if source.fallback && hasPrimary[source.agent] { + continue + } + out = append(out, source) + } + return out +} + +func chooseInstructionContentFromSources(sources []instructionSource, policy InstructionPolicy) ([]byte, error) { + for i := range sources { + sources[i].content = bytes.TrimSpace(sources[i].content) + } switch policy { case InstructionPolicyCodex: - if len(codex) == 0 { - return nil, fmt.Errorf("instruction policy %q selected but Codex AGENTS.md was not found", policy) - } - return append(codex, '\n'), nil + return selectedInstructionContent(sources, policy, AgentCodex) case InstructionPolicyClaude: - if len(claude) == 0 { - return nil, fmt.Errorf("instruction policy %q selected but Claude CLAUDE.md was not found", policy) - } - return append(claude, '\n'), nil + return selectedInstructionContent(sources, policy, AgentClaude) + case InstructionPolicyOpenCode: + return selectedInstructionContent(sources, policy, AgentOpenCode) case InstructionPolicyMerge: - return mergeInstructionContent(codex, claude), nil + return mergeInstructionSourceContent(sources), nil case InstructionPolicyAuto: - if len(codex) == 0 { - return appendIfNotEmpty(claude), nil + var selected []instructionSource + for _, source := range sources { + if len(source.content) > 0 { + selected = append(selected, source) + } } - if len(claude) == 0 || bytes.Equal(codex, claude) { - return append(codex, '\n'), nil + if len(selected) == 0 { + return nil, nil + } + first := selected[0].content + for _, source := range selected[1:] { + if !bytes.Equal(first, source.content) { + return nil, fmt.Errorf("agent instruction files differ; rerun with --instruction-policy codex, claude, opencode, merge, or skip") + } } - return nil, fmt.Errorf("AGENTS.md and CLAUDE.md differ; rerun with --instruction-policy codex, claude, merge, or skip") + return append(first, '\n'), nil default: return nil, nil } } -func mergeInstructionContent(codex, claude []byte) []byte { - if len(codex) == 0 { - return appendIfNotEmpty(claude) +func selectedInstructionContent(sources []instructionSource, policy InstructionPolicy, agent string) ([]byte, error) { + for _, source := range sources { + if source.agent == agent && len(source.content) > 0 { + return append(source.content, '\n'), nil + } } - if len(claude) == 0 || bytes.Equal(codex, claude) { - return append(codex, '\n') + return nil, fmt.Errorf("instruction policy %q selected but %s was not found", policy, instructionLabel(agent)) +} + +func instructionLabel(agent string) string { + switch agent { + case AgentCodex: + return "Codex AGENTS.md" + case AgentClaude: + return "Claude CLAUDE.md" + case AgentOpenCode: + return "OpenCode AGENTS.md" + default: + return "agent instructions" } - var out bytes.Buffer - out.Write(codex) - out.WriteString("\n\n") - out.Write(claude) - out.WriteByte('\n') - return out.Bytes() } -func appendIfNotEmpty(data []byte) []byte { - if len(data) == 0 { +func mergeInstructionContent(codex, claude []byte) []byte { + return mergeInstructionSourceContent([]instructionSource{ + {content: codex}, + {content: claude}, + }) +} + +func mergeInstructionSourceContent(sources []instructionSource) []byte { + var contents [][]byte + for _, source := range sources { + content := bytes.TrimSpace(source.content) + if len(content) == 0 { + continue + } + duplicate := false + for _, existing := range contents { + if bytes.Equal(existing, content) { + duplicate = true + break + } + } + if !duplicate { + contents = append(contents, content) + } + } + if len(contents) == 0 { return nil } - return append(data, '\n') + if len(contents) == 1 { + return append(contents[0], '\n') + } + var out bytes.Buffer + for i, content := range contents { + if i > 0 { + out.WriteString("\n\n") + } + out.Write(content) + } + out.WriteByte('\n') + return out.Bytes() } func importSkills(result *ImportResult, opts ImportOptions) error { @@ -107,12 +184,17 @@ func importSkills(result *ImportResult, opts ImportOptions) error { var roots []root if opts.Agent == AgentAll || opts.Agent == AgentCodex { roots = append(roots, - root{path: filepath.Join(opts.UserHome, ".agents", "skills"), target: AgentCodex}, - root{path: filepath.Join(opts.UserHome, ".codex", "skills"), target: AgentCodex}, + root{path: filepath.Join(skillImportBase(opts), ".agents", "skills"), target: AgentCodex}, + root{path: filepath.Join(skillImportBase(opts), ".codex", "skills"), target: AgentCodex}, ) } if opts.Agent == AgentAll || opts.Agent == AgentClaude { - roots = append(roots, root{path: filepath.Join(opts.UserHome, ".claude", "skills"), target: AgentClaude}) + roots = append(roots, root{path: filepath.Join(skillImportBase(opts), ".claude", "skills"), target: AgentClaude}) + } + if opts.Agent == AgentAll || opts.Agent == AgentOpenCode { + for _, path := range openCodeSkillImportPaths(opts) { + roots = append(roots, root{path: path, target: AgentOpenCode}) + } } seen := map[string]string{} for _, root := range roots { @@ -205,6 +287,9 @@ func uniqueSkillName(base, target, hash string, seen map[string]string) string { func addSkillTarget(cfg *Config, name, target string) { for i := range cfg.Skills { if cfg.Skills[i].Name == name { + if len(cfg.Skills[i].Targets) == 0 { + return + } if !slices.Contains(cfg.Skills[i].Targets, target) { cfg.Skills[i].Targets = append(cfg.Skills[i].Targets, target) } @@ -215,8 +300,57 @@ func addSkillTarget(cfg *Config, name, target string) { } func normalizeImportedSkillTargets(targets []string) []string { - if slices.Contains(targets, AgentCodex) && slices.Contains(targets, AgentClaude) { + if slices.Contains(targets, AgentCodex) && slices.Contains(targets, AgentClaude) && slices.Contains(targets, AgentOpenCode) { return nil } return targets } + +func codexInstructionPath(opts ImportOptions) string { + if opts.Project != "" { + return filepath.Join(opts.Project, "AGENTS.md") + } + return filepath.Join(opts.UserHome, ".codex", "AGENTS.md") +} + +func claudeInstructionPath(opts ImportOptions) string { + if opts.Project != "" { + return filepath.Join(opts.Project, "CLAUDE.md") + } + return filepath.Join(opts.UserHome, ".claude", "CLAUDE.md") +} + +func openCodeInstructionSources(opts ImportOptions) []instructionSource { + if opts.Project != "" { + return []instructionSource{ + {agent: AgentOpenCode, path: filepath.Join(opts.Project, "AGENTS.md")}, + {agent: AgentOpenCode, path: filepath.Join(opts.Project, "CLAUDE.md"), fallback: true}, + } + } + return []instructionSource{ + {agent: AgentOpenCode, path: filepath.Join(opts.UserHome, ".config", "opencode", "AGENTS.md")}, + {agent: AgentOpenCode, path: filepath.Join(opts.UserHome, ".claude", "CLAUDE.md"), fallback: true}, + } +} + +func skillImportBase(opts ImportOptions) string { + if opts.Project != "" { + return opts.Project + } + return opts.UserHome +} + +func openCodeSkillImportPaths(opts ImportOptions) []string { + if opts.Project != "" { + return []string{ + filepath.Join(opts.Project, ".opencode", "skills"), + filepath.Join(opts.Project, ".claude", "skills"), + filepath.Join(opts.Project, ".agents", "skills"), + } + } + return []string{ + filepath.Join(opts.UserHome, ".config", "opencode", "skills"), + filepath.Join(opts.UserHome, ".claude", "skills"), + filepath.Join(opts.UserHome, ".agents", "skills"), + } +} diff --git a/internal/core/import_plan.go b/internal/core/import_plan.go index b723bb7..35392ee 100644 --- a/internal/core/import_plan.go +++ b/internal/core/import_plan.go @@ -405,7 +405,7 @@ func stringFiles(files map[string][]byte) map[string]string { } func importTargetsAgent(targets []string) string { - if len(targets) == 1 && (targets[0] == AgentCodex || targets[0] == AgentClaude) { + if len(targets) == 1 && (targets[0] == AgentCodex || targets[0] == AgentClaude || targets[0] == AgentOpenCode) { return targets[0] } return AgentAll diff --git a/internal/core/import_test.go b/internal/core/import_test.go index b445119..56affd6 100644 --- a/internal/core/import_test.go +++ b/internal/core/import_test.go @@ -144,6 +144,7 @@ func TestImportInstructionPolicy(t *testing.T) { userHome := t.TempDir() writeTestFile(t, filepath.Join(userHome, ".codex", "AGENTS.md"), []byte("Codex rules\n")) writeTestFile(t, filepath.Join(userHome, ".claude", "CLAUDE.md"), []byte("Claude rules\n")) + writeTestFile(t, filepath.Join(userHome, ".config", "opencode", "AGENTS.md"), []byte("OpenCode rules\n")) _, err := ImportAll(ImportOptions{ TargetOptions: TargetOptions{ @@ -171,9 +172,25 @@ func TestImportInstructionPolicy(t *testing.T) { t.Fatalf("unexpected imported instruction files: %#v", result.Config.Instructions.Files) } content := string(result.Files[filepath.Join("instructions", "imported.md")]) - if !strings.Contains(content, "Codex rules") || !strings.Contains(content, "Claude rules") { + if !strings.Contains(content, "Codex rules") || !strings.Contains(content, "Claude rules") || !strings.Contains(content, "OpenCode rules") { t.Fatalf("merged instruction content missing source data: %q", content) } + + result, err = ImportAll(ImportOptions{ + TargetOptions: TargetOptions{ + KanonHome: t.TempDir(), + UserHome: userHome, + Agent: AgentAll, + }, + InstructionPolicy: InstructionPolicyOpenCode, + }) + if err != nil { + t.Fatal(err) + } + content = string(result.Files[filepath.Join("instructions", "imported.md")]) + if content != "OpenCode rules\n" { + t.Fatalf("opencode instruction policy selected %q", content) + } } func TestImportSkillsIntoNeutralTargets(t *testing.T) { @@ -181,6 +198,7 @@ func TestImportSkillsIntoNeutralTargets(t *testing.T) { skill := []byte("---\nname: review\n---\n\nReview code.\n") writeTestFile(t, filepath.Join(userHome, ".agents", "skills", "review", "SKILL.md"), skill) writeTestFile(t, filepath.Join(userHome, ".claude", "skills", "review", "SKILL.md"), skill) + writeTestFile(t, filepath.Join(userHome, ".config", "opencode", "skills", "review", "SKILL.md"), skill) result, err := ImportAll(ImportOptions{ TargetOptions: TargetOptions{ @@ -252,6 +270,7 @@ func TestWriteSelectedImportOmitsAllTargetLocalSkillConfig(t *testing.T) { skill := []byte("---\nname: review\n---\n\nReview code.\n") writeTestFile(t, filepath.Join(userHome, ".agents", "skills", "review", "SKILL.md"), skill) writeTestFile(t, filepath.Join(userHome, ".claude", "skills", "review", "SKILL.md"), skill) + writeTestFile(t, filepath.Join(userHome, ".config", "opencode", "skills", "review", "SKILL.md"), skill) plan, err := PlanImport(ImportOptions{ TargetOptions: TargetOptions{ @@ -288,6 +307,7 @@ func TestWriteSelectedImportReplacesRestrictiveSkillConfigWithImplicitLocalSkill writeTestFile(t, reviewPath, skill) writeTestFile(t, filepath.Join(userHome, ".agents", "skills", "review", "SKILL.md"), skill) writeTestFile(t, filepath.Join(userHome, ".claude", "skills", "review", "SKILL.md"), skill) + writeTestFile(t, filepath.Join(userHome, ".config", "opencode", "skills", "review", "SKILL.md"), skill) if err := WriteConfig(filepath.Join(kanonHome, "kanon.yaml"), &Config{ Version: 1, Skills: []Skill{{Name: "review", Targets: []string{AgentCodex}}}, @@ -459,6 +479,161 @@ trust_level = "trusted" } } +func TestImportNormalizesOpenCodeConfig(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "available") + + userHome := t.TempDir() + writeTestFile(t, filepath.Join(userHome, ".config", "opencode", "opencode.json"), []byte(`{ + // OpenCode accepts JSONC config files. + "mcp": { + "github": { + "type": "local", + "command": ["github-mcp", "stdio"], + "environment": {"GITHUB_TOKEN": "{env:GITHUB_TOKEN}"}, + "timeout": 5500, + }, + "private": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "Bearer {env:MCP_TOKEN}", + "X-Public": "public", + "X-Env": "{env:X_ENV}", + }, + }, + }, + "model": "anthropic/claude-sonnet-4-5", +}`)) + + result, err := ImportAll(ImportOptions{ + TargetOptions: TargetOptions{ + KanonHome: t.TempDir(), + UserHome: userHome, + Agent: AgentOpenCode, + }, + InstructionPolicy: InstructionPolicySkip, + }) + if err != nil { + t.Fatal(err) + } + github := result.Config.MCP.Servers["github"] + if github.Command != "github-mcp" || !slices.Equal(github.Args, []string{"stdio"}) { + t.Fatalf("opencode local MCP command was not normalized: %#v", github) + } + if github.Env["GITHUB_TOKEN"] != "${GITHUB_TOKEN}" { + t.Fatalf("opencode environment ref was not normalized: %#v", github.Env) + } + if github.StartupTimeoutSec != 6 { + t.Fatalf("opencode timeout was not rounded to seconds: %#v", github) + } + private := result.Config.MCP.Servers["private"] + if private.URL != "https://mcp.example.com/mcp" { + t.Fatalf("opencode remote MCP url was not normalized: %#v", private) + } + if private.BearerTokenEnvVar != "MCP_TOKEN" { + t.Fatalf("opencode bearer header was not normalized: %#v", private) + } + if private.EnvHeaders["X-Env"] != "X_ENV" || private.Headers["X-Public"] != "public" { + t.Fatalf("opencode headers were not split correctly: %#v", private) + } + warnings := strings.Join(result.Warnings, "\n") + if !strings.Contains(warnings, `skipped unsupported opencode config field "model"`) { + t.Fatalf("unmapped opencode config was not reported: %v", result.Warnings) + } + if errs := ValidateConfig(result.Config, t.TempDir()); len(errs) > 0 { + t.Fatalf("imported opencode config failed validation: %v", errs) + } +} + +func TestImportOpenCodeEnabledDoesNotDisableOtherAgents(t *testing.T) { + userHome := t.TempDir() + writeTestFile(t, filepath.Join(userHome, ".codex", "config.toml"), []byte(` +[mcp_servers.github] +command = "github-mcp" +`)) + writeTestFile(t, filepath.Join(userHome, ".config", "opencode", "opencode.json"), []byte(`{ + "mcp": { + "github": { + "type": "local", + "command": ["github-mcp"], + "enabled": false + } + } +}`)) + + result, err := ImportAll(ImportOptions{ + TargetOptions: TargetOptions{ + KanonHome: t.TempDir(), + UserHome: userHome, + Agent: AgentAll, + }, + InstructionPolicy: InstructionPolicySkip, + }) + if err != nil { + t.Fatal(err) + } + server := result.Config.MCP.Servers["github"] + if server.Enabled != nil { + t.Fatalf("opencode native enabled field was imported as global enabled: %#v", server) + } + if server.OpenCodeEnabled == nil || *server.OpenCodeEnabled { + t.Fatalf("opencode native enabled field was not preserved: %#v", server) + } + if !slices.Contains(server.Targets, AgentCodex) || !slices.Contains(server.Targets, AgentOpenCode) { + t.Fatalf("merged server targets missing active agents: %#v", server) + } + + files, err := RenderAll(result.Config, TargetOptions{ + KanonHome: t.TempDir(), + UserHome: userHome, + Agent: AgentAll, + }) + if err != nil { + t.Fatal(err) + } + byPath := renderedByPath(files) + codexConfig := string(byPath[filepath.Join(userHome, ".codex", "config.toml")].Content) + if !strings.Contains(codexConfig, "github-mcp") { + t.Fatalf("codex server was disabled by opencode import: %s", codexConfig) + } + openCodeConfig := string(byPath[filepath.Join(userHome, ".config", "opencode", "opencode.json")].Content) + if !strings.Contains(openCodeConfig, `"enabled": false`) { + t.Fatalf("opencode native enabled field was not rendered: %s", openCodeConfig) + } +} + +func TestImportOpenCodeCompatibilityPaths(t *testing.T) { + userHome := t.TempDir() + skill := []byte("---\nname: review\n---\n\nReview code.\n") + writeTestFile(t, filepath.Join(userHome, ".claude", "CLAUDE.md"), []byte("Claude fallback rules\n")) + writeTestFile(t, filepath.Join(userHome, ".agents", "skills", "review", "SKILL.md"), skill) + + result, err := ImportAll(ImportOptions{ + TargetOptions: TargetOptions{ + KanonHome: t.TempDir(), + UserHome: userHome, + Agent: AgentOpenCode, + }, + }) + if err != nil { + t.Fatal(err) + } + content := string(result.Files[filepath.Join("instructions", "imported.md")]) + if content != "Claude fallback rules\n" { + t.Fatalf("opencode compatibility instruction was not imported: %q", content) + } + if len(result.Config.Skills) != 1 { + t.Fatalf("expected one compatibility skill import, got %#v", result.Config.Skills) + } + imported := result.Config.Skills[0] + if imported.Name != "review" || !slices.Equal(imported.Targets, []string{AgentOpenCode}) { + t.Fatalf("opencode compatibility skill targets were not preserved: %#v", imported) + } + if string(result.Files[filepath.Join("skills", "review", "SKILL.md")]) != string(skill) { + t.Fatalf("opencode compatibility skill file was not imported: %#v", result.Files) + } +} + func TestImportNormalizesSecretHeadersAsEnvHeaders(t *testing.T) { t.Setenv("AUTHORIZATION", "available") diff --git a/internal/core/merge.go b/internal/core/merge.go index 2081e0c..551acdf 100644 --- a/internal/core/merge.go +++ b/internal/core/merge.go @@ -19,6 +19,8 @@ func MergeRenderedContent(strategy FileMergeStrategy, path string, existing, des return mergeJSONFields(path, existing, desired, "hooks") case FileMergeClaudeMCP: return mergeJSONMapField(path, existing, desired, "mcpServers") + case FileMergeOpenCodeConfig: + return mergeOpenCodeConfig(path, existing, desired) default: return nil, fmt.Errorf("unsupported merge strategy %q for %s", strategy, path) } @@ -94,6 +96,43 @@ func mergeJSONMapField(path string, existing, desired []byte, field string) ([]b return renderJSON(existingDoc) } +func mergeOpenCodeConfig(path string, existing, desired []byte) ([]byte, error) { + existingDoc, err := parseOpenCodeJSONDocument(path, existing) + if err != nil { + return nil, err + } + originalDoc, err := parseOpenCodeJSONDocument(path, existing) + if err != nil { + return nil, err + } + desiredDoc, err := parseJSONDocument(path, desired) + if err != nil { + return nil, fmt.Errorf("generated content for %s is invalid: %w", path, err) + } + desiredMap, ok, err := objectValue(desiredDoc, "mcp") + if err != nil { + return nil, fmt.Errorf("generated content for %s is invalid: %w", path, err) + } + if !ok { + return renderJSON(existingDoc) + } + existingMap, ok, err := objectValue(existingDoc, "mcp") + if err != nil { + return nil, fmt.Errorf("cannot merge %s: %w", path, err) + } + if !ok { + existingMap = map[string]any{} + } + for name, value := range desiredMap { + existingMap[name] = value + } + existingDoc["mcp"] = existingMap + if reflect.DeepEqual(existingDoc, originalDoc) { + return existing, nil + } + return renderJSON(existingDoc) +} + func mergeJSONFields(path string, existing, desired []byte, fields ...string) ([]byte, error) { existingDoc, err := parseJSONDocument(path, existing) if err != nil { @@ -560,6 +599,20 @@ func parseJSONDocument(path string, data []byte) (map[string]any, error) { return doc, nil } +func parseOpenCodeJSONDocument(path string, data []byte) (map[string]any, error) { + if len(bytes.TrimSpace(data)) == 0 { + return map[string]any{}, nil + } + var doc map[string]any + if err := openCodeJSONUnmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("cannot parse %s for merge: %w", path, err) + } + if doc == nil { + doc = map[string]any{} + } + return doc, nil +} + func objectValue(doc map[string]any, name string) (map[string]any, bool, error) { value, ok := doc[name] if !ok { diff --git a/internal/core/normalize.go b/internal/core/normalize.go index b7f8193..a99d248 100644 --- a/internal/core/normalize.go +++ b/internal/core/normalize.go @@ -273,6 +273,19 @@ func mergeMCPServer(existing, incoming MCPServer) MCPServer { if out.DefaultApproval == "" { out.DefaultApproval = incoming.DefaultApproval } + if out.Tools == nil { + out.Tools = incoming.Tools + } else { + for key, value := range incoming.Tools { + out.Tools[key] = value + } + } + if out.OpenCodeEnabled == nil { + out.OpenCodeEnabled = incoming.OpenCodeEnabled + } + if out.Enabled == nil { + out.Enabled = incoming.Enabled + } out.Targets = mergeTargets(out.Targets, incoming.Targets) return out } diff --git a/internal/core/opencode.go b/internal/core/opencode.go new file mode 100644 index 0000000..69f7ed7 --- /dev/null +++ b/internal/core/opencode.go @@ -0,0 +1,436 @@ +package core + +import ( + "bytes" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strings" +) + +var openCodeEnvRefPattern = regexp.MustCompile(`\{env:([A-Za-z_][A-Za-z0-9_]*)\}`) + +type openCodeAdapter struct{} + +type openCodeDestination struct { + instructionPath string + configPath string + skillRoot string +} + +func (openCodeAdapter) Name() string { + return AgentOpenCode +} + +func (openCodeAdapter) Render(cfg *Config, opts TargetOptions) ([]RenderedFile, error) { + var files []RenderedFile + dest := openCodeDestinationFor(opts) + + instructions, err := readInstruction(opts.KanonHome, cfg.Instructions.Files) + if err != nil { + return nil, err + } + if len(instructions) > 0 { + files = append(files, RenderedFile{ + Agent: AgentOpenCode, + Path: dest.instructionPath, + Content: instructions, + Mode: 0o644, + }) + } + + configDoc := map[string]any{} + if servers := openCodeMCPServers(cfg); len(servers) > 0 { + configDoc["mcp"] = servers + } + if len(configDoc) > 0 { + data, err := renderJSON(configDoc) + if err != nil { + return nil, err + } + files = append(files, RenderedFile{ + Agent: AgentOpenCode, + Path: dest.configPath, + Content: data, + Mode: 0o644, + Merge: FileMergeOpenCodeConfig, + }) + } + + skills, err := renderSkills(cfg, opts, AgentOpenCode, dest.skillRoot) + if err != nil { + return nil, err + } + files = append(files, skills...) + return files, nil +} + +func (openCodeAdapter) Import(opts ImportOptions) (*ImportResult, error) { + configPath := openCodeDestinationFor(opts.TargetOptions).configPath + result := &ImportResult{ + Config: &Config{ + Version: 1, + }, + Files: map[string][]byte{}, + } + if data, err := readIfExists(configPath); err == nil && len(data) > 0 { + var raw map[string]any + if err := openCodeJSONUnmarshal(data, &raw); err != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("could not parse %s: %v", configPath, err)) + } else { + cleaned := sanitizeMap(raw, sanitizeContext{ + policy: opts.SecretPolicy, + warnings: &result.Warnings, + }, "opencode.config") + normalizeOpenCodeConfig(result.Config, cleaned, &result.Warnings) + warnSkippedMap(&result.Warnings, "opencode config", cleaned) + } + } else if err != nil { + return nil, err + } + return result, nil +} + +func openCodeDestinationFor(opts TargetOptions) openCodeDestination { + dest := openCodeDestination{ + instructionPath: filepath.Join(opts.UserHome, ".config", "opencode", "AGENTS.md"), + configPath: filepath.Join(opts.UserHome, ".config", "opencode", "opencode.json"), + skillRoot: filepath.Join(opts.UserHome, ".config", "opencode", "skills"), + } + if opts.Project != "" { + dest.instructionPath = filepath.Join(opts.Project, "AGENTS.md") + dest.configPath = filepath.Join(opts.Project, "opencode.json") + dest.skillRoot = filepath.Join(opts.Project, ".opencode", "skills") + } + return dest +} + +func openCodeMCPServers(cfg *Config) map[string]any { + out := map[string]any{} + names := sortedMCPNames(cfg) + for _, name := range names { + server := cfg.MCP.Servers[name] + if !enabled(server.Enabled) || !HasTarget(server.Targets, AgentOpenCode) { + continue + } + item := map[string]any{} + if server.URL != "" { + item["type"] = "remote" + item["url"] = server.URL + if headers := openCodeHeaders(server); len(headers) > 0 { + item["headers"] = headers + } + } else { + item["type"] = "local" + item["command"] = append([]string{server.Command}, server.Args...) + if len(server.Env) > 0 { + item["environment"] = openCodeEnvironment(server.Env) + } + } + if server.StartupTimeoutSec > 0 { + item["timeout"] = server.StartupTimeoutSec * 1000 + } + if server.OpenCodeEnabled != nil { + item["enabled"] = *server.OpenCodeEnabled + } + out[name] = item + } + return out +} + +func openCodeHeaders(server MCPServer) map[string]string { + headers := map[string]string{} + for key, value := range server.Headers { + if envName, ok := openCodeEnvNameFromKanonRef(value); ok { + headers[key] = fmt.Sprintf("{env:%s}", envName) + continue + } + headers[key] = value + } + for key, value := range server.EnvHeaders { + headers[key] = fmt.Sprintf("{env:%s}", value) + } + if server.BearerTokenEnvVar != "" { + if _, ok := headers["Authorization"]; !ok { + headers["Authorization"] = fmt.Sprintf("Bearer {env:%s}", server.BearerTokenEnvVar) + } + } + return headers +} + +func openCodeEnvironment(values map[string]string) map[string]string { + out := map[string]string{} + for key, value := range values { + if envName, ok := openCodeEnvNameFromKanonRef(value); ok { + out[key] = fmt.Sprintf("{env:%s}", envName) + continue + } + out[key] = value + } + return out +} + +func normalizeOpenCodeConfig(cfg *Config, raw map[string]any, warnings *[]string) { + if raw == nil { + return + } + servers, ok := raw["mcp"].(map[string]any) + if !ok { + return + } + ensureMCPServers(cfg) + for name, value := range servers { + server, ok := normalizeOpenCodeMCPServer(name, value, warnings) + if ok { + cfg.MCP.Servers[name] = mergeMCPServer(cfg.MCP.Servers[name], server) + delete(servers, name) + } + } + if len(servers) == 0 { + delete(raw, "mcp") + } +} + +func normalizeOpenCodeMCPServer(name string, value any, warnings *[]string) (MCPServer, bool) { + raw, ok := value.(map[string]any) + if !ok { + appendNormalizeWarning(warnings, "left MCP server %q raw for %s because it is not an object", name, AgentOpenCode) + return MCPServer{}, false + } + server := MCPServer{Targets: []string{AgentOpenCode}} + remainder := copyMap(raw) + if value, ok := stringValue(raw["type"]); ok { + server.Type = value + delete(remainder, "type") + } + if values, ok := stringList(raw["command"]); ok && len(values) > 0 { + server.Command = values[0] + server.Args = values[1:] + delete(remainder, "command") + } else if value, ok := stringValue(raw["command"]); ok { + server.Command = value + delete(remainder, "command") + } + if values, ok := stringMap(raw["environment"]); ok { + server.Env = normalizeOpenCodeEnvMap(values) + delete(remainder, "environment") + } + if values, ok := stringMap(raw["env"]); ok { + server.Env = normalizeOpenCodeEnvMap(values) + delete(remainder, "env") + } + if value, ok := stringValue(raw["url"]); ok { + server.URL = value + delete(remainder, "url") + } + if values, ok := stringMap(raw["headers"]); ok { + server.Headers, server.EnvHeaders, server.BearerTokenEnvVar = splitOpenCodeHeaders(values) + delete(remainder, "headers") + } + if value, ok := intValue(raw["timeout"]); ok { + server.StartupTimeoutSec = millisecondsToSeconds(value) + delete(remainder, "timeout") + } + if value, ok := boolValue(raw["enabled"]); ok { + server.OpenCodeEnabled = &value + delete(remainder, "enabled") + } + if server.Command == "" && server.URL == "" { + appendNormalizeWarning(warnings, "left MCP server %q raw for %s because it has no command or url", name, AgentOpenCode) + return MCPServer{}, false + } + warnSkippedMap(warnings, fmt.Sprintf("%s mcp server %q", AgentOpenCode, name), remainder) + return server, true +} + +func splitOpenCodeHeaders(values map[string]string) (map[string]string, map[string]string, string) { + literal := map[string]string{} + env := map[string]string{} + var bearerTokenEnvVar string + for key, value := range values { + if strings.EqualFold(key, "Authorization") { + if envName, ok := openCodeBearerEnvName(value); ok { + bearerTokenEnvVar = envName + continue + } + } + if envName, ok := openCodeEnvRefName(value); ok { + env[key] = envName + continue + } + literal[key] = value + } + return literal, env, bearerTokenEnvVar +} + +func normalizeOpenCodeEnvMap(values map[string]string) map[string]string { + out := map[string]string{} + for key, value := range values { + if envName, ok := openCodeEnvRefName(value); ok { + out[key] = fmt.Sprintf("${%s}", envName) + continue + } + out[key] = value + } + return out +} + +func openCodeEnvNameFromKanonRef(value string) (string, bool) { + matches := envRefPattern.FindStringSubmatch(value) + if len(matches) == 0 || matches[0] != value { + return "", false + } + return matches[1], true +} + +func openCodeBearerEnvName(value string) (string, bool) { + const prefix = "Bearer " + if !strings.HasPrefix(value, prefix) { + return "", false + } + return openCodeEnvRefName(strings.TrimPrefix(value, prefix)) +} + +func openCodeEnvRefName(value string) (string, bool) { + matches := openCodeEnvRefPattern.FindStringSubmatch(value) + if len(matches) == 0 || matches[0] != value { + return "", false + } + return matches[1], true +} + +func millisecondsToSeconds(value int) int { + if value <= 0 { + return 0 + } + return (value + 999) / 1000 +} + +func openCodeJSONUnmarshal(data []byte, out any) error { + return json.Unmarshal(stripJSONTrailingCommas(stripJSONComments(data)), out) +} + +func stripJSONComments(data []byte) []byte { + var out bytes.Buffer + inString := false + escaped := false + for i := 0; i < len(data); i++ { + ch := data[i] + if inString { + out.WriteByte(ch) + if escaped { + escaped = false + continue + } + switch ch { + case '\\': + escaped = true + case '"': + inString = false + } + continue + } + if ch == '"' { + inString = true + out.WriteByte(ch) + continue + } + if ch == '/' && i+1 < len(data) { + next := data[i+1] + if next == '/' { + i += 2 + for i < len(data) && data[i] != '\n' && data[i] != '\r' { + i++ + } + if i < len(data) { + out.WriteByte(data[i]) + } + continue + } + if next == '*' { + i += 2 + for i < len(data)-1 { + if data[i] == '\n' || data[i] == '\r' { + out.WriteByte(data[i]) + } + if data[i] == '*' && data[i+1] == '/' { + i++ + break + } + i++ + } + continue + } + } + out.WriteByte(ch) + } + return out.Bytes() +} + +func stripJSONTrailingCommas(data []byte) []byte { + var out bytes.Buffer + inString := false + escaped := false + for i := 0; i < len(data); i++ { + ch := data[i] + if inString { + out.WriteByte(ch) + if escaped { + escaped = false + continue + } + switch ch { + case '\\': + escaped = true + case '"': + inString = false + } + continue + } + if ch == '"' { + inString = true + out.WriteByte(ch) + continue + } + if ch == ',' { + j := i + 1 + for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\n' || data[j] == '\r') { + j++ + } + if j < len(data) && (data[j] == '}' || data[j] == ']') { + continue + } + } + out.WriteByte(ch) + } + return out.Bytes() +} + +func openCodeSkillName(value string) string { + lower := strings.ToLower(value) + var out strings.Builder + lastHyphen := false + for _, r := range lower { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + out.WriteRune(r) + lastHyphen = false + continue + } + if !lastHyphen && out.Len() > 0 { + out.WriteByte('-') + lastHyphen = true + } + } + name := strings.Trim(out.String(), "-") + if name == "" { + return "skill" + } + if len(name) > 64 { + name = strings.TrimRight(name[:64], "-") + } + if name == "" { + return "skill" + } + return name +} diff --git a/internal/core/render.go b/internal/core/render.go index f1e1f7c..f891f0e 100644 --- a/internal/core/render.go +++ b/internal/core/render.go @@ -24,6 +24,11 @@ func RenderAll(cfg *Config, opts TargetOptions) ([]RenderedFile, error) { } files = append(files, rendered...) } + var err error + files, err = dedupeRenderedFiles(files) + if err != nil { + return nil, err + } sort.Slice(files, func(i, j int) bool { if files[i].Path == files[j].Path { return files[i].Agent < files[j].Agent @@ -60,11 +65,30 @@ func adaptersFor(agent string) []Adapter { return []Adapter{codexAdapter{}} case AgentClaude: return []Adapter{claudeAdapter{}} + case AgentOpenCode: + return []Adapter{openCodeAdapter{}} default: - return []Adapter{codexAdapter{}, claudeAdapter{}} + return []Adapter{codexAdapter{}, claudeAdapter{}, openCodeAdapter{}} } } +func dedupeRenderedFiles(files []RenderedFile) ([]RenderedFile, error) { + byPath := map[string]RenderedFile{} + var out []RenderedFile + for _, file := range files { + existing, ok := byPath[file.Path] + if !ok { + byPath[file.Path] = file + out = append(out, file) + continue + } + if existing.Mode != file.Mode || existing.Merge != file.Merge || !bytes.Equal(existing.Content, file.Content) { + return nil, fmt.Errorf("multiple agents render different content for %s (%s and %s)", file.Path, existing.Agent, file.Agent) + } + } + return out, nil +} + func readInstruction(home string, paths []string) ([]byte, error) { var out bytes.Buffer for _, rel := range paths { @@ -171,7 +195,7 @@ func renderSkills(cfg *Config, opts TargetOptions, agent, targetRoot string) ([] } owner := fmt.Sprintf("git skill provider %q", name) for _, item := range items { - renderName := namespacedSkillName(name, item.Name) + renderName := skillProviderRenderName(agent, name, item.Name) if err := renderSkillDirectory(&files, rendered, agent, targetRoot, renderName, item.Path, owner, renderName); err != nil { return nil, err } @@ -286,6 +310,13 @@ func namespacedSkillName(providerID, skillName string) string { return providerID + ":" + skillName } +func skillProviderRenderName(agent, providerID, skillName string) string { + if agent == AgentOpenCode { + return openCodeSkillName(providerID + "-" + skillName) + } + return namespacedSkillName(providerID, skillName) +} + func localSkillDirectoryItems(home string) ([]materializedSkillDirectoryItem, error) { root := filepath.Join(home, "skills") entries, err := os.ReadDir(root) diff --git a/internal/core/render_test.go b/internal/core/render_test.go index 7821211..7bcf272 100644 --- a/internal/core/render_test.go +++ b/internal/core/render_test.go @@ -59,6 +59,10 @@ func TestRenderUserScopedOutputs(t *testing.T) { if !strings.Contains(claudeAgents, "Shared rules") || !strings.Contains(claudeAgents, "Codex rules") || !strings.Contains(claudeAgents, "Claude rules") { t.Fatalf("claude instructions were not combined: %q", claudeAgents) } + openCodeAgents := string(byPath[filepath.Join(userHome, ".config", "opencode", "AGENTS.md")].Content) + if !strings.Contains(openCodeAgents, "Shared rules") || !strings.Contains(openCodeAgents, "Codex rules") || !strings.Contains(openCodeAgents, "Claude rules") { + t.Fatalf("opencode instructions were not combined: %q", openCodeAgents) + } codexConfig := string(byPath[filepath.Join(userHome, ".codex", "config.toml")].Content) if !strings.Contains(codexConfig, "context-server") { t.Fatalf("codex config missing MCP server: %s", codexConfig) @@ -67,12 +71,68 @@ func TestRenderUserScopedOutputs(t *testing.T) { if !strings.Contains(claudeJSON, `"mcpServers"`) || !strings.Contains(claudeJSON, `"context"`) { t.Fatalf("claude mcp output missing server: %s", claudeJSON) } + openCodeJSON := string(byPath[filepath.Join(userHome, ".config", "opencode", "opencode.json")].Content) + if !strings.Contains(openCodeJSON, `"mcp"`) || !strings.Contains(openCodeJSON, `"context"`) || !strings.Contains(openCodeJSON, `"command": [`) || !strings.Contains(openCodeJSON, `{env:CONTEXT_TOKEN}`) || strings.Contains(openCodeJSON, "${CONTEXT_TOKEN:-unset}") { + t.Fatalf("opencode mcp output missing server: %s", openCodeJSON) + } if _, ok := byPath[filepath.Join(userHome, ".agents", "skills", "review", "SKILL.md")]; !ok { t.Fatalf("codex skill was not rendered") } if _, ok := byPath[filepath.Join(userHome, ".claude", "skills", "review", "SKILL.md")]; !ok { t.Fatalf("claude skill was not rendered") } + if _, ok := byPath[filepath.Join(userHome, ".config", "opencode", "skills", "review", "SKILL.md")]; !ok { + t.Fatalf("opencode skill was not rendered") + } +} + +func TestRenderProjectScopedOpenCodeOutputs(t *testing.T) { + kanonHome := t.TempDir() + project := t.TempDir() + writeTestFile(t, filepath.Join(kanonHome, "instructions", "shared.md"), []byte("Shared rules\n")) + writeTestFile(t, filepath.Join(kanonHome, "skills", "review", "SKILL.md"), []byte("---\nname: review\n---\n\nReview code.\n")) + + cfg := &Config{ + Version: 1, + Instructions: Instructions{Files: []string{"instructions/shared.md"}}, + MCP: MCPConfig{Servers: map[string]MCPServer{ + "context": { + Command: "context-server", + Args: []string{"--stdio"}, + Env: map[string]string{"TOKEN": "${CONTEXT_TOKEN:-unset}"}, + StartupTimeoutSec: 5, + }, + }}, + } + + files, err := RenderAll(cfg, TargetOptions{ + KanonHome: kanonHome, + UserHome: t.TempDir(), + Project: project, + Agent: AgentAll, + }) + if err != nil { + t.Fatal(err) + } + byPath := renderedByPath(files) + if got := string(byPath[filepath.Join(project, "AGENTS.md")].Content); got != "Shared rules\n" { + t.Fatalf("shared project AGENTS.md was not rendered once with expected content: %q", got) + } + if _, ok := byPath[filepath.Join(project, "opencode.json")]; !ok { + t.Fatalf("opencode project config was not rendered") + } + if _, ok := byPath[filepath.Join(project, ".opencode", "skills", "review", "SKILL.md")]; !ok { + t.Fatalf("opencode project skill was not rendered") + } + var agentsPathCount int + for _, file := range files { + if file.Path == filepath.Join(project, "AGENTS.md") { + agentsPathCount++ + } + } + if agentsPathCount != 1 { + t.Fatalf("expected one shared AGENTS.md render, got %d: %#v", agentsPathCount, files) + } } func TestRenderTreatsEmptyTargetsAsAllTargets(t *testing.T) { @@ -386,6 +446,30 @@ func TestRemoteSkillDirectoryUsesProviderNamespace(t *testing.T) { } } +func TestOpenCodeRemoteSkillNamespaceUsesValidSkillName(t *testing.T) { + kanonHome := t.TempDir() + userHome := t.TempDir() + repo := t.TempDir() + writeTestFile(t, filepath.Join(repo, "packs", "review", "SKILL.md"), []byte("---\nname: review\n---\n")) + ref := commitTestRepo(t, repo, "add remote skill") + + files, err := RenderAll(&Config{ + Version: 1, + Skills: []Skill{{ + Name: "Shared.Pack", + Git: &GitSkill{URL: repo, Ref: ref, Subdir: "packs"}, + }}, + }, TargetOptions{KanonHome: kanonHome, UserHome: userHome, Agent: AgentOpenCode}) + if err != nil { + t.Fatal(err) + } + byPath := renderedByPath(files) + path := filepath.Join(userHome, ".config", "opencode", "skills", "shared-pack-review", "SKILL.md") + if !strings.Contains(string(byPath[path].Content), "name: shared-pack-review") { + t.Fatalf("remote OpenCode skill was not rendered with a valid namespaced name: %#v", byPath[path]) + } +} + func TestRemoteSkillRootSourceDoesNotCacheGitMetadata(t *testing.T) { kanonHome := t.TempDir() userHome := t.TempDir() diff --git a/internal/core/types.go b/internal/core/types.go index 2fdbdb9..d1c098f 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -3,9 +3,10 @@ package core import "io/fs" const ( - AgentCodex = "codex" - AgentClaude = "claude" - AgentAll = "all" + AgentCodex = "codex" + AgentClaude = "claude" + AgentOpenCode = "opencode" + AgentAll = "all" ) type Config struct { @@ -79,6 +80,7 @@ type MCPServer struct { DisabledTools []string `yaml:"disabled_tools"` DefaultApproval string `yaml:"default_approval"` Tools map[string]MCPToolPolicy `yaml:"tools"` + OpenCodeEnabled *bool `yaml:"opencode_enabled,omitempty"` Targets []string `yaml:"targets,omitempty"` Enabled *bool `yaml:"enabled"` } @@ -124,11 +126,12 @@ const ( type InstructionPolicy string const ( - InstructionPolicyAuto InstructionPolicy = "auto" - InstructionPolicyCodex InstructionPolicy = "codex" - InstructionPolicyClaude InstructionPolicy = "claude" - InstructionPolicyMerge InstructionPolicy = "merge" - InstructionPolicySkip InstructionPolicy = "skip" + InstructionPolicyAuto InstructionPolicy = "auto" + InstructionPolicyCodex InstructionPolicy = "codex" + InstructionPolicyClaude InstructionPolicy = "claude" + InstructionPolicyOpenCode InstructionPolicy = "opencode" + InstructionPolicyMerge InstructionPolicy = "merge" + InstructionPolicySkip InstructionPolicy = "skip" ) type RenderedFile struct { @@ -146,6 +149,7 @@ const ( FileMergeCodexConfig FileMergeStrategy = "codex_config" FileMergeClaudeSettings FileMergeStrategy = "claude_settings" FileMergeClaudeMCP FileMergeStrategy = "claude_mcp" + FileMergeOpenCodeConfig FileMergeStrategy = "opencode_config" ) type Adapter interface { diff --git a/internal/tui/deps.go b/internal/tui/deps.go index 77a4e8d..6c86982 100644 --- a/internal/tui/deps.go +++ b/internal/tui/deps.go @@ -23,7 +23,7 @@ type SelectedChange struct { type Deps struct { Home string UserHome string - Agent string // "all", "codex", or "claude" + Agent string // "all", "codex", "claude", or "opencode" Project string DryRun bool Mode Mode diff --git a/internal/tui/view.go b/internal/tui/view.go index 53e0acb..54b497f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -67,7 +67,7 @@ func (m Model) renderAgents() string { b.WriteString(m.styles.AgentActive.Render("mode: " + string(m.mode))) b.WriteByte('\n') b.WriteByte('\n') - for _, a := range []string{core.AgentClaude, core.AgentCodex} { + for _, a := range []string{core.AgentClaude, core.AgentCodex, core.AgentOpenCode} { active := m.deps.Agent == core.AgentAll || m.deps.Agent == a label := fmt.Sprintf("%s (%d)", a, counts[a]) if active {