feat: sync rules across all harnesses + agents to opencode/kilo

Add Rule as a first-class canonical kind (rules/<name>.md, loaded like
agents) and propagate it the way each harness actually loads it:

- claude-code: native ~/.claude/rules directory symlink (preserves rule
  path-scoping frontmatter); rules NOT folded into CLAUDE.md.
- opencode/kilo/zed/crush: fold rule bodies into the global AGENTS.md
  always-on instructions file (frontmatter stripped). crush has no global
  instructions file, so register the absolute AGENTS.md path in
  options.context_paths (defaults are re-prepended at load, so nothing is
  clobbered).
- goose: fold into ~/.config/goose/.goosehints.
- cagent: fold into the per-agent instruction.

Also:
- Wire markdown agents to opencode (~/.config/opencode/agents) and fix the
  kilo agents symlink: plural ~/.config/kilo/agents (singular "agent" is a
  silent no-op on current Kilo CLI).
- Add ManagesAgents/ManagesRules to HarnessCapabilities + ImportResult.Rules;
  claude-code/opencode import rules/agents round-trip.
- Centralize instruction selection in Bundle.InstructionText /
  InstructionTextWithRules (replaces per-adapter override duplication).
- init scaffolds + imports rules; README capability matrix gains
  Agents/Rules columns with native-vs-folded footnotes.

