@matterailab/orbcode 0.2.3 → 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/core/agent.js +10 -4
- package/dist/headless.js +2 -1
- package/dist/index.js +37 -2
- package/dist/mcp/auth.js +88 -4
- package/dist/mcp/manager.js +15 -1
- package/dist/ui/App.js +114 -15
- 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/core/agent.js
CHANGED
|
@@ -114,10 +114,13 @@ export class Agent {
|
|
|
114
114
|
this.mcp = options.mcp;
|
|
115
115
|
// Build the system prompt with AGENTS.md memory files and the skills
|
|
116
116
|
// catalog injected, so the model sees project/user instructions and
|
|
117
|
-
// knows which skills it can invoke.
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
117
|
+
// knows which skills it can invoke. An explicit override (from
|
|
118
|
+
// `orbcode -s <text>`) bypasses the default entirely.
|
|
119
|
+
const memoryFiles = options.systemPromptOverride ? [] : loadMemoryFiles(options.cwd);
|
|
120
|
+
const skills = options.systemPromptOverride ? new Map() : loadSkills(options.cwd);
|
|
121
|
+
this.systemPrompt = options.systemPromptOverride
|
|
122
|
+
? options.systemPromptOverride
|
|
123
|
+
: buildSystemPrompt(options.cwd, { memoryFiles, skills });
|
|
121
124
|
this.client = createLLMClient({
|
|
122
125
|
token: options.token,
|
|
123
126
|
modelId: options.modelId,
|
|
@@ -684,6 +687,9 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
|
|
|
684
687
|
this.sessionApproveCommands = true;
|
|
685
688
|
}
|
|
686
689
|
}
|
|
690
|
+
// Yield so the UI can paint the "Working" indicator before a
|
|
691
|
+
// potentially long synchronous tool call blocks the event loop.
|
|
692
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
687
693
|
const result = await executeTool(toolCall.name, args, this.toolContext(), this.mcp);
|
|
688
694
|
// PostToolUse can feed extra context (or a block reason) back to the
|
|
689
695
|
// model. PreToolUse additionalContext is delivered here too.
|
package/dist/headless.js
CHANGED
|
@@ -3,7 +3,7 @@ import { getAuthToken, getPendingProjectHooks, loadSettings } from "./config/set
|
|
|
3
3
|
import { Agent } from "./core/agent.js";
|
|
4
4
|
import { McpManager } from "./mcp/manager.js";
|
|
5
5
|
/** Non-interactive `orbcode -p "prompt"` mode: prints the final response to stdout. */
|
|
6
|
-
export async function runHeadless(prompt, yolo) {
|
|
6
|
+
export async function runHeadless(prompt, yolo, systemPromptOverride) {
|
|
7
7
|
const settings = loadSettings();
|
|
8
8
|
// An unknown --model (or MATTERAI_MODEL) silently resolves to the default; say
|
|
9
9
|
// so on stderr instead of quietly running a different model than requested.
|
|
@@ -63,6 +63,7 @@ export async function runHeadless(prompt, yolo) {
|
|
|
63
63
|
autoApproveSafeCommands: yolo,
|
|
64
64
|
hooks: settings.hooks,
|
|
65
65
|
mcp,
|
|
66
|
+
systemPromptOverride,
|
|
66
67
|
callbacks: {
|
|
67
68
|
onEvent: (event) => {
|
|
68
69
|
switch (event.type) {
|
package/dist/index.js
CHANGED
|
@@ -19,10 +19,14 @@ 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
|
|
25
26
|
orbcode --resume <id> resume a previous session by id
|
|
27
|
+
orbcode -s "<prompt>" override the default system prompt (replaces it entirely;
|
|
28
|
+
accepts -s <text>, --system-prompt <text>, and
|
|
29
|
+
--system-prompt=<text> for values that start with '-')
|
|
26
30
|
orbcode --version print version
|
|
27
31
|
orbcode --help show this help
|
|
28
32
|
`);
|
|
@@ -71,6 +75,33 @@ function takeFlagValue(args, name) {
|
|
|
71
75
|
args.splice(index, 2);
|
|
72
76
|
return value;
|
|
73
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Pop the `-s` / `--system-prompt` flag (and its value) out of `args`.
|
|
80
|
+
* Unlike `takeFlagValue` this accepts a value that starts with `-`, because
|
|
81
|
+
* a system-prompt override is free-form text. Supports three forms:
|
|
82
|
+
* -s "<text>" --value in next arg
|
|
83
|
+
* --system-prompt "<text>"
|
|
84
|
+
* --system-prompt="<text>"
|
|
85
|
+
* --system-prompt=-<text>
|
|
86
|
+
*/
|
|
87
|
+
function takeSystemPrompt(args) {
|
|
88
|
+
const longWithEq = args.findIndex((a) => a.startsWith("--system-prompt="));
|
|
89
|
+
if (longWithEq !== -1) {
|
|
90
|
+
const value = args[longWithEq].slice("--system-prompt=".length);
|
|
91
|
+
args.splice(longWithEq, 1);
|
|
92
|
+
return value.length > 0 ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
const index = args.findIndex((a) => a === "-s" || a === "--system-prompt");
|
|
95
|
+
if (index === -1)
|
|
96
|
+
return undefined;
|
|
97
|
+
const value = args[index + 1];
|
|
98
|
+
if (value === undefined) {
|
|
99
|
+
console.error("Missing value after -s / --system-prompt");
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
args.splice(index, 2);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
74
105
|
async function main() {
|
|
75
106
|
// Override Node's default process title so terminals (iTerm2 "current job
|
|
76
107
|
// name", VSCode terminal status, etc.) don't append " (node)" next to our
|
|
@@ -110,6 +141,10 @@ async function main() {
|
|
|
110
141
|
process.exit(1);
|
|
111
142
|
}
|
|
112
143
|
}
|
|
144
|
+
// `-s` / `--system-prompt` lets the user replace the default prompt
|
|
145
|
+
// entirely. Accepts values starting with `-` (unlike other flags), so
|
|
146
|
+
// `orbcode -s '- you are a code reviewer'` works.
|
|
147
|
+
const systemPromptOverride = takeSystemPrompt(args);
|
|
113
148
|
const printIndex = args.findIndex((a) => a === "-p" || a === "--print");
|
|
114
149
|
if (printIndex !== -1) {
|
|
115
150
|
const prompt = args[printIndex + 1];
|
|
@@ -117,7 +152,7 @@ async function main() {
|
|
|
117
152
|
console.error("Missing prompt after -p");
|
|
118
153
|
process.exit(1);
|
|
119
154
|
}
|
|
120
|
-
await runHeadless(prompt, args.includes("--yolo"));
|
|
155
|
+
await runHeadless(prompt, args.includes("--yolo"), systemPromptOverride);
|
|
121
156
|
return;
|
|
122
157
|
}
|
|
123
158
|
const initialView = args[0] === "login" ? "login" : undefined;
|
|
@@ -132,7 +167,7 @@ async function main() {
|
|
|
132
167
|
// Fire-and-forget: a stale or no-network state just means the header shows
|
|
133
168
|
// the "current version" line instead of an upgrade prompt.
|
|
134
169
|
const updateCheckPromise = getUpdateInfo(PACKAGE_NAME, VERSION);
|
|
135
|
-
render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession, updateCheck: updateCheckPromise }));
|
|
170
|
+
render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession, systemPromptOverride: systemPromptOverride, updateCheck: updateCheckPromise }));
|
|
136
171
|
}
|
|
137
172
|
main().catch((error) => {
|
|
138
173
|
console.error(error);
|
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,11 +16,13 @@ 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";
|
|
22
24
|
import { listSessions } from "../core/sessions.js";
|
|
23
|
-
import { RowView
|
|
25
|
+
import { RowView } from "./components/rows.js";
|
|
24
26
|
const SLASH_COMMANDS = [
|
|
25
27
|
{ name: "/help", description: "show available commands" },
|
|
26
28
|
{ name: "/model", description: "select the Axon model to use" },
|
|
@@ -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",
|
|
@@ -161,7 +167,7 @@ let rowCounter = 0;
|
|
|
161
167
|
function rowId() {
|
|
162
168
|
return `row-${rowCounter++}`;
|
|
163
169
|
}
|
|
164
|
-
export function App({ initialView, initialPrompt, initialSession, updateCheck, }) {
|
|
170
|
+
export function App({ initialView, initialPrompt, initialSession, systemPromptOverride, updateCheck, }) {
|
|
165
171
|
const { exit } = useApp();
|
|
166
172
|
const [settings, setSettings] = useState(() => loadSettings());
|
|
167
173
|
const [view, setView] = useState(initialView ?? (getAuthToken(settings) ? "chat" : "login"));
|
|
@@ -178,7 +184,6 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
178
184
|
const [busyLabel, setBusyLabel] = useState("Thinking");
|
|
179
185
|
const [streamingReasoning, setStreamingReasoning] = useState("");
|
|
180
186
|
const [streamingText, setStreamingText] = useState("");
|
|
181
|
-
const [runningTool, setRunningTool] = useState(null);
|
|
182
187
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
183
188
|
const [pendingFollowup, setPendingFollowup] = useState(null);
|
|
184
189
|
// Set when the current project defines hooks that haven't been trusted yet;
|
|
@@ -194,6 +199,10 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
194
199
|
// the first agent is created so we don't spawn servers before login.
|
|
195
200
|
const mcpManagerRef = useRef(null);
|
|
196
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);
|
|
197
206
|
// Project-scope MCP servers awaiting the user's approval at startup.
|
|
198
207
|
const [pendingMcpApproval, setPendingMcpApproval] = useState(null);
|
|
199
208
|
const [staticKey, setStaticKey] = useState(0);
|
|
@@ -234,6 +243,9 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
234
243
|
const deferredPromptRef = useRef(null);
|
|
235
244
|
// Guards endAndExit against double-invocation (Ctrl+D spam).
|
|
236
245
|
const exitingRef = useRef(false);
|
|
246
|
+
// Mirror of the `-s` override so a fresh agent (created mid-session via
|
|
247
|
+
// /new or /resume) keeps the override instead of falling back to default.
|
|
248
|
+
const systemPromptOverrideRef = useRef(systemPromptOverride);
|
|
237
249
|
const enqueueMessage = useCallback((text) => {
|
|
238
250
|
queueRef.current = [...queueRef.current, text];
|
|
239
251
|
setQueuedMessages(queueRef.current);
|
|
@@ -301,6 +313,7 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
301
313
|
});
|
|
302
314
|
reasoningBufferRef.current = "";
|
|
303
315
|
setStreamingReasoning("");
|
|
316
|
+
setBusyLabel("Working");
|
|
304
317
|
break;
|
|
305
318
|
case "text-delta":
|
|
306
319
|
textBufferRef.current += event.text;
|
|
@@ -311,18 +324,13 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
311
324
|
pushRow({ kind: "assistant", text: textBufferRef.current });
|
|
312
325
|
textBufferRef.current = "";
|
|
313
326
|
setStreamingText("");
|
|
327
|
+
setBusyLabel("Working");
|
|
314
328
|
break;
|
|
315
329
|
case "tool-start":
|
|
316
|
-
|
|
317
|
-
id: event.id,
|
|
318
|
-
name: event.name,
|
|
319
|
-
summary: event.summary,
|
|
320
|
-
});
|
|
321
|
-
setBusyLabel(`Running ${event.name}`);
|
|
330
|
+
setBusyLabel("Working");
|
|
322
331
|
break;
|
|
323
332
|
case "tool-end":
|
|
324
|
-
|
|
325
|
-
setBusyLabel("Thinking");
|
|
333
|
+
setBusyLabel("Working");
|
|
326
334
|
pushRow({
|
|
327
335
|
kind: "tool",
|
|
328
336
|
name: event.name,
|
|
@@ -366,7 +374,6 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
366
374
|
reasoningBufferRef.current = "";
|
|
367
375
|
setStreamingReasoning("");
|
|
368
376
|
}
|
|
369
|
-
setRunningTool(null);
|
|
370
377
|
setBusy(false);
|
|
371
378
|
maybeFetchTitle();
|
|
372
379
|
refreshUsage();
|
|
@@ -409,6 +416,9 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
409
416
|
hooks: current.hooks,
|
|
410
417
|
mcp: mcpManagerRef.current,
|
|
411
418
|
resume,
|
|
419
|
+
// The override (from `-s`) is captured in a ref so a /new or /resume
|
|
420
|
+
// mid-session still applies it to the new agent.
|
|
421
|
+
systemPromptOverride: systemPromptOverrideRef.current,
|
|
412
422
|
callbacks: {
|
|
413
423
|
onEvent: handleEvent,
|
|
414
424
|
requestApproval: (request) => new Promise((resolve) => setPendingApproval({ request, resolve })),
|
|
@@ -645,6 +655,13 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
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, updateCheck, }
|
|
|
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,8 +994,9 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
901
994
|
!pendingMcpApproval &&
|
|
902
995
|
!modelPickerOpen &&
|
|
903
996
|
!mcpPickerOpen &&
|
|
997
|
+
!mcpMigrationEntries &&
|
|
904
998
|
!resumableSessions;
|
|
905
|
-
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] }) })),
|
|
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, " ■")
|
|
907
1001
|
.replace(/^[-*]\s*\[-\]/, " ◧")
|
|
908
1002
|
.replace(/^[-*]\s*\[ \]/, " □") }, i))), taskLines.length > 10 && (_jsxs(Text, { dimColor: true, children: [" \u2026 ", taskLines.length - 10, " more"] }))] })), modelPickerOpen && (_jsx(Box, { marginTop: 1, children: _jsx(ModelPicker, { currentId: settings.model, onSelect: (modelId) => {
|
|
@@ -912,7 +1006,10 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
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,7 +1022,9 @@ export function App({ initialView, initialPrompt, initialSession, updateCheck, }
|
|
|
925
1022
|
!pendingHookTrust &&
|
|
926
1023
|
!pendingMcpApproval &&
|
|
927
1024
|
!mcpPickerOpen &&
|
|
928
|
-
!
|
|
1025
|
+
!mcpMigrationEntries &&
|
|
1026
|
+
!streamingText &&
|
|
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 })] })] }))] }));
|
|
929
1028
|
}
|
|
930
1029
|
function tail(text, lines) {
|
|
931
1030
|
const all = text.split("\n").filter((l) => l.trim());
|
|
@@ -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
|
}
|