mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-14 00:57:49 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user