Grounded each harness's sink against official docs + config source this
session. Gate: gofmt, go vet, go test -race ./..., golangci-lint (0),
govulncheck (clean).
This commit is contained in:
2026-06-16 21:32:12 +01:00
parent 0892cb317e
commit f30c0f65a6
28 changed files with 705 additions and 37 deletions
+23 -13
View File
@@ -3,7 +3,7 @@
</p> </p>
<p align="center"> <p align="center">
<strong>One canonical source of truth</strong> for skills, agents, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine. <strong>One canonical source of truth</strong> for skills, agents, rules, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine.
</p> </p>
<p align="center"> <p align="center">
@@ -40,6 +40,7 @@ Edit once. Forget the others. They drift. Things stop working in odd places.
│ • profiles/<name>.yaml │ │ • profiles/<name>.yaml │
│ • skills/<name>/SKILL.md │ │ • skills/<name>/SKILL.md │
│ • agents/<name>.md │ │ • agents/<name>.md │
│ • rules/<name>.md │
│ • mcp.yaml │ │ • mcp.yaml │
│ • instructions/global.md │ │ • instructions/global.md │
└──────────────┬───────────────────┘ └──────────────┬───────────────────┘
@@ -145,21 +146,30 @@ harness-sync goes out of its way to avoid.
### Capability matrix ### Capability matrix
What harness-sync writes per harness. Harnesses with a **built-in subscription** receive MCP + skills + instructions only — provider/model/endpoint config is skipped to avoid conflicting with the harness's own auth. What harness-sync writes per harness. Harnesses with a **built-in subscription** receive MCP + skills + agents + rules + instructions only — provider/model/endpoint config is skipped to avoid conflicting with the harness's own auth.
Skills paths are grounded in each harness's official documentation (kilo.ai, opencode.ai, goose-docs.ai). kilo, opencode, and goose also scan `~/.claude/skills/` as a compatibility path, so the claude-code symlink passively reaches them already. Skills paths are grounded in each harness's official documentation (kilo.ai, opencode.ai, goose-docs.ai). kilo, opencode, and goose also scan `~/.claude/skills/` as a compatibility path, so the claude-code symlink passively reaches them already.
| Harness | Built-in sub | Providers | Models | MCP | Skills | Instructions | | Harness | Built-in sub | Providers | Models | MCP | Skills | Agents | Rules | Instructions |
|---|:---:|:---:|:---:|:---:|:---:|:---:| |---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| **claude-code** | ✓ | — | — | ✓ | ✓ | ✓ | | **claude-code** | ✓ | — | — | ✓ | ✓ | ✓ | ✓ⁿ | ✓ |
| **crush** | — | ✓ | ✓ | ✓ | ✓ | — | | **crush** | — | ✓ | ✓ | ✓ | ✓ | — | ✓ᶠ | ✓ᶠ |
| **kilo** | — | ✓ | ✓ | ✓ | ✓ | — | | **kilo** | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ᶠ | ✓ᶠ |
| **opencode** | — | ✓ | ✓ | ✓ | ✓ | ✓ | | **opencode** | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ᶠ | ✓ |
| **goose** | — | ✓ | ✓ | ✓ | ✓ | — | | **goose** | — | ✓ | ✓ | ✓ | ✓ | — | ✓ᶠ | ✓ᶠ |
| **cagent** | — | ✓ | ✓ | ✓ | — | ✓ | | **cagent** | — | ✓ | ✓ | ✓ | — | — | ✓ᶠ | ✓ |
| **zed** | — | ✓ | ✓ | ✓ | — | — | | **zed** | — | ✓ | ✓ | — | — | — | ✓ᶠ | ✓ᶠ |
The matrix is machine-readable via `adapter.Capabilities()` — every adapter implements `HarnessCapabilities` so tooling can inspect what will change before running `apply`. The matrix is machine-readable via `adapter.Capabilities()` — every adapter implements `HarnessCapabilities` (`ManagesProviders/Models/MCP/Skills/Agents/Rules/Instructions`) so tooling can inspect what will change before running `apply`.
### Agents & rules — native where possible, folded where necessary
Each kind is delivered the way the target harness actually loads it (grounded in each harness's docs and config source — a wrong path is a silent no-op):
- **Agents** are markdown subagents. They map cleanly to **claude-code** (`~/.claude/agents/`), **kilo** (`~/.config/kilo/agents/` — plural; the older singular `agent/` is a no-op on current Kilo CLI), and **opencode** (`~/.config/opencode/agents/`), all as directory symlinks. **goose** (YAML recipes) and **cagent** (inline YAML `agents:`) use incompatible agent formats, and **crush**/**zed** have no markdown-subagent concept — so agents are not propagated there.
- **Rules** (`rules/<name>.md`) are topic-scoped instruction fragments. **claude-code** auto-loads a rules *directory* natively (ⁿ), so they're delivered as a `~/.claude/rules` symlink — preserving each rule's optional path-scoping frontmatter. No other harness has a rules directory, so their bodies are **folded** (ᶠ) into that harness's global always-on instructions file — `AGENTS.md` (opencode/kilo/zed/crush), `.goosehints` (goose), or the per-agent `instruction:` (cagent). crush has no global instructions file, so harness-sync writes `~/.config/crush/AGENTS.md` and registers its absolute path in `options.context_paths` (crush always re-adds its built-in defaults at load, so nothing is clobbered).
ⁿ delivered as a native rules directory ᶠ folded into the harness's global instructions file
> **Merged, not replaced.** For harnesses with user-managed config keys (every one > **Merged, not replaced.** For harnesses with user-managed config keys (every one
> except cagent), harness-sync reads the existing file, overlays only the keys > except cagent), harness-sync reads the existing file, overlays only the keys
@@ -279,7 +289,7 @@ end-to-end test that runs the real binary against a fake `$HOME`.
``` ```
cmd/harness-sync/main.go # cobra entrypoint + adapter registration cmd/harness-sync/main.go # cobra entrypoint + adapter registration
internal/canonical/ # Bundle, Profile, MCPRegistry, Skill, Agent types + loader internal/canonical/ # Bundle, Profile, MCPRegistry, Skill, Agent, Rule types + loader
internal/adapter/ # Adapter interface, Registry, FileSet internal/adapter/ # Adapter interface, Registry, FileSet
internal/adapter/common/ # shared building blocks (BuildProviders, BuildMCPMapStyled, MergeJSONKeys, MergeYAMLKeys, …) internal/adapter/common/ # shared building blocks (BuildProviders, BuildMCPMapStyled, MergeJSONKeys, MergeYAMLKeys, …)
internal/adapters/<harness>/ # one package per harness, ~50-100 LOC of harness-specific glue internal/adapters/<harness>/ # one package per harness, ~50-100 LOC of harness-specific glue
+3
View File
@@ -31,6 +31,8 @@ type HarnessCapabilities struct {
ManagesModels bool // writes model selection config ManagesModels bool // writes model selection config
ManagesMCP bool // writes MCP server config ManagesMCP bool // writes MCP server config
ManagesSkills bool // writes skills symlink or equivalent ManagesSkills bool // writes skills symlink or equivalent
ManagesAgents bool // writes agents symlink or equivalent
ManagesRules bool // delivers rules (native rules dir, or folded into instructions)
ManagesInstructions bool // writes instructions file (CLAUDE.md, AGENTS.md, etc.) ManagesInstructions bool // writes instructions file (CLAUDE.md, AGENTS.md, etc.)
HasBuiltInSub bool // has its own subscription; provider/model config is skipped HasBuiltInSub bool // has its own subscription; provider/model config is skipped
} }
@@ -47,6 +49,7 @@ type ImportResult struct {
Instructions string Instructions string
Skills []canonical.Skill Skills []canonical.Skill
Agents []canonical.Agent Agents []canonical.Agent
Rules []canonical.Rule
MCP []canonical.MCPServer MCP []canonical.MCPServer
Profiles []canonical.Profile Profiles []canonical.Profile
} }
+4 -4
View File
@@ -41,6 +41,7 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: false, ManagesSkills: false,
ManagesRules: true,
ManagesInstructions: true, ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
@@ -57,10 +58,9 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet() fs := adapter.NewFileSet()
cfgPath := filepath.Join(a.Home, ".config", "cagent", "default.yaml") cfgPath := filepath.Join(a.Home, ".config", "cagent", "default.yaml")
instructions := b.Instructions.Global // cagent has no rules concept; the per-agent instruction (system prompt) is
if override, ok := b.Instructions.PerHarness[name]; ok && override != "" { // the only sink, so fold rules into it.
instructions = override instructions := b.InstructionTextWithRules(name)
}
mcps := map[string]any{} mcps := map[string]any{}
for _, s := range b.MCP.Servers { for _, s := range b.MCP.Servers {
+37
View File
@@ -0,0 +1,37 @@
package cagent
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"gopkg.in/yaml.v3"
)
func TestCagentFoldsRulesIntoInstruction(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "rust", Body: "# Rust\n\nno unwrap"}},
}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfg := seen[filepath.Join(home, ".config", "cagent", "default.yaml")]
require.Equal(t, adapter.RenderedFile, cfg.Kind)
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(cfg.Content, &parsed))
agents := parsed["agents"].(map[string]any)
def := agents["default"].(map[string]any)
instr := def["instruction"].(string)
assert.Contains(t, instr, "# global")
assert.Contains(t, instr, "no unwrap")
}
+11 -5
View File
@@ -37,6 +37,8 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: false, ManagesModels: false,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: true, ManagesSkills: true,
ManagesAgents: true,
ManagesRules: true,
ManagesInstructions: true, ManagesInstructions: true,
HasBuiltInSub: true, HasBuiltInSub: true,
} }
@@ -65,15 +67,19 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Kind: adapter.SymlinkDir, Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "agents"), SymlinkTarget: filepath.Join(b.Root, "agents"),
}) })
// Claude Code natively auto-loads ~/.claude/rules/*.md (memory feature), so
// rules are delivered as their own directory symlink — NOT folded into
// CLAUDE.md — preserving each rule's optional path-scoping frontmatter.
fs.Add(adapter.File{
Dest: filepath.Join(base, "rules"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "rules"),
})
instructions := b.Instructions.Global
if override, ok := b.Instructions.PerHarness[name]; ok && override != "" {
instructions = override
}
fs.Add(adapter.File{ fs.Add(adapter.File{
Dest: filepath.Join(base, "CLAUDE.md"), Dest: filepath.Join(base, "CLAUDE.md"),
Kind: adapter.RenderedFile, Kind: adapter.RenderedFile,
Content: []byte(instructions), Content: []byte(b.InstructionText(name)),
}) })
// Claude Code reads MCP servers from two files on disk: // Claude Code reads MCP servers from two files on disk:
+10
View File
@@ -32,6 +32,16 @@ func importFrom(home string) (*adapter.ImportResult, error) {
}) })
} }
rules, err := common.ImportMarkdownTree(filepath.Join(base, "rules"), "")
if err != nil {
return nil, err
}
for _, r := range rules {
res.Rules = append(res.Rules, canonical.Rule{
Name: r.Name, Description: r.Description, Body: r.Body, Path: r.Path,
})
}
body, err := common.ReadIfExists(filepath.Join(base, "CLAUDE.md")) body, err := common.ReadIfExists(filepath.Join(base, "CLAUDE.md"))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -0,0 +1,47 @@
package claudecode
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestClaudeCodeRendersRulesSymlink(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{Root: "/canon"}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
rulesDest := filepath.Join(home, ".claude", "rules")
assert.Equal(t, adapter.SymlinkDir, seen[rulesDest].Kind)
assert.Equal(t, "/canon/rules", seen[rulesDest].SymlinkTarget)
// Rules are delivered natively as a directory — never folded into CLAUDE.md.
claudemd := string(seen[filepath.Join(home, ".claude", "CLAUDE.md")].Content)
assert.NotContains(t, claudemd, "/canon/rules")
}
func TestClaudeCodeImportsRules(t *testing.T) {
home := t.TempDir()
rulesDir := filepath.Join(home, ".claude", "rules")
require.NoError(t, os.MkdirAll(rulesDir, 0o750))
require.NoError(t, os.WriteFile(filepath.Join(rulesDir, "go.md"),
[]byte("# Go Rules\n\ngofmt always"), 0o600))
ad := New(WithHome(home))
res, err := ad.Import(home)
require.NoError(t, err)
require.Len(t, res.Rules, 1)
assert.Equal(t, "go", res.Rules[0].Name)
assert.Contains(t, res.Rules[0].Body, "gofmt always")
}
+44 -1
View File
@@ -2,6 +2,7 @@
package crush package crush
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -36,7 +37,8 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: true, ManagesSkills: true,
ManagesInstructions: false, ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
} }
@@ -60,12 +62,25 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
SymlinkTarget: filepath.Join(b.Root, "skills"), SymlinkTarget: filepath.Join(b.Root, "skills"),
}) })
// crush has no global instructions file and no rules directory: it loads
// context from the files listed in options.context_paths (defaults are always
// prepended; absolute paths load verbatim; the global config applies to every
// project). Write a global AGENTS.md with rules folded in, and register its
// absolute path so crush loads it in every session.
agentsMD := filepath.Join(base, "AGENTS.md")
fs.Add(adapter.File{
Dest: agentsMD,
Kind: adapter.RenderedFile,
Content: []byte(b.InstructionTextWithRules(name)),
})
cfgPath := filepath.Join(base, "crush.json") cfgPath := filepath.Join(base, "crush.json")
existing, err := common.ReadExistingFile(cfgPath) existing, err := common.ReadExistingFile(cfgPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("read %s: %w", cfgPath, err) return nil, fmt.Errorf("read %s: %w", cfgPath, err)
} }
overlay := map[string]any{} overlay := map[string]any{}
overlay["options"] = crushOptionsWithContextPath(existing, agentsMD)
if providers := common.ProvidersAsCrushMap(&b.Profile); len(providers) > 0 { if providers := common.ProvidersAsCrushMap(&b.Profile); len(providers) > 0 {
overlay["providers"] = providers overlay["providers"] = providers
} }
@@ -89,6 +104,34 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
return fs, nil return fs, nil
} }
// crushOptionsWithContextPath returns the crush "options" object with contextFile
// added to options.context_paths, preserving every other existing option key
// (lsp, permissions, …). crush always prepends its built-in default context
// paths at load time, so only the extra path is set here. Idempotent.
func crushOptionsWithContextPath(existing []byte, contextFile string) map[string]any {
opts := map[string]any{}
if len(existing) > 0 {
var raw map[string]any
clean := common.StripJSONComments(string(existing))
if json.Unmarshal([]byte(clean), &raw) == nil {
if o, ok := raw["options"].(map[string]any); ok {
opts = o
}
}
}
var paths []any
if existingPaths, ok := opts["context_paths"].([]any); ok {
paths = existingPaths
}
for _, p := range paths {
if s, ok := p.(string); ok && s == contextFile {
return opts // already registered
}
}
opts["context_paths"] = append(paths, contextFile)
return opts
}
// Import reads crush config from home and returns a canonical ImportResult. // Import reads crush config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) { func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home) return importFrom(home)
+83
View File
@@ -0,0 +1,83 @@
package crush
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func renderCrush(t *testing.T, home string, b *canonical.Bundle) map[string]adapter.File {
t.Helper()
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
return seen
}
func TestCrushWritesAGENTSAndRegistersContextPath(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Root: "/canon",
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "sec", Body: "# Security\n\nno secrets in code"}},
}
seen := renderCrush(t, home, b)
base := filepath.Join(home, ".config", "crush")
agentsMD := filepath.Join(base, "AGENTS.md")
assert.Equal(t, adapter.RenderedFile, seen[agentsMD].Kind)
body := string(seen[agentsMD].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "no secrets in code")
var cfg map[string]any
require.NoError(t, json.Unmarshal(seen[filepath.Join(base, "crush.json")].Content, &cfg))
opts := cfg["options"].(map[string]any)
paths := opts["context_paths"].([]any)
assert.Contains(t, paths, agentsMD, "absolute AGENTS.md path must be registered for global load")
}
func TestCrushPreservesExistingOptionsAndIsIdempotent(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "crush")
require.NoError(t, os.MkdirAll(base, 0o750))
agentsMD := filepath.Join(base, "AGENTS.md")
// Existing crush.json with other options keys and a pre-existing context path
// (including our own — second apply must not duplicate it).
existing := map[string]any{
"options": map[string]any{
"context_paths": []any{"PROJECT.md", agentsMD},
"lsp": map[string]any{"go": true},
},
}
body, _ := json.Marshal(existing)
require.NoError(t, os.WriteFile(filepath.Join(base, "crush.json"), body, 0o600))
seen := renderCrush(t, home, &canonical.Bundle{Root: "/canon"})
var cfg map[string]any
require.NoError(t, json.Unmarshal(seen[filepath.Join(base, "crush.json")].Content, &cfg))
opts := cfg["options"].(map[string]any)
// Other option keys survive.
assert.Contains(t, opts, "lsp")
// Pre-existing user path survives; ours is present exactly once (idempotent).
paths := opts["context_paths"].([]any)
count := 0
for _, p := range paths {
if p == agentsMD {
count++
}
}
assert.Equal(t, 1, count, "our context path must not be duplicated on re-apply")
assert.Contains(t, paths, "PROJECT.md")
}
+12 -1
View File
@@ -45,7 +45,8 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: true, ManagesSkills: true,
ManagesInstructions: false, ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
} }
@@ -113,6 +114,16 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: merged, Content: merged,
}) })
// goose has no native rules directory; ~/.config/goose/.goosehints is the
// global hints file applied to every session, so fold rules into it.
if hints := b.InstructionTextWithRules(name); hints != "" {
fs.Add(adapter.File{
Dest: filepath.Join(a.Home, ".config", "goose", ".goosehints"),
Kind: adapter.RenderedFile,
Content: []byte(hints),
})
}
return fs, nil return fs, nil
} }
+31
View File
@@ -0,0 +1,31 @@
package goose
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestGooseFoldsRulesIntoGoosehints(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "k8s", Body: "# Kube\n\nlabel everything"}},
}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
hints := filepath.Join(home, ".config", "goose", ".goosehints")
require.Contains(t, seen, hints)
assert.Equal(t, adapter.RenderedFile, seen[hints].Kind)
body := string(seen[hints].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "label everything")
}
+14 -2
View File
@@ -38,7 +38,9 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: true, ManagesSkills: true,
ManagesInstructions: false, ManagesAgents: true,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
} }
@@ -58,8 +60,11 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet() fs := adapter.NewFileSet()
base := filepath.Join(a.Home, ".config", "kilo") base := filepath.Join(a.Home, ".config", "kilo")
// Kilo CLI reads global markdown subagents from ~/.config/kilo/agents/<name>.md
// (PLURAL — the singular "agent/" used by older OpenCode-fork builds is a
// silent no-op on current Kilo).
fs.Add(adapter.File{ fs.Add(adapter.File{
Dest: filepath.Join(base, "agent"), Dest: filepath.Join(base, "agents"),
Kind: adapter.SymlinkDir, Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "agents"), SymlinkTarget: filepath.Join(b.Root, "agents"),
}) })
@@ -69,6 +74,13 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Kind: adapter.SymlinkDir, Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "skills"), SymlinkTarget: filepath.Join(b.Root, "skills"),
}) })
// kilo has no global rules directory; ~/.config/kilo/AGENTS.md is the
// always-on global instructions file, so fold rules into it.
fs.Add(adapter.File{
Dest: filepath.Join(base, "AGENTS.md"),
Kind: adapter.RenderedFile,
Content: []byte(b.InstructionTextWithRules(name)),
})
cfgPath := filepath.Join(base, "kilo.json") cfgPath := filepath.Join(base, "kilo.json")
existing, err := common.ReadExistingFile(cfgPath) existing, err := common.ReadExistingFile(cfgPath)
+2 -1
View File
@@ -33,7 +33,8 @@ func TestKiloRender(t *testing.T) {
seen := map[string]adapter.File{} seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
agentDest := filepath.Join(home, ".config", "kilo", "agent") // Global markdown subagents dir is PLURAL "agents" on current Kilo CLI.
agentDest := filepath.Join(home, ".config", "kilo", "agents")
assert.Equal(t, adapter.SymlinkDir, seen[agentDest].Kind) assert.Equal(t, adapter.SymlinkDir, seen[agentDest].Kind)
assert.Equal(t, "/canon/agents", seen[agentDest].SymlinkTarget) assert.Equal(t, "/canon/agents", seen[agentDest].SymlinkTarget)
+32
View File
@@ -0,0 +1,32 @@
package kilo
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestKiloFoldsRulesIntoAGENTS(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Root: "/canon",
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "py", Body: "# Py\n\ntype hints"}},
}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
agentsMD := filepath.Join(home, ".config", "kilo", "AGENTS.md")
require.Contains(t, seen, agentsMD)
assert.Equal(t, adapter.RenderedFile, seen[agentsMD].Kind)
body := string(seen[agentsMD].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "type hints")
}
+11
View File
@@ -5,12 +5,23 @@ import (
"github.com/lukaszraczylo/harness-sync/internal/adapter" "github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common" "github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
) )
func importFrom(home string) (*adapter.ImportResult, error) { func importFrom(home string) (*adapter.ImportResult, error) {
base := filepath.Join(home, ".config", "opencode") base := filepath.Join(home, ".config", "opencode")
res := &adapter.ImportResult{} res := &adapter.ImportResult{}
agents, err := common.ImportMarkdownTree(filepath.Join(base, "agents"), "")
if err != nil {
return nil, err
}
for _, a := range agents {
res.Agents = append(res.Agents, canonical.Agent{
Name: a.Name, Description: a.Description, Body: a.Body, Path: a.Path,
})
}
cfgBody, err := common.ReadIfExists(filepath.Join(base, "opencode.jsonc")) cfgBody, err := common.ReadIfExists(filepath.Join(base, "opencode.jsonc"))
if err != nil { if err != nil {
return nil, err return nil, err
+11 -5
View File
@@ -37,6 +37,8 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: true, ManagesSkills: true,
ManagesAgents: true,
ManagesRules: true,
ManagesInstructions: true, ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
@@ -62,6 +64,12 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Kind: adapter.SymlinkDir, Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "skills"), SymlinkTarget: filepath.Join(b.Root, "skills"),
}) })
// opencode reads markdown subagents from ~/.config/opencode/agents/<name>.md
fs.Add(adapter.File{
Dest: filepath.Join(base, "agents"),
Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "agents"),
})
cfgPath := filepath.Join(base, "opencode.jsonc") cfgPath := filepath.Join(base, "opencode.jsonc")
existing, err := common.ReadExistingFile(cfgPath) existing, err := common.ReadExistingFile(cfgPath)
@@ -104,14 +112,12 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: merged, Content: merged,
}) })
instructions := b.Instructions.Global // opencode has no native rules directory; rule content reaches it only via
if override, ok := b.Instructions.PerHarness[name]; ok && override != "" { // the global AGENTS.md instructions file, so fold the rules in here.
instructions = override
}
fs.Add(adapter.File{ fs.Add(adapter.File{
Dest: filepath.Join(base, "AGENTS.md"), Dest: filepath.Join(base, "AGENTS.md"),
Kind: adapter.RenderedFile, Kind: adapter.RenderedFile,
Content: []byte(instructions), Content: []byte(b.InstructionTextWithRules(name)),
}) })
return fs, nil return fs, nil
+52
View File
@@ -0,0 +1,52 @@
package opencode
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestOpencodeRendersAgentsSymlinkAndFoldsRules(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Root: "/canon",
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "go", Body: "# Go\n\ngofmt always"}},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
agentsDest := filepath.Join(home, ".config", "opencode", "agents")
assert.Equal(t, adapter.SymlinkDir, seen[agentsDest].Kind)
assert.Equal(t, "/canon/agents", seen[agentsDest].SymlinkTarget)
agentsMD := filepath.Join(home, ".config", "opencode", "AGENTS.md")
assert.Equal(t, adapter.RenderedFile, seen[agentsMD].Kind)
body := string(seen[agentsMD].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "gofmt always")
}
func TestOpencodeImportsAgents(t *testing.T) {
home := t.TempDir()
agentsDir := filepath.Join(home, ".config", "opencode", "agents")
require.NoError(t, os.MkdirAll(agentsDir, 0o750))
require.NoError(t, os.WriteFile(filepath.Join(agentsDir, "review.md"),
[]byte("---\nname: review\ndescription: d\n---\nbody"), 0o600))
ad := New(WithHome(home))
res, err := ad.Import(home)
require.NoError(t, err)
require.Len(t, res.Agents, 1)
assert.Equal(t, "review", res.Agents[0].Name)
}
+31
View File
@@ -0,0 +1,31 @@
package zed
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func TestZedFoldsRulesIntoAGENTS(t *testing.T) {
home := t.TempDir()
b := &canonical.Bundle{
Instructions: canonical.Instructions{Global: "# global"},
Rules: []canonical.Rule{{Name: "ts", Body: "# TS\n\nstrict mode"}},
}
fs, err := New(WithHome(home)).Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
agentsMD := filepath.Join(home, ".config", "zed", "AGENTS.md")
require.Contains(t, seen, agentsMD)
assert.Equal(t, adapter.RenderedFile, seen[agentsMD].Kind)
body := string(seen[agentsMD].Content)
assert.Contains(t, body, "# global")
assert.Contains(t, body, "strict mode")
}
+13 -1
View File
@@ -42,7 +42,8 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: false, // Agent panel uses Extensions; context_servers key causes serde parse errors ManagesMCP: false, // Agent panel uses Extensions; context_servers key causes serde parse errors
ManagesSkills: false, ManagesSkills: false,
ManagesInstructions: false, ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
} }
@@ -97,6 +98,17 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
NoMerge: true, NoMerge: true,
}) })
// Zed reads personal/global instructions from ~/.config/zed/AGENTS.md
// (always-on for the Agent across all projects). Zed has no rules directory,
// so fold rules into it.
if instr := b.InstructionTextWithRules(name); instr != "" {
fs.Add(adapter.File{
Dest: filepath.Join(a.Home, ".config", "zed", "AGENTS.md"),
Kind: adapter.RenderedFile,
Content: []byte(instr),
})
}
return fs, nil return fs, nil
} }
+47
View File
@@ -0,0 +1,47 @@
package canonical
import "strings"
// InstructionText returns the instruction body a harness should receive: the
// per-harness override when one is set, otherwise the global instructions.
func (b *Bundle) InstructionText(harness string) string {
if override, ok := b.Instructions.PerHarness[harness]; ok && override != "" {
return override
}
return b.Instructions.Global
}
// InstructionTextWithRules returns InstructionText for the harness with every
// canonical rule body folded in. Harnesses without a native rules directory
// (everything except claude-code/kilo) only receive rule content this way —
// via their global instructions file. Rule frontmatter is stripped so the
// folded output stays clean; bodies are joined in load order (deterministic).
func (b *Bundle) InstructionTextWithRules(harness string) string {
base := b.InstructionText(harness)
var sb strings.Builder
sb.WriteString(base)
for _, r := range b.Rules {
body := strings.TrimSpace(stripFrontmatter(r.Body))
if body == "" {
continue
}
if sb.Len() > 0 {
sb.WriteString("\n\n")
}
sb.WriteString(body)
}
return sb.String()
}
// stripFrontmatter removes a leading YAML frontmatter block ("---\n…\n---") so
// folding a rule into a plain instructions file does not leak its metadata.
func stripFrontmatter(s string) string {
if !strings.HasPrefix(s, "---\n") {
return s
}
end := strings.Index(s[4:], "\n---")
if end == -1 {
return s
}
return strings.TrimPrefix(s[4+end+len("\n---"):], "\n")
}
+102
View File
@@ -0,0 +1,102 @@
package canonical
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestInstructionText(t *testing.T) {
t.Run("global when no override", func(t *testing.T) {
t.Parallel()
b := Bundle{Instructions: Instructions{Global: "G"}}
assert.Equal(t, "G", b.InstructionText("zed"))
})
t.Run("per-harness override wins", func(t *testing.T) {
t.Parallel()
b := Bundle{Instructions: Instructions{Global: "G", PerHarness: map[string]string{"zed": "Z"}}}
assert.Equal(t, "Z", b.InstructionText("zed"))
})
t.Run("empty override falls back to global", func(t *testing.T) {
t.Parallel()
b := Bundle{Instructions: Instructions{Global: "G", PerHarness: map[string]string{"zed": ""}}}
assert.Equal(t, "G", b.InstructionText("zed"))
})
t.Run("empty everything", func(t *testing.T) {
t.Parallel()
b := Bundle{}
assert.Equal(t, "", b.InstructionText("zed"))
})
}
func TestInstructionTextWithRules(t *testing.T) {
t.Run("folds rules after global", func(t *testing.T) {
t.Parallel()
b := Bundle{
Instructions: Instructions{Global: "# Global"},
Rules: []Rule{
{Name: "a", Body: "# Rule A\n\nbe concise"},
{Name: "b", Body: "# Rule B\n\nbe safe"},
},
}
got := b.InstructionTextWithRules("opencode")
assert.Equal(t, "# Global\n\n# Rule A\n\nbe concise\n\n# Rule B\n\nbe safe", got)
})
t.Run("strips rule frontmatter", func(t *testing.T) {
t.Parallel()
b := Bundle{
Instructions: Instructions{Global: "G"},
Rules: []Rule{
{Name: "scoped", Body: "---\nname: scoped\npaths: \"**/*.go\"\n---\n# Go\n\ngofmt"},
},
}
got := b.InstructionTextWithRules("opencode")
assert.Equal(t, "G\n\n# Go\n\ngofmt", got)
assert.NotContains(t, got, "paths:")
assert.NotContains(t, got, "---")
})
t.Run("no rules returns plain instruction text", func(t *testing.T) {
t.Parallel()
b := Bundle{Instructions: Instructions{Global: "G"}}
assert.Equal(t, "G", b.InstructionTextWithRules("opencode"))
})
t.Run("empty global still emits rules", func(t *testing.T) {
t.Parallel()
b := Bundle{Rules: []Rule{{Name: "a", Body: "rule body"}}}
assert.Equal(t, "rule body", b.InstructionTextWithRules("opencode"))
})
t.Run("blank rule bodies are skipped", func(t *testing.T) {
t.Parallel()
b := Bundle{
Instructions: Instructions{Global: "G"},
Rules: []Rule{
{Name: "empty", Body: " \n "},
{Name: "real", Body: "keep"},
},
}
assert.Equal(t, "G\n\nkeep", b.InstructionTextWithRules("opencode"))
})
}
func TestStripFrontmatter(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"no frontmatter", "# Title\nbody", "# Title\nbody"},
{"with frontmatter", "---\nname: x\n---\n# Title", "# Title"},
{"unterminated frontmatter left intact", "---\nname: x\nbody", "---\nname: x\nbody"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, stripFrontmatter(tt.in))
})
}
}
+8
View File
@@ -72,6 +72,14 @@ func LoadFS(fs fsx.FS, root string) (*Bundle, error) {
b.Agents = append(b.Agents, Agent(m)) b.Agents = append(b.Agents, Agent(m))
} }
rules, err := loadMarkdownDirFS(fs, filepath.Join(root, "rules"), "")
if err != nil {
return nil, err
}
for _, m := range rules {
b.Rules = append(b.Rules, Rule(m))
}
b.Instructions.PerHarness = map[string]string{} b.Instructions.PerHarness = map[string]string{}
body, err := readFileIfExistsFS(fs, filepath.Join(root, "instructions", "global.md")) body, err := readFileIfExistsFS(fs, filepath.Join(root, "instructions", "global.md"))
if err != nil { if err != nil {
+4
View File
@@ -28,6 +28,10 @@ func TestLoad(t *testing.T) {
require.Len(t, b.Agents, 1) require.Len(t, b.Agents, 1)
assert.Equal(t, "sample", b.Agents[0].Name) assert.Equal(t, "sample", b.Agents[0].Name)
require.Len(t, b.Rules, 1)
assert.Equal(t, "sample-rule", b.Rules[0].Name)
assert.Contains(t, b.Rules[0].Body, "Always be concise.")
assert.Contains(t, b.Instructions.Global, "Be helpful.") assert.Contains(t, b.Instructions.Global, "Be helpful.")
} }
@@ -0,0 +1,3 @@
# Sample Rule
Always be concise.
+11
View File
@@ -65,6 +65,16 @@ type Agent struct {
Path string Path string
} }
// Rule is a topic-scoped instruction fragment (e.g. ~/.claude/rules/go.md).
// Claude Code auto-loads a directory of these; harnesses without a native rules
// directory receive the bodies folded into their global instructions file.
type Rule struct {
Name string
Description string
Body string
Path string
}
type Instructions struct { type Instructions struct {
PerHarness map[string]string PerHarness map[string]string
Global string Global string
@@ -77,6 +87,7 @@ type Bundle struct {
Profile Profile Profile Profile
Skills []Skill Skills []Skill
Agents []Agent Agents []Agent
Rules []Rule
MCP MCPRegistry MCP MCPRegistry
} }
+25 -3
View File
@@ -93,6 +93,7 @@ func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, pic
} }
seenSkills := map[string]bool{} seenSkills := map[string]bool{}
seenAgents := map[string]bool{} seenAgents := map[string]bool{}
seenRules := map[string]bool{}
seenMCP := map[string]bool{} seenMCP := map[string]bool{}
var instructions []string var instructions []string
@@ -121,6 +122,14 @@ func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, pic
seenAgents[key] = true seenAgents[key] = true
bundle.Agents = append(bundle.Agents, a) bundle.Agents = append(bundle.Agents, a)
} }
for _, r := range res.Rules {
key := r.Name + "|" + r.Body
if seenRules[key] {
continue
}
seenRules[key] = true
bundle.Rules = append(bundle.Rules, r)
}
for _, m := range res.MCP { for _, m := range res.MCP {
if seenMCP[m.Name] { if seenMCP[m.Name] {
continue continue
@@ -153,8 +162,8 @@ func runImport(cmd *cobra.Command, reg *adapter.Registry, root, home string, pic
return err return err
} }
_, _ = fmt.Fprintf(cmd.OutOrStdout(), _, _ = fmt.Fprintf(cmd.OutOrStdout(),
"imported from %v into %s\n skills: %d\n agents: %d\n mcp servers: %d\n instructions: %d source(s)\n", "imported from %v into %s\n skills: %d\n agents: %d\n rules: %d\n mcp servers: %d\n instructions: %d source(s)\n",
picked, root, len(bundle.Skills), len(bundle.Agents), len(bundle.MCP.Servers), len(instructions), picked, root, len(bundle.Skills), len(bundle.Agents), len(bundle.Rules), len(bundle.MCP.Servers), len(instructions),
) )
return nil return nil
} }
@@ -172,7 +181,7 @@ const importedProfileBody = "# harness-sync profile \xe2\x80\x94 generated by in
"models: []\n" "models: []\n"
func writeCanonical(root string, b *canonical.Bundle, picked []string) error { func writeCanonical(root string, b *canonical.Bundle, picked []string) error {
for _, sub := range []string{"profiles", "skills", "agents", "instructions"} { for _, sub := range []string{"profiles", "skills", "agents", "rules", "instructions"} {
if err := os.MkdirAll(filepath.Join(root, sub), 0o750); err != nil { if err := os.MkdirAll(filepath.Join(root, sub), 0o750); err != nil {
return err return err
} }
@@ -233,6 +242,19 @@ func writeCanonical(root string, b *canonical.Bundle, picked []string) error {
return err return err
} }
} }
for _, r := range b.Rules {
rel := r.Path
if rel == "" {
rel = safeFilename(r.Name) + ".md"
}
path := filepath.Join(root, "rules", rel)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
if err := os.WriteFile(path, []byte(r.Body), 0o600); err != nil {
return err
}
}
if b.Instructions.Global != "" { if b.Instructions.Global != "" {
if err := os.WriteFile(filepath.Join(root, "instructions", "global.md"), if err := os.WriteFile(filepath.Join(root, "instructions", "global.md"),
[]byte(b.Instructions.Global), 0o600); err != nil { []byte(b.Instructions.Global), 0o600); err != nil {
+28
View File
@@ -53,6 +53,34 @@ func TestInitImportWritesCanonical(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
func TestInitImportsRules(t *testing.T) {
root := t.TempDir()
home := t.TempDir()
reg := adapter.NewRegistry()
reg.Register(&importableAdapter{
name: "stub",
res: &adapter.ImportResult{
Rules: []canonical.Rule{
{Name: "go", Body: "# Go\n\ngofmt", Path: "go.md"},
{Name: "py", Body: "# Py\n\ntypes", Path: "py.md"},
},
},
})
cmd := NewInit(reg)
cmd.SetArgs([]string{"--root", root, "--home", home, "--from", "stub", "--no-prompt"})
require.NoError(t, cmd.Execute())
assert.FileExists(t, filepath.Join(root, "rules", "go.md"))
assert.FileExists(t, filepath.Join(root, "rules", "py.md"))
// Round-trips back through the loader.
b, err := canonical.Load(root)
require.NoError(t, err)
require.Len(t, b.Rules, 2)
}
func makeStubReg() *adapter.Registry { func makeStubReg() *adapter.Registry {
reg := adapter.NewRegistry() reg := adapter.NewRegistry()
reg.Register(&importableAdapter{ reg.Register(&importableAdapter{
+6 -1
View File
@@ -17,11 +17,15 @@ func TestInitApplyDiffCycle(t *testing.T) {
home := t.TempDir() home := t.TempDir()
root := filepath.Join(home, ".config", "harness-sync") root := filepath.Join(home, ".config", "harness-sync")
// Fake a claude-code install with one skill // Fake a claude-code install with one skill and one rule
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "skills", "hi"), 0o750)) require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "skills", "hi"), 0o750))
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
filepath.Join(home, ".claude", "skills", "hi", "SKILL.md"), filepath.Join(home, ".claude", "skills", "hi", "SKILL.md"),
[]byte("---\nname: hi\n---\nbody"), 0o600)) []byte("---\nname: hi\n---\nbody"), 0o600))
require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude", "rules"), 0o750))
require.NoError(t, os.WriteFile(
filepath.Join(home, ".claude", "rules", "go.md"),
[]byte("# Go Rules\n\ngofmt always"), 0o600))
run := func(args ...string) ([]byte, error) { run := func(args ...string) ([]byte, error) {
cmd := exec.Command(bin, args...) cmd := exec.Command(bin, args...)
@@ -33,6 +37,7 @@ func TestInitApplyDiffCycle(t *testing.T) {
require.NoError(t, err, string(out)) require.NoError(t, err, string(out))
assert.FileExists(t, filepath.Join(root, "config.yaml")) assert.FileExists(t, filepath.Join(root, "config.yaml"))
assert.FileExists(t, filepath.Join(root, "skills", "hi", "SKILL.md")) assert.FileExists(t, filepath.Join(root, "skills", "hi", "SKILL.md"))
assert.FileExists(t, filepath.Join(root, "rules", "go.md"))
// apply may produce conflicts on a freshly imported tree (because the // apply may produce conflicts on a freshly imported tree (because the
// claude-code adapter merges into existing settings.json which differs // claude-code adapter merges into existing settings.json which differs