Skip to content

Commit c63b4ac

Browse files
authored
Merge pull request #49 from githubnext/autoloop/python-to-go-migration
[Autoloop: python-to-go-migration]
2 parents 027c47d + 90ddae7 commit c63b4ac

339 files changed

Lines changed: 68359 additions & 7 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

benchmarks/migration-status.json

Lines changed: 16291 additions & 6 deletions
Large diffs are not rendered by default.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Package base defines the MCPClientAdapter interface and shared regex helpers.
2+
//
3+
// Mirrors src/apm_cli/adapters/client/base.py.
4+
package base
5+
6+
import (
7+
"regexp"
8+
)
9+
10+
// InputVarRE matches ${input:NAME} placeholders that adapters must warn about.
11+
var InputVarRE = regexp.MustCompile(`\$\{input:([^}]+)\}`)
12+
13+
// EnvVarRE matches ${VAR} and ${env:VAR}, capturing the variable name.
14+
// Does NOT match ${input:VAR} or GitHub Actions ${{ ... }}.
15+
var EnvVarRE = regexp.MustCompile(`\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}`)
16+
17+
// MCPClientAdapter is the interface all MCP client adapters must satisfy.
18+
type MCPClientAdapter interface {
19+
// GetConfigPath returns the path to this adapter's config file.
20+
GetConfigPath() string
21+
22+
// UpdateConfig merges config_updates into the adapter's config file.
23+
UpdateConfig(configUpdates map[string]interface{}) error
24+
25+
// GetCurrentConfig reads and returns the current config, or empty map on error.
26+
GetCurrentConfig() map[string]interface{}
27+
28+
// ConfigureMCPServer installs a single MCP server into the adapter config.
29+
// Returns true on success.
30+
ConfigureMCPServer(serverURL, serverName string, enabled bool,
31+
envOverrides, serverInfoCache map[string]interface{},
32+
runtimeVars map[string]string) bool
33+
34+
// FormatServerConfig converts registry server info to the adapter's wire format.
35+
FormatServerConfig(serverInfo map[string]interface{},
36+
envOverrides map[string]interface{},
37+
runtimeVars map[string]string) (map[string]interface{}, error)
38+
39+
// TargetName returns the canonical adapter target name (e.g. "copilot", "vscode").
40+
TargetName() string
41+
42+
// MCPServersKey returns the top-level JSON key for server entries.
43+
MCPServersKey() string
44+
45+
// SupportsUserScope reports whether this adapter has a user/global config scope.
46+
SupportsUserScope() bool
47+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package base_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/githubnext/apm/internal/adapters/client/base"
7+
)
8+
9+
func TestInputVarRE(t *testing.T) {
10+
cases := []struct {
11+
input string
12+
match bool
13+
name string
14+
}{
15+
{"${input:MY_VAR}", true, "MY_VAR"},
16+
{"${input:foo}", true, "foo"},
17+
{"${env:BAR}", false, ""},
18+
{"${BAR}", false, ""},
19+
{"no placeholder", false, ""},
20+
{"${input:a} and ${input:b}", true, "a"},
21+
}
22+
for _, c := range cases {
23+
m := base.InputVarRE.FindStringSubmatch(c.input)
24+
if c.match {
25+
if m == nil {
26+
t.Errorf("InputVarRE: expected match for %q", c.input)
27+
} else if m[1] != c.name {
28+
t.Errorf("InputVarRE: got name %q, want %q", m[1], c.name)
29+
}
30+
} else {
31+
if m != nil {
32+
t.Errorf("InputVarRE: expected no match for %q, got %v", c.input, m)
33+
}
34+
}
35+
}
36+
}
37+
38+
func TestEnvVarRE(t *testing.T) {
39+
cases := []struct {
40+
input string
41+
match bool
42+
name string
43+
}{
44+
{"${MY_VAR}", true, "MY_VAR"},
45+
{"${env:MY_VAR}", true, "MY_VAR"},
46+
{"${input:foo}", false, ""},
47+
{"${{ ctx.token }}", false, ""},
48+
{"no placeholder", false, ""},
49+
{"${A_1}", true, "A_1"},
50+
}
51+
for _, c := range cases {
52+
m := base.EnvVarRE.FindStringSubmatch(c.input)
53+
if c.match {
54+
if m == nil {
55+
t.Errorf("EnvVarRE: expected match for %q", c.input)
56+
} else if m[1] != c.name {
57+
t.Errorf("EnvVarRE: got name %q, want %q", m[1], c.name)
58+
}
59+
} else {
60+
if m != nil {
61+
t.Errorf("EnvVarRE: expected no match for %q, got %v", c.input, m)
62+
}
63+
}
64+
}
65+
}
66+
67+
func TestEnvVarREAllMatches(t *testing.T) {
68+
input := "${FOO} and ${env:BAR} and ${input:skip}"
69+
matches := base.EnvVarRE.FindAllStringSubmatch(input, -1)
70+
if len(matches) != 2 {
71+
t.Fatalf("expected 2 matches, got %d", len(matches))
72+
}
73+
if matches[0][1] != "FOO" {
74+
t.Errorf("first match: want FOO, got %s", matches[0][1])
75+
}
76+
if matches[1][1] != "BAR" {
77+
t.Errorf("second match: want BAR, got %s", matches[1][1])
78+
}
79+
}
80+
81+
func TestInputVarRE_MultipleMatches(t *testing.T) {
82+
input := "${input:FOO} and ${input:BAR}"
83+
matches := base.InputVarRE.FindAllStringSubmatch(input, -1)
84+
if len(matches) != 2 {
85+
t.Fatalf("expected 2 matches, got %d", len(matches))
86+
}
87+
if matches[0][1] != "FOO" {
88+
t.Errorf("first match: want FOO, got %s", matches[0][1])
89+
}
90+
if matches[1][1] != "BAR" {
91+
t.Errorf("second match: want BAR, got %s", matches[1][1])
92+
}
93+
}
94+
95+
func TestInputVarRE_EnvNotMatched(t *testing.T) {
96+
cases := []string{"${MY_VAR}", "${env:MY_VAR}", "${{ secrets.TOKEN }}"}
97+
for _, c := range cases {
98+
if base.InputVarRE.MatchString(c) {
99+
t.Errorf("InputVarRE should not match %q", c)
100+
}
101+
}
102+
}
103+
104+
func TestEnvVarRE_NoMatchGitHubActions(t *testing.T) {
105+
cases := []string{"${{ secrets.TOKEN }}", "${{ env.VAR }}", "literal"}
106+
for _, c := range cases {
107+
if base.EnvVarRE.MatchString(c) {
108+
t.Errorf("EnvVarRE should not match %q", c)
109+
}
110+
}
111+
}
112+
113+
func TestEnvVarRE_CaseSensitive(t *testing.T) {
114+
// Variable names are case-sensitive in the regex
115+
if !base.EnvVarRE.MatchString("${MY_VAR}") {
116+
t.Error("expected match for ${MY_VAR}")
117+
}
118+
}
119+
120+
func TestEnvVarRE_WithPrefix(t *testing.T) {
121+
m := base.EnvVarRE.FindStringSubmatch("${env:SECRET_KEY}")
122+
if m == nil {
123+
t.Fatal("expected match for ${env:SECRET_KEY}")
124+
}
125+
if m[1] != "SECRET_KEY" {
126+
t.Errorf("expected SECRET_KEY, got %q", m[1])
127+
}
128+
}
129+
130+
func TestEnvVarRE_DigitStartNotMatched(t *testing.T) {
131+
// Variable names cannot start with a digit
132+
if base.EnvVarRE.MatchString("${1VAR}") {
133+
t.Error("EnvVarRE should not match variable starting with digit")
134+
}
135+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Package claude implements the Claude Code MCP client adapter.
2+
//
3+
// Mirrors src/apm_cli/adapters/client/claude.py.
4+
//
5+
// Claude Code uses .mcp.json at the project root (project scope) or
6+
// ~/.claude.json (user scope) with top-level "mcpServers" key.
7+
package claude
8+
9+
import (
10+
"encoding/json"
11+
"fmt"
12+
"os"
13+
"path/filepath"
14+
"strings"
15+
16+
"github.com/githubnext/apm/internal/adapters/client/copilot"
17+
)
18+
19+
// Adapter is the Claude Code MCP client adapter.
20+
//
21+
// It inherits all helper methods from the Copilot adapter but:
22+
// - Does NOT support runtime env substitution (installs literal values)
23+
// - Normalises entries for Claude Code's on-disk shape (strips Copilot-only fields)
24+
// - Supports both project scope (.mcp.json) and user scope (~/.claude.json)
25+
type Adapter struct {
26+
*copilot.Adapter
27+
}
28+
29+
// New creates a new Claude adapter.
30+
func New(projectRoot string, userScope bool) *Adapter {
31+
base := copilot.New(projectRoot, userScope)
32+
base.SupportsRuntimeEnvSubstitution = false
33+
return &Adapter{Adapter: base}
34+
}
35+
36+
// TargetName returns "claude".
37+
func (a *Adapter) TargetName() string { return "claude" }
38+
39+
// MCPServersKey returns "mcpServers".
40+
func (a *Adapter) MCPServersKey() string { return "mcpServers" }
41+
42+
// SupportsUserScope returns true.
43+
func (a *Adapter) SupportsUserScope() bool { return true }
44+
45+
// GetConfigPath returns the scope-resolved config file path.
46+
//
47+
// Project scope: <project_root>/.mcp.json
48+
// User scope: ~/.claude.json
49+
func (a *Adapter) GetConfigPath() string {
50+
if a.UserScope {
51+
home, _ := os.UserHomeDir()
52+
return filepath.Join(home, ".claude.json")
53+
}
54+
root := a.ProjectRoot
55+
if root == "" {
56+
var err error
57+
root, err = os.Getwd()
58+
if err != nil {
59+
root = "."
60+
}
61+
}
62+
return filepath.Join(root, ".mcp.json")
63+
}
64+
65+
// GetCurrentConfig reads the current config from the appropriate file.
66+
func (a *Adapter) GetCurrentConfig() map[string]interface{} {
67+
data, err := os.ReadFile(a.GetConfigPath())
68+
if err != nil {
69+
return map[string]interface{}{}
70+
}
71+
var cfg map[string]interface{}
72+
if err := json.Unmarshal(data, &cfg); err != nil {
73+
return map[string]interface{}{}
74+
}
75+
return cfg
76+
}
77+
78+
// UpdateConfig merges configUpdates into the mcpServers section.
79+
//
80+
// For user scope, creates the file with 0o600 permissions on first write.
81+
func (a *Adapter) UpdateConfig(configUpdates map[string]interface{}) error {
82+
configPath := a.GetConfigPath()
83+
current := a.GetCurrentConfig()
84+
85+
if _, ok := current["mcpServers"]; !ok {
86+
current["mcpServers"] = map[string]interface{}{}
87+
}
88+
servers, _ := current["mcpServers"].(map[string]interface{})
89+
for k, v := range configUpdates {
90+
servers[k] = v
91+
}
92+
current["mcpServers"] = servers
93+
94+
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
95+
return err
96+
}
97+
98+
data, err := json.MarshalIndent(current, "", " ")
99+
if err != nil {
100+
return err
101+
}
102+
103+
perm := os.FileMode(0o644)
104+
if a.UserScope {
105+
perm = 0o600
106+
}
107+
return os.WriteFile(configPath, data, perm)
108+
}
109+
110+
// FormatServerConfig wraps the Copilot formatter then normalises the entry
111+
// for Claude Code's on-disk shape.
112+
func (a *Adapter) FormatServerConfig(
113+
serverInfo map[string]interface{},
114+
envOverrides map[string]interface{},
115+
runtimeVars map[string]string,
116+
) (map[string]interface{}, error) {
117+
raw, err := a.Adapter.FormatServerConfig(serverInfo, envOverrides, runtimeVars)
118+
if err != nil {
119+
return nil, err
120+
}
121+
return normalizeMCPEntryForClaudeCode(raw), nil
122+
}
123+
124+
// normalizeMCPEntryForClaudeCode strips Copilot-only fields and emits
125+
// the Claude Code on-disk shape.
126+
//
127+
// For remote servers: keeps type/url/headers per Claude Code docs.
128+
// For stdio servers: drops type:"local", tools, and empty id; emits type:"stdio".
129+
func normalizeMCPEntryForClaudeCode(entry map[string]interface{}) map[string]interface{} {
130+
out := make(map[string]interface{}, len(entry))
131+
for k, v := range entry {
132+
out[k] = v
133+
}
134+
135+
entryType, _ := out["type"].(string)
136+
137+
if entryType == "http" || entryType == "remote" {
138+
// Remote: keep as-is, delete Copilot-only fields.
139+
delete(out, "tools")
140+
delete(out, "id")
141+
return out
142+
}
143+
144+
// stdio: normalise.
145+
delete(out, "tools")
146+
if id, _ := out["id"].(string); id == "" {
147+
delete(out, "id")
148+
}
149+
delete(out, "type")
150+
151+
// Only emit type:stdio when command is present.
152+
if cmd, _ := out["command"].(string); cmd != "" {
153+
out["type"] = "stdio"
154+
}
155+
156+
return out
157+
}
158+
159+
// ConfigureMCPServer installs a single MCP server into the Claude config.
160+
func (a *Adapter) ConfigureMCPServer(
161+
serverURL, serverName string,
162+
enabled bool,
163+
envOverrides map[string]interface{},
164+
serverInfoCache map[string]interface{},
165+
runtimeVars map[string]string,
166+
) bool {
167+
if serverURL == "" {
168+
fmt.Fprintln(os.Stderr, "[x] server_url cannot be empty")
169+
return false
170+
}
171+
172+
var serverInfo map[string]interface{}
173+
if serverInfoCache != nil {
174+
if v, ok := serverInfoCache[serverURL]; ok {
175+
serverInfo, _ = v.(map[string]interface{})
176+
}
177+
}
178+
if serverInfo == nil {
179+
fmt.Fprintf(os.Stderr, "[x] MCP server '%s' not found in registry\n", serverURL)
180+
return false
181+
}
182+
183+
serverConfig, err := a.FormatServerConfig(serverInfo, envOverrides, runtimeVars)
184+
if err != nil {
185+
fmt.Fprintf(os.Stderr, "[x] Error formatting server config: %s\n", err)
186+
return false
187+
}
188+
189+
configKey := serverKeyFor(serverURL, serverName)
190+
if err := a.UpdateConfig(map[string]interface{}{configKey: serverConfig}); err != nil {
191+
fmt.Fprintf(os.Stderr, "[x] Error writing Claude config: %s\n", err)
192+
return false
193+
}
194+
195+
fmt.Printf("[+] Configured MCP server '%s' for Claude Code\n", configKey)
196+
return true
197+
}
198+
199+
// ---- helpers ----
200+
201+
func serverKeyFor(serverURL, serverName string) string {
202+
if serverName != "" {
203+
return serverName
204+
}
205+
if idx := strings.LastIndex(serverURL, "/"); idx >= 0 {
206+
return serverURL[idx+1:]
207+
}
208+
return serverURL
209+
}

0 commit comments

Comments
 (0)