@ishlabs/cli 0.17.6 → 0.18.0

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.
Files changed (64) hide show
  1. package/README.md +54 -54
  2. package/dist/commands/ask.d.ts +4 -4
  3. package/dist/commands/ask.js +66 -66
  4. package/dist/commands/chat.js +10 -10
  5. package/dist/commands/config.js +1 -1
  6. package/dist/commands/docs.js +1 -1
  7. package/dist/commands/iteration.js +57 -57
  8. package/dist/commands/mcp.d.ts +23 -0
  9. package/dist/commands/mcp.js +676 -0
  10. package/dist/commands/person.d.ts +5 -0
  11. package/dist/commands/{profile.js → person.js} +197 -162
  12. package/dist/commands/source.d.ts +6 -2
  13. package/dist/commands/source.js +35 -30
  14. package/dist/commands/study-analyze.d.ts +1 -1
  15. package/dist/commands/study-analyze.js +3 -3
  16. package/dist/commands/study-participant.d.ts +8 -0
  17. package/dist/commands/{study-tester.js → study-participant.js} +50 -50
  18. package/dist/commands/study-run.d.ts +6 -6
  19. package/dist/commands/study-run.js +295 -271
  20. package/dist/commands/study.js +89 -66
  21. package/dist/commands/workspace.js +13 -13
  22. package/dist/connect.js +5 -5
  23. package/dist/index.js +6 -4
  24. package/dist/lib/accessibility-profile.d.ts +1 -1
  25. package/dist/lib/accessibility-profile.js +1 -1
  26. package/dist/lib/alias-hydrate.js +4 -4
  27. package/dist/lib/alias-store.d.ts +5 -5
  28. package/dist/lib/alias-store.js +8 -8
  29. package/dist/lib/api-client.d.ts +1 -1
  30. package/dist/lib/api-client.js +1 -1
  31. package/dist/lib/billing.d.ts +11 -11
  32. package/dist/lib/billing.js +16 -16
  33. package/dist/lib/chat-endpoint-templates.js +1 -1
  34. package/dist/lib/command-helpers.d.ts +18 -18
  35. package/dist/lib/command-helpers.js +83 -53
  36. package/dist/lib/docs.js +560 -386
  37. package/dist/lib/enums.d.ts +2 -2
  38. package/dist/lib/enums.js +2 -2
  39. package/dist/lib/local-sim/browser.d.ts +1 -1
  40. package/dist/lib/local-sim/browser.js +1 -1
  41. package/dist/lib/local-sim/debug-report.d.ts +2 -2
  42. package/dist/lib/local-sim/debug-report.js +3 -3
  43. package/dist/lib/local-sim/loop.d.ts +5 -5
  44. package/dist/lib/local-sim/loop.js +38 -38
  45. package/dist/lib/local-sim/types.d.ts +12 -12
  46. package/dist/lib/mcp-clients.d.ts +51 -0
  47. package/dist/lib/mcp-clients.js +175 -0
  48. package/dist/lib/modality.d.ts +10 -10
  49. package/dist/lib/modality.js +46 -46
  50. package/dist/lib/observability.d.ts +11 -0
  51. package/dist/lib/observability.js +16 -3
  52. package/dist/lib/output.d.ts +13 -12
  53. package/dist/lib/output.js +244 -184
  54. package/dist/lib/profile-sources.d.ts +64 -16
  55. package/dist/lib/profile-sources.js +91 -30
  56. package/dist/lib/skill-content.js +215 -168
  57. package/dist/lib/study-events.d.ts +3 -3
  58. package/dist/lib/study-events.js +1 -1
  59. package/dist/lib/study-inputs.d.ts +11 -1
  60. package/dist/lib/study-inputs.js +68 -17
  61. package/dist/lib/types.d.ts +105 -34
  62. package/package.json +1 -1
  63. package/dist/commands/profile.d.ts +0 -5
  64. package/dist/commands/study-tester.d.ts +0 -8
