Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 21 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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.
10 changes: 5 additions & 5 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <home>/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))
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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{
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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())
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
92 changes: 92 additions & 0 deletions internal/core/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion internal/core/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down
3 changes: 2 additions & 1 deletion internal/core/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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://")
Expand Down
Loading
Loading