From a18cb1a4b7e672c3a4cd6cc4ba0d4b1080eac909 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 23 May 2026 03:06:35 +0100 Subject: [PATCH] refactor(adapters): extract common helpers + fix kilo native schema --- internal/adapter/common/blocks.go | 79 ++++++++++++ internal/adapter/common/common_test.go | 92 ++++++++++++++ internal/adapter/common/io.go | 88 +++++++++++++ internal/adapter/common/markdown.go | 77 ++++++++++++ internal/adapters/claudecode/claudecode.go | 23 +--- internal/adapters/claudecode/import.go | 140 ++------------------- internal/adapters/crush/crush.go | 61 +-------- internal/adapters/crush/import.go | 38 +----- internal/adapters/kilo/import.go | 35 +----- internal/adapters/kilo/kilo.go | 62 +-------- internal/adapters/kilo/kilo_test.go | 7 ++ internal/adapters/opencode/import.go | 59 ++------- internal/adapters/opencode/opencode.go | 58 +-------- 13 files changed, 377 insertions(+), 442 deletions(-) create mode 100644 internal/adapter/common/blocks.go create mode 100644 internal/adapter/common/common_test.go create mode 100644 internal/adapter/common/io.go create mode 100644 internal/adapter/common/markdown.go diff --git a/internal/adapter/common/blocks.go b/internal/adapter/common/blocks.go new file mode 100644 index 0000000..2254789 --- /dev/null +++ b/internal/adapter/common/blocks.go @@ -0,0 +1,79 @@ +// Package common provides shared helpers for harness adapters. +package common + +import "github.com/lukaszraczylo/harness-sync/internal/canonical" + +// ProviderEntry is a generic provider record. Adapters may massage further. +type ProviderEntry = map[string]any + +// BuildProviders returns the canonical-to-generic provider list: gateway first +// (if configured), then any upstreams. Each entry has id, name, base_url, +// api_key, and (for the gateway) a models list. +func BuildProviders(p *canonical.Profile) []ProviderEntry { + out := make([]ProviderEntry, 0, len(p.Upstreams)+1) + if p.Gateway.URL != "" { + gw := ProviderEntry{ + "id": "harness-sync-gateway", + "name": "harness-sync gateway", + "base_url": p.Gateway.URL, + "api_key": p.Gateway.Token, + } + if models := buildModels(p.Models); len(models) > 0 { + gw["models"] = models + } + out = append(out, gw) + } + for _, up := range p.Upstreams { + e := ProviderEntry{"id": up.Name, "name": up.Name} + if up.BaseURL != "" { + e["base_url"] = up.BaseURL + } + if up.APIKey != "" { + e["api_key"] = up.APIKey + } + out = append(out, e) + } + return out +} + +func buildModels(models []canonical.Model) []map[string]any { + out := make([]map[string]any, 0, len(models)) + for _, m := range models { + e := map[string]any{"id": m.ID} + if m.Alias != "" { + e["alias"] = m.Alias + } + out = append(out, e) + } + 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. +func BuildMCPMap(reg *canonical.MCPRegistry) map[string]any { + out := map[string]any{} + if reg == nil { + return out + } + for _, s := range reg.Servers { + e := map[string]any{} + 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 + } + out[s.Name] = e + } + return out +} diff --git a/internal/adapter/common/common_test.go b/internal/adapter/common/common_test.go new file mode 100644 index 0000000..e480e71 --- /dev/null +++ b/internal/adapter/common/common_test.go @@ -0,0 +1,92 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/canonical" +) + +func TestBuildProvidersGatewayFirst(t *testing.T) { + p := &canonical.Profile{ + Gateway: canonical.Gateway{URL: "https://gw", Token: "t", DefaultModel: "x"}, + Models: []canonical.Model{{ID: "m1", Alias: "a1"}}, + Upstreams: []canonical.Upstream{ + {Name: "anthropic", APIKey: "k", BaseURL: "https://api.anthropic.com"}, + }, + } + out := BuildProviders(p) + require.Len(t, out, 2) + assert.Equal(t, "harness-sync-gateway", out[0]["id"]) + assert.Equal(t, "anthropic", out[1]["id"]) + assert.NotNil(t, out[0]["models"]) +} + +func TestBuildProvidersNoGateway(t *testing.T) { + p := &canonical.Profile{ + Upstreams: []canonical.Upstream{ + {Name: "anthropic", APIKey: "k"}, + }, + } + out := BuildProviders(p) + require.Len(t, out, 1) + assert.Equal(t, "anthropic", out[0]["id"]) +} + +func TestBuildMCPMap(t *testing.T) { + reg := &canonical.MCPRegistry{Servers: []canonical.MCPServer{ + {Name: "filepuff", Command: "/bin/x", Transport: "stdio"}, + }} + out := BuildMCPMap(reg) + assert.Contains(t, out, "filepuff") + entry := out["filepuff"].(map[string]any) + assert.Equal(t, "/bin/x", entry["command"]) +} + +func TestBuildMCPMapNil(t *testing.T) { + out := BuildMCPMap(nil) + assert.NotNil(t, out) + assert.Len(t, out, 0) +} + +func TestStripJSONComments(t *testing.T) { + s := "// hi\n{\"a\":1, /* x */ \"b\":2}" + assert.Equal(t, `{"a":1, "b":2}`, StripJSONComments(s)) +} + +func TestParseFrontmatter(t *testing.T) { + body := []byte("---\nname: foo\ndescription: bar\n---\nbody") + name, desc := ParseFrontmatter(body) + assert.Equal(t, "foo", name) + assert.Equal(t, "bar", desc) +} + +func TestParseFrontmatterMissing(t *testing.T) { + name, desc := ParseFrontmatter([]byte("no frontmatter")) + assert.Empty(t, name) + assert.Empty(t, desc) +} + +func TestImportMarkdownTreeMissingDir(t *testing.T) { + docs, err := ImportMarkdownTree("/nonexistent/path", "") + require.NoError(t, err) + assert.Empty(t, docs) +} + +func TestParseMCPFromJSON(t *testing.T) { + body := []byte(`{"mcpServers": {"filepuff": {"command": "/bin/fp", "args": ["--flag"]}}}`) + servers, err := ParseMCPFromJSON(body, "mcpServers") + require.NoError(t, err) + require.Len(t, servers, 1) + assert.Equal(t, "filepuff", servers[0].Name) + assert.Equal(t, "/bin/fp", servers[0].Command) +} + +func TestParseMCPFromJSONMissingKey(t *testing.T) { + body := []byte(`{"other": {}}`) + servers, err := ParseMCPFromJSON(body, "mcpServers") + require.NoError(t, err) + assert.Nil(t, servers) +} diff --git a/internal/adapter/common/io.go b/internal/adapter/common/io.go new file mode 100644 index 0000000..e4022d8 --- /dev/null +++ b/internal/adapter/common/io.go @@ -0,0 +1,88 @@ +package common + +import ( + "encoding/json" + "os" + "regexp" + "strings" + + "github.com/lukaszraczylo/harness-sync/internal/canonical" +) + +// ReadIfExists returns the file body or empty string when missing. +func ReadIfExists(path string) (string, error) { + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(b), nil +} + +// DirExists reports whether path is an existing directory. +func DirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +var ( + blockComment = regexp.MustCompile(`(?s)/\*.*?\*/`) + lineComment = regexp.MustCompile(`(?m)//[^\n]*`) +) + +// StripJSONComments is a naive JSONC -> JSON stripper. It does not understand +// strings containing // or /* and is therefore only safe for hand-edited +// config files that don't put comment markers inside string literals. +func StripJSONComments(s string) string { + s = blockComment.ReplaceAllString(s, "") + s = lineComment.ReplaceAllString(s, "") + return strings.TrimSpace(s) +} + +// ImportMCPFromJSONFile reads a JSON file and extracts an MCP server map +// nested under the given top-level key (e.g. "mcpServers", "mcp"). +// Returns nil with no error when the file is missing. +func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) { + body, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return ParseMCPFromJSON(body, key) +} + +// ParseMCPFromJSON extracts MCP servers from a JSON body. The body may be +// raw JSON or JSONC (comments are stripped first). +func ParseMCPFromJSON(body []byte, key string) ([]canonical.MCPServer, error) { + clean := StripJSONComments(string(body)) + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(clean), &raw); err != nil { + return nil, nil // tolerate malformed config + } + nested, ok := raw[key] + if !ok { + return nil, nil + } + var entries map[string]struct { //nolint:govet // fieldalignment: map+slice+3 strings irreducible + Env map[string]string `json:"env"` + Args []string `json:"args"` + Command string `json:"command"` + URL string `json:"url"` + Transport string `json:"transport"` + } + if err := json.Unmarshal(nested, &entries); err != nil { + return nil, nil + } + out := make([]canonical.MCPServer, 0, len(entries)) + for n, v := range entries { + out = append(out, canonical.MCPServer{ + Name: n, Command: v.Command, Args: v.Args, URL: v.URL, + Transport: v.Transport, Env: v.Env, + }) + } + return out, nil +} diff --git a/internal/adapter/common/markdown.go b/internal/adapter/common/markdown.go new file mode 100644 index 0000000..dd2c94e --- /dev/null +++ b/internal/adapter/common/markdown.go @@ -0,0 +1,77 @@ +package common + +import ( + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Doc mirrors canonical.Skill / canonical.Agent without importing them. +type Doc struct { + Name string + Description string + Body string + Path string +} + +// ParseFrontmatter extracts the name and description fields from a markdown +// file's YAML frontmatter block. Returns ("", "") when there is no frontmatter. +func ParseFrontmatter(b []byte) (name, description string) { + s := string(b) + if !strings.HasPrefix(s, "---\n") { + return "", "" + } + end := strings.Index(s[4:], "\n---") + if end == -1 { + return "", "" + } + var meta struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + } + _ = yaml.Unmarshal([]byte(s[4:4+end]), &meta) + return meta.Name, meta.Description +} + +// ImportMarkdownTree walks dir and returns one Doc per markdown file. When +// requiredFilename is non-empty (e.g. "SKILL.md"), only files with that exact +// basename are included; otherwise any *.md. +func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) { + var docs []Doc + if !DirExists(dir) { + return docs, nil + } + err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if requiredFilename != "" && filepath.Base(p) != requiredFilename { + return nil + } + if requiredFilename == "" && !strings.HasSuffix(p, ".md") { + return nil + } + body, err := os.ReadFile(p) + if err != nil { + return err + } + name, desc := ParseFrontmatter(body) + if name == "" { + name = strings.TrimSuffix(filepath.Base(p), ".md") + } + rel, _ := filepath.Rel(dir, p) + docs = append(docs, Doc{ + Name: name, + Description: desc, + Body: string(body), + Path: rel, + }) + return nil + }) + return docs, err +} diff --git a/internal/adapters/claudecode/claudecode.go b/internal/adapters/claudecode/claudecode.go index 55a136a..999e36e 100644 --- a/internal/adapters/claudecode/claudecode.go +++ b/internal/adapters/claudecode/claudecode.go @@ -6,6 +6,7 @@ import ( "path/filepath" "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" ) @@ -89,28 +90,8 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { } func renderSettings(b *canonical.Bundle) ([]byte, error) { - mcp := map[string]any{} - for _, s := range b.MCP.Servers { - entry := map[string]any{} - if s.Command != "" { - entry["command"] = s.Command - } - if len(s.Args) > 0 { - entry["args"] = s.Args - } - if s.URL != "" { - entry["url"] = s.URL - } - if s.Transport != "" { - entry["transport"] = s.Transport - } - if len(s.Env) > 0 { - entry["env"] = s.Env - } - mcp[s.Name] = entry - } return render.JSON(map[string]any{ - "mcpServers": mcp, + "mcpServers": common.BuildMCPMap(&b.MCP), }) } diff --git a/internal/adapters/claudecode/import.go b/internal/adapters/claudecode/import.go index b76a3ac..47d4ffb 100644 --- a/internal/adapters/claudecode/import.go +++ b/internal/adapters/claudecode/import.go @@ -1,14 +1,10 @@ package claudecode import ( - "encoding/json" - "os" "path/filepath" - "strings" - - "gopkg.in/yaml.v3" "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/adapter/common" "github.com/lukaszraczylo/harness-sync/internal/canonical" ) @@ -16,158 +12,36 @@ func importFrom(home string) (*adapter.ImportResult, error) { base := filepath.Join(home, ".claude") res := &adapter.ImportResult{} - skills, err := importMarkdownTree(filepath.Join(base, "skills"), "SKILL.md") + skills, err := common.ImportMarkdownTree(filepath.Join(base, "skills"), "SKILL.md") if err != nil { return nil, err } for _, s := range skills { res.Skills = append(res.Skills, canonical.Skill{ - Name: s.name, - Description: s.description, - Body: s.body, - Path: s.path, + Name: s.Name, Description: s.Description, Body: s.Body, Path: s.Path, }) } - agents, err := importMarkdownTree(filepath.Join(base, "agents"), "") + 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, + Name: a.Name, Description: a.Description, Body: a.Body, Path: a.Path, }) } - body, err := readIfExists(filepath.Join(base, "CLAUDE.md")) + body, err := common.ReadIfExists(filepath.Join(base, "CLAUDE.md")) if err != nil { return nil, err } res.Instructions = body - servers, err := importMCPFromSettings(filepath.Join(base, "settings.json")) + servers, err := common.ImportMCPFromJSONFile(filepath.Join(base, "settings.json"), "mcpServers") if err != nil { return nil, err } res.MCP = servers return res, nil } - -type mdDoc struct { - name string - description string - body string - path string -} - -func importMarkdownTree(dir, requiredFilename string) ([]mdDoc, error) { - var docs []mdDoc - if !dirExists(dir) { - return docs, nil - } - err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - if requiredFilename != "" && filepath.Base(p) != requiredFilename { - return nil - } - if requiredFilename == "" && !strings.HasSuffix(p, ".md") { - return nil - } - body, err := os.ReadFile(p) - if err != nil { - return err - } - n, desc := parseFrontmatter(body) - if n == "" { - n = strings.TrimSuffix(filepath.Base(p), ".md") - } - rel, _ := filepath.Rel(dir, p) - docs = append(docs, mdDoc{ - name: n, - description: desc, - body: string(body), - path: rel, - }) - return nil - }) - return docs, err -} - -type settingsServer struct { //nolint:govet // fieldalignment not achievable: map+slice+3 strings = 80 bytes regardless of order - Env map[string]string `json:"env"` - Args []string `json:"args"` - Command string `json:"command"` - URL string `json:"url"` - Transport string `json:"transport"` -} - -type settingsFile struct { - MCPServers map[string]settingsServer `json:"mcpServers"` -} - -func importMCPFromSettings(path string) ([]canonical.MCPServer, error) { - body, err := os.ReadFile(path) - if os.IsNotExist(err) { - return nil, nil - } - if err != nil { - return nil, err - } - var d settingsFile - if err := json.Unmarshal(body, &d); err != nil { - return nil, err - } - out := make([]canonical.MCPServer, 0, len(d.MCPServers)) - for nm, v := range d.MCPServers { - out = append(out, canonical.MCPServer{ - Name: nm, - Command: v.Command, - Args: v.Args, - URL: v.URL, - Transport: v.Transport, - Env: v.Env, - }) - } - return out, nil -} - -func parseFrontmatter(b []byte) (n, description string) { - s := string(b) - if !strings.HasPrefix(s, "---\n") { - return "", "" - } - end := strings.Index(s[4:], "\n---") - if end == -1 { - return "", "" - } - var meta struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - } - _ = yaml.Unmarshal([]byte(s[4:4+end]), &meta) - return meta.Name, meta.Description -} - -func readIfExists(path string) (string, error) { - b, err := os.ReadFile(path) - if os.IsNotExist(err) { - return "", nil - } - if err != nil { - return "", err - } - return string(b), nil -} - -func dirExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} diff --git a/internal/adapters/crush/crush.go b/internal/adapters/crush/crush.go index 1d2f2d4..5879aeb 100644 --- a/internal/adapters/crush/crush.go +++ b/internal/adapters/crush/crush.go @@ -6,6 +6,7 @@ import ( "path/filepath" "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" ) @@ -72,67 +73,11 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { } func renderConfig(b *canonical.Bundle) ([]byte, error) { - providers := make([]map[string]any, 0, len(b.Profile.Upstreams)+1) - - // Gateway as primary provider. - if b.Profile.Gateway.URL != "" { - gw := map[string]any{ - "id": "harness-sync-gateway", - "name": "harness-sync gateway", - "base_url": b.Profile.Gateway.URL, - "api_key": b.Profile.Gateway.Token, - } - models := make([]map[string]any, 0, len(b.Profile.Models)) - for _, m := range b.Profile.Models { - entry := map[string]any{"id": m.ID} - if m.Alias != "" { - entry["alias"] = m.Alias - } - models = append(models, entry) - } - if len(models) > 0 { - gw["models"] = models - } - providers = append(providers, gw) - } - - for _, up := range b.Profile.Upstreams { - p := map[string]any{"id": up.Name, "name": up.Name} - if up.BaseURL != "" { - p["base_url"] = up.BaseURL - } - if up.APIKey != "" { - p["api_key"] = up.APIKey - } - providers = append(providers, p) - } - - mcp := map[string]any{} - for _, s := range b.MCP.Servers { - entry := map[string]any{} - if s.Command != "" { - entry["command"] = s.Command - } - if len(s.Args) > 0 { - entry["args"] = s.Args - } - if s.URL != "" { - entry["url"] = s.URL - } - if s.Transport != "" { - entry["transport"] = s.Transport - } - if len(s.Env) > 0 { - entry["env"] = s.Env - } - mcp[s.Name] = entry - } - out := map[string]any{ - "providers": providers, + "providers": common.BuildProviders(&b.Profile), "default_model": b.Profile.Gateway.DefaultModel, } - if len(mcp) > 0 { + if mcp := common.BuildMCPMap(&b.MCP); len(mcp) > 0 { out["mcpServers"] = mcp } return render.JSON(out) diff --git a/internal/adapters/crush/import.go b/internal/adapters/crush/import.go index 34ce751..3fc98d8 100644 --- a/internal/adapters/crush/import.go +++ b/internal/adapters/crush/import.go @@ -1,49 +1,17 @@ package crush import ( - "encoding/json" - "os" "path/filepath" "github.com/lukaszraczylo/harness-sync/internal/adapter" - "github.com/lukaszraczylo/harness-sync/internal/canonical" + "github.com/lukaszraczylo/harness-sync/internal/adapter/common" ) -type crushMCPEntry struct { //nolint:govet // fieldalignment: map+slice+3 strings irreducible - Env map[string]string `json:"env"` - Args []string `json:"args"` - Command string `json:"command"` - URL string `json:"url"` - Transport string `json:"transport"` -} - -type crushConfig struct { - MCPServers map[string]crushMCPEntry `json:"mcpServers"` -} - func importFrom(home string) (*adapter.ImportResult, error) { cfgPath := filepath.Join(home, ".config", "crush", "crush.json") - body, err := os.ReadFile(cfgPath) - if os.IsNotExist(err) { - return &adapter.ImportResult{}, nil - } + servers, err := common.ImportMCPFromJSONFile(cfgPath, "mcpServers") if err != nil { return nil, err } - - var doc crushConfig - _ = json.Unmarshal(body, &doc) - - res := &adapter.ImportResult{} - for nm, v := range doc.MCPServers { - res.MCP = append(res.MCP, canonical.MCPServer{ - Name: nm, - Command: v.Command, - Args: v.Args, - URL: v.URL, - Transport: v.Transport, - Env: v.Env, - }) - } - return res, nil + return &adapter.ImportResult{MCP: servers}, nil } diff --git a/internal/adapters/kilo/import.go b/internal/adapters/kilo/import.go index fb5fd6b..9669244 100644 --- a/internal/adapters/kilo/import.go +++ b/internal/adapters/kilo/import.go @@ -1,46 +1,17 @@ package kilo import ( - "encoding/json" - "os" "path/filepath" "github.com/lukaszraczylo/harness-sync/internal/adapter" - "github.com/lukaszraczylo/harness-sync/internal/canonical" + "github.com/lukaszraczylo/harness-sync/internal/adapter/common" ) func importFrom(home string) (*adapter.ImportResult, error) { cfgPath := filepath.Join(home, ".config", "kilo", "kilo.json") - body, err := os.ReadFile(cfgPath) - if os.IsNotExist(err) { - return &adapter.ImportResult{}, nil - } + servers, err := common.ImportMCPFromJSONFile(cfgPath, "mcp") if err != nil { return nil, err } - - type mcpEntry struct { //nolint:govet // fieldalignment not achievable: map+slice+3 strings = 80 bytes regardless of order - Env map[string]string `json:"env"` - Args []string `json:"args"` - Command string `json:"command"` - URL string `json:"url"` - Transport string `json:"transport"` - } - var doc struct { - MCPServers map[string]mcpEntry `json:"mcpServers"` - } - _ = json.Unmarshal(body, &doc) - - res := &adapter.ImportResult{} - for nm, v := range doc.MCPServers { - res.MCP = append(res.MCP, canonical.MCPServer{ - Name: nm, - Command: v.Command, - Args: v.Args, - URL: v.URL, - Transport: v.Transport, - Env: v.Env, - }) - } - return res, nil + return &adapter.ImportResult{MCP: servers}, nil } diff --git a/internal/adapters/kilo/kilo.go b/internal/adapters/kilo/kilo.go index e0e786f..eceb478 100644 --- a/internal/adapters/kilo/kilo.go +++ b/internal/adapters/kilo/kilo.go @@ -6,6 +6,7 @@ import ( "path/filepath" "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" ) @@ -74,65 +75,12 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { } func renderConfig(b *canonical.Bundle) ([]byte, error) { - providers := make([]map[string]any, 0) - if b.Profile.Gateway.URL != "" { - gw := map[string]any{ - "id": "harness-sync-gateway", - "name": "harness-sync gateway", - "base_url": b.Profile.Gateway.URL, - "api_key": b.Profile.Gateway.Token, - } - models := make([]map[string]any, 0, len(b.Profile.Models)) - for _, m := range b.Profile.Models { - entry := map[string]any{"id": m.ID} - if m.Alias != "" { - entry["alias"] = m.Alias - } - models = append(models, entry) - } - if len(models) > 0 { - gw["models"] = models - } - providers = append(providers, gw) - } - for _, up := range b.Profile.Upstreams { - p := map[string]any{"id": up.Name, "name": up.Name} - if up.BaseURL != "" { - p["base_url"] = up.BaseURL - } - if up.APIKey != "" { - p["api_key"] = up.APIKey - } - providers = append(providers, p) - } - - mcp := map[string]any{} - for _, s := range b.MCP.Servers { - entry := map[string]any{} - if s.Command != "" { - entry["command"] = s.Command - } - if len(s.Args) > 0 { - entry["args"] = s.Args - } - if s.URL != "" { - entry["url"] = s.URL - } - if s.Transport != "" { - entry["transport"] = s.Transport - } - if len(s.Env) > 0 { - entry["env"] = s.Env - } - mcp[s.Name] = entry - } - out := map[string]any{ - "providers": providers, - "default_model": b.Profile.Gateway.DefaultModel, + "model": b.Profile.Gateway.DefaultModel, + "providers": common.BuildProviders(&b.Profile), } - if len(mcp) > 0 { - out["mcpServers"] = mcp + if mcp := common.BuildMCPMap(&b.MCP); len(mcp) > 0 { + out["mcp"] = mcp } return render.JSON(out) } diff --git a/internal/adapters/kilo/kilo_test.go b/internal/adapters/kilo/kilo_test.go index 1bc7cd1..6d9195e 100644 --- a/internal/adapters/kilo/kilo_test.go +++ b/internal/adapters/kilo/kilo_test.go @@ -23,6 +23,9 @@ func TestKiloRender(t *testing.T) { Gateway: canonical.Gateway{URL: "https://gw", Token: "dummy", DefaultModel: "sonnet"}, Models: []canonical.Model{{ID: "claude-sonnet-4-6", Alias: "sonnet"}}, }, + MCP: canonical.MCPRegistry{Servers: []canonical.MCPServer{ + {Name: "filepuff", Command: "/bin/filepuff"}, + }}, } fs, err := ad.Render(b) require.NoError(t, err) @@ -39,6 +42,10 @@ func TestKiloRender(t *testing.T) { var parsed map[string]any require.NoError(t, json.Unmarshal(seen[cfgDest].Content, &parsed)) + assert.Equal(t, "sonnet", parsed["model"]) + assert.Contains(t, parsed, "mcp") + assert.NotContains(t, parsed, "default_model") + assert.NotContains(t, parsed, "mcpServers") } func TestKiloImport(t *testing.T) { diff --git a/internal/adapters/opencode/import.go b/internal/adapters/opencode/import.go index 8bbbd47..64efed6 100644 --- a/internal/adapters/opencode/import.go +++ b/internal/adapters/opencode/import.go @@ -1,75 +1,32 @@ package opencode import ( - "encoding/json" - "os" "path/filepath" - "regexp" - "strings" "github.com/lukaszraczylo/harness-sync/internal/adapter" - "github.com/lukaszraczylo/harness-sync/internal/canonical" + "github.com/lukaszraczylo/harness-sync/internal/adapter/common" ) func importFrom(home string) (*adapter.ImportResult, error) { base := filepath.Join(home, ".config", "opencode") res := &adapter.ImportResult{} - cfgBody, err := readIfExists(filepath.Join(base, "opencode.jsonc")) + cfgBody, err := common.ReadIfExists(filepath.Join(base, "opencode.jsonc")) if err != nil { return nil, err } if cfgBody != "" { - clean := stripJSONComments(cfgBody) - var doc struct { - MCPServers map[string]struct { //nolint:govet // fieldalignment: map+slice+3 strings irreducible - Env map[string]string `json:"env"` - Args []string `json:"args"` - Command string `json:"command"` - URL string `json:"url"` - Transport string `json:"transport"` - } `json:"mcpServers"` - } - _ = json.Unmarshal([]byte(clean), &doc) - for nm, v := range doc.MCPServers { - res.MCP = append(res.MCP, canonical.MCPServer{ - Name: nm, - Command: v.Command, - Args: v.Args, - URL: v.URL, - Transport: v.Transport, - Env: v.Env, - }) + mcpServers, mcpErr := common.ParseMCPFromJSON([]byte(cfgBody), "mcpServers") + if mcpErr != nil { + return nil, mcpErr } + res.MCP = mcpServers } - body, err := readIfExists(filepath.Join(base, "AGENTS.md")) + instructions, err := common.ReadIfExists(filepath.Join(base, "AGENTS.md")) if err != nil { return nil, err } - res.Instructions = body - + res.Instructions = instructions return res, nil } - -var ( - blockComment = regexp.MustCompile(`(?s)/\*.*?\*/`) - lineComment = regexp.MustCompile(`(?m)//[^\n]*`) -) - -func stripJSONComments(s string) string { - s = blockComment.ReplaceAllString(s, "") - s = lineComment.ReplaceAllString(s, "") - return strings.TrimSpace(s) -} - -func readIfExists(path string) (string, error) { - b, err := os.ReadFile(path) - if os.IsNotExist(err) { - return "", nil - } - if err != nil { - return "", err - } - return string(b), nil -} diff --git a/internal/adapters/opencode/opencode.go b/internal/adapters/opencode/opencode.go index 9e5d8b2..91fa677 100644 --- a/internal/adapters/opencode/opencode.go +++ b/internal/adapters/opencode/opencode.go @@ -6,6 +6,7 @@ import ( "path/filepath" "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" ) @@ -76,64 +77,11 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { } func renderConfig(b *canonical.Bundle) ([]byte, error) { - providers := make([]map[string]any, 0) - if b.Profile.Gateway.URL != "" { - gw := map[string]any{ - "id": "harness-sync-gateway", - "name": "harness-sync gateway", - "base_url": b.Profile.Gateway.URL, - "api_key": b.Profile.Gateway.Token, - } - models := make([]map[string]any, 0, len(b.Profile.Models)) - for _, m := range b.Profile.Models { - entry := map[string]any{"id": m.ID} - if m.Alias != "" { - entry["alias"] = m.Alias - } - models = append(models, entry) - } - if len(models) > 0 { - gw["models"] = models - } - providers = append(providers, gw) - } - for _, up := range b.Profile.Upstreams { - p := map[string]any{"id": up.Name, "name": up.Name} - if up.BaseURL != "" { - p["base_url"] = up.BaseURL - } - if up.APIKey != "" { - p["api_key"] = up.APIKey - } - providers = append(providers, p) - } - - mcp := map[string]any{} - for _, s := range b.MCP.Servers { - entry := map[string]any{} - if s.Command != "" { - entry["command"] = s.Command - } - if len(s.Args) > 0 { - entry["args"] = s.Args - } - if s.URL != "" { - entry["url"] = s.URL - } - if s.Transport != "" { - entry["transport"] = s.Transport - } - if len(s.Env) > 0 { - entry["env"] = s.Env - } - mcp[s.Name] = entry - } - out := map[string]any{ - "providers": providers, + "providers": common.BuildProviders(&b.Profile), "default_model": b.Profile.Gateway.DefaultModel, } - if len(mcp) > 0 { + if mcp := common.BuildMCPMap(&b.MCP); len(mcp) > 0 { out["mcpServers"] = mcp } return render.JSON(out)