@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.
@@ -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
+ }
@@ -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
- const memoryFiles = loadMemoryFiles(options.cwd);
119
- const skills = loadSkills(options.cwd);
120
- this.systemPrompt = buildSystemPrompt(options.cwd, { memoryFiles, skills });
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).end("not found");
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("<h1>Authorized</h1><p>You can close this tab and return to OrbCode.</p>");
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(`<h1>Authorization failed</h1><p>${error}</p>`);
170
+ res.end(callbackPage("error", desc));
169
171
  }
170
172
  else {
171
173
  res.writeHead(400, { "Content-Type": "text/html" });
172
- res.end("<h1>Missing code</h1>");
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 ? "&#10003;" : "&#10007;";
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 &middot; ${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 &middot; <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.
@@ -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, formatToolName } from "./components/rows.js";
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
- setRunningTool({
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
- setRunningTool(null);
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] }) })), runningTool && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.warning, children: [formatToolName(runningTool.name), " ", _jsx(Text, { dimColor: true, children: runningTool.summary })] }) })), 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
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) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, children: _jsx(ApprovalPrompt, { request: pendingApproval.request, onDecision: (decision) => {
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
- !streamingText && (_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 })] })] }))] }));
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
- useInput((_input, key) => {
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
- return (_jsxs(Text, { color: isSelected ? COLORS.accent : undefined, children: [isSelected ? "❯ " : " ", i + 1, ". ", action] }, action));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matterailab/orbcode",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
5
5
  "type": "module",
6
6
  "bin": {