@@ -0,0 +1,676 @@
1
+ /**
2
+ * ish mcp — wire the ish MCP server into local AI clients.
3
+ *
4
+ * Three verbs:
5
+ * - `ish mcp add` Wire ish MCP into selected clients (idempotent).
6
+ * - `ish mcp list` Show detected clients + ish-MCP wiring status.
7
+ * - `ish mcp remove` Inverse of add — cleanly unwire the ish block.
8
+ *
9
+ * Design choices (per plan workstream C):
10
+ * - No API call required — all writes are local JSON files. We use
11
+ * `runInline` instead of `withClient`.
12
+ * - Atomic writes (tmp file + rename), deep-equal idempotence, and
13
+ * unrelated keys in the target config are preserved verbatim.
14
+ * - Never embed access tokens in the file. The hosted ish MCP server
15
+ * handles OAuth on first connect; we only write the URL.
16
+ * - No interactive prompts by default (CLI is for autonomous agents).
17
+ * `--yes` is required to commit writes under `--json` / non-TTY.
18
+ *
19
+ * Per-client config paths + per-client server-block shapes live in
20
+ * `src/lib/mcp-clients.ts` (MCP_CLIENT_TARGETS).
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { runInline, collectIds, confirmDestructive } from "../lib/command-helpers.js";
25
+ import { output } from "../lib/output.js";
26
+ import { MCP_CLIENT_TARGETS, ISH_MCP_URL, ISH_SERVER_NAME, getClientSpec, allClientKeys, } from "../lib/mcp-clients.js";
27
+ // ---------------------------------------------------------------------------
28
+ // File I/O helpers
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Load the target client config as JSON. Returns:
32
+ * - undefined when the file doesn't exist (caller treats as empty object).
33
+ * - the parsed object on success.
34
+ * - throws a usage-shaped error when the file is malformed JSON, so the
35
+ * agent doesn't silently overwrite a partially-edited config.
36
+ */
37
+ function readClientConfig(file) {
38
+ let raw;
39
+ try {
40
+ raw = fs.readFileSync(file, "utf-8");
41
+ }
42
+ catch (err) {
43
+ if (err.code === "ENOENT")
44
+ return undefined;
45
+ throw err;
46
+ }
47
+ // An empty file is treated as `{}` so we never lose unrelated keys we
48
+ // don't have, and we still write a valid JSON document on write.
49
+ if (raw.trim().length === 0)
50
+ return {};
51
+ try {
52
+ const parsed = JSON.parse(raw);
53
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
54
+ const e = new Error(`Client config at ${file} is not a JSON object; refusing to edit. Inspect by hand and re-run.`);
55
+ e.name = "ValidationError";
56
+ throw e;
57
+ }
58
+ return parsed;
59
+ }
60
+ catch (err) {
61
+ if (err instanceof Error && err.name === "ValidationError")
62
+ throw err;
63
+ const e = new Error(`Client config at ${file} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. Inspect by hand and re-run.`);
64
+ e.name = "ValidationError";
65
+ throw e;
66
+ }
67
+ }
68
+ /**
69
+ * Atomic write — tmp file in the same directory + rename. Preserves
70
+ * 2-space indent and a trailing newline. Creates parent dirs as needed
71
+ * (the per-client config dir might not exist yet for never-launched
72
+ * clients the user is opting into via `--client <name>`).
73
+ */
74
+ function writeClientConfig(file, payload) {
75
+ const dir = path.dirname(file);
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ const serialized = JSON.stringify(payload, null, 2) + "\n";
78
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
79
+ fs.writeFileSync(tmp, serialized, "utf-8");
80
+ try {
81
+ fs.renameSync(tmp, file);
82
+ }
83
+ catch (err) {
84
+ // Best-effort cleanup of the tmp file before re-throwing — leaving
85
+ // it around would clutter the user's config dir.
86
+ try {
87
+ fs.unlinkSync(tmp);
88
+ }
89
+ catch { /* ignore */ }
90
+ throw err;
91
+ }
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Server-block shape helpers
95
+ // ---------------------------------------------------------------------------
96
+ /**
97
+ * Stable deep-equal for plain JSON values. Sufficient for ish server
98
+ * blocks (no Dates, no Maps, no Symbols — only the shapes we render).
99
+ */
100
+ function deepEqual(a, b) {
101
+ if (a === b)
102
+ return true;
103
+ if (typeof a !== typeof b)
104
+ return false;
105
+ if (a === null || b === null)
106
+ return a === b;
107
+ if (Array.isArray(a)) {
108
+ if (!Array.isArray(b) || a.length !== b.length)
109
+ return false;
110
+ for (let i = 0; i < a.length; i++) {
111
+ if (!deepEqual(a[i], b[i]))
112
+ return false;
113
+ }
114
+ return true;
115
+ }
116
+ if (typeof a === "object" && typeof b === "object") {
117
+ const ao = a;
118
+ const bo = b;
119
+ const aKeys = Object.keys(ao);
120
+ const bKeys = Object.keys(bo);
121
+ if (aKeys.length !== bKeys.length)
122
+ return false;
123
+ for (const k of aKeys) {
124
+ if (!Object.prototype.hasOwnProperty.call(bo, k))
125
+ return false;
126
+ if (!deepEqual(ao[k], bo[k]))
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+ return false;
132
+ }
133
+ /**
134
+ * Read the current ish server block from a client config. Returns undefined
135
+ * when either the `<serverKey>` object or the ish entry is missing.
136
+ */
137
+ function readCurrentIshBlock(config, spec) {
138
+ if (!config)
139
+ return undefined;
140
+ const bucket = config[spec.serverKey];
141
+ if (!bucket || typeof bucket !== "object" || Array.isArray(bucket))
142
+ return undefined;
143
+ const entry = bucket[ISH_SERVER_NAME];
144
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
145
+ return undefined;
146
+ return entry;
147
+ }
148
+ function classifyStatus(config, expected, spec, fileExists) {
149
+ if (!fileExists)
150
+ return "no-config-file";
151
+ const current = readCurrentIshBlock(config, spec);
152
+ if (!current)
153
+ return "absent";
154
+ return deepEqual(current, expected) ? "present-up-to-date" : "present-drifted";
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // `list` — read-only status per client
158
+ // ---------------------------------------------------------------------------
159
+ function buildClientStatuses() {
160
+ return MCP_CLIENT_TARGETS.map((spec) => {
161
+ const file = spec.configPath();
162
+ const expected = spec.renderServer(ISH_MCP_URL);
163
+ let fileExists = false;
164
+ let config;
165
+ try {
166
+ config = readClientConfig(file);
167
+ fileExists = config !== undefined;
168
+ }
169
+ catch {
170
+ // Malformed JSON shouldn't crash `list`; surface as drifted so the
171
+ // user notices it and can re-run `mcp add --force` (which would
172
+ // re-raise the same parse error and stop, so no silent overwrite).
173
+ return {
174
+ client: spec.key,
175
+ display_name: spec.displayName,
176
+ detected: spec.detect(),
177
+ unsupported_on_this_os: spec.unsupportedOnThisOs ?? false,
178
+ config_path: file,
179
+ status: "present-drifted",
180
+ };
181
+ }
182
+ return {
183
+ client: spec.key,
184
+ display_name: spec.displayName,
185
+ detected: spec.detect(),
186
+ unsupported_on_this_os: spec.unsupportedOnThisOs ?? false,
187
+ config_path: file,
188
+ status: classifyStatus(config, expected, spec, fileExists),
189
+ };
190
+ });
191
+ }
192
+ /**
193
+ * Resolve which clients the user asked to wire.
194
+ *
195
+ * - explicit `--client a,b` → validate every key, return spec list.
196
+ * - `--all` → every *detected* spec on this OS.
197
+ * - neither → every detected spec (default behavior
198
+ * renders the plan with no writes, suggests
199
+ * a re-invocation with --all --yes).
200
+ *
201
+ * Validation: unknown client keys throw a usage error so the agent can
202
+ * self-correct without inspecting prose.
203
+ */
204
+ function resolveClients(flags) {
205
+ if (flags.client.length > 0) {
206
+ if (flags.all) {
207
+ const err = new Error("Use either --client or --all, not both.");
208
+ err.name = "ValidationError";
209
+ throw err;
210
+ }
211
+ const seen = new Set();
212
+ const specs = [];
213
+ for (const raw of flags.client) {
214
+ if (seen.has(raw))
215
+ continue;
216
+ seen.add(raw);
217
+ const spec = getClientSpec(raw);
218
+ if (!spec) {
219
+ const e = new Error(`Unknown client "${raw}". Valid: ${allClientKeys().join(", ")}.`);
220
+ e.name = "ValidationError";
221
+ throw e;
222
+ }
223
+ specs.push(spec);
224
+ }
225
+ return { specs, explicit: true };
226
+ }
227
+ const detected = MCP_CLIENT_TARGETS.filter((s) => s.detect() && !(s.unsupportedOnThisOs ?? false));
228
+ return { specs: detected, explicit: false };
229
+ }
230
+ /**
231
+ * Build the per-client write plan. Pure (no I/O beyond file reads) so
232
+ * `--dry-run` and the real write share the same plan computation.
233
+ */
234
+ function buildAddPlan(specs, force) {
235
+ const plan = [];
236
+ for (const spec of specs) {
237
+ const file = spec.configPath();
238
+ const expected = spec.renderServer(ISH_MCP_URL);
239
+ let config;
240
+ try {
241
+ config = readClientConfig(file);
242
+ }
243
+ catch (err) {
244
+ // Malformed JSON: refuse — agent should inspect manually.
245
+ plan.push({
246
+ client: spec.key,
247
+ display_name: spec.displayName,
248
+ config_path: file,
249
+ action: "refuse-drift",
250
+ reason: err instanceof Error ? err.message : String(err),
251
+ expected,
252
+ });
253
+ continue;
254
+ }
255
+ const current = readCurrentIshBlock(config, spec);
256
+ if (!current) {
257
+ plan.push({
258
+ client: spec.key,
259
+ display_name: spec.displayName,
260
+ config_path: file,
261
+ action: config === undefined ? "create" : "update",
262
+ expected,
263
+ });
264
+ continue;
265
+ }
266
+ if (deepEqual(current, expected)) {
267
+ plan.push({
268
+ client: spec.key,
269
+ display_name: spec.displayName,
270
+ config_path: file,
271
+ action: "skip",
272
+ reason: "ish block already up to date",
273
+ expected,
274
+ current,
275
+ });
276
+ continue;
277
+ }
278
+ if (force) {
279
+ plan.push({
280
+ client: spec.key,
281
+ display_name: spec.displayName,
282
+ config_path: file,
283
+ action: "update",
284
+ expected,
285
+ current,
286
+ });
287
+ }
288
+ else {
289
+ plan.push({
290
+ client: spec.key,
291
+ display_name: spec.displayName,
292
+ config_path: file,
293
+ action: "refuse-drift",
294
+ reason: "existing ish block differs; pass --force to overwrite",
295
+ expected,
296
+ current,
297
+ });
298
+ }
299
+ }
300
+ return plan;
301
+ }
302
+ /**
303
+ * Apply the write plan. Returns `{ written, skipped, refused }` counts.
304
+ * Drift-refusals throw a usage-shaped error so the CLI exits 2.
305
+ */
306
+ function applyAddPlan(plan) {
307
+ const refused = plan.filter((p) => p.action === "refuse-drift");
308
+ if (refused.length > 0) {
309
+ const lines = refused.map((p) => ` - ${p.client} (${p.config_path}): ${p.reason ?? "drift"}`);
310
+ const err = new Error(`Refusing to overwrite drifted ish server block in ${refused.length} client${refused.length === 1 ? "" : "s"}:\n` +
311
+ lines.join("\n") +
312
+ "\n\nRe-run with --force to overwrite, or remove the existing block by hand.");
313
+ err.name = "ValidationError";
314
+ throw err;
315
+ }
316
+ const written = [];
317
+ const skipped = [];
318
+ for (const entry of plan) {
319
+ if (entry.action === "skip") {
320
+ skipped.push(entry);
321
+ continue;
322
+ }
323
+ const spec = getClientSpec(entry.client);
324
+ if (!spec)
325
+ continue;
326
+ const existing = readClientConfig(entry.config_path) ?? {};
327
+ const next = { ...existing };
328
+ const bucket = next[spec.serverKey] && typeof next[spec.serverKey] === "object" && !Array.isArray(next[spec.serverKey])
329
+ ? { ...next[spec.serverKey] }
330
+ : {};
331
+ bucket[ISH_SERVER_NAME] = entry.expected ?? spec.renderServer(ISH_MCP_URL);
332
+ next[spec.serverKey] = bucket;
333
+ writeClientConfig(entry.config_path, next);
334
+ written.push(entry);
335
+ }
336
+ return { written, skipped };
337
+ }
338
+ // ---------------------------------------------------------------------------
339
+ // `remove` — inverse of add
340
+ // ---------------------------------------------------------------------------
341
+ function buildRemovePlan(specs) {
342
+ return specs.map((spec) => {
343
+ const file = spec.configPath();
344
+ let config;
345
+ try {
346
+ config = readClientConfig(file);
347
+ }
348
+ catch (err) {
349
+ return {
350
+ client: spec.key,
351
+ display_name: spec.displayName,
352
+ config_path: file,
353
+ action: "refuse-drift",
354
+ reason: err instanceof Error ? err.message : String(err),
355
+ };
356
+ }
357
+ if (config === undefined) {
358
+ return {
359
+ client: spec.key,
360
+ display_name: spec.displayName,
361
+ config_path: file,
362
+ action: "remove-noop",
363
+ reason: "no client config file",
364
+ };
365
+ }
366
+ const current = readCurrentIshBlock(config, spec);
367
+ if (!current) {
368
+ return {
369
+ client: spec.key,
370
+ display_name: spec.displayName,
371
+ config_path: file,
372
+ action: "remove-noop",
373
+ reason: "no ish block to remove",
374
+ };
375
+ }
376
+ return {
377
+ client: spec.key,
378
+ display_name: spec.displayName,
379
+ config_path: file,
380
+ action: "remove",
381
+ current,
382
+ };
383
+ });
384
+ }
385
+ function applyRemovePlan(plan) {
386
+ const refused = plan.filter((p) => p.action === "refuse-drift");
387
+ if (refused.length > 0) {
388
+ const lines = refused.map((p) => ` - ${p.client} (${p.config_path}): ${p.reason ?? "drift"}`);
389
+ const err = new Error(`Cannot read client config for ${refused.length} client${refused.length === 1 ? "" : "s"}:\n` +
390
+ lines.join("\n"));
391
+ err.name = "ValidationError";
392
+ throw err;
393
+ }
394
+ const removed = [];
395
+ const skipped = [];
396
+ for (const entry of plan) {
397
+ if (entry.action !== "remove") {
398
+ skipped.push(entry);
399
+ continue;
400
+ }
401
+ const spec = getClientSpec(entry.client);
402
+ if (!spec)
403
+ continue;
404
+ const existing = readClientConfig(entry.config_path) ?? {};
405
+ const next = { ...existing };
406
+ const bucket = next[spec.serverKey];
407
+ if (bucket && typeof bucket === "object" && !Array.isArray(bucket)) {
408
+ const cloned = { ...bucket };
409
+ delete cloned[ISH_SERVER_NAME];
410
+ if (Object.keys(cloned).length === 0) {
411
+ // Drop the empty bucket so we don't leave `{ "mcpServers": {} }`
412
+ // shrapnel behind. Other clients ignore empty buckets, but it's
413
+ // tidier and matches what a fresh-from-uninstall config looks like.
414
+ delete next[spec.serverKey];
415
+ }
416
+ else {
417
+ next[spec.serverKey] = cloned;
418
+ }
419
+ }
420
+ writeClientConfig(entry.config_path, next);
421
+ removed.push(entry);
422
+ }
423
+ return { removed, skipped };
424
+ }
425
+ // ---------------------------------------------------------------------------
426
+ // Commander registration
427
+ // ---------------------------------------------------------------------------
428
+ function renderAddHumanOutput(plan, dryRun, explicit, results) {
429
+ // Progress to stderr (per CLAUDE.md "stdout = data, stderr = progress").
430
+ const lead = dryRun ? "Planned" : (results ? "Done" : "Planned");
431
+ process.stderr.write(`${lead}: ${plan.length} client${plan.length === 1 ? "" : "s"} (server URL ${ISH_MCP_URL})\n`);
432
+ for (const entry of plan) {
433
+ const verb = entry.action === "create"
434
+ ? "create"
435
+ : entry.action === "update"
436
+ ? "update"
437
+ : entry.action === "skip"
438
+ ? "skip"
439
+ : entry.action === "refuse-drift"
440
+ ? "drifted"
441
+ : entry.action;
442
+ process.stderr.write(` ${verb.padEnd(8)} ${entry.client.padEnd(16)} ${entry.config_path}\n`);
443
+ if (entry.reason) {
444
+ process.stderr.write(` ${entry.reason}\n`);
445
+ }
446
+ }
447
+ if (dryRun && !explicit && plan.length > 0) {
448
+ process.stderr.write(`\nThis was a dry run. To wire every detected client, re-run with:\n ish mcp add --all --yes\n`);
449
+ }
450
+ if (results) {
451
+ process.stderr.write(`\nWrote ${results.written.length}, skipped ${results.skipped.length} (already up to date).\n`);
452
+ }
453
+ }
454
+ export function registerMcpCommands(program) {
455
+ const mcp = program
456
+ .command("mcp")
457
+ .description("Wire the ish MCP server into local AI clients (Cursor, VS Code, Claude Code, Claude Desktop, Windsurf)")
458
+ .addHelpText("after", `
459
+ The hosted ish MCP server lets agents in Cursor, VS Code, Claude Code,
460
+ Claude Desktop, and Windsurf call ish operations (study run, ask
461
+ run, person generate, …) directly. This command writes the per-client
462
+ config block so each client knows where to find the server. OAuth is
463
+ handled by the server itself on first connect — no token is written to
464
+ your client config.
465
+
466
+ Verbs:
467
+ ish mcp list Read-only: detected clients + wiring status
468
+ ish mcp add Wire ish MCP into selected clients
469
+ ish mcp remove Remove the ish block from selected clients
470
+
471
+ Run \`ish mcp <verb> --help\` for flags.
472
+
473
+ Override the server URL via the \`ISH_MCP_URL\` env var (defaults to
474
+ ${ISH_MCP_URL}).`);
475
+ // --- list -----------------------------------------------------------
476
+ mcp
477
+ .command("list")
478
+ .description("Show detected clients and ish MCP wiring status")
479
+ .addHelpText("after", `
480
+ Inspects every supported client's config file (or its absence) and
481
+ reports one of: present-up-to-date, present-drifted, absent,
482
+ no-config-file. Read-only — never writes.
483
+
484
+ JSON output is the stable surface for scripts and agents.`)
485
+ .action(async (_opts, cmd) => {
486
+ await runInline(cmd, async (globals) => {
487
+ const statuses = buildClientStatuses();
488
+ const payload = {
489
+ server_url: ISH_MCP_URL,
490
+ clients: statuses,
491
+ };
492
+ if (globals.json) {
493
+ output(payload, true, { preProjected: true });
494
+ return;
495
+ }
496
+ process.stdout.write(`ish MCP server: ${ISH_MCP_URL}\n\n`);
497
+ const rows = statuses.map((s) => {
498
+ const detected = s.unsupported_on_this_os
499
+ ? "n/a (this OS)"
500
+ : (s.detected ? "yes" : "no");
501
+ return ` ${s.client.padEnd(16)} ${detected.padEnd(13)} ${s.status.padEnd(20)} ${s.config_path}`;
502
+ });
503
+ process.stdout.write(` ${"CLIENT".padEnd(16)} ${"DETECTED".padEnd(13)} ${"STATUS".padEnd(20)} CONFIG PATH\n`);
504
+ for (const r of rows)
505
+ process.stdout.write(`${r}\n`);
506
+ });
507
+ });
508
+ // --- add ------------------------------------------------------------
509
+ mcp
510
+ .command("add")
511
+ .description("Wire the ish MCP server into selected client configs")
512
+ .option("--client <names>", "Comma-separated and/or repeatable: cursor, vscode, claude-code, claude-desktop, windsurf", collectIds, [])
513
+ .option("--all", "Wire every detected client on this OS")
514
+ .option("--dry-run", "Print planned mutations as JSON; write nothing")
515
+ .option("--force", "Overwrite an existing ish server block that has drifted")
516
+ .option("-y, --yes", "Confirm writes (required when stdout is piped or --json is set)")
517
+ .addHelpText("after", `
518
+ Examples:
519
+ $ ish mcp add # dry-run plan + next-step hint
520
+ $ ish mcp add --all --yes # wire every detected client
521
+ $ ish mcp add --client cursor,vscode --yes # wire two specific clients
522
+ $ ish mcp add --client windsurf --dry-run --json # inspect a planned mutation
523
+
524
+ Conventions:
525
+ - Atomic writes (tmp + rename); never partial files.
526
+ - Idempotent: re-running is a no-op when the ish block is already up to date.
527
+ - Preserves every unrelated key in the target config file.
528
+ - Never embeds a token. OAuth happens on first connect to the MCP server.
529
+
530
+ Override the server URL with the \`ISH_MCP_URL\` env var.`)
531
+ .action(async (rawOpts, cmd) => {
532
+ await runInline(cmd, async (globals) => {
533
+ const flags = {
534
+ client: Array.isArray(rawOpts.client) ? rawOpts.client : [],
535
+ all: rawOpts.all === true,
536
+ dryRun: rawOpts.dryRun === true,
537
+ force: rawOpts.force === true,
538
+ yes: rawOpts.yes === true,
539
+ };
540
+ const { specs, explicit } = resolveClients(flags);
541
+ if (specs.length === 0) {
542
+ const message = explicit
543
+ ? "No client specs matched."
544
+ : "No supported AI clients detected on this machine. Pass --client <name> to opt into a specific client (cursor, vscode, claude-code, claude-desktop, windsurf).";
545
+ if (globals.json) {
546
+ output({
547
+ ok: true,
548
+ server_url: ISH_MCP_URL,
549
+ dry_run: true,
550
+ plan: [],
551
+ hint: message,
552
+ }, true, { preProjected: true });
553
+ return;
554
+ }
555
+ process.stderr.write(`${message}\n`);
556
+ return;
557
+ }
558
+ const plan = buildAddPlan(specs, !!flags.force);
559
+ // Default with no client flags and no --all: behave like --dry-run
560
+ // and suggest the explicit re-invocation, so a one-word invocation
561
+ // is safe to run blind.
562
+ const implicitDryRun = !flags.dryRun && !explicit && !flags.all;
563
+ const effectiveDryRun = flags.dryRun || implicitDryRun;
564
+ if (effectiveDryRun) {
565
+ if (globals.json) {
566
+ output({
567
+ ok: true,
568
+ server_url: ISH_MCP_URL,
569
+ dry_run: true,
570
+ plan,
571
+ hint: implicitDryRun
572
+ ? "Re-run with `ish mcp add --all --yes` to commit these writes."
573
+ : "Re-run without --dry-run (and with --yes for non-TTY) to commit.",
574
+ }, true, { preProjected: true });
575
+ return;
576
+ }
577
+ renderAddHumanOutput(plan, true, explicit);
578
+ return;
579
+ }
580
+ // Will any actual writes happen? Skip-only plans don't need confirmation.
581
+ const willWrite = plan.some((p) => p.action === "create" || p.action === "update");
582
+ if (willWrite) {
583
+ await confirmDestructive(`About to write the ish MCP server block into ${plan.filter((p) => p.action !== "skip").length} client config file(s). Continue?`, { yes: !!flags.yes, json: globals.json });
584
+ }
585
+ const results = applyAddPlan(plan);
586
+ if (globals.json) {
587
+ output({
588
+ ok: true,
589
+ server_url: ISH_MCP_URL,
590
+ dry_run: false,
591
+ written: results.written,
592
+ skipped: results.skipped,
593
+ }, true, { preProjected: true });
594
+ return;
595
+ }
596
+ renderAddHumanOutput(plan, false, explicit, results);
597
+ });
598
+ });
599
+ // --- remove ---------------------------------------------------------
600
+ mcp
601
+ .command("remove")
602
+ .description("Remove the ish server block from selected client configs")
603
+ .option("--client <names>", "Comma-separated and/or repeatable: cursor, vscode, claude-code, claude-desktop, windsurf", collectIds, [])
604
+ .option("--all", "Remove from every detected client on this OS")
605
+ .option("--dry-run", "Print planned mutations as JSON; write nothing")
606
+ .option("-y, --yes", "Confirm writes (required when stdout is piped or --json is set)")
607
+ .addHelpText("after", `
608
+ Examples:
609
+ $ ish mcp remove --client cursor --yes
610
+ $ ish mcp remove --all --yes
611
+ $ ish mcp remove --all --dry-run --json
612
+
613
+ Idempotent: a client without an ish block is a no-op. Other keys in
614
+ the client config (other MCP servers, unrelated settings) are preserved.`)
615
+ .action(async (rawOpts, cmd) => {
616
+ await runInline(cmd, async (globals) => {
617
+ const flags = {
618
+ client: Array.isArray(rawOpts.client) ? rawOpts.client : [],
619
+ all: rawOpts.all === true,
620
+ dryRun: rawOpts.dryRun === true,
621
+ yes: rawOpts.yes === true,
622
+ };
623
+ const { specs, explicit } = resolveClients(flags);
624
+ if (specs.length === 0) {
625
+ const message = explicit
626
+ ? "No client specs matched."
627
+ : "No supported AI clients detected on this machine. Pass --client <name> to target a specific client.";
628
+ if (globals.json) {
629
+ output({ ok: true, dry_run: true, plan: [], hint: message }, true, { preProjected: true });
630
+ return;
631
+ }
632
+ process.stderr.write(`${message}\n`);
633
+ return;
634
+ }
635
+ const plan = buildRemovePlan(specs);
636
+ const implicitDryRun = !flags.dryRun && !explicit && !flags.all;
637
+ const effectiveDryRun = flags.dryRun || implicitDryRun;
638
+ if (effectiveDryRun) {
639
+ if (globals.json) {
640
+ output({
641
+ ok: true,
642
+ dry_run: true,
643
+ plan,
644
+ hint: implicitDryRun
645
+ ? "Re-run with `ish mcp remove --all --yes` to commit these removals."
646
+ : "Re-run without --dry-run (and with --yes for non-TTY) to commit.",
647
+ }, true, { preProjected: true });
648
+ return;
649
+ }
650
+ process.stderr.write(`Planned: remove ish block from ${plan.length} client${plan.length === 1 ? "" : "s"}\n`);
651
+ for (const entry of plan) {
652
+ const verb = entry.action === "remove" ? "remove" : entry.action === "remove-noop" ? "skip" : entry.action;
653
+ process.stderr.write(` ${verb.padEnd(8)} ${entry.client.padEnd(16)} ${entry.config_path}\n`);
654
+ if (entry.reason)
655
+ process.stderr.write(` ${entry.reason}\n`);
656
+ }
657
+ return;
658
+ }
659
+ const willWrite = plan.some((p) => p.action === "remove");
660
+ if (willWrite) {
661
+ await confirmDestructive(`About to remove the ish MCP server block from ${plan.filter((p) => p.action === "remove").length} client config file(s). Continue?`, { yes: !!flags.yes, json: globals.json });
662
+ }
663
+ const results = applyRemovePlan(plan);
664
+ if (globals.json) {
665
+ output({
666
+ ok: true,
667
+ dry_run: false,
668
+ removed: results.removed,
669
+ skipped: results.skipped,
670
+ }, true, { preProjected: true });
671
+ return;
672
+ }
673
+ process.stderr.write(`Removed ish block from ${results.removed.length} client${results.removed.length === 1 ? "" : "s"}; ${results.skipped.length} had no block to remove.\n`);
674
+ });
675
+ });
676
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ish person — Manage people, generation, and source uploads.
3
+ */
4
+ import type { Command } from "commander";
5
+ export declare function registerPersonCommands(program: Command): void;