diff --git a/AGENTS.md b/AGENTS.md index 595b63e..d89097f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,8 @@ and you **must** pause there — do not bypass prompts or run with it themselves** — you should not pipe input into it. Up front the user picks which MCP hosts to wire: Claude Code, - Gemini CLI, Codex CLI, GitHub Copilot CLI, Cursor, or none. + Gemini CLI, Codex CLI, GitHub Copilot CLI, Cursor, VS Code, + Antigravity, or none. OpenClaw is a separate yes/no step later in setup. If Claude Code is selected, there are two scope decisions that only the user can make: diff --git a/README.md b/README.md index b5a3bfd..afcd114 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Codex CLI GitHub Copilot CLI Cursor + VS Code + Antigravity OpenClaw

diff --git a/cmd/clawdchan/agents.go b/cmd/clawdchan/agents.go index a0728dd..5d316b9 100644 --- a/cmd/clawdchan/agents.go +++ b/cmd/clawdchan/agents.go @@ -58,6 +58,8 @@ func allAgents() []*agentWiring { codexAgent(), copilotAgent(), cursorAgent(), + vscodeAgent(), + antigravityAgent(), } } @@ -99,15 +101,18 @@ func describeScopeFlag(a *agentWiring, scope string) string { } // mcpScopeChoices returns the MCP-registration scope names the agent -// supports. Gemini and CC support user+project; Codex and Copilot are -// user-only (per their upstream docs). +// supports. Gemini and CC support user+project; the rest are user-only +// (per their upstream docs). func mcpScopeChoices(a *agentWiring) []string { switch a.key { case "cc", "gemini": return []string{"user", "project"} default: - // Codex, Copilot, and Cursor are user-scope only. Cursor specifically - // ignores project-level .cursor/mcp.json — only ~/.cursor/mcp.json loads. + // Codex, Copilot, Cursor, VS Code, and Antigravity are user-scope only. + // Cursor specifically ignores project-level .cursor/mcp.json — only + // ~/.cursor/mcp.json loads. VS Code and Antigravity also support a + // workspace file (.vscode/mcp.json, etc.) but registering at user + // scope means a single setup run covers every project the user opens. return []string{"user"} } } diff --git a/cmd/clawdchan/colors.go b/cmd/clawdchan/colors.go index 0591768..d1a4691 100644 --- a/cmd/clawdchan/colors.go +++ b/cmd/clawdchan/colors.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/mattn/go-isatty" ) @@ -106,10 +107,27 @@ const ( // documented for the in-tree OpenClaw host. Change when we adopt // a real brand color. openclawRed = "#E34234" + // vscodeBlue is the published "VS Code Blue" used in the product + // icon. Source: https://code.visualstudio.com/brand + vscodeBlue = "#007ACC" ) -// agentColor returns the brand hex for an agent key ("cc", "gemini", -// "codex", "copilot", "openclaw"). Returns "" for unknown keys. +// antigravityGradient is the per-character color sweep used for +// Google Antigravity. Antigravity's brand identity is a gradient +// (pink → purple → blue → cyan → mint), not a single hex; rendering +// it as an interpolated sweep across the display name is the +// closest the terminal can get to the actual product mark. +var antigravityGradient = []string{ + "#FF5A8A", // rose + "#A155F0", // purple + "#5B7FFF", // blue + "#00C8FF", // cyan + "#34F5C5", // mint +} + +// agentColor returns the brand hex for an agent key. Returns "" for +// unknown keys, or for keys whose brand identity is a gradient rather +// than a single hex (Antigravity — see antigravityGradient). func agentColor(key string) string { switch key { case "cc": @@ -122,17 +140,23 @@ func agentColor(key string) string { return copilotPurple case "openclaw": return openclawRed + case "vscode": + return vscodeBlue } return "" } // agentStyle wraps s in bold + the agent's brand color, using 24-bit // ANSI truecolor. Falls back to plain bold when color is disabled or -// the key is unknown. +// the key is unknown. Antigravity is a gradient, not a single color, +// so it routes through agentGradient. func agentStyle(key, s string) string { if !colorEnabled { return s } + if key == "antigravity" { + return agentGradient(s, antigravityGradient) + } h := agentColor(key) if h == "" { return bold(s) @@ -144,6 +168,51 @@ func agentStyle(key, s string) string { return fmt.Sprintf("\x1b[1;38;2;%d;%d;%dm%s\x1b[0m", r, g, b, s) } +// agentGradient renders s with a per-character color sweep across the +// supplied hex stops, linearly interpolated in RGB. Bold attribute is +// applied across the whole run so the gradient still reads as a label. +// Returns plain s when color is disabled, falls back to plain bold on +// malformed stops or empty input. +func agentGradient(s string, stops []string) string { + if !colorEnabled || len(stops) < 2 { + return bold(s) + } + type rgb struct{ r, g, b int } + pal := make([]rgb, 0, len(stops)) + for _, h := range stops { + r, g, b, ok := parseHex(h) + if !ok { + return bold(s) + } + pal = append(pal, rgb{r, g, b}) + } + runes := []rune(s) + if len(runes) == 0 { + return s + } + var out strings.Builder + out.WriteString("\x1b[1m") + for i, ch := range runes { + pos := 0.0 + if len(runes) > 1 { + pos = float64(i) / float64(len(runes)-1) + } + seg := pos * float64(len(pal)-1) + a := int(seg) + b := a + 1 + if b >= len(pal) { + b = len(pal) - 1 + } + t := seg - float64(a) + r := pal[a].r + int(float64(pal[b].r-pal[a].r)*t) + g := pal[a].g + int(float64(pal[b].g-pal[a].g)*t) + bl := pal[a].b + int(float64(pal[b].b-pal[a].b)*t) + fmt.Fprintf(&out, "\x1b[38;2;%d;%d;%dm%c", r, g, bl, ch) + } + out.WriteString("\x1b[0m") + return out.String() +} + // parseHex parses a "#RRGGBB" string into 0..255 components. func parseHex(h string) (r, g, b int, ok bool) { if len(h) != 7 || h[0] != '#' { diff --git a/cmd/clawdchan/setup_agent_antigravity.go b/cmd/clawdchan/setup_agent_antigravity.go new file mode 100644 index 0000000..f25bed5 --- /dev/null +++ b/cmd/clawdchan/setup_agent_antigravity.go @@ -0,0 +1,106 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// antigravityAgent wires the Google Antigravity host. Antigravity is a +// VS Code-derived editor in Google's Gemini ecosystem; its MCP config +// lives under the parent ~/.gemini directory, not its own home, which +// is why the path is ~/.gemini/antigravity/mcp_config.json on every +// platform. The schema reuses the "mcpServers" convention shared with +// Cursor, Gemini CLI, and GitHub Copilot CLI. +// +// User-scope only — Antigravity's documented entry point ("Manage MCP +// Servers" → "View raw config") opens a single file, with no project +// scope mentioned in the upstream docs. +// +// Reference: https://antigravity.google (Editor → MCP Integration). +func antigravityAgent() *agentWiring { + return &agentWiring{ + key: "antigravity", + flagName: "antigravity", + displayName: "Antigravity", + defaultOn: false, + scopeFlags: []string{"mcp"}, + setup: setupAntigravity, + doctorReport: doctorAntigravity, + uninstallHints: uninstallHintsAntigravity, + } +} + +// antigravityUserMCPPath returns the user-scope mcp_config.json path +// for Antigravity. Layout matches the docs verbatim on every platform: +// ~/.gemini/antigravity/mcp_config.json. +func antigravityUserMCPPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".gemini", "antigravity", "mcp_config.json"), nil +} + +func setupAntigravity(yes bool, scopes map[string]string) error { + scope := strings.ToLower(strings.TrimSpace(scopes["mcp"])) + if scope == "" && (yes || !stdinIsTTY()) { + scope = "user" + } + if scope == "" { + fmt.Println(" MCP scope " + dim("(~/.gemini/antigravity/mcp_config.json — user only)") + ":") + fmt.Printf(" %s user %s\n", cyan("[1]"), green("(recommended)")) + fmt.Printf(" %s skip\n", cyan("[2]")) + switch promptChoice(" Choice [1]: ", 1, 2) { + case 2: + scope = "skip" + default: + scope = "user" + } + } + + switch scope { + case "user": + mcpBin, err := requireMCPBinary() + if err != nil { + return err + } + path, err := antigravityUserMCPPath() + if err != nil { + return err + } + _, err = mergeJSONMCPServer(path, map[string]any{ + "command": mcpBin, + }, "(user)") + return err + case "skip": + fmt.Printf(" %s MCP %s %s\n", okTag(), dim("→"), dim("skipped")) + return nil + default: + return fmt.Errorf("unknown -antigravity-mcp-scope %q (use user|skip)", scope) + } +} + +func doctorAntigravity() []string { + path, err := antigravityUserMCPPath() + if err != nil { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + if strings.Contains(string(data), `"clawdchan"`) { + return []string{"Antigravity: MCP registered (user scope, " + path + ")"} + } + return nil +} + +func uninstallHintsAntigravity() []string { + return []string{ + "If you wired Antigravity, remove the clawdchan entry from:", + " ~/.gemini/antigravity/mcp_config.json", + "Delete the \"clawdchan\" key under the \"mcpServers\" object.", + } +} diff --git a/cmd/clawdchan/setup_agent_vscode.go b/cmd/clawdchan/setup_agent_vscode.go new file mode 100644 index 0000000..3d295ef --- /dev/null +++ b/cmd/clawdchan/setup_agent_vscode.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// vscodeAgent is the Visual Studio Code wiring. VS Code 1.102+ supports +// MCP servers natively. User-scope config lives at +// `/Code/User/mcp.json` — the same User profile dir that +// holds settings.json/keybindings.json — and uses a top-level "servers" +// key rather than the "mcpServers" convention shared by the other hosts. +// +// We register at user scope only. Workspace `.vscode/mcp.json` is also +// supported by VS Code, but matching the Cursor/Codex/Copilot pattern +// keeps the setup flow consistent and means a single registration works +// across every project the user opens. +// +// Reference: https://code.visualstudio.com/docs/copilot/customization/mcp-servers +func vscodeAgent() *agentWiring { + return &agentWiring{ + key: "vscode", + flagName: "vscode", + displayName: "VS Code", + defaultOn: false, + scopeFlags: []string{"mcp"}, + setup: setupVSCode, + doctorReport: doctorVSCode, + uninstallHints: uninstallHintsVSCode, + } +} + +// vscodeUserMCPPath returns the user-scope mcp.json path for VS Code +// Stable. The User folder under the OS-specific config dir is where VS +// Code stores per-user settings.json and keybindings.json; mcp.json +// lives alongside them. +func vscodeUserMCPPath() (string, error) { + cfg, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(cfg, "Code", "User", "mcp.json"), nil +} + +func setupVSCode(yes bool, scopes map[string]string) error { + scope := strings.ToLower(strings.TrimSpace(scopes["mcp"])) + if scope == "" && (yes || !stdinIsTTY()) { + scope = "user" + } + if scope == "" { + fmt.Println(" MCP scope " + dim("(VS Code user profile — Code/User/mcp.json)") + ":") + fmt.Printf(" %s user %s\n", cyan("[1]"), green("(recommended)")) + fmt.Printf(" %s skip\n", cyan("[2]")) + switch promptChoice(" Choice [1]: ", 1, 2) { + case 2: + scope = "skip" + default: + scope = "user" + } + } + + switch scope { + case "user": + mcpBin, err := requireMCPBinary() + if err != nil { + return err + } + path, err := vscodeUserMCPPath() + if err != nil { + return err + } + if _, err := mergeJSONMCPServerAt(path, "servers", map[string]any{ + "command": mcpBin, + }, "(user)"); err != nil { + return err + } + fmt.Printf(" %s Reload VS Code %s to pick up the new server.\n", + warnTag(), dim("(Command Palette → \"Developer: Reload Window\")")) + return nil + case "skip": + fmt.Printf(" %s MCP %s %s\n", okTag(), dim("→"), dim("skipped")) + return nil + default: + return fmt.Errorf("unknown -vscode-mcp-scope %q (use user|skip)", scope) + } +} + +func doctorVSCode() []string { + path, err := vscodeUserMCPPath() + if err != nil { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + if strings.Contains(string(data), `"clawdchan"`) { + return []string{"VS Code: MCP registered (user scope, " + path + ")"} + } + return nil +} + +func uninstallHintsVSCode() []string { + return []string{ + "If you wired VS Code, remove the clawdchan entry from the user mcp.json:", + " Command Palette → \"MCP: Open User Configuration\"", + " (or edit the file at /Code/User/mcp.json)", + "Delete the \"clawdchan\" key under the \"servers\" object.", + } +} diff --git a/cmd/clawdchan/setup_agents_test.go b/cmd/clawdchan/setup_agents_test.go index e323f77..6dee994 100644 --- a/cmd/clawdchan/setup_agents_test.go +++ b/cmd/clawdchan/setup_agents_test.go @@ -201,6 +201,98 @@ key = "y" } } +// TestMergeJSONMCPServerAt_EmptyFile guards against a real install +// case: Antigravity (and likely others) create a zero-byte stub +// mcp_config.json on first run. json.Unmarshal of empty bytes errors +// with "unexpected end of JSON input"; the helper has to treat +// zero-length files as if they were "{}". +func TestMergeJSONMCPServerAt_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mcp_config.json") + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatal(err) + } + if _, err := mergeJSONMCPServer(path, map[string]any{ + "command": "/bin/clawdchan-mcp", + }, "(user)"); err != nil { + t.Fatalf("mergeJSONMCPServer on empty file: %v", err) + } + data, _ := os.ReadFile(path) + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + t.Fatalf("result not valid JSON: %v\n%s", err, string(data)) + } + servers, _ := obj["mcpServers"].(map[string]any) + if _, ok := servers["clawdchan"]; !ok { + t.Errorf("clawdchan entry missing: %s", string(data)) + } +} + +// TestMergeJSONMCPServerAt_VSCodeKey verifies that VS Code's "servers" +// top-level key is honored end-to-end: the entry lands under "servers", +// the wrong key "mcpServers" is NOT created as a sibling, and any +// pre-existing "servers" siblings survive. +func TestMergeJSONMCPServerAt_VSCodeKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mcp.json") + existing := `{"servers":{"playwright":{"command":"npx","args":["-y","@microsoft/mcp-server-playwright"]}}}` + if err := os.WriteFile(path, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if _, err := mergeJSONMCPServerAt(path, "servers", map[string]any{ + "command": "/bin/clawdchan-mcp", + }, "(user)"); err != nil { + t.Fatalf("mergeJSONMCPServerAt: %v", err) + } + data, _ := os.ReadFile(path) + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, wrong := obj["mcpServers"]; wrong { + t.Errorf("must not create mcpServers when topKey is servers: %s", string(data)) + } + servers, ok := obj["servers"].(map[string]any) + if !ok { + t.Fatalf("servers missing: %s", string(data)) + } + if _, ok := servers["playwright"]; !ok { + t.Errorf("sibling 'playwright' dropped") + } + entry, ok := servers["clawdchan"].(map[string]any) + if !ok { + t.Fatalf("no clawdchan entry: %s", string(data)) + } + if entry["command"] != "/bin/clawdchan-mcp" { + t.Errorf("command=%v", entry["command"]) + } +} + +// TestVSCodeUserMCPPath_Layout pins the mcp.json path under the OS +// config dir's Code/User folder — the location VS Code uses for +// user-profile settings. +func TestVSCodeUserMCPPath_Layout(t *testing.T) { + got, err := vscodeUserMCPPath() + if err != nil { + t.Fatalf("vscodeUserMCPPath: %v", err) + } + if !strings.HasSuffix(filepath.ToSlash(got), "Code/User/mcp.json") { + t.Errorf("path = %q, want suffix Code/User/mcp.json", got) + } +} + +// TestAntigravityUserMCPPath_Layout pins the gemini-rooted layout +// documented by Antigravity (.gemini/antigravity/mcp_config.json). +func TestAntigravityUserMCPPath_Layout(t *testing.T) { + got, err := antigravityUserMCPPath() + if err != nil { + t.Fatalf("antigravityUserMCPPath: %v", err) + } + if !strings.HasSuffix(filepath.ToSlash(got), ".gemini/antigravity/mcp_config.json") { + t.Errorf("path = %q, want suffix .gemini/antigravity/mcp_config.json", got) + } +} + // TestAgentRegistryUnique guards against copy-paste mistakes where two // agents share a flag name — which would silently break CLI parsing. func TestAgentRegistryUnique(t *testing.T) { diff --git a/cmd/clawdchan/setup_helpers.go b/cmd/clawdchan/setup_helpers.go index 7395674..e4e2715 100644 --- a/cmd/clawdchan/setup_helpers.go +++ b/cmd/clawdchan/setup_helpers.go @@ -28,17 +28,26 @@ func requireMCPBinary() (string, error) { // sibling field. Creates the file (and its parent dir) if missing. // // Used by hosts whose config format is a plain JSON object keyed by -// mcpServers (Gemini CLI, GitHub Copilot CLI, Cursor). Returns whether -// the clawdchan entry was newly added (true) or updated in place -// (false). The logSuffix is the parenthetical shown at the end of the -// success line, e.g. "(trust=true)" for Gemini or `(tools=["*"])` for -// Copilot. +// mcpServers (Gemini CLI, GitHub Copilot CLI, Cursor, Antigravity). +// Returns whether the clawdchan entry was newly added (true) or +// updated in place (false). The logSuffix is the parenthetical shown +// at the end of the success line, e.g. "(trust=true)" for Gemini or +// `(tools=["*"])` for Copilot. func mergeJSONMCPServer(path string, entry map[string]any, logSuffix string) (bool, error) { + return mergeJSONMCPServerAt(path, "mcpServers", entry, logSuffix) +} + +// mergeJSONMCPServerAt is the general form. VS Code is the one host +// that nests its servers under the top-level "servers" key (matching +// the MCP spec's native naming) instead of "mcpServers". +func mergeJSONMCPServerAt(path, topKey string, entry map[string]any, logSuffix string) (bool, error) { data, err := os.ReadFile(path) - if err != nil { - if !os.IsNotExist(err) { - return false, err - } + if err != nil && !os.IsNotExist(err) { + return false, err + } + // Missing file and empty-stub file (some hosts — e.g. Antigravity — + // create a zero-byte placeholder on first run) both decode as {}. + if len(data) == 0 { data = []byte("{}") } var obj map[string]any @@ -48,13 +57,13 @@ func mergeJSONMCPServer(path string, entry map[string]any, logSuffix string) (bo if obj == nil { obj = map[string]any{} } - servers, _ := obj["mcpServers"].(map[string]any) + servers, _ := obj[topKey].(map[string]any) if servers == nil { servers = map[string]any{} } _, existed := servers["clawdchan"] servers["clawdchan"] = entry - obj["mcpServers"] = servers + obj[topKey] = servers out, err := json.MarshalIndent(obj, "", " ") if err != nil { diff --git a/npm/README.md b/npm/README.md index ef8af7a..cb4f3fc 100644 --- a/npm/README.md +++ b/npm/README.md @@ -2,7 +2,8 @@ Let your Claude talk to mine — private channel between two (human, agent) pairs. Today the setup flow wires Claude Code, Gemini CLI, Codex CLI, -GitHub Copilot CLI, Cursor, and OpenClaw. +GitHub Copilot CLI, Cursor, VS Code, and Antigravity, with an optional +OpenClaw gateway step. ```sh npm i -g clawdchan diff --git a/site/antigravity-mark.png b/site/antigravity-mark.png new file mode 100644 index 0000000..c677e4f Binary files /dev/null and b/site/antigravity-mark.png differ diff --git a/site/index.html b/site/index.html index a0e3bb7..c31f114 100644 --- a/site/index.html +++ b/site/index.html @@ -53,6 +53,8 @@

Let your Claude talk to mine.

  • Codex CLI
  • GitHub Copilot CLI
  • Cursor
  • +
  • VS Code
  • +
  • Antigravity
  • OpenClaw