Compare commits

..
2 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub dadfd5ca06 Update go.mod and go.sum (#9) 2026-06-09 05:31:11 +01:00
lukaszraczyloandGitHub 406382a6d4 fix(ci): bump go.mod toolchain to 1.25.10 to clear govulncheck (#8)
Security Scan workflow on main failed with 18 standard-library
vulnerabilities (GO-2025-4007..4015, GO-2025-4155, GO-2026-4337,
plus a few more). All are fixed in later 1.25.x patches. Lowest
1.25.x version that govulncheck accepts clean is 1.25.10.

go.sum unchanged — the repo's go.sum already pointed at
oss-telemetry v0.2.3 and go-runewidth v0.0.24, which is what
1.25.10 + tidy resolves to.

Verified locally with GOTOOLCHAIN=go1.25.10:
  - go build ./...            clean
  - go test -count=1 ./...    20/20 packages pass
  - govulncheck ./...         No vulnerabilities found

go-version-file: go.mod in lint.yaml and security.yaml will
auto-pick the new version; no workflow changes needed.
2026-06-01 13:32:30 +01:00
34 changed files with 43 additions and 735 deletions
+13 -23
View File
@@ -3,7 +3,7 @@
</p> </p>
<p align="center"> <p align="center">
<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. <strong>One canonical source of truth</strong> for skills, agents, MCP servers, LLM endpoints, and global instructions across every LLM harness on your machine.
</p> </p>
<p align="center"> <p align="center">
@@ -40,7 +40,6 @@ 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 │
└──────────────┬───────────────────┘ └──────────────┬───────────────────┘
@@ -146,30 +145,21 @@ 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 + agents + rules + 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 + 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 | Agents | Rules | Instructions | | Harness | Built-in sub | Providers | Models | MCP | Skills | 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` (`ManagesProviders/Models/MCP/Skills/Agents/Rules/Instructions`) so tooling can inspect what will change before running `apply`. The matrix is machine-readable via `adapter.Capabilities()` — every adapter implements `HarnessCapabilities` 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
@@ -289,7 +279,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, Rule types + loader internal/canonical/ # Bundle, Profile, MCPRegistry, Skill, Agent 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
+2 -2
View File
@@ -1,11 +1,11 @@
module github.com/lukaszraczylo/harness-sync module github.com/lukaszraczylo/harness-sync
go 1.25.0 go 1.25.10
require ( require (
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
github.com/lukaszraczylo/oss-telemetry v0.2.3 github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/pelletier/go-toml/v2 v2.4.2 github.com/pelletier/go-toml/v2 v2.3.1
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
+2 -2
View File
@@ -69,8 +69,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-3
View File
@@ -31,8 +31,6 @@ 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
} }
@@ -49,7 +47,6 @@ 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
} }
-2
View File
@@ -168,7 +168,6 @@ func parseMCPEntry(name string, raw json.RawMessage) (canonical.MCPServer, bool)
Cmd json.RawMessage `json:"cmd"` Cmd json.RawMessage `json:"cmd"`
Args []string `json:"args"` Args []string `json:"args"`
URL string `json:"url"` URL string `json:"url"`
Headers map[string]string `json:"headers"`
Env map[string]string `json:"env"` Env map[string]string `json:"env"`
Environment map[string]string `json:"environment"` Environment map[string]string `json:"environment"`
} }
@@ -199,7 +198,6 @@ func parseMCPEntry(name string, raw json.RawMessage) (canonical.MCPServer, bool)
Args: args, Args: args,
URL: e.URL, URL: e.URL,
Transport: transport, Transport: transport,
Headers: e.Headers,
Env: env, Env: env,
}, true }, true
} }
-2
View File
@@ -42,7 +42,6 @@ func TestImportMCPFromJSONFileFSExtractsServers(t *testing.T) {
"myserver": { "myserver": {
"command": "/usr/bin/mytool", "command": "/usr/bin/mytool",
"args": ["--flag"], "args": ["--flag"],
"headers": {"Authorization": "Bearer ${MCP_TOKEN}"},
"type": "stdio" "type": "stdio"
} }
} }
@@ -56,7 +55,6 @@ func TestImportMCPFromJSONFileFSExtractsServers(t *testing.T) {
assert.Equal(t, "/usr/bin/mytool", servers[0].Command) assert.Equal(t, "/usr/bin/mytool", servers[0].Command)
assert.Equal(t, []string{"--flag"}, servers[0].Args) assert.Equal(t, []string{"--flag"}, servers[0].Args)
assert.Equal(t, "stdio", servers[0].Transport) assert.Equal(t, "stdio", servers[0].Transport)
assert.Equal(t, map[string]string{"Authorization": "Bearer ${MCP_TOKEN}"}, servers[0].Headers)
} }
func TestImportMCPFromJSONFileFSReadOnlyFS(t *testing.T) { func TestImportMCPFromJSONFileFSReadOnlyFS(t *testing.T) {
-9
View File
@@ -39,9 +39,6 @@ func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
} }
e["type"] = t e["type"] = t
e["url"] = s.URL e["url"] = s.URL
if len(s.Headers) > 0 {
e["headers"] = s.Headers
}
} else { } else {
// Claude honours Transport field; Crush always uses "stdio". // Claude honours Transport field; Crush always uses "stdio".
t := "stdio" t := "stdio"
@@ -63,9 +60,6 @@ func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
if s.URL != "" { if s.URL != "" {
e["type"] = "remote" e["type"] = "remote"
e["url"] = s.URL e["url"] = s.URL
if len(s.Headers) > 0 {
e["headers"] = s.Headers
}
} else { } else {
e["type"] = "local" e["type"] = "local"
cmd := []string{} cmd := []string{}
@@ -85,9 +79,6 @@ func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
// fail all of them. Omitting "enabled" defaults to true. // fail all of them. Omitting "enabled" defaults to true.
if s.URL != "" { if s.URL != "" {
e["url"] = s.URL e["url"] = s.URL
if len(s.Headers) > 0 {
e["headers"] = s.Headers
}
} else { } else {
if s.Command != "" { if s.Command != "" {
e["command"] = s.Command e["command"] = s.Command
+2 -12
View File
@@ -26,16 +26,11 @@ func TestBuildMCPMapStyledClaudeStyle(t *testing.T) {
} }
func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) { func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) {
r := reg(canonical.MCPServer{ r := reg(canonical.MCPServer{Name: "remote", URL: "https://example.com"})
Name: "remote",
URL: "https://example.com",
Headers: map[string]string{"Authorization": "Bearer ${MCP_TOKEN}"},
})
out := BuildMCPMapStyled(r, MCPClaudeStyle) out := BuildMCPMapStyled(r, MCPClaudeStyle)
e := out["remote"].(map[string]any) e := out["remote"].(map[string]any)
assert.Equal(t, "https://example.com", e["url"]) assert.Equal(t, "https://example.com", e["url"])
assert.Equal(t, "http", e["type"]) assert.Equal(t, "http", e["type"])
assert.Equal(t, map[string]string{"Authorization": "Bearer ${MCP_TOKEN}"}, e["headers"])
} }
func TestBuildMCPMapStyledCrushStdio(t *testing.T) { func TestBuildMCPMapStyledCrushStdio(t *testing.T) {
@@ -75,17 +70,12 @@ func TestBuildMCPMapStyledOpencodeLocal(t *testing.T) {
} }
func TestBuildMCPMapStyledOpencodeRemote(t *testing.T) { func TestBuildMCPMapStyledOpencodeRemote(t *testing.T) {
r := reg(canonical.MCPServer{ r := reg(canonical.MCPServer{Name: "r", URL: "https://example.com"})
Name: "r",
URL: "https://example.com",
Headers: map[string]string{"X-Tenant": "tenant-a"},
})
out := BuildMCPMapStyled(r, MCPOpencodeStyle) out := BuildMCPMapStyled(r, MCPOpencodeStyle)
e := out["r"].(map[string]any) e := out["r"].(map[string]any)
assert.Equal(t, "remote", e["type"]) assert.Equal(t, "remote", e["type"])
assert.Equal(t, true, e["enabled"]) assert.Equal(t, true, e["enabled"])
assert.Equal(t, "https://example.com", e["url"]) assert.Equal(t, "https://example.com", e["url"])
assert.Equal(t, map[string]string{"X-Tenant": "tenant-a"}, e["headers"])
} }
func TestBuildMCPMapStyledOpencodeEnv(t *testing.T) { func TestBuildMCPMapStyledOpencodeEnv(t *testing.T) {
+4 -4
View File
@@ -41,7 +41,6 @@ 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,
} }
@@ -58,9 +57,10 @@ 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")
// cagent has no rules concept; the per-agent instruction (system prompt) is instructions := b.Instructions.Global
// the only sink, so fold rules into it. if override, ok := b.Instructions.PerHarness[name]; ok && override != "" {
instructions := b.InstructionTextWithRules(name) instructions = override
}
mcps := map[string]any{} mcps := map[string]any{}
for _, s := range b.MCP.Servers { for _, s := range b.MCP.Servers {
-37
View File
@@ -1,37 +0,0 @@
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")
}
+5 -11
View File
@@ -37,8 +37,6 @@ 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,
} }
@@ -67,19 +65,15 @@ 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(b.InstructionText(name)), Content: []byte(instructions),
}) })
// Claude Code reads MCP servers from two files on disk: // Claude Code reads MCP servers from two files on disk:
-10
View File
@@ -32,16 +32,6 @@ 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
@@ -1,47 +0,0 @@
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")
}
+1 -44
View File
@@ -2,7 +2,6 @@
package crush package crush
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -37,8 +36,7 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: true, ManagesSkills: true,
ManagesRules: true, ManagesInstructions: false,
ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
} }
@@ -62,25 +60,12 @@ 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
} }
@@ -104,34 +89,6 @@ 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
@@ -1,83 +0,0 @@
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")
}
+1 -12
View File
@@ -45,8 +45,7 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: true, ManagesSkills: true,
ManagesRules: true, ManagesInstructions: false,
ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
} }
@@ -114,16 +113,6 @@ 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
@@ -1,31 +0,0 @@
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")
}
+2 -14
View File
@@ -38,9 +38,7 @@ func (a *Adapter) Capabilities() adapter.HarnessCapabilities {
ManagesModels: true, ManagesModels: true,
ManagesMCP: true, ManagesMCP: true,
ManagesSkills: true, ManagesSkills: true,
ManagesAgents: true, ManagesInstructions: false,
ManagesRules: true,
ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
} }
@@ -60,11 +58,8 @@ 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, "agents"), Dest: filepath.Join(base, "agent"),
Kind: adapter.SymlinkDir, Kind: adapter.SymlinkDir,
SymlinkTarget: filepath.Join(b.Root, "agents"), SymlinkTarget: filepath.Join(b.Root, "agents"),
}) })
@@ -74,13 +69,6 @@ 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)
+1 -2
View File
@@ -33,8 +33,7 @@ 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 })
// Global markdown subagents dir is PLURAL "agents" on current Kilo CLI. agentDest := filepath.Join(home, ".config", "kilo", "agent")
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
@@ -1,32 +0,0 @@
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,23 +5,12 @@ 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
+5 -11
View File
@@ -37,8 +37,6 @@ 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,
} }
@@ -64,12 +62,6 @@ 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)
@@ -112,12 +104,14 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: merged, Content: merged,
}) })
// opencode has no native rules directory; rule content reaches it only via instructions := b.Instructions.Global
// the global AGENTS.md instructions file, so fold the rules in here. if override, ok := b.Instructions.PerHarness[name]; ok && override != "" {
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(b.InstructionTextWithRules(name)), Content: []byte(instructions),
}) })
return fs, nil return fs, nil
-52
View File
@@ -1,52 +0,0 @@
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
@@ -1,31 +0,0 @@
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")
}
+1 -13
View File
@@ -42,8 +42,7 @@ 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,
ManagesRules: true, ManagesInstructions: false,
ManagesInstructions: true,
HasBuiltInSub: false, HasBuiltInSub: false,
} }
} }
@@ -98,17 +97,6 @@ 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
@@ -1,47 +0,0 @@
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
@@ -1,102 +0,0 @@
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,14 +72,6 @@ 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,10 +28,6 @@ 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.")
} }
@@ -1,3 +0,0 @@
# Sample Rule
Always be concise.
-12
View File
@@ -43,7 +43,6 @@ type MCPRegistry struct {
type MCPServer struct { type MCPServer struct {
Env map[string]string `yaml:"env,omitempty"` Env map[string]string `yaml:"env,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Name string `yaml:"name"` Name string `yaml:"name"`
Command string `yaml:"command,omitempty"` Command string `yaml:"command,omitempty"`
URL string `yaml:"url,omitempty"` URL string `yaml:"url,omitempty"`
@@ -65,16 +64,6 @@ 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
@@ -87,7 +76,6 @@ type Bundle struct {
Profile Profile Profile Profile
Skills []Skill Skills []Skill
Agents []Agent Agents []Agent
Rules []Rule
MCP MCPRegistry MCP MCPRegistry
} }
+3 -25
View File
@@ -93,7 +93,6 @@ 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
@@ -122,14 +121,6 @@ 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
@@ -162,8 +153,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 rules: %d\n mcp servers: %d\n instructions: %d source(s)\n", "imported from %v into %s\n skills: %d\n agents: %d\n mcp servers: %d\n instructions: %d source(s)\n",
picked, root, len(bundle.Skills), len(bundle.Agents), len(bundle.Rules), len(bundle.MCP.Servers), len(instructions), picked, root, len(bundle.Skills), len(bundle.Agents), len(bundle.MCP.Servers), len(instructions),
) )
return nil return nil
} }
@@ -181,7 +172,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", "rules", "instructions"} { for _, sub := range []string{"profiles", "skills", "agents", "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
} }
@@ -242,19 +233,6 @@ 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,34 +53,6 @@ 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{
+1 -6
View File
@@ -17,15 +17,11 @@ 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 and one rule // Fake a claude-code install with one skill
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...)
@@ -37,7 +33,6 @@ 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