mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +00:00
223 lines
6.3 KiB
Go
223 lines
6.3 KiB
Go
package common
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/spf13/afero"
|
|
|
|
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
|
"github.com/lukaszraczylo/harness-sync/internal/fsx"
|
|
)
|
|
|
|
// ReadIfExistsFS returns the file body or empty string when missing, using fs.
|
|
func ReadIfExistsFS(fs fsx.FS, path string) (string, error) {
|
|
b, err := afero.ReadFile(fs, path)
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// ReadIfExists returns the file body or empty string when missing.
|
|
func ReadIfExists(path string) (string, error) {
|
|
return ReadIfExistsFS(fsx.OS(), path)
|
|
}
|
|
|
|
// ReadExistingFile returns the file body or (nil, nil) when missing.
|
|
// Other errors (permission denied, I/O failure) are returned verbatim so
|
|
// read-modify-write callers can distinguish a fresh first-run state from a
|
|
// broken read. Treating every error as "file is empty" silently overwrites
|
|
// the user's on-disk edits during apply.
|
|
func ReadExistingFile(path string) ([]byte, error) {
|
|
b, err := os.ReadFile(path) //nolint:gosec // caller-controlled path
|
|
if err != nil && os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return b, err
|
|
}
|
|
|
|
// DirExistsFS reports whether path is an existing directory on fs.
|
|
func DirExistsFS(fs fsx.FS, path string) bool {
|
|
info, err := fs.Stat(path)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// DirExists reports whether path is an existing directory.
|
|
func DirExists(path string) bool {
|
|
return DirExistsFS(fsx.OS(), path)
|
|
}
|
|
|
|
var trailingComma = regexp.MustCompile(`,(\s*[}\]])`)
|
|
|
|
// StripJSONComments converts JSONC to standard JSON by:
|
|
// - removing // line comments (only outside string literals)
|
|
// - removing /* */ block comments (only outside string literals)
|
|
// - removing trailing commas before } or ]
|
|
//
|
|
// It is string-aware so it won't mangle // inside URL values like "https://...".
|
|
func StripJSONComments(s string) string {
|
|
result := make([]byte, 0, len(s))
|
|
i, n := 0, len(s)
|
|
for i < n {
|
|
switch {
|
|
case s[i] == '"': // string literal — copy verbatim including escape sequences
|
|
result = append(result, s[i])
|
|
i++
|
|
for i < n {
|
|
c := s[i]
|
|
result = append(result, c)
|
|
i++
|
|
if c == '\\' && i < n {
|
|
result = append(result, s[i])
|
|
i++
|
|
} else if c == '"' {
|
|
break
|
|
}
|
|
}
|
|
case i+1 < n && s[i] == '/' && s[i+1] == '*': // /* block comment */
|
|
i += 2
|
|
for i+1 < n && (s[i] != '*' || s[i+1] != '/') {
|
|
i++
|
|
}
|
|
i += 2
|
|
case i+1 < n && s[i] == '/' && s[i+1] == '/': // // line comment
|
|
i += 2
|
|
for i < n && s[i] != '\n' {
|
|
i++
|
|
}
|
|
default:
|
|
result = append(result, s[i])
|
|
i++
|
|
}
|
|
}
|
|
out := trailingComma.ReplaceAllString(string(result), "$1")
|
|
return strings.TrimSpace(out)
|
|
}
|
|
|
|
// ImportMCPFromJSONFileFS reads a JSON file from fs and extracts an MCP server
|
|
// map nested under the given top-level key. Returns nil with no error when missing.
|
|
func ImportMCPFromJSONFileFS(fs fsx.FS, path, key string) ([]canonical.MCPServer, error) {
|
|
body, err := afero.ReadFile(fs, path)
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseMCPFromJSON(body, key)
|
|
}
|
|
|
|
// 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) {
|
|
return ImportMCPFromJSONFileFS(fsx.OS(), path, key)
|
|
}
|
|
|
|
// ParseMCPFromJSON extracts MCP servers from a JSON body. The body may be
|
|
// raw JSON or JSONC (comments are stripped first). Tolerates the dialect
|
|
// differences across harnesses:
|
|
// - claude / crush: command is a string, transport key is "type"
|
|
// - kilo / opencode: command is a []string (first element is the binary)
|
|
// - some configs: env appears as "env", others as "environment"
|
|
// - some configs: transport appears as "type", others as "transport"
|
|
func ParseMCPFromJSON(body []byte, key string) ([]canonical.MCPServer, error) {
|
|
// Try plain JSON first — StripJSONComments eats // inside URL strings.
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
clean := StripJSONComments(string(body))
|
|
if err := json.Unmarshal([]byte(clean), &raw); err != nil {
|
|
return nil, nil
|
|
}
|
|
}
|
|
nested, ok := raw[key]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
var entries map[string]json.RawMessage
|
|
if err := json.Unmarshal(nested, &entries); err != nil {
|
|
return nil, nil
|
|
}
|
|
out := make([]canonical.MCPServer, 0, len(entries))
|
|
for n, raw := range entries {
|
|
srv, ok := parseMCPEntry(n, raw)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Skip extension-managed entries: they have no command and no url
|
|
// (e.g. zed extensions providing their own MCP). Nothing to propagate.
|
|
if srv.Command == "" && srv.URL == "" {
|
|
continue
|
|
}
|
|
out = append(out, srv)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseMCPEntry(name string, raw json.RawMessage) (canonical.MCPServer, bool) {
|
|
var e struct { //nolint:govet // fieldalignment: dialect-tolerant entry, irreducible
|
|
Type string `json:"type"`
|
|
Transport string `json:"transport"`
|
|
Command json.RawMessage `json:"command"`
|
|
Cmd json.RawMessage `json:"cmd"`
|
|
Args []string `json:"args"`
|
|
URL string `json:"url"`
|
|
Headers map[string]string `json:"headers"`
|
|
Env map[string]string `json:"env"`
|
|
Environment map[string]string `json:"environment"`
|
|
}
|
|
if err := json.Unmarshal(raw, &e); err != nil {
|
|
return canonical.MCPServer{}, false
|
|
}
|
|
|
|
cmdRaw := e.Command
|
|
if len(cmdRaw) == 0 {
|
|
cmdRaw = e.Cmd
|
|
}
|
|
cmd, args := splitCommand(cmdRaw)
|
|
args = append(args, e.Args...)
|
|
|
|
env := e.Env
|
|
if len(env) == 0 {
|
|
env = e.Environment
|
|
}
|
|
|
|
transport := e.Type
|
|
if transport == "" {
|
|
transport = e.Transport
|
|
}
|
|
|
|
return canonical.MCPServer{
|
|
Name: name,
|
|
Command: cmd,
|
|
Args: args,
|
|
URL: e.URL,
|
|
Transport: transport,
|
|
Headers: e.Headers,
|
|
Env: env,
|
|
}, true
|
|
}
|
|
|
|
// splitCommand accepts a JSON value that may be a string ("foo") or a
|
|
// string array (["foo","--bar"]). Returns (head, tail).
|
|
func splitCommand(raw json.RawMessage) (string, []string) {
|
|
if len(raw) == 0 {
|
|
return "", nil
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return s, nil
|
|
}
|
|
var arr []string
|
|
if err := json.Unmarshal(raw, &arr); err == nil && len(arr) > 0 {
|
|
return arr[0], arr[1:]
|
|
}
|
|
return "", nil
|
|
}
|