@matterailab/orbcode 0.2.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/mcp.js +149 -1
- package/dist/commands/migrate.js +333 -0
- package/dist/index.js +1 -0
- package/dist/mcp/auth.js +88 -4
- package/dist/mcp/manager.js +15 -1
- package/dist/ui/App.js +99 -1
- package/dist/ui/components/McpMigrationPicker.js +66 -0
- package/dist/ui/components/McpPicker.js +93 -4
- package/package.json +1 -1
package/dist/commands/mcp.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { addMcpServer, configPathForScope, loadMcpConfig, removeMcpServerAnyScope, } from "../mcp/config.js";
|
|
2
|
+
import { applyMigration, describeEntry, discoverSources, listMigrationEntries, } from "./migrate.js";
|
|
3
|
+
import { loadSettings } from "../config/settings.js";
|
|
2
4
|
/**
|
|
3
5
|
* `orbcode mcp` subcommand — manage MCP servers from the command line, like
|
|
4
6
|
* Claude Code's `claude mcp add/remove/list`.
|
|
@@ -11,6 +13,7 @@ import { addMcpServer, configPathForScope, loadMcpConfig, removeMcpServerAnyScop
|
|
|
11
13
|
* orbcode mcp add --header KEY=value ... <name> <url> http/sse headers
|
|
12
14
|
* orbcode mcp remove <name> remove from any scope
|
|
13
15
|
* orbcode mcp list list all servers
|
|
16
|
+
* orbcode mcp migrate [--all] [--dry-run] import from Claude/Codex
|
|
14
17
|
*
|
|
15
18
|
* Flags:
|
|
16
19
|
* -s, --scope <user|project|local> config scope (default: project)
|
|
@@ -39,6 +42,8 @@ Usage:
|
|
|
39
42
|
orbcode mcp add [options] --transport sse <name> <url> add an sse server
|
|
40
43
|
orbcode mcp remove <name> remove a server
|
|
41
44
|
orbcode mcp list list all servers
|
|
45
|
+
orbcode mcp migrate [--all] [--dry-run] import MCP servers from
|
|
46
|
+
Claude Code / Claude Desktop
|
|
42
47
|
|
|
43
48
|
Options:
|
|
44
49
|
-s, --scope <user|project|local> config scope (default: project)
|
|
@@ -47,11 +52,34 @@ Options:
|
|
|
47
52
|
--header KEY=value HTTP header (repeatable, http/sse)
|
|
48
53
|
--oauth enable OAuth for http/sse
|
|
49
54
|
--oauth-scope <scope> OAuth scope (implies --oauth)
|
|
55
|
+
--all (migrate) import every detected server
|
|
56
|
+
--dry-run (migrate) show what would be imported, don't write
|
|
50
57
|
|
|
51
58
|
Scopes:
|
|
52
59
|
project .mcp.json in the current directory (shared, checked into git)
|
|
53
60
|
user ~/.orbcode/settings.json (applies to all projects on this machine)
|
|
54
|
-
local .orbcode/settings.json (per-project, per-machine, not checked in)
|
|
61
|
+
local .orbcode/settings.json (per-project, per-machine, not checked in)
|
|
62
|
+
|
|
63
|
+
Migration sources:
|
|
64
|
+
orbcode mcp migrate scans these files and copies their mcpServers blocks
|
|
65
|
+
into ~/.orbcode/settings.json (user scope). Servers whose name already
|
|
66
|
+
exists in the destination are silently skipped.
|
|
67
|
+
|
|
68
|
+
Claude Code (user) ~/.claude/settings.json
|
|
69
|
+
Claude Code (user, json) ~/.claude.json -> root mcpServers
|
|
70
|
+
(the most common location — this is where
|
|
71
|
+
"claude mcp add -s user ..." writes)
|
|
72
|
+
Claude Code (this project) ~/.claude.json -> projects.<cwd>.mcpServers
|
|
73
|
+
Claude Desktop ~/Library/Application Support/Claude/claude_desktop_config.json
|
|
74
|
+
(macOS) %APPDATA%\\Claude\\claude_desktop_config.json
|
|
75
|
+
(Linux) $XDG_CONFIG_HOME/Claude/claude_desktop_config.json
|
|
76
|
+
|
|
77
|
+
When the same name appears in both layers of ~/.claude.json, the
|
|
78
|
+
project-layer entry is treated as a per-project override (Claude's own
|
|
79
|
+
precedence) and is hidden from the picker — you'll only see the root
|
|
80
|
+
version, which is the one that applies across projects.
|
|
81
|
+
|
|
82
|
+
Use the /migrate slash command in the TUI for an interactive picker.`);
|
|
55
83
|
}
|
|
56
84
|
/** Handle `orbcode mcp ...`. Returns the process exit code. */
|
|
57
85
|
export async function runMcpCommand(args) {
|
|
@@ -65,6 +93,9 @@ export async function runMcpCommand(args) {
|
|
|
65
93
|
case "list":
|
|
66
94
|
case "ls":
|
|
67
95
|
return runList(rest);
|
|
96
|
+
case "migrate":
|
|
97
|
+
case "import":
|
|
98
|
+
return runMigrate(rest);
|
|
68
99
|
case "help":
|
|
69
100
|
case "--help":
|
|
70
101
|
case "-h":
|
|
@@ -264,3 +295,120 @@ function runList(_args) {
|
|
|
264
295
|
}
|
|
265
296
|
return 0;
|
|
266
297
|
}
|
|
298
|
+
/** `orbcode mcp migrate [--all] [--dry-run]` — copy MCP servers from Claude
|
|
299
|
+
* Code / Claude Desktop into `~/.orbcode/settings.json` (user scope).
|
|
300
|
+
* Without `--all`, prints a preview and exits 0. `--dry-run` is a read-only
|
|
301
|
+
* preview even with `--all`. Name conflicts are silently skipped. */
|
|
302
|
+
function runMigrate(args) {
|
|
303
|
+
let migrateAll = false;
|
|
304
|
+
let dryRun = false;
|
|
305
|
+
for (const arg of args) {
|
|
306
|
+
if (arg === "--all" || arg === "-a")
|
|
307
|
+
migrateAll = true;
|
|
308
|
+
else if (arg === "--dry-run" || arg === "-n")
|
|
309
|
+
dryRun = true;
|
|
310
|
+
else if (arg === "--help" || arg === "-h") {
|
|
311
|
+
console.log("orbcode mcp migrate [--all] [--dry-run]\n");
|
|
312
|
+
console.log("Import MCP servers from Claude Code / Claude Desktop into");
|
|
313
|
+
console.log("~/.orbcode/settings.json (user scope).");
|
|
314
|
+
console.log("");
|
|
315
|
+
console.log(" --all, -a import every detected server (default: preview only)");
|
|
316
|
+
console.log(" --dry-run, -n show what would be imported, never write");
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
console.error(`Unknown flag: ${arg}. Try: orbcode mcp migrate --help`);
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const sources = discoverSources();
|
|
325
|
+
if (sources.length === 0) {
|
|
326
|
+
console.log("No MCP server sources found on this machine.");
|
|
327
|
+
console.log("");
|
|
328
|
+
console.log("Searched:");
|
|
329
|
+
console.log(" - ~/.claude/settings.json (Claude Code, user scope)");
|
|
330
|
+
console.log(" - ~/.claude.json -> root mcpServers (Claude Code, user scope)");
|
|
331
|
+
console.log(" - ~/.claude.json -> projects.<cwd>.mcpServers (Claude Code, this project)");
|
|
332
|
+
console.log(" - Claude Desktop config (claude_desktop_config.json)");
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
const entries = listMigrationEntries();
|
|
336
|
+
if (entries.length === 0) {
|
|
337
|
+
console.log("Found MCP server source files, but none contain server entries.");
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
340
|
+
// Always show what we found, so the user knows where the data is coming from.
|
|
341
|
+
console.log("Detected MCP server sources:");
|
|
342
|
+
for (const source of sources) {
|
|
343
|
+
const count = Object.keys(source.servers).length;
|
|
344
|
+
console.log(` ${source.label.padEnd(28)} ${count} server${count === 1 ? "" : "s"}`);
|
|
345
|
+
console.log(` ${source.configPath}`);
|
|
346
|
+
}
|
|
347
|
+
console.log("");
|
|
348
|
+
// If `--all` was passed, copy everything. Otherwise preview and exit.
|
|
349
|
+
const toApply = migrateAll ? entries : [];
|
|
350
|
+
// Always classify skipped (name conflicts) so the user sees the dry-run
|
|
351
|
+
// impact, not just the would-be additions.
|
|
352
|
+
const { skipped } = applyMigrationDryRun(entries);
|
|
353
|
+
if (!migrateAll) {
|
|
354
|
+
console.log("Preview — no files modified. Pass --all to apply.");
|
|
355
|
+
console.log("");
|
|
356
|
+
console.log("Servers that would be imported:");
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
const conflict = skipped.some((s) => s.entry.key === entry.key);
|
|
359
|
+
const tag = conflict ? "skip" : "add ";
|
|
360
|
+
console.log(` [${tag}] ${entry.name.padEnd(24)} ${describeEntry(entry)}`);
|
|
361
|
+
console.log(` ${entry.sourceLabel}`);
|
|
362
|
+
}
|
|
363
|
+
console.log("");
|
|
364
|
+
console.log(`Would add: ${entries.length - skipped.length}`);
|
|
365
|
+
console.log(`Would skip: ${skipped.length} (already exists in destination)`);
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
// migrateAll path. If also --dry-run, don't write.
|
|
369
|
+
if (dryRun) {
|
|
370
|
+
console.log("Dry run — no files modified.");
|
|
371
|
+
console.log("");
|
|
372
|
+
console.log("Servers that would be imported:");
|
|
373
|
+
for (const entry of toApply) {
|
|
374
|
+
const conflict = skipped.some((s) => s.entry.key === entry.key);
|
|
375
|
+
const tag = conflict ? "skip" : "add ";
|
|
376
|
+
console.log(` [${tag}] ${entry.name.padEnd(24)} ${describeEntry(entry)}`);
|
|
377
|
+
console.log(` ${entry.sourceLabel}`);
|
|
378
|
+
}
|
|
379
|
+
console.log("");
|
|
380
|
+
console.log(`Would add: ${toApply.length - skipped.length}`);
|
|
381
|
+
console.log(`Would skip: ${skipped.length} (already exists in destination)`);
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
const result = applyMigration(toApply);
|
|
385
|
+
console.log(`Added ${result.added.length} MCP server${result.added.length === 1 ? "" : "s"} to ~/.orbcode/settings.json`);
|
|
386
|
+
for (const entry of result.added) {
|
|
387
|
+
console.log(` + ${entry.name.padEnd(24)} ${describeEntry(entry)}`);
|
|
388
|
+
}
|
|
389
|
+
if (result.skipped.length > 0) {
|
|
390
|
+
console.log(`Skipped ${result.skipped.length} (already exists):`);
|
|
391
|
+
for (const { entry, reason } of result.skipped) {
|
|
392
|
+
console.log(` - ${entry.name.padEnd(24)} ${reason}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
/** Pure classification pass that mirrors `applyMigration` but never writes.
|
|
398
|
+
* Used by `--dry-run` and the default preview to surface the skipped count
|
|
399
|
+
* without touching the file. */
|
|
400
|
+
function applyMigrationDryRun(entries) {
|
|
401
|
+
const settings = loadSettings();
|
|
402
|
+
const existing = settings.mcpServers ?? {};
|
|
403
|
+
const added = [];
|
|
404
|
+
const skipped = [];
|
|
405
|
+
for (const entry of entries) {
|
|
406
|
+
if (entry.name in existing) {
|
|
407
|
+
skipped.push({ entry, reason: "already exists in ~/.orbcode/settings.json" });
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
added.push(entry.key);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { added, skipped };
|
|
414
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrate MCP server configurations from other CLI tools into OrbCode's
|
|
3
|
+
* `~/.orbcode/settings.json` (user scope, `mcpServers` block).
|
|
4
|
+
*
|
|
5
|
+
* Detected sources:
|
|
6
|
+
* - Claude Code user-scope: `~/.claude/settings.json` -> mcpServers
|
|
7
|
+
* - Claude Code (user, top of ~/.claude.json): same file, root `mcpServers`.
|
|
8
|
+
* Claude stores the user-scope servers here, at the file root, not under
|
|
9
|
+
* a per-project key. This is the most common location on a fresh install
|
|
10
|
+
* — `claude mcp add -s user …` writes to it.
|
|
11
|
+
* - Claude Code per-project: `~/.claude.json` -> projects.<cwd>.mcpServers
|
|
12
|
+
* (and a fallback to the first project entry that has any servers, for
|
|
13
|
+
* mismatched cwd paths).
|
|
14
|
+
* - Claude Desktop: `claude_desktop_config.json` (platform path)
|
|
15
|
+
*
|
|
16
|
+
* Both the TUI (`/migrate`) and the CLI (`orbcode mcp migrate`) call into this
|
|
17
|
+
* module. The TUI lets the user pick a subset; the CLI either does a dry-run
|
|
18
|
+
* preview or copies everything that doesn't conflict.
|
|
19
|
+
*
|
|
20
|
+
* Conflict policy: if a server with the same name already exists in the
|
|
21
|
+
* destination, the incoming entry is silently dropped. The summary at the
|
|
22
|
+
* end reports the skipped count.
|
|
23
|
+
*/
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as os from "node:os";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { getConfigDir, loadSettings } from "../config/settings.js";
|
|
28
|
+
/** A safe label for the picker (one per source). */
|
|
29
|
+
function sourceLabel(id) {
|
|
30
|
+
switch (id) {
|
|
31
|
+
case "claude-code-user":
|
|
32
|
+
return "Claude Code (user)";
|
|
33
|
+
case "claude-code-json-user":
|
|
34
|
+
return "Claude Code (user, ~/.claude.json)";
|
|
35
|
+
case "claude-code-project":
|
|
36
|
+
return "Claude Code (this project)";
|
|
37
|
+
case "claude-desktop":
|
|
38
|
+
return "Claude Desktop";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Cross-platform location of Claude Desktop's MCP config file. */
|
|
42
|
+
function claudeDesktopConfigPath() {
|
|
43
|
+
const home = os.homedir();
|
|
44
|
+
if (process.platform === "darwin") {
|
|
45
|
+
return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
46
|
+
}
|
|
47
|
+
if (process.platform === "win32") {
|
|
48
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
49
|
+
return path.join(appData, "Claude", "claude_desktop_config.json");
|
|
50
|
+
}
|
|
51
|
+
// Linux/WSL — best-effort XDG location; Claude Desktop doesn't have an
|
|
52
|
+
// official Linux build, but some forks (e.g. open-source wrappers) use it.
|
|
53
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
54
|
+
return path.join(xdg, "Claude", "claude_desktop_config.json");
|
|
55
|
+
}
|
|
56
|
+
function readJson(filePath) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isPlainObject(value) {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Normalize a single MCP server config from an external source. We do not run
|
|
69
|
+
* the full `normalizeServerConfig` from `mcp/config.ts` because it has side
|
|
70
|
+
* effects (env expansion) and Claude's shape is already a subset of ours.
|
|
71
|
+
* Only stdio / http / sse entries with the required fields survive.
|
|
72
|
+
*/
|
|
73
|
+
function normalizeExternalServer(raw) {
|
|
74
|
+
if (!isPlainObject(raw))
|
|
75
|
+
return undefined;
|
|
76
|
+
const type = typeof raw.type === "string" ? raw.type : "stdio";
|
|
77
|
+
if (type === "stdio") {
|
|
78
|
+
if (typeof raw.command !== "string" || !raw.command.trim())
|
|
79
|
+
return undefined;
|
|
80
|
+
const config = {
|
|
81
|
+
type: "stdio",
|
|
82
|
+
command: raw.command,
|
|
83
|
+
};
|
|
84
|
+
if (Array.isArray(raw.args)) {
|
|
85
|
+
const args = raw.args.filter((a) => typeof a === "string");
|
|
86
|
+
if (args.length > 0)
|
|
87
|
+
config.args = args;
|
|
88
|
+
}
|
|
89
|
+
if (isPlainObject(raw.env)) {
|
|
90
|
+
const env = {};
|
|
91
|
+
for (const [k, v] of Object.entries(raw.env)) {
|
|
92
|
+
if (typeof v === "string")
|
|
93
|
+
env[k] = v;
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(env).length > 0)
|
|
96
|
+
config.env = env;
|
|
97
|
+
}
|
|
98
|
+
return config;
|
|
99
|
+
}
|
|
100
|
+
if (type === "http" || type === "sse") {
|
|
101
|
+
if (typeof raw.url !== "string" || !raw.url.trim())
|
|
102
|
+
return undefined;
|
|
103
|
+
const config = {
|
|
104
|
+
type,
|
|
105
|
+
url: raw.url,
|
|
106
|
+
};
|
|
107
|
+
if (isPlainObject(raw.headers)) {
|
|
108
|
+
const headers = {};
|
|
109
|
+
for (const [k, v] of Object.entries(raw.headers)) {
|
|
110
|
+
if (typeof v === "string")
|
|
111
|
+
headers[k] = v;
|
|
112
|
+
}
|
|
113
|
+
if (Object.keys(headers).length > 0)
|
|
114
|
+
config.headers = headers;
|
|
115
|
+
}
|
|
116
|
+
return config;
|
|
117
|
+
}
|
|
118
|
+
// `streamable-http` is Claude Code's preferred name for the modern HTTP
|
|
119
|
+
// transport; OrbCode aliases it as "http".
|
|
120
|
+
if (type === "streamable-http" || type === "streamable_http") {
|
|
121
|
+
if (typeof raw.url !== "string" || !raw.url.trim())
|
|
122
|
+
return undefined;
|
|
123
|
+
const config = { type: "http", url: raw.url };
|
|
124
|
+
if (isPlainObject(raw.headers)) {
|
|
125
|
+
const headers = {};
|
|
126
|
+
for (const [k, v] of Object.entries(raw.headers)) {
|
|
127
|
+
if (typeof v === "string")
|
|
128
|
+
headers[k] = v;
|
|
129
|
+
}
|
|
130
|
+
if (Object.keys(headers).length > 0)
|
|
131
|
+
config.headers = headers;
|
|
132
|
+
}
|
|
133
|
+
return config;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
/** Read a `mcpServers` block, skipping any entry that fails to normalize. */
|
|
138
|
+
function readMcpServers(filePath) {
|
|
139
|
+
const json = readJson(filePath);
|
|
140
|
+
if (!json)
|
|
141
|
+
return {};
|
|
142
|
+
const block = json.mcpServers;
|
|
143
|
+
if (!isPlainObject(block))
|
|
144
|
+
return {};
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const [name, raw] of Object.entries(block)) {
|
|
147
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name))
|
|
148
|
+
continue;
|
|
149
|
+
const config = normalizeExternalServer(raw);
|
|
150
|
+
if (config)
|
|
151
|
+
out[name] = config;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** Pull the user-scope MCP servers Claude Code stores at the top level of
|
|
156
|
+
* `~/.claude.json`. This is where `claude mcp add -s user …` writes — the
|
|
157
|
+
* root `mcpServers` block on the same file that also holds per-project
|
|
158
|
+
* entries under `projects.<path>`. */
|
|
159
|
+
function readClaudeCodeRootServers() {
|
|
160
|
+
const json = readJson(path.join(os.homedir(), ".claude.json"));
|
|
161
|
+
if (!json)
|
|
162
|
+
return {};
|
|
163
|
+
return readMcpServersFromObject(json);
|
|
164
|
+
}
|
|
165
|
+
/** Pull the per-project MCP servers Claude Code stores in `~/.claude.json`. */
|
|
166
|
+
function readClaudeCodeProjectServers() {
|
|
167
|
+
const json = readJson(path.join(os.homedir(), ".claude.json"));
|
|
168
|
+
if (!json)
|
|
169
|
+
return {};
|
|
170
|
+
const projects = json.projects;
|
|
171
|
+
if (!isPlainObject(projects))
|
|
172
|
+
return {};
|
|
173
|
+
const cwd = process.cwd();
|
|
174
|
+
// Prefer the cwd entry, fall back to the first project entry that has
|
|
175
|
+
// any servers (helps when the cwd path doesn't match exactly, e.g. a
|
|
176
|
+
// symlinked or differently-cased home directory).
|
|
177
|
+
const entries = Object.entries(projects).filter((entry) => isPlainObject(entry[1]));
|
|
178
|
+
const direct = entries.find(([projectPath]) => projectPath === cwd);
|
|
179
|
+
const fallback = entries.find(([, project]) => isPlainObject(project.mcpServers));
|
|
180
|
+
const target = direct ?? fallback;
|
|
181
|
+
if (!target)
|
|
182
|
+
return {};
|
|
183
|
+
return readMcpServersFromObject(target[1]);
|
|
184
|
+
}
|
|
185
|
+
/** Same as `readMcpServers` but takes a pre-parsed object. */
|
|
186
|
+
function readMcpServersFromObject(obj) {
|
|
187
|
+
const block = obj.mcpServers;
|
|
188
|
+
if (!isPlainObject(block))
|
|
189
|
+
return {};
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const [name, raw] of Object.entries(block)) {
|
|
192
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name))
|
|
193
|
+
continue;
|
|
194
|
+
const config = normalizeExternalServer(raw);
|
|
195
|
+
if (config)
|
|
196
|
+
out[name] = config;
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
/** Discover every available migration source on the current machine. */
|
|
201
|
+
export function discoverSources() {
|
|
202
|
+
const sources = [];
|
|
203
|
+
const claudeCodeUserPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
204
|
+
const claudeCodeUserServers = readMcpServers(claudeCodeUserPath);
|
|
205
|
+
if (Object.keys(claudeCodeUserServers).length > 0) {
|
|
206
|
+
sources.push({
|
|
207
|
+
id: "claude-code-user",
|
|
208
|
+
label: sourceLabel("claude-code-user"),
|
|
209
|
+
configPath: claudeCodeUserPath,
|
|
210
|
+
servers: claudeCodeUserServers,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// ~/.claude.json can hold entries in two layers: the root mcpServers block
|
|
214
|
+
// (user-scope, written by `claude mcp add -s user …`) and per-project
|
|
215
|
+
// entries under `projects.<path>.mcpServers`. Both can define a server with
|
|
216
|
+
// the same name; Claude treats the per-project one as the override. To
|
|
217
|
+
// avoid showing the same name twice in the picker, we collect both layers
|
|
218
|
+
// and drop any project-layer name that the root layer also has.
|
|
219
|
+
const claudeCodeJsonPath = path.join(os.homedir(), ".claude.json");
|
|
220
|
+
const rootServers = readClaudeCodeRootServers();
|
|
221
|
+
const projectServers = readClaudeCodeProjectServers();
|
|
222
|
+
const rootNames = new Set(Object.keys(rootServers));
|
|
223
|
+
if (Object.keys(rootServers).length > 0) {
|
|
224
|
+
sources.push({
|
|
225
|
+
id: "claude-code-json-user",
|
|
226
|
+
label: sourceLabel("claude-code-json-user"),
|
|
227
|
+
configPath: claudeCodeJsonPath,
|
|
228
|
+
servers: rootServers,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (Object.keys(projectServers).length > 0) {
|
|
232
|
+
const deduped = {};
|
|
233
|
+
for (const [name, cfg] of Object.entries(projectServers)) {
|
|
234
|
+
if (rootNames.has(name))
|
|
235
|
+
continue;
|
|
236
|
+
deduped[name] = cfg;
|
|
237
|
+
}
|
|
238
|
+
if (Object.keys(deduped).length > 0) {
|
|
239
|
+
sources.push({
|
|
240
|
+
id: "claude-code-project",
|
|
241
|
+
label: sourceLabel("claude-code-project"),
|
|
242
|
+
configPath: claudeCodeJsonPath,
|
|
243
|
+
servers: deduped,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const claudeDesktopPath = claudeDesktopConfigPath();
|
|
248
|
+
const claudeDesktopServers = readMcpServers(claudeDesktopPath);
|
|
249
|
+
if (Object.keys(claudeDesktopServers).length > 0) {
|
|
250
|
+
sources.push({
|
|
251
|
+
id: "claude-desktop",
|
|
252
|
+
label: sourceLabel("claude-desktop"),
|
|
253
|
+
configPath: claudeDesktopPath,
|
|
254
|
+
servers: claudeDesktopServers,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return sources;
|
|
258
|
+
}
|
|
259
|
+
/** Flatten all sources into a single checklist. Stable order: source first,
|
|
260
|
+
* then server name. */
|
|
261
|
+
export function listMigrationEntries() {
|
|
262
|
+
const entries = [];
|
|
263
|
+
for (const source of discoverSources()) {
|
|
264
|
+
for (const [name, config] of Object.entries(source.servers)) {
|
|
265
|
+
entries.push({
|
|
266
|
+
key: `${source.id}::${name}`,
|
|
267
|
+
source: source.id,
|
|
268
|
+
sourceLabel: source.label,
|
|
269
|
+
name,
|
|
270
|
+
config,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return entries;
|
|
275
|
+
}
|
|
276
|
+
/** Short summary of a server for the picker / dry-run output. */
|
|
277
|
+
export function describeEntry(entry) {
|
|
278
|
+
const cfg = entry.config;
|
|
279
|
+
if (cfg.type === "http" || cfg.type === "sse") {
|
|
280
|
+
return `${cfg.type} ${cfg.url}`;
|
|
281
|
+
}
|
|
282
|
+
const args = cfg.args ?? [];
|
|
283
|
+
return `stdio ${cfg.command}${args.length ? " " + args.join(" ") : ""}`;
|
|
284
|
+
}
|
|
285
|
+
/** True when `name` already exists in the user-scope mcpServers block of
|
|
286
|
+
* OrbCode's settings. This is the only conflict that matters — the CLI and
|
|
287
|
+
* TUI both target user scope. */
|
|
288
|
+
function destinationHas(name) {
|
|
289
|
+
const settings = loadSettings();
|
|
290
|
+
return Boolean(settings.mcpServers && name in settings.mcpServers);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Apply a set of migration entries to `~/.orbcode/settings.json` (user scope).
|
|
294
|
+
* Returns the new entries plus the entries that were skipped (with a reason).
|
|
295
|
+
*/
|
|
296
|
+
export function applyMigration(entries) {
|
|
297
|
+
const filePath = path.join(getConfigDir(), "settings.json");
|
|
298
|
+
const existing = readJson(filePath) ?? {};
|
|
299
|
+
const servers = isPlainObject(existing.mcpServers)
|
|
300
|
+
? { ...existing.mcpServers }
|
|
301
|
+
: {};
|
|
302
|
+
const added = [];
|
|
303
|
+
const skipped = [];
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
if (entry.name in servers) {
|
|
306
|
+
skipped.push({
|
|
307
|
+
entry,
|
|
308
|
+
reason: "already exists in ~/.orbcode/settings.json",
|
|
309
|
+
});
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (destinationHas(entry.name)) {
|
|
313
|
+
// Race-safe double-check: the in-memory snapshot is the source of
|
|
314
|
+
// truth at write time, so if it changed between our read and now
|
|
315
|
+
// we still skip.
|
|
316
|
+
skipped.push({
|
|
317
|
+
entry,
|
|
318
|
+
reason: "already exists in ~/.orbcode/settings.json",
|
|
319
|
+
});
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
servers[entry.name] = entry.config;
|
|
323
|
+
added.push(entry);
|
|
324
|
+
}
|
|
325
|
+
if (added.length > 0) {
|
|
326
|
+
existing.mcpServers = servers;
|
|
327
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
328
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, "\t") + "\n", {
|
|
329
|
+
mode: 0o600,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return { added, skipped };
|
|
333
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ Usage:
|
|
|
19
19
|
orbcode mcp add ... add an MCP server (see: orbcode mcp help)
|
|
20
20
|
orbcode mcp remove ... remove an MCP server
|
|
21
21
|
orbcode mcp list list configured MCP servers
|
|
22
|
+
orbcode mcp migrate import MCP servers from Claude Code / Claude Desktop
|
|
22
23
|
orbcode -p "<prompt>" run a single prompt non-interactively (prints only the final response)
|
|
23
24
|
orbcode -p "…" --yolo non-interactive with auto-approved edits/commands
|
|
24
25
|
orbcode --model <id> use a specific model for this run
|
package/dist/mcp/auth.js
CHANGED
|
@@ -153,7 +153,8 @@ function startCallbackServer(port, onCode) {
|
|
|
153
153
|
const server = http.createServer((req, res) => {
|
|
154
154
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
155
155
|
if (url.pathname !== "/callback") {
|
|
156
|
-
res.writeHead(404
|
|
156
|
+
res.writeHead(404, { "Content-Type": "text/html" });
|
|
157
|
+
res.end(callbackPage("not-found"));
|
|
157
158
|
return;
|
|
158
159
|
}
|
|
159
160
|
const code = url.searchParams.get("code");
|
|
@@ -161,15 +162,16 @@ function startCallbackServer(port, onCode) {
|
|
|
161
162
|
if (code) {
|
|
162
163
|
onCode(code);
|
|
163
164
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
164
|
-
res.end("
|
|
165
|
+
res.end(callbackPage("success"));
|
|
165
166
|
}
|
|
166
167
|
else if (error) {
|
|
168
|
+
const desc = url.searchParams.get("error_description") ?? error;
|
|
167
169
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
168
|
-
res.end(
|
|
170
|
+
res.end(callbackPage("error", desc));
|
|
169
171
|
}
|
|
170
172
|
else {
|
|
171
173
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
172
|
-
res.end("
|
|
174
|
+
res.end(callbackPage("error", "Missing authorization code in the callback."));
|
|
173
175
|
}
|
|
174
176
|
// Close after responding so the port is freed immediately.
|
|
175
177
|
server.close();
|
|
@@ -177,6 +179,88 @@ function startCallbackServer(port, onCode) {
|
|
|
177
179
|
server.listen(port, "127.0.0.1");
|
|
178
180
|
return server;
|
|
179
181
|
}
|
|
182
|
+
/** Render the OAuth callback page. Styled to match matterai.so: dark
|
|
183
|
+
* background, cyan/teal accents, centered card, clean typography. */
|
|
184
|
+
function callbackPage(kind, detail) {
|
|
185
|
+
const isSuccess = kind === "success";
|
|
186
|
+
const title = isSuccess ? "Authorized" : kind === "not-found" ? "Not Found" : "Authorization Failed";
|
|
187
|
+
const message = isSuccess
|
|
188
|
+
? "You can close this tab and return to OrbCode."
|
|
189
|
+
: kind === "not-found"
|
|
190
|
+
? "This page was not found."
|
|
191
|
+
: detail ?? "An unexpected error occurred during authentication.";
|
|
192
|
+
const icon = isSuccess ? "✓" : "✗";
|
|
193
|
+
const accent = isSuccess ? "#43df94" : "#e86464";
|
|
194
|
+
return `<!DOCTYPE html>
|
|
195
|
+
<html lang="en">
|
|
196
|
+
<head>
|
|
197
|
+
<meta charset="utf-8">
|
|
198
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
199
|
+
<title>OrbCode · ${title}</title>
|
|
200
|
+
<style>
|
|
201
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
202
|
+
body {
|
|
203
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
204
|
+
background: #0d1117;
|
|
205
|
+
color: #e6edf3;
|
|
206
|
+
min-height: 100vh;
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
justify-content: center;
|
|
210
|
+
-webkit-font-smoothing: antialiased;
|
|
211
|
+
}
|
|
212
|
+
.card {
|
|
213
|
+
text-align: center;
|
|
214
|
+
padding: 3rem 2.5rem;
|
|
215
|
+
max-width: 440px;
|
|
216
|
+
width: 90%;
|
|
217
|
+
}
|
|
218
|
+
.icon {
|
|
219
|
+
width: 72px;
|
|
220
|
+
height: 72px;
|
|
221
|
+
border-radius: 50%;
|
|
222
|
+
display: flex;
|
|
223
|
+
align-items: center;
|
|
224
|
+
justify-content: center;
|
|
225
|
+
margin: 0 auto 1.75rem;
|
|
226
|
+
font-size: 36px;
|
|
227
|
+
font-weight: 700;
|
|
228
|
+
background: ${accent}1a;
|
|
229
|
+
border: 2px solid ${accent};
|
|
230
|
+
color: ${accent};
|
|
231
|
+
}
|
|
232
|
+
h1 {
|
|
233
|
+
font-size: 1.6rem;
|
|
234
|
+
font-weight: 600;
|
|
235
|
+
letter-spacing: -0.02em;
|
|
236
|
+
margin-bottom: 0.6rem;
|
|
237
|
+
}
|
|
238
|
+
p {
|
|
239
|
+
font-size: 0.95rem;
|
|
240
|
+
line-height: 1.5;
|
|
241
|
+
color: #8b949e;
|
|
242
|
+
margin-bottom: 2rem;
|
|
243
|
+
}
|
|
244
|
+
.brand {
|
|
245
|
+
margin-top: 2.5rem;
|
|
246
|
+
font-size: 0.8rem;
|
|
247
|
+
color: #484f58;
|
|
248
|
+
letter-spacing: 0.05em;
|
|
249
|
+
text-transform: uppercase;
|
|
250
|
+
}
|
|
251
|
+
.brand span { color: #c4fdff; }
|
|
252
|
+
</style>
|
|
253
|
+
</head>
|
|
254
|
+
<body>
|
|
255
|
+
<div class="card">
|
|
256
|
+
<div class="icon">${icon}</div>
|
|
257
|
+
<h1>${title}</h1>
|
|
258
|
+
<p>${message}</p>
|
|
259
|
+
<div class="brand">OrbCode CLI · <span>powered by Axon</span></div>
|
|
260
|
+
</div>
|
|
261
|
+
</body>
|
|
262
|
+
</html>`;
|
|
263
|
+
}
|
|
180
264
|
/** Pick an ephemeral loopback port for the callback server. */
|
|
181
265
|
function ephemeralPort() {
|
|
182
266
|
// Bind to port 0 and read back the assigned port, then close immediately.
|
package/dist/mcp/manager.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { callMcpTool, connectMcpServer } from "./client.js";
|
|
2
|
-
import { configPathForScope, loadMcpConfig } from "./config.js";
|
|
2
|
+
import { configPathForScope, loadMcpConfig, removeMcpServerAnyScope } from "./config.js";
|
|
3
3
|
import { hasStoredAuth, isOAuthConfig } from "./auth.js";
|
|
4
4
|
/**
|
|
5
5
|
* Owns the lifecycle of all MCP server connections for a session.
|
|
@@ -154,6 +154,20 @@ export class McpManager {
|
|
|
154
154
|
state.disabled = true;
|
|
155
155
|
await this.disconnectOne(name);
|
|
156
156
|
}
|
|
157
|
+
/** Permanently remove a server: disconnect, drop from the in-memory
|
|
158
|
+
* config/state, and remove from the on-disk config (whichever scope it
|
|
159
|
+
* lives in). Returns true if a config entry was actually removed. The
|
|
160
|
+
* caller is responsible for re-persisting the enabled/disabled lists
|
|
161
|
+
* (which no longer reference the removed name). */
|
|
162
|
+
async removeServer(name) {
|
|
163
|
+
await this.disconnectOne(name);
|
|
164
|
+
this.disabled.delete(name);
|
|
165
|
+
this.enabled.delete(name);
|
|
166
|
+
this.pendingApproval.delete(name);
|
|
167
|
+
this.configs.delete(name);
|
|
168
|
+
this.states.delete(name);
|
|
169
|
+
return removeMcpServerAnyScope(this.cwd, name) !== undefined;
|
|
170
|
+
}
|
|
157
171
|
/** Re-authenticate a server in the needs-auth state: disconnect, clear
|
|
158
172
|
* persisted OAuth tokens, then reconnect with `forceOAuth` so even servers
|
|
159
173
|
* without an explicit `oauth` config use the OAuth flow (auto-detect).
|
package/dist/ui/App.js
CHANGED
|
@@ -16,6 +16,8 @@ import { FollowupPrompt } from "./components/FollowupPrompt.js";
|
|
|
16
16
|
import { HookTrustPrompt } from "./components/HookTrustPrompt.js";
|
|
17
17
|
import { McpApprovalPrompt } from "./components/McpApprovalPrompt.js";
|
|
18
18
|
import { McpPicker } from "./components/McpPicker.js";
|
|
19
|
+
import { McpMigrationPicker } from "./components/McpMigrationPicker.js";
|
|
20
|
+
import { applyMigration, listMigrationEntries } from "../commands/migrate.js";
|
|
19
21
|
import { StatusBar } from "./components/StatusBar.js";
|
|
20
22
|
import { ModelPicker } from "./components/ModelPicker.js";
|
|
21
23
|
import { SessionPicker } from "./components/SessionPicker.js";
|
|
@@ -48,6 +50,10 @@ const SLASH_COMMANDS = [
|
|
|
48
50
|
name: "/mcp",
|
|
49
51
|
description: "manage MCP servers — enable, disable, reconnect, view status",
|
|
50
52
|
},
|
|
53
|
+
{
|
|
54
|
+
name: "/migrate",
|
|
55
|
+
description: "import MCP servers from Claude Code / Claude Desktop",
|
|
56
|
+
},
|
|
51
57
|
{
|
|
52
58
|
name: "/commit",
|
|
53
59
|
description: "check pending changes and create detailed commits",
|
|
@@ -193,6 +199,10 @@ export function App({ initialView, initialPrompt, initialSession, systemPromptOv
|
|
|
193
199
|
// the first agent is created so we don't spawn servers before login.
|
|
194
200
|
const mcpManagerRef = useRef(null);
|
|
195
201
|
const [mcpPickerOpen, setMcpPickerOpen] = useState(false);
|
|
202
|
+
// Set when /migrate is open. Holds the entries to show in the picker; null
|
|
203
|
+
// means the picker isn't open. We cache the entries on first open so the
|
|
204
|
+
// picker shows a stable list even if the user cancels and reopens.
|
|
205
|
+
const [mcpMigrationEntries, setMcpMigrationEntries] = useState(null);
|
|
196
206
|
// Project-scope MCP servers awaiting the user's approval at startup.
|
|
197
207
|
const [pendingMcpApproval, setPendingMcpApproval] = useState(null);
|
|
198
208
|
const [staticKey, setStaticKey] = useState(0);
|
|
@@ -645,6 +655,13 @@ export function App({ initialView, initialPrompt, initialSession, systemPromptOv
|
|
|
645
655
|
setMcpPickerOpen(true);
|
|
646
656
|
break;
|
|
647
657
|
}
|
|
658
|
+
case "/migrate": {
|
|
659
|
+
// Scan for sources now (cheap — just file reads) and cache the list
|
|
660
|
+
// so the picker has a stable view. `applyMigration` re-reads the
|
|
661
|
+
// destination on confirm, so a stale snapshot is fine.
|
|
662
|
+
setMcpMigrationEntries(listMigrationEntries());
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
648
665
|
case "/commit":
|
|
649
666
|
if (!getAuthToken(settings)) {
|
|
650
667
|
setView("login");
|
|
@@ -787,6 +804,82 @@ export function App({ initialView, initialPrompt, initialSession, systemPromptOv
|
|
|
787
804
|
if (deferred)
|
|
788
805
|
handleSubmit(deferred);
|
|
789
806
|
}, [handleSubmit, pushRow]);
|
|
807
|
+
// Tear down the live MCP manager (if any), then create a fresh one from
|
|
808
|
+
// the current on-disk settings and start it. Used after /migrate (new
|
|
809
|
+
// servers added) and after a /mcp delete (server removed) so /mcp works
|
|
810
|
+
// immediately without needing to send a message first. The agent is also
|
|
811
|
+
// dropped so the next message rebuilds it against the new manager's tool
|
|
812
|
+
// list.
|
|
813
|
+
const rebuildMcpManager = useCallback(async () => {
|
|
814
|
+
const oldManager = mcpManagerRef.current;
|
|
815
|
+
mcpManagerRef.current = null;
|
|
816
|
+
if (oldManager) {
|
|
817
|
+
await oldManager.stop().catch(() => { });
|
|
818
|
+
}
|
|
819
|
+
agentRef.current = null;
|
|
820
|
+
const refreshed = loadSettings();
|
|
821
|
+
setSettings(refreshed);
|
|
822
|
+
if (getAuthToken(refreshed)) {
|
|
823
|
+
const manager = new McpManager(process.cwd(), refreshed.disabledMcpServers ?? [], refreshed.enabledMcpServers ?? []);
|
|
824
|
+
mcpManagerRef.current = manager;
|
|
825
|
+
void manager.start().then(() => {
|
|
826
|
+
const pendingMcp = manager.getPendingApproval();
|
|
827
|
+
if (pendingMcp.length > 0)
|
|
828
|
+
setPendingMcpApproval(pendingMcp);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
}, []);
|
|
832
|
+
// Apply a user-confirmed migration selection: write the chosen entries to
|
|
833
|
+
// ~/.orbcode/settings.json, then rebuild the MCP manager so the new
|
|
834
|
+
// user-scope servers connect immediately. Skips (name conflicts)
|
|
835
|
+
// are reported but not fatal.
|
|
836
|
+
const resolveMcpMigration = useCallback(async (selected) => {
|
|
837
|
+
if (selected.length === 0) {
|
|
838
|
+
pushRow({ kind: "info", text: "MCP migration cancelled (no servers selected)." });
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const result = applyMigration(selected);
|
|
842
|
+
const addedNames = result.added.map((e) => e.name);
|
|
843
|
+
const skippedCount = result.skipped.length;
|
|
844
|
+
if (result.added.length === 0) {
|
|
845
|
+
pushRow({
|
|
846
|
+
kind: "info",
|
|
847
|
+
text: `MCP migration: nothing added (${skippedCount} skipped — name${skippedCount === 1 ? "" : "s"} already in use).`,
|
|
848
|
+
});
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
pushRow({
|
|
852
|
+
kind: "info",
|
|
853
|
+
text: `MCP migration: added ${result.added.length} server${result.added.length === 1 ? "" : "s"} to ~/.orbcode/settings.json (${addedNames.join(", ")})${skippedCount > 0 ? `; skipped ${skippedCount} (name already in use).` : "."}`,
|
|
854
|
+
});
|
|
855
|
+
// Tear down the live manager + agent, then immediately rebuild the
|
|
856
|
+
// manager so /mcp works without needing to send a message first.
|
|
857
|
+
// The agent is dropped so the next message creates a fresh one that
|
|
858
|
+
// picks up the new manager's tool list.
|
|
859
|
+
await rebuildMcpManager();
|
|
860
|
+
}, [pushRow, rebuildMcpManager]);
|
|
861
|
+
// Handle a permanent delete from the /mcp picker. The manager has already
|
|
862
|
+
// dropped the server from its in-memory state and from the on-disk config;
|
|
863
|
+
// we persist the cleaned enabled/disabled lists, rebuild the manager so
|
|
864
|
+
// /mcp reflects the deletion immediately, and surface a one-line
|
|
865
|
+
// confirmation.
|
|
866
|
+
const handleMcpServerDeleted = useCallback(async (name) => {
|
|
867
|
+
const manager = mcpManagerRef.current;
|
|
868
|
+
if (manager) {
|
|
869
|
+
// `removeServer` already removed the name from both lists, but
|
|
870
|
+
// `saveMcpApproval` needs the post-removal snapshot so the
|
|
871
|
+
// per-project settings don't keep a dangling reference.
|
|
872
|
+
saveMcpApproval(process.cwd(), manager.getEnabled(), manager.getDisabled());
|
|
873
|
+
}
|
|
874
|
+
pushRow({
|
|
875
|
+
kind: "info",
|
|
876
|
+
text: `MCP: removed "${name}" from the config. Use \`orbcode mcp add ${name} …\` to re-add it.`,
|
|
877
|
+
});
|
|
878
|
+
// Rebuild the manager from the now-trimmed on-disk config so /mcp
|
|
879
|
+
// works immediately and the next message gets a fresh agent with the
|
|
880
|
+
// correct tool list.
|
|
881
|
+
await rebuildMcpManager();
|
|
882
|
+
}, [pushRow, rebuildMcpManager]);
|
|
790
883
|
// Apply --resume and an initial prompt (`orbcode "do something"`) on startup.
|
|
791
884
|
const bootedRef = useRef(false);
|
|
792
885
|
useEffect(() => {
|
|
@@ -901,6 +994,7 @@ export function App({ initialView, initialPrompt, initialSession, systemPromptOv
|
|
|
901
994
|
!pendingMcpApproval &&
|
|
902
995
|
!modelPickerOpen &&
|
|
903
996
|
!mcpPickerOpen &&
|
|
997
|
+
!mcpMigrationEntries &&
|
|
904
998
|
!resumableSessions;
|
|
905
999
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: rows, children: (row) => _jsx(RowView, { row: row }, row.id) }, staticKey), view === "login" ? (_jsx(LoginSection, { onLogin: handleLogin })) : (_jsxs(Box, { flexDirection: "column", children: [updateInfo?.updateAvailable && updateInfo.latest && (_jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: COLORS.warning, paddingX: 2, alignSelf: "flex-start", children: [_jsxs(Text, { color: COLORS.warning, bold: true, children: ["\u2191 Update available: v", updateInfo.current, " \u2192 v", updateInfo.latest] }), _jsxs(Text, { children: ["Run ", _jsx(Text, { color: COLORS.accent, children: "orbcode update" }), " to install the latest version, then relaunch."] })] })), streamingReasoning && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: COLORS.thinking, italic: true, children: "\u2726 Thinking\u2026" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: tail(streamingReasoning, expandReasoningRef.current ? 30 : 3) }) })] })), streamingText && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), streamingText] }) })), taskLines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Tasks" }), taskLines.slice(0, 10).map((line, i) => (_jsx(Text, { dimColor: /^[-*]\s*\[x\]/i.test(line), children: line
|
|
906
1000
|
.replace(/^[-*]\s*\[x\]/i, " ■")
|
|
@@ -912,7 +1006,10 @@ export function App({ initialView, initialPrompt, initialSession, systemPromptOv
|
|
|
912
1006
|
const m = mcpManagerRef.current;
|
|
913
1007
|
if (m)
|
|
914
1008
|
saveMcpApproval(process.cwd(), m.getEnabled(), m.getDisabled());
|
|
915
|
-
}, onCancel: () => setMcpPickerOpen(false) }) })),
|
|
1009
|
+
}, onCancel: () => setMcpPickerOpen(false), onDeleted: handleMcpServerDeleted }) })), mcpMigrationEntries && (_jsx(Box, { marginTop: 1, children: _jsx(McpMigrationPicker, { entries: mcpMigrationEntries, onCancel: () => setMcpMigrationEntries(null), onConfirm: (selected) => {
|
|
1010
|
+
setMcpMigrationEntries(null);
|
|
1011
|
+
resolveMcpMigration(selected);
|
|
1012
|
+
} }) })), pendingApproval && (_jsx(Box, { marginTop: 1, children: _jsx(ApprovalPrompt, { request: pendingApproval.request, onDecision: (decision) => {
|
|
916
1013
|
pendingApproval.resolve(decision);
|
|
917
1014
|
setPendingApproval(null);
|
|
918
1015
|
} }) })), pendingFollowup && (_jsx(Box, { marginTop: 1, children: _jsx(FollowupPrompt, { question: pendingFollowup.question, suggestions: pendingFollowup.suggestions, onAnswer: (answer) => {
|
|
@@ -925,6 +1022,7 @@ export function App({ initialView, initialPrompt, initialSession, systemPromptOv
|
|
|
925
1022
|
!pendingHookTrust &&
|
|
926
1023
|
!pendingMcpApproval &&
|
|
927
1024
|
!mcpPickerOpen &&
|
|
1025
|
+
!mcpMigrationEntries &&
|
|
928
1026
|
!streamingText &&
|
|
929
1027
|
!streamingReasoning && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [queuedMessages.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, marginBottom: 1, children: [_jsxs(Text, { dimColor: true, bold: true, children: ["Queue (", queuedMessages.length, ")"] }), queuedMessages.slice(0, 5).map((msg, i) => (_jsxs(Text, { dimColor: true, children: [i + 1, ". ", truncateForQueue(msg).replace(/\n/g, "↵")] }, i))), queuedMessages.length > 5 && (_jsxs(Text, { dimColor: true, children: [" \u2026 ", queuedMessages.length - 5, " more"] }))] })), _jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage, tieredUsage: usage?.tieredUsage })] })] }))] }));
|
|
930
1028
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { COLORS } from "../../branding.js";
|
|
5
|
+
import { describeEntry } from "../../commands/migrate.js";
|
|
6
|
+
const VISIBLE_ROWS = 10;
|
|
7
|
+
/**
|
|
8
|
+
* Combined checklist of all detected MCP servers across Claude Code (user),
|
|
9
|
+
* Claude Code (this project), and Claude Desktop. Each row shows the source
|
|
10
|
+
* as a dim label. Space toggles, enter confirms, esc cancels.
|
|
11
|
+
*
|
|
12
|
+
* Default state: every entry pre-checked, matching the CLI's `--all` flag.
|
|
13
|
+
* The user unchecks the ones they don't want; the confirmation runs the
|
|
14
|
+
* real `applyMigration` and reports the skipped count from conflicts.
|
|
15
|
+
*/
|
|
16
|
+
export function McpMigrationPicker({ entries, onConfirm, onCancel, }) {
|
|
17
|
+
const [selected, setSelected] = useState(0);
|
|
18
|
+
const [checked, setChecked] = useState(() => new Set(entries.map((e) => e.key)));
|
|
19
|
+
useInput((input, key) => {
|
|
20
|
+
if (key.escape) {
|
|
21
|
+
onCancel();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (key.upArrow) {
|
|
25
|
+
setSelected((s) => (s - 1 + entries.length) % entries.length);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (key.downArrow || key.tab) {
|
|
29
|
+
setSelected((s) => (s + 1) % entries.length);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (key.return) {
|
|
33
|
+
const picked = [];
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (checked.has(entry.key))
|
|
36
|
+
picked.push(entry);
|
|
37
|
+
}
|
|
38
|
+
onConfirm(picked);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (input === " ") {
|
|
42
|
+
const entry = entries[selected];
|
|
43
|
+
if (!entry)
|
|
44
|
+
return;
|
|
45
|
+
setChecked((prev) => {
|
|
46
|
+
const next = new Set(prev);
|
|
47
|
+
if (next.has(entry.key))
|
|
48
|
+
next.delete(entry.key);
|
|
49
|
+
else
|
|
50
|
+
next.add(entry.key);
|
|
51
|
+
return next;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
if (entries.length === 0) {
|
|
56
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: COLORS.primary, paddingX: 1, children: [_jsx(Text, { bold: true, color: COLORS.primary, children: "Migrate MCP servers" }), _jsx(Text, { dimColor: true, children: "No MCP servers found on this machine to migrate." }), _jsx(Text, { dimColor: true, children: "Install Claude Code or Claude Desktop, or add servers with `orbcode mcp add`." }), _jsx(Text, { dimColor: true, children: "esc to close" })] }));
|
|
57
|
+
}
|
|
58
|
+
const count = entries.length;
|
|
59
|
+
const windowStart = Math.max(0, Math.min(selected - VISIBLE_ROWS + 1, count - VISIBLE_ROWS));
|
|
60
|
+
const visible = entries.slice(windowStart, windowStart + VISIBLE_ROWS);
|
|
61
|
+
const checkedCount = entries.reduce((n, e) => (checked.has(e.key) ? n + 1 : n), 0);
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: COLORS.primary, paddingX: 1, children: [_jsx(Text, { bold: true, color: COLORS.primary, children: "Migrate MCP servers from Claude Code / Desktop" }), _jsx(Text, { dimColor: true, children: "Servers will be copied to ~/.orbcode/settings.json. Conflicts (same name already exists) are skipped." }), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((entry, i) => {
|
|
63
|
+
const isSelected = windowStart + i === selected;
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: isSelected ? COLORS.accent : undefined, children: [isSelected ? "❯ " : " ", checked.has(entry.key) ? "☑" : "☐", " ", entry.name, _jsxs(Text, { dimColor: true, children: [" \u00B7 ", describeEntry(entry)] })] }), _jsx(Box, { paddingLeft: 5, children: _jsx(Text, { dimColor: true, children: entry.sourceLabel }) })] }, entry.key));
|
|
65
|
+
}), windowStart + VISIBLE_ROWS < count && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", count - windowStart - VISIBLE_ROWS, " more"] })), _jsxs(Text, { dimColor: true, children: ["space toggle \u00B7 enter confirm (", checkedCount, "/", count, ") \u00B7 esc cancel"] })] }));
|
|
66
|
+
}
|
|
@@ -42,6 +42,11 @@ function buildActions(state) {
|
|
|
42
42
|
else
|
|
43
43
|
actions.push("Disable");
|
|
44
44
|
actions.push("Reconnect");
|
|
45
|
+
// Delete is last so it's a deliberate choice past the everyday toggles.
|
|
46
|
+
// It's always available — disabling and re-enabling a server still leaves
|
|
47
|
+
// the config entry, which is the right behaviour when you might want it
|
|
48
|
+
// back. Delete is the explicit "I don't want this server here at all" path.
|
|
49
|
+
actions.push("Delete");
|
|
45
50
|
return actions;
|
|
46
51
|
}
|
|
47
52
|
/**
|
|
@@ -52,9 +57,9 @@ function buildActions(state) {
|
|
|
52
57
|
* - Action menu: ↑/↓ to select an action, enter to execute, esc to go back.
|
|
53
58
|
*
|
|
54
59
|
* Actions adapt to the server's state: Authenticate (needs-auth), Enable
|
|
55
|
-
* (disabled), Disable (connected/failed), Reconnect (always).
|
|
60
|
+
* (disabled), Disable (connected/failed), Reconnect (always), Delete (always).
|
|
56
61
|
*/
|
|
57
|
-
export function McpPicker({ manager, onChanged, onCancel }) {
|
|
62
|
+
export function McpPicker({ manager, onChanged, onCancel, onDeleted }) {
|
|
58
63
|
const [snapshot, setSnapshot] = useState(() => manager.snapshot());
|
|
59
64
|
const [selected, setSelected] = useState(0);
|
|
60
65
|
const [busy, setBusy] = useState(false);
|
|
@@ -63,15 +68,51 @@ export function McpPicker({ manager, onChanged, onCancel }) {
|
|
|
63
68
|
const [authUrl, setAuthUrl] = useState("");
|
|
64
69
|
const [actionMode, setActionMode] = useState(false);
|
|
65
70
|
const [actionSelected, setActionSelected] = useState(0);
|
|
71
|
+
// Pending destructive confirmation. The resolve ref unblocks the awaiting
|
|
72
|
+
// `deleteSelected` call when the user picks y/n at the prompt.
|
|
73
|
+
const [pendingConfirm, setPendingConfirm] = useState(null);
|
|
66
74
|
const codeResolveRef = useRef(null);
|
|
67
75
|
const codeRejectRef = useRef(null);
|
|
68
76
|
const servers = snapshot.servers;
|
|
69
77
|
const count = servers.length;
|
|
70
78
|
const current = servers[selected];
|
|
71
79
|
const actions = current ? buildActions(current) : [];
|
|
72
|
-
|
|
80
|
+
/** Show a yes/no confirmation and resolve when the user picks one. Used
|
|
81
|
+
* for destructive actions like deleting a server. */
|
|
82
|
+
function confirmDangerousAction(title, body) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
setPendingConfirm({ title, body, resolve });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
useInput((input, key) => {
|
|
73
88
|
if (authingServer || busy)
|
|
74
89
|
return;
|
|
90
|
+
// A destructive confirmation is in flight. Eat every key except the
|
|
91
|
+
// explicit y/n + enter / esc — matches the rest of the picker's
|
|
92
|
+
// "no shorthand" rule.
|
|
93
|
+
if (pendingConfirm) {
|
|
94
|
+
if (key.escape) {
|
|
95
|
+
pendingConfirm.resolve(false);
|
|
96
|
+
setPendingConfirm(null);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (key.return) {
|
|
100
|
+
pendingConfirm.resolve(true);
|
|
101
|
+
setPendingConfirm(null);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (input === "y" || input === "Y") {
|
|
105
|
+
pendingConfirm.resolve(true);
|
|
106
|
+
setPendingConfirm(null);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (input === "n" || input === "N") {
|
|
110
|
+
pendingConfirm.resolve(false);
|
|
111
|
+
setPendingConfirm(null);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
75
116
|
if (actionMode) {
|
|
76
117
|
if (key.escape) {
|
|
77
118
|
setActionMode(false);
|
|
@@ -126,6 +167,8 @@ export function McpPicker({ manager, onChanged, onCancel }) {
|
|
|
126
167
|
await disableSelected();
|
|
127
168
|
else if (action === "Reconnect")
|
|
128
169
|
await reconnectSelected();
|
|
170
|
+
else if (action === "Delete")
|
|
171
|
+
await deleteSelected();
|
|
129
172
|
}
|
|
130
173
|
async function enableSelected() {
|
|
131
174
|
if (!current)
|
|
@@ -155,6 +198,39 @@ export function McpPicker({ manager, onChanged, onCancel }) {
|
|
|
155
198
|
setBusyMessage("");
|
|
156
199
|
}
|
|
157
200
|
}
|
|
201
|
+
async function deleteSelected() {
|
|
202
|
+
if (!current)
|
|
203
|
+
return;
|
|
204
|
+
const name = current.name;
|
|
205
|
+
// Pull the config path so the confirmation can show what file gets
|
|
206
|
+
// modified — without this, the user has to remember the scope rules.
|
|
207
|
+
const configPath = manager.getConfigPath(name) ?? "~/.orbcode/settings.json";
|
|
208
|
+
const confirmed = await confirmDangerousAction(`Delete MCP server "${name}"?`, `This removes the entry from ${configPath}. You'll have to re-add it manually.`);
|
|
209
|
+
if (!confirmed)
|
|
210
|
+
return;
|
|
211
|
+
setBusy(true);
|
|
212
|
+
setBusyMessage("Deleting…");
|
|
213
|
+
try {
|
|
214
|
+
const removed = await manager.removeServer(name);
|
|
215
|
+
if (!removed) {
|
|
216
|
+
// Race: someone else removed it between opening the picker and
|
|
217
|
+
// confirming. Refresh and bail — the user can see it's gone.
|
|
218
|
+
await refresh();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Move the cursor to a safe position before the snapshot shrinks.
|
|
222
|
+
// `setSelected` runs before the next render commits, so clamping
|
|
223
|
+
// here is correct.
|
|
224
|
+
const next = manager.snapshot().servers;
|
|
225
|
+
setSelected((s) => Math.min(s, Math.max(0, next.length - 1)));
|
|
226
|
+
onDeleted(name);
|
|
227
|
+
await refresh();
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
setBusy(false);
|
|
231
|
+
setBusyMessage("");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
158
234
|
async function reconnectSelected() {
|
|
159
235
|
if (!current)
|
|
160
236
|
return;
|
|
@@ -221,6 +297,9 @@ export function McpPicker({ manager, onChanged, onCancel }) {
|
|
|
221
297
|
if (authingServer) {
|
|
222
298
|
return (_jsx(McpAuthScreen, { serverName: authingServer, authUrl: authUrl, onPasteCode: handlePasteCode, onCancel: handleAuthCancel }));
|
|
223
299
|
}
|
|
300
|
+
if (pendingConfirm) {
|
|
301
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: COLORS.error, paddingX: 1, children: [_jsx(Text, { bold: true, color: COLORS.error, children: pendingConfirm.title }), _jsx(Text, { children: pendingConfirm.body }), _jsx(Text, { dimColor: true, children: "y confirm \u00B7 n / esc cancel" })] }));
|
|
302
|
+
}
|
|
224
303
|
const windowStart = Math.max(0, Math.min(selected - VISIBLE_ROWS + 1, count - VISIBLE_ROWS));
|
|
225
304
|
const visible = servers.slice(windowStart, windowStart + VISIBLE_ROWS);
|
|
226
305
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: COLORS.primary, paddingX: 1, children: [_jsxs(Text, { bold: true, color: COLORS.primary, children: ["Manage MCP servers (", count, ")"] }), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((state, i) => {
|
|
@@ -257,6 +336,16 @@ function DetailPanel({ manager, state, actions, actionMode, actionSelected, }) {
|
|
|
257
336
|
}
|
|
258
337
|
return (_jsxs(Box, { paddingLeft: 1, marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [state.name, " MCP Server"] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Status: " }), _jsxs(Text, { color: statusColor(state), children: [statusIcon(state), " ", state.status] })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Auth: " }), _jsx(Text, { color: isOAuth && !authenticated ? COLORS.error : isOAuth ? COLORS.success : COLORS.dim, children: authText })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "URL: " }), urlText] }), configPath && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Config location: " }), _jsx(Text, { dimColor: true, children: configPath })] })), state.detail && (_jsx(Text, { color: state.status === "failed" ? COLORS.error : state.status === "needs-auth" ? COLORS.warning : COLORS.dim, children: state.detail })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: actions.map((action, i) => {
|
|
259
338
|
const isSelected = actionMode && i === actionSelected;
|
|
260
|
-
|
|
339
|
+
// Color destructive actions red so they read differently
|
|
340
|
+
// from the everyday toggles. Keeps the affordance visually
|
|
341
|
+
// separate from the harmless enable/disable/reconnect
|
|
342
|
+
// choices right above it.
|
|
343
|
+
const isDestructive = action === "Delete";
|
|
344
|
+
const color = isSelected
|
|
345
|
+
? COLORS.accent
|
|
346
|
+
: isDestructive
|
|
347
|
+
? COLORS.error
|
|
348
|
+
: undefined;
|
|
349
|
+
return (_jsxs(Text, { color: color, children: [isSelected ? "❯ " : " ", i + 1, ". ", action] }, action));
|
|
261
350
|
}) })] }));
|
|
262
351
|
}
|