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
+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
}