fix(adapters): ground native schemas, merge-not-replace, add goose/cagent/zed

- common/mcp.go: MCPStyle enum + BuildMCPMapStyled for claude/crush/opencode/zed dialects
- common/jsonmerge.go: MergeJSONKeys (JSON-first, JSONC fallback) preserves user keys;
  fix: try plain json.Unmarshal before StripJSONComments to avoid mangling URLs with //
- common/yamlmerge.go: MergeYAMLKeys for YAML configs (goose)
- common/blocks.go: add ProvidersAsMap for opencode map-shaped provider key
- claudecode: merge settings.json (preserves hooks/permissions/env); MCPClaudeStyle (no type field)
- crush: fix mcp key (was mcpServers); MCPCrushStyle entries include type field; merge crush.json; fix import key mcp
- kilo: drop providers key (kilo has none); MCPOpencodeStyle (local/remote); merge kilo.json
- opencode: fix provider (singular map) + model (not default_model); MCPOpencodeStyle; merge jsonc; fix import key mcp
- goose: new adapter — YAML merge, extensions map, splitProviderModel, import reconstructs MCP from extensions
- cagent: new adapter — renders starter default.yaml, import returns empty
- zed: new adapter — merge settings.json, context_servers (MCPZedStyle) + agent.default_model
- docs: append Grounded harness schemas section to design spec
This commit is contained in:
2026-05-23 03:23:02 +01:00
parent a18cb1a4b7
commit 63bd976f4b
25 changed files with 1557 additions and 58 deletions
@@ -275,6 +275,47 @@ harness-sync/
- **YAML library:** `goccy/go-yaml` (preserves comments) vs `gopkg.in/yaml.v3`. Recommend goccy since canonical YAML is hand-edited and comments matter.
- **Adapter discovery:** static registry slice (decided: yes; simple, fast).
## Grounded harness schemas
Verified from live config files and official docs (2026-05-23).
### Config file locations and top-level key ownership
| Harness | Config file | Managed by harness-sync | User-managed (preserve) |
|---|---|---|---|
| claude-code | `~/.claude/settings.json` | `mcpServers` | `hooks`, `permissions`, `env`, and everything else |
| crush | `~/.config/crush/crush.json` | `providers`, `default_model`, `mcp` | `$schema`, `lsp`, `options`, `permissions` |
| kilo | `~/.config/kilo/kilo.json` | `model`, `mcp` | `$schema`, `small_model`, `instructions`, `permission`, `compaction`, `watcher`, `formatter`, `skills` |
| opencode | `~/.config/opencode/opencode.jsonc` | `provider`, `model`, `mcp` | `$schema`, `agent`, `instructions`, and everything else |
| zed | `~/.config/zed/settings.json` | `agent`, `context_servers` | everything else (theme, fonts, keybindings, …) |
| goose | `~/.config/goose/config.yaml` | `GOOSE_PROVIDER`, `GOOSE_MODEL`, `extensions` | `GOOSE_TEMPERATURE` and everything else |
### MCP / server key names
| Harness | Key | Entry shape |
|---|---|---|
| claude-code | `mcpServers` (map) | `{command, args, env, url, transport}` — no `type` field |
| crush | `mcp` (map) | `{type: stdio\|http\|sse, command, args, env}` or `{type: http\|sse, url}` |
| kilo | `mcp` (map) | `{type: local\|remote, command: []string, enabled}` or `{type: remote, url, enabled}` |
| opencode | `mcp` (map) | `{type: local\|remote, command: []string, enabled}` or `{type: remote, url, enabled}` |
| zed | `context_servers` (map) | `{enabled, source: "custom", command, args}` or `{enabled, url}` |
| goose | `extensions` (map) | `{type: stdio, cmd, args, enabled}` — no MCP key; goose extension format |
### Model key names
| Harness | Provider key | Model key |
|---|---|---|
| claude-code | — (none) | `env.ANTHROPIC_DEFAULT_MODEL` (string) |
| crush | `providers` (array of objects) | `default_model` (string) |
| kilo | — (no providers) | `model` (string, e.g. `"llmgw/anthropic/claude-sonnet-4-6"`) |
| opencode | `provider` (singular, map keyed by name) | `model` (string) |
| zed | — | `agent.default_model` (object `{provider, model}`) |
| goose | `GOOSE_PROVIDER` (string) | `GOOSE_MODEL` (string) |
### Merge policy
All adapters write config files using **merge-not-replace**: existing top-level keys not in the managed set are preserved verbatim. This prevents harness-sync from destroying user configuration on every apply.
## Acceptance criteria
1. `harness-sync init` on a fresh machine with several harness configs produces a populated, git-committed canonical tree without data loss.
+39
View File
@@ -48,6 +48,45 @@ func buildModels(models []canonical.Model) []map[string]any {
return out
}
// ProvidersAsMap returns a name-keyed map of provider objects for harnesses
// like opencode that use `provider` (singular, map-shaped) instead of a slice.
// The gateway entry is keyed "harness-sync-gateway"; upstreams use their name.
func ProvidersAsMap(p *canonical.Profile) map[string]any {
out := map[string]any{}
if p.Gateway.URL != "" {
entry := map[string]any{
"name": "harness-sync gateway",
"options": map[string]any{
"baseURL": p.Gateway.URL,
"apiKey": p.Gateway.Token,
},
}
if models := buildModels(p.Models); len(models) > 0 {
modelsMap := map[string]any{}
for _, m := range p.Models {
modelsMap[m.ID] = map[string]any{"name": m.ID}
}
entry["models"] = modelsMap
}
out["harness-sync-gateway"] = entry
}
for _, up := range p.Upstreams {
entry := map[string]any{"name": up.Name}
opts := map[string]any{}
if up.BaseURL != "" {
opts["baseURL"] = up.BaseURL
}
if up.APIKey != "" {
opts["apiKey"] = up.APIKey
}
if len(opts) > 0 {
entry["options"] = opts
}
out[up.Name] = entry
}
return out
}
// BuildMCPMap returns canonical MCP servers as a name -> object map.
// Empty map (not nil) is returned when there are no servers so the caller
// can decide whether to include the key.
+34
View File
@@ -0,0 +1,34 @@
package common
import "encoding/json"
// MergeJSONKeys reads existing JSON/JSONC body, overlays the given top-level
// keys (replacing values for listed keys, preserving every other key), and
// returns the merged JSON. If existing is empty or invalid JSON the function
// returns just the overlay encoded fresh (2-space indent + trailing \n).
// An overlay value of nil deletes the key from the base.
func MergeJSONKeys(existing []byte, overlay map[string]any) ([]byte, error) {
base := map[string]any{}
if len(existing) > 0 {
// Try plain JSON first (avoids mangling URL strings that contain //).
// Only fall back to comment-stripping for JSONC content.
if err := json.Unmarshal(existing, &base); err != nil {
clean := StripJSONComments(string(existing))
if err2 := json.Unmarshal([]byte(clean), &base); err2 != nil {
base = map[string]any{}
}
}
}
for k, v := range overlay {
if v == nil {
delete(base, k)
continue
}
base[k] = v
}
out, err := json.MarshalIndent(base, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
+80
View File
@@ -0,0 +1,80 @@
package common
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeJSONKeysPreservesOtherKeys(t *testing.T) {
existing := []byte(`{"a":1,"b":2}`)
overlay := map[string]any{"b": 3, "c": 4}
out, err := MergeJSONKeys(existing, overlay)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
assert.Equal(t, float64(1), m["a"])
assert.Equal(t, float64(3), m["b"])
assert.Equal(t, float64(4), m["c"])
}
func TestMergeJSONKeysEmptyExisting(t *testing.T) {
overlay := map[string]any{"x": "y"}
out, err := MergeJSONKeys(nil, overlay)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
assert.Equal(t, "y", m["x"])
assert.Len(t, m, 1)
}
func TestMergeJSONKeysInvalidExisting(t *testing.T) {
existing := []byte(`not json at all`)
overlay := map[string]any{"k": "v"}
out, err := MergeJSONKeys(existing, overlay)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
assert.Equal(t, "v", m["k"])
assert.Len(t, m, 1)
}
func TestMergeJSONKeysNilOverlayValueDeletesKey(t *testing.T) {
existing := []byte(`{"a":1,"b":2}`)
overlay := map[string]any{"b": nil}
out, err := MergeJSONKeys(existing, overlay)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
assert.Equal(t, float64(1), m["a"])
assert.NotContains(t, m, "b")
}
func TestMergeJSONKeysJSONC(t *testing.T) {
existing := []byte(`// comment
{
"a": 1, // trailing
/* block */ "b": 2
}`)
overlay := map[string]any{"c": 3}
out, err := MergeJSONKeys(existing, overlay)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(out, &m))
assert.Equal(t, float64(1), m["a"])
assert.Equal(t, float64(2), m["b"])
assert.Equal(t, float64(3), m["c"])
}
func TestMergeJSONKeysTrailingNewline(t *testing.T) {
out, err := MergeJSONKeys(nil, map[string]any{"k": "v"})
require.NoError(t, err)
assert.Equal(t, '\n', rune(out[len(out)-1]))
}
+102
View File
@@ -0,0 +1,102 @@
package common
import "github.com/lukaszraczylo/harness-sync/internal/canonical"
// MCPStyle selects the dialect for MCP server entries.
type MCPStyle int
const (
// MCPClaudeStyle: claude-code shape — {command, args, env, url, transport}.
MCPClaudeStyle MCPStyle = iota
// MCPCrushStyle: crush shape — adds type: stdio|http|sse.
MCPCrushStyle
// MCPOpencodeStyle: kilo + opencode shape — type: local|remote, command as []string.
MCPOpencodeStyle
// MCPZedStyle: zed context_servers — {enabled, source, command, args} or {enabled, url}.
MCPZedStyle
)
// BuildMCPMapStyled returns the MCP map for the given harness dialect.
func BuildMCPMapStyled(reg *canonical.MCPRegistry, style MCPStyle) map[string]any {
out := map[string]any{}
if reg == nil {
return out
}
for _, s := range reg.Servers {
out[s.Name] = mcpEntry(s, style)
}
return out
}
func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
e := map[string]any{}
switch style {
case MCPClaudeStyle:
if s.Command != "" {
e["command"] = s.Command
}
if len(s.Args) > 0 {
e["args"] = s.Args
}
if s.URL != "" {
e["url"] = s.URL
}
if s.Transport != "" {
e["transport"] = s.Transport
}
if len(s.Env) > 0 {
e["env"] = s.Env
}
case MCPCrushStyle:
if s.URL != "" {
t := s.Transport
if t == "" {
t = "http"
}
e["type"] = t
e["url"] = s.URL
} else {
e["type"] = "stdio"
if s.Command != "" {
e["command"] = s.Command
}
if len(s.Args) > 0 {
e["args"] = s.Args
}
if len(s.Env) > 0 {
e["env"] = s.Env
}
}
case MCPOpencodeStyle:
if s.URL != "" {
e["type"] = "remote"
e["url"] = s.URL
} else {
e["type"] = "local"
cmd := []string{}
if s.Command != "" {
cmd = append(cmd, s.Command)
}
cmd = append(cmd, s.Args...)
e["command"] = cmd
if len(s.Env) > 0 {
e["environment"] = s.Env
}
}
e["enabled"] = true
case MCPZedStyle:
e["enabled"] = true
if s.URL != "" {
e["url"] = s.URL
} else {
e["source"] = "custom"
if s.Command != "" {
e["command"] = s.Command
}
if len(s.Args) > 0 {
e["args"] = s.Args
}
}
}
return e
}
+110
View File
@@ -0,0 +1,110 @@
package common
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func reg(servers ...canonical.MCPServer) *canonical.MCPRegistry {
return &canonical.MCPRegistry{Servers: servers}
}
func TestBuildMCPMapStyledClaudeStyle(t *testing.T) {
r := reg(canonical.MCPServer{Name: "fp", Command: "/bin/fp", Args: []string{"--flag"}, Transport: "stdio"})
out := BuildMCPMapStyled(r, MCPClaudeStyle)
require.Contains(t, out, "fp")
e := out["fp"].(map[string]any)
assert.Equal(t, "/bin/fp", e["command"])
assert.Equal(t, []string{"--flag"}, e["args"])
assert.Equal(t, "stdio", e["transport"])
assert.NotContains(t, e, "type")
}
func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) {
r := reg(canonical.MCPServer{Name: "remote", URL: "https://example.com"})
out := BuildMCPMapStyled(r, MCPClaudeStyle)
e := out["remote"].(map[string]any)
assert.Equal(t, "https://example.com", e["url"])
assert.NotContains(t, e, "type")
}
func TestBuildMCPMapStyledCrushStdio(t *testing.T) {
r := reg(canonical.MCPServer{Name: "fp", Command: "/bin/fp", Args: []string{"-x"}})
out := BuildMCPMapStyled(r, MCPCrushStyle)
e := out["fp"].(map[string]any)
assert.Equal(t, "stdio", e["type"])
assert.Equal(t, "/bin/fp", e["command"])
assert.Equal(t, []string{"-x"}, e["args"])
}
func TestBuildMCPMapStyledCrushHTTP(t *testing.T) {
r := reg(canonical.MCPServer{Name: "remote", URL: "https://example.com", Transport: "sse"})
out := BuildMCPMapStyled(r, MCPCrushStyle)
e := out["remote"].(map[string]any)
assert.Equal(t, "sse", e["type"])
assert.Equal(t, "https://example.com", e["url"])
}
func TestBuildMCPMapStyledCrushHTTPDefaultType(t *testing.T) {
// No transport set → default to "http"
r := reg(canonical.MCPServer{Name: "remote", URL: "https://example.com"})
out := BuildMCPMapStyled(r, MCPCrushStyle)
e := out["remote"].(map[string]any)
assert.Equal(t, "http", e["type"])
}
func TestBuildMCPMapStyledOpencodeLocal(t *testing.T) {
r := reg(canonical.MCPServer{Name: "fp", Command: "/bin/fp", Args: []string{"--flag"}})
out := BuildMCPMapStyled(r, MCPOpencodeStyle)
e := out["fp"].(map[string]any)
assert.Equal(t, "local", e["type"])
assert.Equal(t, true, e["enabled"])
cmd := e["command"].([]string)
assert.Equal(t, []string{"/bin/fp", "--flag"}, cmd)
assert.NotContains(t, e, "environment")
}
func TestBuildMCPMapStyledOpencodeRemote(t *testing.T) {
r := reg(canonical.MCPServer{Name: "r", URL: "https://example.com"})
out := BuildMCPMapStyled(r, MCPOpencodeStyle)
e := out["r"].(map[string]any)
assert.Equal(t, "remote", e["type"])
assert.Equal(t, true, e["enabled"])
assert.Equal(t, "https://example.com", e["url"])
}
func TestBuildMCPMapStyledOpencodeEnv(t *testing.T) {
r := reg(canonical.MCPServer{Name: "fp", Command: "/bin/fp", Env: map[string]string{"K": "V"}})
out := BuildMCPMapStyled(r, MCPOpencodeStyle)
e := out["fp"].(map[string]any)
assert.Equal(t, map[string]string{"K": "V"}, e["environment"])
}
func TestBuildMCPMapStyledZedStdio(t *testing.T) {
r := reg(canonical.MCPServer{Name: "fp", Command: "/bin/fp", Args: []string{"-x"}})
out := BuildMCPMapStyled(r, MCPZedStyle)
e := out["fp"].(map[string]any)
assert.Equal(t, true, e["enabled"])
assert.Equal(t, "custom", e["source"])
assert.Equal(t, "/bin/fp", e["command"])
assert.Equal(t, []string{"-x"}, e["args"])
}
func TestBuildMCPMapStyledZedURL(t *testing.T) {
r := reg(canonical.MCPServer{Name: "r", URL: "https://example.com"})
out := BuildMCPMapStyled(r, MCPZedStyle)
e := out["r"].(map[string]any)
assert.Equal(t, true, e["enabled"])
assert.Equal(t, "https://example.com", e["url"])
assert.NotContains(t, e, "source")
}
func TestBuildMCPMapStyledNilReg(t *testing.T) {
out := BuildMCPMapStyled(nil, MCPClaudeStyle)
assert.NotNil(t, out)
assert.Len(t, out, 0)
}
+29
View File
@@ -0,0 +1,29 @@
package common
import "gopkg.in/yaml.v3"
// MergeYAMLKeys reads existing YAML body, overlays the given top-level keys
// (replacing values for listed keys, preserving every other key), and returns
// the merged YAML. If existing is empty or invalid YAML, the function returns
// just the overlay encoded fresh (with 2-space indent + trailing \n).
// An overlay value of nil deletes the key from the base.
func MergeYAMLKeys(existing []byte, overlay map[string]any) ([]byte, error) {
base := map[string]any{}
if len(existing) > 0 {
if err := yaml.Unmarshal(existing, &base); err != nil {
base = map[string]any{}
}
}
for k, v := range overlay {
if v == nil {
delete(base, k)
continue
}
base[k] = v
}
out, err := yaml.Marshal(base)
if err != nil {
return nil, err
}
return out, nil
}
+118
View File
@@ -0,0 +1,118 @@
// Package cagent is the cagent harness adapter.
//
// cagent is per-run (no persistent agent state). v1 renders a starter
// ~/.config/cagent/default.yaml with a single default agent pointing at the
// gateway. Import always returns an empty result.
package cagent
import (
"os"
"path/filepath"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"gopkg.in/yaml.v3"
)
const name = "cagent"
// Adapter implements adapter.Adapter for the cagent harness.
type Adapter struct{ home string }
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Detect returns true when ~/.config/cagent/ exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "cagent"))
return err == nil
}
// Render produces a starter default.yaml for cagent.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
cfgPath := filepath.Join(a.home, ".config", "cagent", "default.yaml")
instructions := b.Instructions.Global
if override, ok := b.Instructions.PerHarness[name]; ok && override != "" {
instructions = override
}
mcps := map[string]any{}
for _, s := range b.MCP.Servers {
e := map[string]any{}
if s.Command != "" {
e["command"] = s.Command
}
args := s.Args
if args == nil {
args = []string{}
}
e["args"] = args
mcps[s.Name] = e
}
cfg := map[string]any{
"version": 8,
"metadata": map[string]any{
"name": "harness-sync",
},
"agents": map[string]any{
"default": map[string]any{
"model": "gateway/" + b.Profile.Gateway.DefaultModel,
"instruction": instructions,
},
},
"models": map[string]any{
"gateway": map[string]any{
"provider": "openai", // cagent gateway via openai-compat
"base_url": b.Profile.Gateway.URL,
},
},
}
if len(mcps) > 0 {
cfg["mcps"] = mcps
}
body, err := yaml.Marshal(cfg)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: cfgPath,
Kind: adapter.RenderedFile,
Content: body,
})
return fs, nil
}
// Import returns an empty result — cagent has no persistent state to import.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return &adapter.ImportResult{}, nil
}
+98
View File
@@ -0,0 +1,98 @@
package cagent
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func bundle() *canonical.Bundle {
return &canonical.Bundle{
Root: "/canon",
Profile: canonical.Profile{
Gateway: canonical.Gateway{
URL: "https://gw",
DefaultModel: "claude-sonnet-4-6",
},
},
Instructions: canonical.Instructions{Global: "# global instruction"},
MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{
{Name: "filepuff", Command: "/bin/filepuff", Args: []string{"--serve"}},
}},
}
}
func TestCagentRenderProducesExpectedTargets(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
fs, err := ad.Render(bundle())
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "cagent", "default.yaml")
require.Contains(t, seen, cfgDest)
assert.Equal(t, adapter.RenderedFile, seen[cfgDest].Kind)
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(seen[cfgDest].Content, &parsed))
assert.Equal(t, 8, parsed["version"])
agents := parsed["agents"].(map[string]any)
def := agents["default"].(map[string]any)
assert.Equal(t, "gateway/claude-sonnet-4-6", def["model"])
assert.Contains(t, def["instruction"], "# global instruction")
models := parsed["models"].(map[string]any)
gw := models["gateway"].(map[string]any)
assert.Equal(t, "openai", gw["provider"])
assert.Equal(t, "https://gw", gw["base_url"])
mcps := parsed["mcps"].(map[string]any)
fp := mcps["filepuff"].(map[string]any)
assert.Equal(t, "/bin/filepuff", fp["command"])
}
func TestCagentRenderNoMCP(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw", DefaultModel: "sonnet"},
},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "cagent", "default.yaml")
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(seen[cfgDest].Content, &parsed))
assert.NotContains(t, parsed, "mcps")
}
func TestCagentImportReturnsEmpty(t *testing.T) {
ad := New(WithHome(t.TempDir()))
res, err := ad.Import(t.TempDir())
require.NoError(t, err)
assert.NotNil(t, res)
assert.Empty(t, res.MCP)
assert.Empty(t, res.Skills)
assert.Empty(t, res.Agents)
}
func TestCagentDetect(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
assert.False(t, ad.Detect())
}
+11 -10
View File
@@ -8,7 +8,6 @@ import (
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"github.com/lukaszraczylo/harness-sync/internal/render"
)
const name = "claude-code"
@@ -51,6 +50,9 @@ func (a *Adapter) Detect() bool {
}
// Render produces the FileSet that applies the canonical bundle to claude-code.
// settings.json is MERGED into any existing file so user-managed keys
// settings.json is MERGED into any existing file so user-managed keys
// (hooks, permissions, env, …) are preserved.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.home, ".claude")
@@ -76,25 +78,24 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: []byte(instructions),
})
settings, err := renderSettings(b)
settingsPath := filepath.Join(base, "settings.json")
existing, _ := os.ReadFile(settingsPath)
overlay := map[string]any{
"mcpServers": common.BuildMCPMapStyled(&b.MCP, common.MCPClaudeStyle),
}
merged, err := common.MergeJSONKeys(existing, overlay)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: filepath.Join(base, "settings.json"),
Dest: settingsPath,
Kind: adapter.RenderedFile,
Content: settings,
Content: merged,
})
return fs, nil
}
func renderSettings(b *canonical.Bundle) ([]byte, error) {
return render.JSON(map[string]any{
"mcpServers": common.BuildMCPMap(&b.MCP),
})
}
// Import reads claude-code config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
@@ -48,6 +48,48 @@ func TestRenderProducesExpectedTargets(t *testing.T) {
assert.Contains(t, settingsBody, "filepuff")
assert.Contains(t, settingsBody, "/bin/filepuff")
}
func TestRenderMergesExistingSettings(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".claude")
require.NoError(t, os.MkdirAll(base, 0o750))
// Existing settings.json with user-managed keys.
existing := map[string]any{
"hooks": map[string]any{"preToolUse": []string{"/bin/hook"}},
"permissions": map[string]any{"allow": []string{"Bash"}},
"env": map[string]any{"ANTHROPIC_DEFAULT_MODEL": "opus"},
}
body, _ := json.Marshal(existing)
require.NoError(t, os.WriteFile(filepath.Join(base, "settings.json"), body, 0o600))
ad := New(WithHome(home))
b := &canonical.Bundle{
Root: "/canon",
MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{
{Name: "filepuff", Command: "/bin/filepuff"},
}},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
settingsDest := filepath.Join(base, "settings.json")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[settingsDest].Content, &parsed))
// User keys preserved.
assert.Contains(t, parsed, "hooks")
assert.Contains(t, parsed, "permissions")
assert.Contains(t, parsed, "env")
// MCP updated.
mcp := parsed["mcpServers"].(map[string]any)
assert.Contains(t, mcp, "filepuff")
// MCP entries must NOT have a "type" field (claude-code style).
fp := mcp["filepuff"].(map[string]any)
assert.NotContains(t, fp, "type")
}
func TestImport(t *testing.T) {
home := t.TempDir()
+14 -15
View File
@@ -8,7 +8,6 @@ import (
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"github.com/lukaszraczylo/harness-sync/internal/render"
)
const name = "crush"
@@ -49,6 +48,8 @@ func (a *Adapter) Detect() bool {
}
// Render produces the FileSet that applies the canonical bundle to crush.
// crush.json is MERGED so user-managed keys ($schema, lsp, options, permissions) are preserved.
// MCP key is "mcp" (not "mcpServers"); entries include a "type" field.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.home, ".config", "crush")
@@ -59,30 +60,28 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
SymlinkTarget: filepath.Join(b.Root, "skills"),
})
cfg, err := renderConfig(b)
cfgPath := filepath.Join(base, "crush.json")
existing, _ := os.ReadFile(cfgPath)
overlay := map[string]any{
"providers": common.BuildProviders(&b.Profile),
"default_model": b.Profile.Gateway.DefaultModel,
}
if mcp := common.BuildMCPMapStyled(&b.MCP, common.MCPCrushStyle); len(mcp) > 0 {
overlay["mcp"] = mcp
}
merged, err := common.MergeJSONKeys(existing, overlay)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: filepath.Join(base, "crush.json"),
Dest: cfgPath,
Kind: adapter.RenderedFile,
Content: cfg,
Content: merged,
})
return fs, nil
}
func renderConfig(b *canonical.Bundle) ([]byte, error) {
out := map[string]any{
"providers": common.BuildProviders(&b.Profile),
"default_model": b.Profile.Gateway.DefaultModel,
}
if mcp := common.BuildMCPMap(&b.MCP); len(mcp) > 0 {
out["mcpServers"] = mcp
}
return render.JSON(out)
}
// Import reads crush config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
+63 -1
View File
@@ -40,9 +40,71 @@ func TestCrushRenderProducesExpectedTargets(t *testing.T) {
// Parse rendered JSON to verify structure.
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
assert.NotNil(t, parsed) // at least it's valid JSON
assert.NotNil(t, parsed)
// Must use "default_model", not "model".
assert.Contains(t, parsed, "default_model")
// Must not write "mcpServers" — crush uses "mcp".
assert.NotContains(t, parsed, "mcpServers")
}
func TestCrushRenderMCPHasType(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Root: "/canon",
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "sonnet"},
},
MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{
{Name: "fp", Command: "/bin/fp"},
}},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "crush", "crush.json")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
mcp := parsed["mcp"].(map[string]any)
fp := mcp["fp"].(map[string]any)
// Each MCP entry must have a "type" field.
assert.Equal(t, "stdio", fp["type"])
}
func TestCrushRenderMergesExistingKeys(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "crush")
require.NoError(t, os.MkdirAll(base, 0o750))
existing := map[string]any{
"$schema": "https://crush.schema/v1",
"lsp": []string{"gopls"},
"permissions": map[string]any{"allow": []string{"Bash"}},
}
body, _ := json.Marshal(existing)
require.NoError(t, os.WriteFile(filepath.Join(base, "crush.json"), body, 0o600))
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "sonnet"},
},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(base, "crush.json")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// Unrelated user keys preserved.
assert.Equal(t, "https://crush.schema/v1", parsed["$schema"])
assert.Contains(t, parsed, "lsp")
assert.Contains(t, parsed, "permissions")
}
func TestCrushImportRoundtrip(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "crush")
+1 -1
View File
@@ -9,7 +9,7 @@ import (
func importFrom(home string) (*adapter.ImportResult, error) {
cfgPath := filepath.Join(home, ".config", "crush", "crush.json")
servers, err := common.ImportMCPFromJSONFile(cfgPath, "mcpServers")
servers, err := common.ImportMCPFromJSONFile(cfgPath, "mcp")
if err != nil {
return nil, err
}
+135
View File
@@ -0,0 +1,135 @@
// Package goose is the Goose (Block/square) harness adapter.
//
// Goose uses ~/.config/goose/config.yaml with a flat top-level structure.
// MCP-equivalent servers go under "extensions" (not "mcp").
// Model is split into GOOSE_PROVIDER + GOOSE_MODEL.
//
// Custom provider files (~/.config/goose/custom_providers/<name>.yaml) are
// out of v1 scope. If a gateway URL is configured we set GOOSE_PROVIDER to
// signal the intent; the user must hand-write the custom_providers file.
package goose
import (
"os"
"path/filepath"
"strings"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
const name = "goose"
// Adapter implements adapter.Adapter for the Goose harness.
type Adapter struct{ home string }
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Detect returns true when ~/.config/goose/ exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "goose"))
return err == nil
}
// Render produces the FileSet that applies the canonical bundle to goose.
// config.yaml is MERGED; only GOOSE_PROVIDER, GOOSE_MODEL, and the
// managed extension entries are overlaid.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
cfgPath := filepath.Join(a.home, ".config", "goose", "config.yaml")
existing, _ := os.ReadFile(cfgPath)
provider, model := splitProviderModel(b.Profile.Gateway.DefaultModel)
if b.Profile.Gateway.URL != "" {
// Custom gateway: signal with provider name. User must create
// ~/.config/goose/custom_providers/harness-sync.yaml manually.
provider = "harness-sync"
}
overlay := map[string]any{
"GOOSE_PROVIDER": provider,
"GOOSE_MODEL": model,
}
if len(b.MCP.Servers) > 0 {
extensions := buildExtensions(b.MCP.Servers)
overlay["extensions"] = extensions
}
merged, err := common.MergeYAMLKeys(existing, overlay)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: cfgPath,
Kind: adapter.RenderedFile,
Content: merged,
})
return fs, nil
}
// buildExtensions converts canonical MCP servers to goose extension entries.
func buildExtensions(servers []canonical.MCPServer) map[string]any {
out := map[string]any{}
for _, s := range servers {
e := map[string]any{
"enabled": true,
"type": "stdio",
}
if s.Command != "" {
e["cmd"] = s.Command
}
args := s.Args
if args == nil {
args = []string{}
}
e["args"] = args
if len(s.Env) > 0 {
e["env"] = s.Env
}
out[s.Name] = e
}
return out
}
// splitProviderModel splits "provider/model" into its parts.
// If no slash, provider is "anthropic" and the whole string is the model.
func splitProviderModel(s string) (string, string) {
if idx := strings.IndexByte(s, '/'); idx >= 0 {
return s[:idx], s[idx+1:]
}
return "anthropic", s
}
// Import reads goose config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
}
+145
View File
@@ -0,0 +1,145 @@
package goose
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func bundle() *canonical.Bundle {
return &canonical.Bundle{
Root: "/canon",
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "anthropic/claude-sonnet-4-6"},
},
MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{
{Name: "filepuff", Command: "/bin/filepuff", Args: []string{"--serve"}},
}},
}
}
func TestGooseRenderProducesExpectedTargets(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
fs, err := ad.Render(bundle())
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "goose", "config.yaml")
require.Contains(t, seen, cfgDest)
assert.Equal(t, adapter.RenderedFile, seen[cfgDest].Kind)
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(seen[cfgDest].Content, &parsed))
assert.Equal(t, "anthropic", parsed["GOOSE_PROVIDER"])
assert.Equal(t, "claude-sonnet-4-6", parsed["GOOSE_MODEL"])
assert.Contains(t, parsed, "extensions")
exts := parsed["extensions"].(map[string]any)
fp := exts["filepuff"].(map[string]any)
assert.Equal(t, "stdio", fp["type"])
assert.Equal(t, "/bin/filepuff", fp["cmd"])
}
func TestGooseRenderMergesExistingKeys(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "goose")
require.NoError(t, os.MkdirAll(base, 0o750))
require.NoError(t, os.WriteFile(filepath.Join(base, "config.yaml"),
[]byte("GOOSE_TEMPERATURE: \"0.7\"\nGOOSE_PROVIDER: old\n"), 0o600))
ad := New(WithHome(home))
fs, err := ad.Render(bundle())
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(seen[filepath.Join(base, "config.yaml")].Content, &parsed))
// Existing unrelated key preserved.
assert.Equal(t, "0.7", parsed["GOOSE_TEMPERATURE"])
// Provider updated.
assert.Equal(t, "anthropic", parsed["GOOSE_PROVIDER"])
}
func TestGooseRenderGatewayURL(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{URL: "https://gw", DefaultModel: "sonnet"},
},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "goose", "config.yaml")
var parsed map[string]any
require.NoError(t, yaml.Unmarshal(seen[cfgDest].Content, &parsed))
// Custom gateway → provider is "harness-sync" (user must write custom_providers file).
assert.Equal(t, "harness-sync", parsed["GOOSE_PROVIDER"])
}
func TestGooseImport(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "goose")
require.NoError(t, os.MkdirAll(base, 0o750))
cfg := map[string]any{
"GOOSE_PROVIDER": "anthropic",
"GOOSE_MODEL": "claude-sonnet-4-6",
"extensions": map[string]any{
"filepuff": map[string]any{
"type": "stdio",
"cmd": "/bin/filepuff",
"args": []string{"--serve"},
"enabled": true,
},
},
}
body, err := yaml.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(base, "config.yaml"), body, 0o600))
ad := New(WithHome(home))
res, err := ad.Import(home)
require.NoError(t, err)
require.NotNil(t, res)
require.Len(t, res.MCP, 1)
assert.Equal(t, "filepuff", res.MCP[0].Name)
assert.Equal(t, "/bin/filepuff", res.MCP[0].Command)
}
func TestGooseImportMissing(t *testing.T) {
ad := New(WithHome(t.TempDir()))
res, err := ad.Import(t.TempDir())
require.NoError(t, err)
assert.NotNil(t, res)
assert.Empty(t, res.MCP)
}
func TestSplitProviderModel(t *testing.T) {
t.Run("with slash", func(t *testing.T) {
p, m := splitProviderModel("anthropic/claude-sonnet-4-6")
assert.Equal(t, "anthropic", p)
assert.Equal(t, "claude-sonnet-4-6", m)
})
t.Run("no slash", func(t *testing.T) {
p, m := splitProviderModel("claude-sonnet")
assert.Equal(t, "anthropic", p)
assert.Equal(t, "claude-sonnet", m)
})
}
+67
View File
@@ -0,0 +1,67 @@
package goose
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
func importFrom(home string) (*adapter.ImportResult, error) {
cfgPath := filepath.Join(home, ".config", "goose", "config.yaml")
body, err := os.ReadFile(cfgPath)
if os.IsNotExist(err) {
return &adapter.ImportResult{}, nil
}
if err != nil {
return nil, err
}
var raw map[string]any
if err := yaml.Unmarshal(body, &raw); err != nil {
return &adapter.ImportResult{}, nil
}
res := &adapter.ImportResult{}
// Reconstruct MCP servers from extensions entries with type: stdio.
if exts, ok := raw["extensions"]; ok {
if extMap, ok := exts.(map[string]any); ok {
for name, v := range extMap {
entry, ok := v.(map[string]any)
if !ok {
continue
}
t, _ := entry["type"].(string)
if t != "stdio" {
continue
}
s := canonical.MCPServer{Name: name}
if cmd, ok := entry["cmd"].(string); ok {
s.Command = cmd
}
if args, ok := entry["args"].([]any); ok {
for _, a := range args {
if str, ok := a.(string); ok {
s.Args = append(s.Args, str)
}
}
}
if envRaw, ok := entry["env"].(map[string]any); ok {
s.Env = make(map[string]string, len(envRaw))
for k, val := range envRaw {
if str, ok := val.(string); ok {
s.Env[k] = str
}
}
}
res.MCP = append(res.MCP, s)
}
}
}
return res, nil
}
+15 -15
View File
@@ -8,7 +8,6 @@ import (
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"github.com/lukaszraczylo/harness-sync/internal/render"
)
const name = "kilo"
@@ -51,6 +50,10 @@ func (a *Adapter) Detect() bool {
}
// Render produces the FileSet that applies the canonical bundle to kilo.
// kilo.json is MERGED to preserve user-managed keys ($schema, small_model,
// instructions, permission, compaction, watcher, formatter, skills).
// Key "model" is a plain string; MCP uses "mcp" with local/remote type entries.
// No "providers" key — kilo has no such concept.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.home, ".config", "kilo")
@@ -61,30 +64,27 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
SymlinkTarget: filepath.Join(b.Root, "agents"),
})
cfg, err := renderConfig(b)
cfgPath := filepath.Join(base, "kilo.json")
existing, _ := os.ReadFile(cfgPath)
overlay := map[string]any{
"model": b.Profile.Gateway.DefaultModel,
}
if mcp := common.BuildMCPMapStyled(&b.MCP, common.MCPOpencodeStyle); len(mcp) > 0 {
overlay["mcp"] = mcp
}
merged, err := common.MergeJSONKeys(existing, overlay)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: filepath.Join(base, "kilo.json"),
Dest: cfgPath,
Kind: adapter.RenderedFile,
Content: cfg,
Content: merged,
})
return fs, nil
}
func renderConfig(b *canonical.Bundle) ([]byte, error) {
out := map[string]any{
"model": b.Profile.Gateway.DefaultModel,
"providers": common.BuildProviders(&b.Profile),
}
if mcp := common.BuildMCPMap(&b.MCP); len(mcp) > 0 {
out["mcp"] = mcp
}
return render.JSON(out)
}
// Import reads kilo config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
+42
View File
@@ -46,8 +46,50 @@ func TestKiloRender(t *testing.T) {
assert.Contains(t, parsed, "mcp")
assert.NotContains(t, parsed, "default_model")
assert.NotContains(t, parsed, "mcpServers")
// kilo has no "providers" concept.
assert.NotContains(t, parsed, "providers")
// MCP entries use local/remote type (MCPOpencodeStyle).
mcp := parsed["mcp"].(map[string]any)
fp := mcp["filepuff"].(map[string]any)
assert.Equal(t, "local", fp["type"])
cmd, _ := fp["command"].([]any)
assert.Equal(t, "/bin/filepuff", cmd[0])
}
func TestKiloRenderMergesExistingKeys(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "kilo")
require.NoError(t, os.MkdirAll(base, 0o750))
existing := map[string]any{
"$schema": "https://kilo.schema/v1",
"small_model": "claude-haiku",
"compaction": map[string]any{"enabled": true},
}
body, _ := json.Marshal(existing)
require.NoError(t, os.WriteFile(filepath.Join(base, "kilo.json"), body, 0o600))
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "sonnet"},
},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(base, "kilo.json")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// User keys preserved.
assert.Equal(t, "https://kilo.schema/v1", parsed["$schema"])
assert.Equal(t, "claude-haiku", parsed["small_model"])
assert.Contains(t, parsed, "compaction")
// Model updated.
assert.Equal(t, "sonnet", parsed["model"])
}
func TestKiloImport(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "kilo")
+1 -1
View File
@@ -16,7 +16,7 @@ func importFrom(home string) (*adapter.ImportResult, error) {
return nil, err
}
if cfgBody != "" {
mcpServers, mcpErr := common.ParseMCPFromJSON([]byte(cfgBody), "mcpServers")
mcpServers, mcpErr := common.ParseMCPFromJSON([]byte(cfgBody), "mcp")
if mcpErr != nil {
return nil, mcpErr
}
+15 -15
View File
@@ -8,7 +8,6 @@ import (
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
"github.com/lukaszraczylo/harness-sync/internal/render"
)
const name = "opencode"
@@ -49,18 +48,30 @@ func (a *Adapter) Detect() bool {
}
// Render produces the FileSet that applies the canonical bundle to opencode.
// opencode.jsonc is MERGED to preserve user-managed keys ($schema, agent,
// instructions, …). Correct keys: "provider" (singular, map), "model" (string),
// "mcp" (map with type: local|remote entries).
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
base := filepath.Join(a.home, ".config", "opencode")
cfg, err := renderConfig(b)
cfgPath := filepath.Join(base, "opencode.jsonc")
existing, _ := os.ReadFile(cfgPath)
overlay := map[string]any{
"provider": common.ProvidersAsMap(&b.Profile),
"model": b.Profile.Gateway.DefaultModel,
}
if mcp := common.BuildMCPMapStyled(&b.MCP, common.MCPOpencodeStyle); len(mcp) > 0 {
overlay["mcp"] = mcp
}
merged, err := common.MergeJSONKeys(existing, overlay)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: filepath.Join(base, "opencode.jsonc"),
Dest: cfgPath,
Kind: adapter.RenderedFile,
Content: cfg,
Content: merged,
})
instructions := b.Instructions.Global
@@ -76,17 +87,6 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
return fs, nil
}
func renderConfig(b *canonical.Bundle) ([]byte, error) {
out := map[string]any{
"providers": common.BuildProviders(&b.Profile),
"default_model": b.Profile.Gateway.DefaultModel,
}
if mcp := common.BuildMCPMap(&b.MCP); len(mcp) > 0 {
out["mcpServers"] = mcp
}
return render.JSON(out)
}
// Import reads opencode config from home and returns a canonical ImportResult.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
@@ -34,12 +34,81 @@ func TestOpencodeRender(t *testing.T) {
assert.Equal(t, adapter.RenderedFile, seen[cfgDest].Kind)
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// Correct key is "provider" (singular map), not "providers" (slice).
assert.Contains(t, parsed, "provider")
assert.NotContains(t, parsed, "providers")
// Correct key is "model", not "default_model".
assert.Contains(t, parsed, "model")
assert.NotContains(t, parsed, "default_model")
// No "mcpServers" — opencode uses "mcp".
assert.NotContains(t, parsed, "mcpServers")
agentsDest := filepath.Join(home, ".config", "opencode", "AGENTS.md")
assert.Equal(t, adapter.RenderedFile, seen[agentsDest].Kind)
assert.Contains(t, string(seen[agentsDest].Content), "# global")
}
func TestOpencodeRenderWithMCP(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "sonnet"},
},
MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{
{Name: "fp", Command: "/bin/fp"},
}},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "opencode", "opencode.jsonc")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
mcp := parsed["mcp"].(map[string]any)
fp := mcp["fp"].(map[string]any)
// MCPOpencodeStyle: type local, command as array, enabled true.
assert.Equal(t, "local", fp["type"])
assert.Equal(t, true, fp["enabled"])
cmd, _ := fp["command"].([]any)
assert.Equal(t, "/bin/fp", cmd[0])
}
func TestOpencodeRenderMergesExistingKeys(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "opencode")
require.NoError(t, os.MkdirAll(base, 0o750))
existing := map[string]any{
"$schema": "https://opencode.schema/v1",
"agent": map[string]any{"maxTokens": 8192},
}
body, _ := json.Marshal(existing)
require.NoError(t, os.WriteFile(filepath.Join(base, "opencode.jsonc"), body, 0o600))
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "sonnet"},
},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(base, "opencode.jsonc")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// User keys preserved.
assert.Equal(t, "https://opencode.schema/v1", parsed["$schema"])
assert.Contains(t, parsed, "agent")
// Managed keys overlaid.
assert.Contains(t, parsed, "model")
assert.Contains(t, parsed, "provider")
}
func TestOpencodeImportStripsComments(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "opencode")
+19
View File
@@ -0,0 +1,19 @@
package zed
import (
"path/filepath"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
)
func importFrom(home string) (*adapter.ImportResult, error) {
cfgPath := filepath.Join(home, ".config", "zed", "settings.json")
// context_servers entries use source:"custom" for stdio; the MCP command
// field maps directly to canonical MCPServer.Command.
servers, err := common.ImportMCPFromJSONFile(cfgPath, "context_servers")
if err != nil {
return nil, err
}
return &adapter.ImportResult{MCP: servers}, nil
}
+102
View File
@@ -0,0 +1,102 @@
// Package zed is the Zed editor harness adapter.
//
// Zed uses ~/.config/zed/settings.json with many user-managed keys.
// We MERGE only "agent" and "context_servers"; all other keys are preserved.
// MCP servers become context_servers entries with source: "custom".
package zed
import (
"os"
"path/filepath"
"strings"
"github.com/lukaszraczylo/harness-sync/internal/adapter"
"github.com/lukaszraczylo/harness-sync/internal/adapter/common"
"github.com/lukaszraczylo/harness-sync/internal/canonical"
)
const name = "zed"
// Adapter implements adapter.Adapter for the Zed harness.
type Adapter struct{ home string }
// Option configures an Adapter.
type Option func(*Adapter)
// WithHome overrides the home directory (defaults to os.UserHomeDir).
func WithHome(h string) Option { return func(a *Adapter) { a.home = h } }
// New returns a new Adapter with the given options applied.
func New(opts ...Option) *Adapter {
a := &Adapter{home: defaultHome()}
for _, o := range opts {
o(a)
}
return a
}
func defaultHome() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
}
// Name returns the harness identifier.
func (a *Adapter) Name() string { return name }
// Detect returns true when ~/.config/zed/ exists.
func (a *Adapter) Detect() bool {
_, err := os.Stat(filepath.Join(a.home, ".config", "zed"))
return err == nil
}
// Render produces the FileSet that applies the canonical bundle to Zed.
// settings.json is MERGED to preserve all user-managed Zed configuration.
// Only "agent" (default_model) and "context_servers" (MCP) are overlaid.
func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
fs := adapter.NewFileSet()
cfgPath := filepath.Join(a.home, ".config", "zed", "settings.json")
existing, _ := os.ReadFile(cfgPath)
overlay := map[string]any{
"context_servers": common.BuildMCPMapStyled(&b.MCP, common.MCPZedStyle),
}
if dm := b.Profile.Gateway.DefaultModel; dm != "" {
provider, model := splitProviderModel(dm)
overlay["agent"] = map[string]any{
"default_model": map[string]any{
"provider": provider,
"model": model,
},
}
}
merged, err := common.MergeJSONKeys(existing, overlay)
if err != nil {
return nil, err
}
fs.Add(adapter.File{
Dest: cfgPath,
Kind: adapter.RenderedFile,
Content: merged,
})
return fs, nil
}
// Import reads context_servers from Zed settings.json back to MCP servers.
func (a *Adapter) Import(home string) (*adapter.ImportResult, error) {
return importFrom(home)
}
// splitProviderModel splits "provider/model" at the first slash.
// If no slash, provider is "anthropic".
func splitProviderModel(s string) (string, string) {
if idx := strings.IndexByte(s, '/'); idx >= 0 {
return s[:idx], s[idx+1:]
}
return "anthropic", s
}
+165
View File
@@ -0,0 +1,165 @@
package zed
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 bundle() *canonical.Bundle {
return &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "anthropic/claude-sonnet-4-6"},
},
MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{
{Name: "filepuff", Command: "/bin/filepuff", Args: []string{"--serve"}},
}},
}
}
func TestZedRenderProducesExpectedTargets(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
fs, err := ad.Render(bundle())
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "zed", "settings.json")
require.Contains(t, seen, cfgDest)
assert.Equal(t, adapter.RenderedFile, seen[cfgDest].Kind)
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// Agent default_model is an object.
agent := parsed["agent"].(map[string]any)
dm := agent["default_model"].(map[string]any)
assert.Equal(t, "anthropic", dm["provider"])
assert.Equal(t, "claude-sonnet-4-6", dm["model"])
// context_servers.
cs := parsed["context_servers"].(map[string]any)
fp := cs["filepuff"].(map[string]any)
assert.Equal(t, true, fp["enabled"])
assert.Equal(t, "custom", fp["source"])
assert.Equal(t, "/bin/filepuff", fp["command"])
}
func TestZedRenderMergesExistingKeys(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "zed")
require.NoError(t, os.MkdirAll(base, 0o750))
// Existing settings with unrelated user config.
existing := map[string]any{
"theme": "One Dark",
"font_size": 16,
"vim_mode": true,
}
body, _ := json.Marshal(existing)
require.NoError(t, os.WriteFile(filepath.Join(base, "settings.json"), body, 0o600))
ad := New(WithHome(home))
fs, err := ad.Render(bundle())
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(base, "settings.json")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// Unrelated keys preserved.
assert.Equal(t, "One Dark", parsed["theme"])
assert.Equal(t, float64(16), parsed["font_size"])
assert.Equal(t, true, parsed["vim_mode"])
// Managed keys present.
assert.Contains(t, parsed, "agent")
assert.Contains(t, parsed, "context_servers")
}
func TestZedRenderNoMCP(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
b := &canonical.Bundle{
Profile: canonical.Profile{
Gateway: canonical.Gateway{DefaultModel: "anthropic/sonnet"},
},
}
fs, err := ad.Render(b)
require.NoError(t, err)
seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f })
cfgDest := filepath.Join(home, ".config", "zed", "settings.json")
var parsed map[string]any
require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed))
// context_servers key present but empty map.
cs, ok := parsed["context_servers"]
assert.True(t, ok)
assert.Empty(t, cs)
}
func TestZedImport(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".config", "zed")
require.NoError(t, os.MkdirAll(base, 0o750))
settings := map[string]any{
"context_servers": map[string]any{
"filepuff": map[string]any{
"enabled": true,
"source": "custom",
"command": "/bin/filepuff",
"args": []string{"--serve"},
},
},
}
body, _ := json.Marshal(settings)
require.NoError(t, os.WriteFile(filepath.Join(base, "settings.json"), body, 0o600))
ad := New(WithHome(home))
res, err := ad.Import(home)
require.NoError(t, err)
require.NotNil(t, res)
require.Len(t, res.MCP, 1)
assert.Equal(t, "filepuff", res.MCP[0].Name)
assert.Equal(t, "/bin/filepuff", res.MCP[0].Command)
}
func TestZedImportMissing(t *testing.T) {
ad := New(WithHome(t.TempDir()))
res, err := ad.Import(t.TempDir())
require.NoError(t, err)
assert.NotNil(t, res)
}
func TestZedDetect(t *testing.T) {
home := t.TempDir()
ad := New(WithHome(home))
assert.False(t, ad.Detect())
}
func TestSplitProviderModel(t *testing.T) {
t.Run("with slash", func(t *testing.T) {
p, m := splitProviderModel("anthropic/claude-sonnet-4-6")
assert.Equal(t, "anthropic", p)
assert.Equal(t, "claude-sonnet-4-6", m)
})
t.Run("no slash", func(t *testing.T) {
p, m := splitProviderModel("claude-sonnet")
assert.Equal(t, "anthropic", p)
assert.Equal(t, "claude-sonnet", m)
})
}