@synsci/thesis 0.1.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.
package/src/cli.mjs ADDED
@@ -0,0 +1,1111 @@
1
+ import { Command } from "commander";
2
+ import inquirer from "inquirer";
3
+ import ora from "ora";
4
+ import pc from "picocolors";
5
+ import { rm } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { randomBytes } from "node:crypto";
8
+
9
+ import {
10
+ ALL_HOST_NAMES,
11
+ SERVER_NAME,
12
+ SETUP_HOST_NAMES,
13
+ detectHosts,
14
+ getHost,
15
+ normalizeHosts,
16
+ } from "./agents.mjs";
17
+ import {
18
+ appendTomlServer,
19
+ mergeServerEntry,
20
+ readNamedConfigEntry,
21
+ readJsonConfig,
22
+ readTomlServerEntry,
23
+ readYamlConfig,
24
+ removeCodexTomlServer,
25
+ removeJsonServerEntry,
26
+ resolveMcpPath,
27
+ writeJsonConfig,
28
+ writeYamlConfig,
29
+ } from "./mcp-writer.mjs";
30
+ import {
31
+ acquireApiKeyViaBrowserBridge,
32
+ acquireApiKeyViaDeviceFlow,
33
+ resolveSetupAuthMode,
34
+ } from "./setup-auth.mjs";
35
+ import {
36
+ cleanupInstalledSkillArtifactsForHosts,
37
+ formatSupportedInstallSkillHosts,
38
+ inspectInstalledSkillArtifactsForHosts,
39
+ installBundledSkillForHosts,
40
+ listBundledSkills,
41
+ partitionInstallSkillHosts,
42
+ resolveInstalledSkillDir,
43
+ resolvePackageRoot,
44
+ } from "./skill-installer.mjs";
45
+
46
+ const INSTALL_SKILL_BASE_URL = "https://thesis.syntheticsciences.ai";
47
+ const DEFAULT_BASE_URL =
48
+ process.env.THESIS_PUBLIC_BASE_URL || INSTALL_SKILL_BASE_URL;
49
+ const NON_INTERACTIVE_SKILL_DECISION_ERROR =
50
+ "Non-interactive setup requires one of --install-skill or --skip-skill.";
51
+ const MUTUALLY_EXCLUSIVE_SKILL_FLAGS_ERROR =
52
+ "--install-skill and --skip-skill cannot be used together.";
53
+ const GUIDED_SKILL_DECISION_TEST_ENV =
54
+ "THESIS_GUIDED_SKILL_DECISION_FOR_TESTS";
55
+ const ENABLE_TEST_SEAMS_ENV = "THESIS_ENABLE_TEST_SEAMS";
56
+
57
+ const log = {
58
+ info: (message) => console.log(pc.cyan(message)),
59
+ warn: (message) => console.log(pc.yellow(`Warning: ${message}`)),
60
+ error: (message) => console.log(pc.red(`Error: ${message}`)),
61
+ plain: (message) => console.log(message),
62
+ blank: () => console.log(""),
63
+ };
64
+
65
+ const CHECKBOX_THEME = {
66
+ style: {
67
+ highlight: (text) => pc.green(text),
68
+ disabledChoice: (text) => ` ${pc.dim("o")} ${pc.dim(text)}`,
69
+ },
70
+ };
71
+
72
+ function normalizeBaseUrl(value) {
73
+ const normalized = String(value || "").trim();
74
+ let parsedUrl;
75
+ try {
76
+ parsedUrl = new URL(normalized);
77
+ } catch {
78
+ throw new Error("Base URL must be a valid absolute URL.");
79
+ }
80
+
81
+ if (parsedUrl.pathname !== "/" || parsedUrl.search || parsedUrl.hash) {
82
+ throw new Error(
83
+ "Base URL must be a public Thesis origin without a path, query, or hash.",
84
+ );
85
+ }
86
+
87
+ return parsedUrl.origin;
88
+ }
89
+
90
+ function normalizeServerName(value) {
91
+ const normalized = String(value || "").trim();
92
+ if (!normalized) {
93
+ throw new Error("Server name cannot be empty.");
94
+ }
95
+ return normalized;
96
+ }
97
+
98
+ function normalizeAuthMode(value) {
99
+ const normalized = String(value || "auto")
100
+ .trim()
101
+ .toLowerCase();
102
+ if (
103
+ normalized === "auto" ||
104
+ normalized === "loopback" ||
105
+ normalized === "device"
106
+ ) {
107
+ return normalized;
108
+ }
109
+ throw new Error("Auth mode must be one of: auto, loopback, device.");
110
+ }
111
+
112
+ function selectedHostsFromOptions(options) {
113
+ const hosts = [];
114
+ if (options.claude) hosts.push("claude");
115
+ if (options.opencode) hosts.push("opencode");
116
+ if (options.codex) hosts.push("codex");
117
+ if (options.cursor) hosts.push("cursor");
118
+ if (options.piMono) hosts.push("pi-mono");
119
+ if (options.hermesAgent) hosts.push("hermes-agent");
120
+ if (options.openclaw) hosts.push("openclaw");
121
+ return hosts;
122
+ }
123
+
124
+ function parseHostsList(value) {
125
+ if (!value) return [];
126
+ return normalizeHosts(
127
+ String(value)
128
+ .split(",")
129
+ .map((item) => item.trim())
130
+ .filter(Boolean),
131
+ );
132
+ }
133
+
134
+ function mcpCandidatesForScope(host, scope) {
135
+ if (scope === "global") return host.mcp.globalPaths;
136
+ return host.mcp.projectPaths.map((candidate) =>
137
+ path.join(process.cwd(), candidate),
138
+ );
139
+ }
140
+
141
+ async function isAlreadyConfigured(hostName, scope, serverName) {
142
+ const existingState = await readExistingServerState(
143
+ hostName,
144
+ scope,
145
+ serverName,
146
+ );
147
+ return existingState.exists;
148
+ }
149
+
150
+ async function readExistingServerState(hostName, scope, serverName) {
151
+ const host = getHost(hostName);
152
+ const mcpPath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
153
+
154
+ if (host.mcp.configType === "toml") {
155
+ const { exists, url } = await readTomlServerEntry(mcpPath, serverName);
156
+ return { exists, url, mcpPath };
157
+ }
158
+
159
+ const existing =
160
+ host.mcp.configType === "yaml"
161
+ ? await readYamlConfig(mcpPath)
162
+ : await readJsonConfig(mcpPath);
163
+ const entry = readNamedConfigEntry(
164
+ existing,
165
+ host.mcp.configKey,
166
+ serverName,
167
+ );
168
+ return {
169
+ exists: entry !== null,
170
+ url: typeof entry?.url === "string" ? entry.url : null,
171
+ mcpPath,
172
+ };
173
+ }
174
+
175
+ async function inspectInstallSkillHosts({
176
+ hostNames,
177
+ scope,
178
+ serverName,
179
+ serverUrl,
180
+ }) {
181
+ const hostStates = new Map();
182
+ const needsSetupHosts = [];
183
+ const driftHosts = [];
184
+
185
+ for (const hostName of hostNames) {
186
+ // eslint-disable-next-line no-await-in-loop
187
+ const existingState = await readExistingServerState(
188
+ hostName,
189
+ scope,
190
+ serverName,
191
+ );
192
+ if (!existingState.exists) {
193
+ needsSetupHosts.push(hostName);
194
+ hostStates.set(hostName, {
195
+ classification: "needs-setup",
196
+ mcpPath: existingState.mcpPath,
197
+ });
198
+ continue;
199
+ }
200
+
201
+ if (existingState.url === serverUrl) {
202
+ hostStates.set(hostName, {
203
+ classification: "matching",
204
+ mcpPath: existingState.mcpPath,
205
+ });
206
+ continue;
207
+ }
208
+
209
+ driftHosts.push({
210
+ hostName,
211
+ mcpPath: existingState.mcpPath,
212
+ existingUrl: existingState.url ?? "(missing URL)",
213
+ });
214
+ hostStates.set(hostName, {
215
+ classification: "drift",
216
+ mcpPath: existingState.mcpPath,
217
+ });
218
+ }
219
+
220
+ return { hostStates, needsSetupHosts, driftHosts };
221
+ }
222
+
223
+ function formatInstallSkillDriftError({
224
+ hostName,
225
+ scope,
226
+ serverName,
227
+ existingUrl,
228
+ requestedUrl,
229
+ }) {
230
+ const host = getHost(hostName);
231
+ const scopeFlag =
232
+ scope === "project" ? "--scope project" : "--scope global";
233
+ return `Thesis MCP entry '${serverName}' for ${host.displayName} already points to ${existingUrl}, not ${requestedUrl}. Run 'thesis uninstall ${scopeFlag} --${hostName} --name ${serverName}' first or choose a different --name.`;
234
+ }
235
+
236
+ function getGuidedSkillDecisionOverrideForTests() {
237
+ const testSeamsEnabled =
238
+ String(process.env[ENABLE_TEST_SEAMS_ENV] || "").trim() === "1";
239
+ if (!testSeamsEnabled) {
240
+ return "";
241
+ }
242
+
243
+ const normalizedGuidedDecisionOverride = String(
244
+ process.env[GUIDED_SKILL_DECISION_TEST_ENV] || "",
245
+ )
246
+ .trim()
247
+ .toLowerCase();
248
+ if (normalizedGuidedDecisionOverride.length === 0) {
249
+ return "";
250
+ }
251
+ if (
252
+ normalizedGuidedDecisionOverride === "accept" ||
253
+ normalizedGuidedDecisionOverride === "decline" ||
254
+ normalizedGuidedDecisionOverride === "abort"
255
+ ) {
256
+ return normalizedGuidedDecisionOverride;
257
+ }
258
+
259
+ throw new Error(
260
+ `${GUIDED_SKILL_DECISION_TEST_ENV} must be one of: accept, decline, abort.`,
261
+ );
262
+ }
263
+
264
+ async function promptHosts(scope, detected, serverName) {
265
+ const choices = await Promise.all(
266
+ ALL_HOST_NAMES.map(async (hostName) => {
267
+ const configured = await isAlreadyConfigured(
268
+ hostName,
269
+ scope,
270
+ serverName,
271
+ ).catch(() => false);
272
+ return {
273
+ name: SETUP_HOST_NAMES[hostName],
274
+ value: hostName,
275
+ checked: detected.includes(hostName),
276
+ disabled: configured ? "(already configured)" : false,
277
+ };
278
+ }),
279
+ );
280
+
281
+ if (choices.every((choice) => Boolean(choice.disabled))) {
282
+ log.info("Thesis is already configured for all detected hosts.");
283
+ return null;
284
+ }
285
+
286
+ try {
287
+ const { selectedHosts } = await inquirer.prompt([
288
+ {
289
+ type: "checkbox",
290
+ name: "selectedHosts",
291
+ message: "Which hosts do you want to set up?",
292
+ choices,
293
+ loop: false,
294
+ validate: (selected) =>
295
+ selected.length > 0 || "Select at least one host.",
296
+ },
297
+ ]);
298
+ return selectedHosts;
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
304
+ async function resolveHosts(
305
+ options,
306
+ scope,
307
+ serverName,
308
+ allowPrompt = true,
309
+ ) {
310
+ const explicit = selectedHostsFromOptions(options);
311
+ if (explicit.length > 0) return explicit;
312
+
313
+ const detected = await detectHosts(scope);
314
+ const promptDisabled =
315
+ options.yes || process.stdout.isTTY !== true || !allowPrompt;
316
+ if (detected.length > 0 && promptDisabled) {
317
+ return detected;
318
+ }
319
+
320
+ if (promptDisabled) {
321
+ log.warn(
322
+ "No hosts were detected for prompt-free setup. Pass explicit host flags such as --codex.",
323
+ );
324
+ return [];
325
+ }
326
+
327
+ log.blank();
328
+ const selected = await promptHosts(scope, detected, serverName);
329
+ if (!selected) {
330
+ log.warn("Setup cancelled");
331
+ return [];
332
+ }
333
+ return selected;
334
+ }
335
+
336
+ async function resolveApiKey(options, baseUrl) {
337
+ if (Object.hasOwn(options, "apiKey")) {
338
+ const normalizedApiKey = String(options.apiKey || "").trim();
339
+ if (normalizedApiKey.length === 0) {
340
+ throw new Error("--api-key cannot be empty.");
341
+ }
342
+ return normalizedApiKey;
343
+ }
344
+
345
+ const requestedAuthMode = normalizeAuthMode(options.authMode || "auto");
346
+ const resolvedAuthMode = resolveSetupAuthMode({
347
+ requestedMode: requestedAuthMode,
348
+ });
349
+
350
+ const spinner = ora("Configuring authentication...").start();
351
+ try {
352
+ const keyName = `thesis-setup-${randomBytes(3).toString("hex")}`;
353
+ const authResult =
354
+ resolvedAuthMode === "device"
355
+ ? await acquireApiKeyViaDeviceFlow({
356
+ baseUrl,
357
+ keyName,
358
+ verificationBaseUrl: baseUrl,
359
+ })
360
+ : await acquireApiKeyViaBrowserBridge({
361
+ baseUrl,
362
+ keyName,
363
+ });
364
+ spinner.succeed(`Authenticated (${resolvedAuthMode})`);
365
+ return authResult.apiKey;
366
+ } catch (error) {
367
+ spinner.fail("Authentication failed");
368
+ const message =
369
+ error instanceof Error ? error.message : "Authentication failed.";
370
+ if (resolvedAuthMode === "loopback" && requestedAuthMode === "auto") {
371
+ throw new Error(
372
+ `${message} Try --auth-mode device on remote shells.`,
373
+ );
374
+ }
375
+ throw new Error(message);
376
+ }
377
+ }
378
+
379
+ async function setupHost(hostName, scope, serverUrl, apiKey, serverName) {
380
+ const host = getHost(hostName);
381
+ const mcpPath = await resolveMcpPath(mcpCandidatesForScope(host, scope));
382
+
383
+ if (host.mcp.configType === "toml") {
384
+ const { alreadyExists } = await appendTomlServer(
385
+ mcpPath,
386
+ serverName,
387
+ host.mcp.buildEntry({ serverUrl, apiKey }),
388
+ );
389
+ return {
390
+ host: host.displayName,
391
+ status: alreadyExists
392
+ ? "already configured"
393
+ : "configured with API Key",
394
+ filePath: mcpPath,
395
+ };
396
+ }
397
+
398
+ const existing =
399
+ host.mcp.configType === "yaml"
400
+ ? await readYamlConfig(mcpPath)
401
+ : await readJsonConfig(mcpPath);
402
+ const { config, alreadyExists } = mergeServerEntry(
403
+ existing,
404
+ host.mcp.configKey,
405
+ serverName,
406
+ host.mcp.buildEntry({ serverUrl, apiKey }),
407
+ );
408
+ if (config !== existing) {
409
+ if (host.mcp.configType === "yaml") {
410
+ await writeYamlConfig(mcpPath, config);
411
+ } else {
412
+ await writeJsonConfig(mcpPath, config);
413
+ }
414
+ }
415
+ return {
416
+ host: host.displayName,
417
+ status: alreadyExists
418
+ ? "already configured"
419
+ : "configured with API Key",
420
+ filePath: mcpPath,
421
+ };
422
+ }
423
+
424
+ function printSetupResults(results, scope, serverUrl) {
425
+ log.blank();
426
+ log.plain(pc.green("Thesis setup complete"));
427
+ log.blank();
428
+ log.plain(` Scope: ${pc.bold(scope)}`);
429
+ log.plain(` Server URL: ${pc.bold(serverUrl)}`);
430
+ for (const result of results) {
431
+ const icon = result.status.startsWith("configured")
432
+ ? pc.green("+")
433
+ : pc.dim("~");
434
+ log.plain(` ${pc.bold(result.host)}`);
435
+ log.plain(` ${icon} ${result.status}`);
436
+ log.plain(` ${pc.dim(result.filePath)}`);
437
+ }
438
+ log.blank();
439
+ }
440
+
441
+ async function promptGuidedSkillInstall(
442
+ bundledSkillNames,
443
+ unsupportedHosts,
444
+ guidedDecisionOverride = "",
445
+ ) {
446
+ if (guidedDecisionOverride === "accept") {
447
+ return true;
448
+ }
449
+ if (
450
+ guidedDecisionOverride === "decline" ||
451
+ guidedDecisionOverride === "abort"
452
+ ) {
453
+ return false;
454
+ }
455
+
456
+ const message = buildGuidedSkillInstallPrompt({
457
+ unsupportedHosts,
458
+ bundledSkillNames,
459
+ });
460
+
461
+ try {
462
+ const { shouldInstall } = await inquirer.prompt([
463
+ {
464
+ type: "confirm",
465
+ name: "shouldInstall",
466
+ message,
467
+ default: true,
468
+ },
469
+ ]);
470
+ return shouldInstall;
471
+ } catch {
472
+ return false;
473
+ }
474
+ }
475
+
476
+ export function buildGuidedSkillInstallPrompt({
477
+ unsupportedHosts,
478
+ bundledSkillNames,
479
+ }) {
480
+ const usesPlural = bundledSkillNames.length > 1;
481
+ if (unsupportedHosts.length > 0) {
482
+ return usesPlural
483
+ ? `Also install or refresh the bundled Thesis skills for supported hosts? Unsupported hosts (${unsupportedHosts.join(", ")}) will remain MCP-only.`
484
+ : `Also install or refresh the bundled Thesis skill for supported hosts? Unsupported hosts (${unsupportedHosts.join(", ")}) will remain MCP-only.`;
485
+ }
486
+ return usesPlural
487
+ ? "Also install or refresh the bundled Thesis skills?"
488
+ : "Also install or refresh the bundled Thesis skill?";
489
+ }
490
+
491
+ function logBundledSkillInstallPlan({
492
+ bundledSkillNames,
493
+ projectRoot,
494
+ scope,
495
+ supportedHosts,
496
+ }) {
497
+ for (const hostName of supportedHosts) {
498
+ const hostDisplayName = getHost(hostName).displayName;
499
+ const resolvedSkillRoot = path.dirname(
500
+ resolveInstalledSkillDir(
501
+ projectRoot,
502
+ hostName,
503
+ scope,
504
+ bundledSkillNames[0],
505
+ ),
506
+ );
507
+ log.plain(
508
+ `Installing bundled skills for ${hostDisplayName} (${scope}: ${resolvedSkillRoot}): ${bundledSkillNames.join(", ")}`,
509
+ );
510
+ }
511
+ }
512
+
513
+ function normalizeSkillDecisionMode(options) {
514
+ if (options.installSkill && options.skipSkill) {
515
+ throw new Error(MUTUALLY_EXCLUSIVE_SKILL_FLAGS_ERROR);
516
+ }
517
+ if (options.installSkill) return "install";
518
+ if (options.skipSkill) return "skip";
519
+ return "guided";
520
+ }
521
+
522
+ async function discardInstallSkillCleanupState(cleanupState) {
523
+ if (!cleanupState?.backupRoot) {
524
+ return null;
525
+ }
526
+ await rm(cleanupState.backupRoot, { force: true, recursive: true });
527
+ return null;
528
+ }
529
+
530
+ async function runSetupCommand(options) {
531
+ const projectRoot = process.cwd();
532
+ const skillDecisionMode = normalizeSkillDecisionMode(options);
533
+ const guidedSkillDecisionOverride =
534
+ getGuidedSkillDecisionOverrideForTests();
535
+ const nonInteractive = process.stdout.isTTY !== true;
536
+ const hasGuidedDecisionOverride =
537
+ guidedSkillDecisionOverride.length > 0;
538
+ if (
539
+ nonInteractive &&
540
+ skillDecisionMode === "guided" &&
541
+ !hasGuidedDecisionOverride
542
+ ) {
543
+ throw new Error(NON_INTERACTIVE_SKILL_DECISION_ERROR);
544
+ }
545
+
546
+ const scope = options.project ? "project" : "global";
547
+ const baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL);
548
+ const serverUrl = `${baseUrl}/mcp-server`;
549
+ const serverName = normalizeServerName(options.name || SERVER_NAME);
550
+ const hosts = await resolveHosts(
551
+ options,
552
+ scope,
553
+ serverName,
554
+ skillDecisionMode === "guided",
555
+ );
556
+ if (hosts.length === 0) return;
557
+
558
+ const { supportedHosts, unsupportedHosts } =
559
+ partitionInstallSkillHosts(hosts);
560
+ const packageRoot = resolvePackageRoot();
561
+ const bundledSkillNames =
562
+ skillDecisionMode === "skip" || supportedHosts.length === 0
563
+ ? []
564
+ : await listBundledSkills(packageRoot);
565
+
566
+ if (skillDecisionMode === "install" && supportedHosts.length === 0) {
567
+ throw new Error(
568
+ `--install-skill is not supported for: ${unsupportedHosts.join(", ")}. Supported hosts: ${formatSupportedInstallSkillHosts()}.`,
569
+ );
570
+ }
571
+ if (
572
+ skillDecisionMode !== "skip" &&
573
+ supportedHosts.length > 0 &&
574
+ bundledSkillNames.length === 0
575
+ ) {
576
+ throw new Error(
577
+ `No bundled skills found under ${path.join(packageRoot, "skills")}.`,
578
+ );
579
+ }
580
+
581
+ const {
582
+ hostStates = new Map(),
583
+ needsSetupHosts = [],
584
+ driftHosts = [],
585
+ } = await inspectInstallSkillHosts({
586
+ hostNames: hosts,
587
+ scope,
588
+ serverName,
589
+ serverUrl,
590
+ });
591
+
592
+ const installDriftHosts = driftHosts;
593
+
594
+ if (skillDecisionMode === "install" && installDriftHosts.length > 0) {
595
+ throw new Error(
596
+ formatInstallSkillDriftError({
597
+ hostName: installDriftHosts[0].hostName,
598
+ scope,
599
+ serverName,
600
+ existingUrl: installDriftHosts[0].existingUrl,
601
+ requestedUrl: serverUrl,
602
+ }),
603
+ );
604
+ }
605
+
606
+ let cleanupState = null;
607
+ try {
608
+ if (skillDecisionMode === "install") {
609
+ logBundledSkillInstallPlan({
610
+ bundledSkillNames,
611
+ projectRoot,
612
+ scope,
613
+ supportedHosts,
614
+ });
615
+ cleanupState = await inspectInstalledSkillArtifactsForHosts({
616
+ projectRoot,
617
+ hostNames: supportedHosts,
618
+ scope,
619
+ skillNames: bundledSkillNames,
620
+ });
621
+ await installBundledSkillForHosts({
622
+ projectRoot,
623
+ hostNames: supportedHosts,
624
+ scope,
625
+ serverName,
626
+ serverUrl,
627
+ skillNames: bundledSkillNames,
628
+ });
629
+ if (unsupportedHosts.length > 0) {
630
+ log.plain(
631
+ `--install-skill skipped unsupported hosts (MCP-only): ${unsupportedHosts.join(", ")}.`,
632
+ );
633
+ }
634
+ }
635
+
636
+ const hostsNeedingSetup = needsSetupHosts;
637
+ let apiKey = null;
638
+ if (hostsNeedingSetup.length > 0) {
639
+ apiKey = await resolveApiKey(options, baseUrl);
640
+ if (!apiKey) {
641
+ if (cleanupState) {
642
+ await cleanupInstalledSkillArtifactsForHosts({
643
+ projectRoot,
644
+ hostNames: supportedHosts,
645
+ priorState: cleanupState,
646
+ scope,
647
+ skillNames: bundledSkillNames,
648
+ });
649
+ cleanupState = null;
650
+ }
651
+ log.warn("Setup cancelled");
652
+ return;
653
+ }
654
+ }
655
+
656
+ let setupResults = [];
657
+ const spinner = ora("Setting up Thesis MCP...").start();
658
+ try {
659
+ const results = [];
660
+ for (const hostName of hosts) {
661
+ if (!needsSetupHosts.includes(hostName)) {
662
+ results.push({
663
+ host: getHost(hostName).displayName,
664
+ status: "already configured",
665
+ filePath: hostStates.get(hostName).mcpPath,
666
+ });
667
+ continue;
668
+ }
669
+
670
+ spinner.text = `Setting up ${getHost(hostName).displayName}...`;
671
+ // eslint-disable-next-line no-await-in-loop
672
+ results.push(
673
+ await setupHost(
674
+ hostName,
675
+ scope,
676
+ serverUrl,
677
+ apiKey,
678
+ serverName,
679
+ ),
680
+ );
681
+ }
682
+ spinner.succeed("MCP setup complete");
683
+ setupResults = results;
684
+ } catch (error) {
685
+ spinner.fail("Setup failed");
686
+ throw error;
687
+ }
688
+
689
+ if (skillDecisionMode !== "guided") {
690
+ const completedCleanupState = cleanupState;
691
+ cleanupState = null;
692
+ await discardInstallSkillCleanupState(completedCleanupState);
693
+ printSetupResults(setupResults, scope, serverUrl);
694
+ return;
695
+ }
696
+
697
+ if (supportedHosts.length === 0) {
698
+ log.plain(
699
+ `Bundled skill installation is unavailable for the selected hosts (${hosts.join(", ")}). Continuing with MCP-only setup.`,
700
+ );
701
+ printSetupResults(setupResults, scope, serverUrl);
702
+ return;
703
+ }
704
+
705
+ const shouldInstallSkill = await promptGuidedSkillInstall(
706
+ bundledSkillNames,
707
+ unsupportedHosts,
708
+ guidedSkillDecisionOverride,
709
+ );
710
+ if (!shouldInstallSkill) {
711
+ printSetupResults(setupResults, scope, serverUrl);
712
+ return;
713
+ }
714
+
715
+ if (installDriftHosts.length > 0) {
716
+ throw new Error(
717
+ formatInstallSkillDriftError({
718
+ hostName: installDriftHosts[0].hostName,
719
+ scope,
720
+ serverName,
721
+ existingUrl: installDriftHosts[0].existingUrl,
722
+ requestedUrl: serverUrl,
723
+ }),
724
+ );
725
+ }
726
+
727
+ logBundledSkillInstallPlan({
728
+ bundledSkillNames,
729
+ projectRoot,
730
+ scope,
731
+ supportedHosts,
732
+ });
733
+ cleanupState = await inspectInstalledSkillArtifactsForHosts({
734
+ projectRoot,
735
+ hostNames: supportedHosts,
736
+ scope,
737
+ skillNames: bundledSkillNames,
738
+ });
739
+ await installBundledSkillForHosts({
740
+ projectRoot,
741
+ hostNames: supportedHosts,
742
+ scope,
743
+ serverName,
744
+ serverUrl,
745
+ skillNames: bundledSkillNames,
746
+ });
747
+ if (unsupportedHosts.length > 0) {
748
+ log.plain(
749
+ `Guided skill install skipped unsupported hosts (MCP-only): ${unsupportedHosts.join(", ")}.`,
750
+ );
751
+ }
752
+ const completedCleanupState = cleanupState;
753
+ cleanupState = null;
754
+ await discardInstallSkillCleanupState(completedCleanupState);
755
+ printSetupResults(setupResults, scope, serverUrl);
756
+ } catch (error) {
757
+ if (cleanupState) {
758
+ await cleanupInstalledSkillArtifactsForHosts({
759
+ projectRoot,
760
+ hostNames: supportedHosts,
761
+ priorState: cleanupState,
762
+ scope,
763
+ skillNames: bundledSkillNames,
764
+ });
765
+ }
766
+ throw error;
767
+ }
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Uninstall
772
+ // ---------------------------------------------------------------------------
773
+
774
+ function parseUninstallScope(value) {
775
+ const normalized = String(value || "")
776
+ .trim()
777
+ .toLowerCase();
778
+ if (
779
+ normalized === "global" ||
780
+ normalized === "project" ||
781
+ normalized === "all"
782
+ ) {
783
+ return normalized;
784
+ }
785
+ return "all";
786
+ }
787
+
788
+ function scopesFromUninstallScope(scope) {
789
+ return scope === "all" ? ["global", "project"] : [scope];
790
+ }
791
+
792
+ async function isConfiguredForUninstallScope(
793
+ hostName,
794
+ scope,
795
+ serverName,
796
+ ) {
797
+ const scopes = scopesFromUninstallScope(scope);
798
+ for (const singleScope of scopes) {
799
+ // eslint-disable-next-line no-await-in-loop
800
+ const configured = await isAlreadyConfigured(
801
+ hostName,
802
+ singleScope,
803
+ serverName,
804
+ ).catch(() => false);
805
+ if (configured) return true;
806
+ }
807
+ return false;
808
+ }
809
+
810
+ async function promptUninstallScope(defaultScope) {
811
+ try {
812
+ const { selectedScope } = await inquirer.prompt([
813
+ {
814
+ type: "list",
815
+ name: "selectedScope",
816
+ message: "Which scope do you want to uninstall from?",
817
+ choices: [
818
+ { name: "All (global and project)", value: "all" },
819
+ { name: "Global", value: "global" },
820
+ { name: "Project", value: "project" },
821
+ ],
822
+ default: defaultScope,
823
+ },
824
+ ]);
825
+ return selectedScope;
826
+ } catch {
827
+ return null;
828
+ }
829
+ }
830
+
831
+ async function promptUninstallHosts(scope, serverName) {
832
+ const choices = await Promise.all(
833
+ ALL_HOST_NAMES.map(async (hostName) => {
834
+ const configured = await isConfiguredForUninstallScope(
835
+ hostName,
836
+ scope,
837
+ serverName,
838
+ );
839
+ return {
840
+ name: SETUP_HOST_NAMES[hostName],
841
+ value: hostName,
842
+ checked: configured,
843
+ disabled: configured ? false : "(not configured)",
844
+ };
845
+ }),
846
+ );
847
+
848
+ if (choices.every((choice) => Boolean(choice.disabled))) {
849
+ log.info("Thesis is not configured for the selected scope.");
850
+ return null;
851
+ }
852
+
853
+ try {
854
+ const { selectedHosts } = await inquirer.prompt([
855
+ {
856
+ type: "checkbox",
857
+ name: "selectedHosts",
858
+ message: "Which hosts do you want to uninstall?",
859
+ choices,
860
+ loop: false,
861
+ validate: (selected) =>
862
+ selected.length > 0 || "Select at least one host.",
863
+ },
864
+ ]);
865
+ return selectedHosts;
866
+ } catch {
867
+ return null;
868
+ }
869
+ }
870
+
871
+ async function resolveUninstallTargets(options, serverName) {
872
+ const explicitHostsFromFlags = selectedHostsFromOptions(options);
873
+ const explicitHostsFromList =
874
+ explicitHostsFromFlags.length === 0
875
+ ? parseHostsList(options.hosts)
876
+ : [];
877
+ const hasExplicitHosts =
878
+ explicitHostsFromFlags.length > 0 ||
879
+ explicitHostsFromList.length > 0;
880
+ const hasExplicitScope =
881
+ typeof options.scope === "string" &&
882
+ options.scope.trim().length > 0;
883
+
884
+ let scope = parseUninstallScope(options.scope || "all");
885
+ let hosts =
886
+ explicitHostsFromFlags.length > 0
887
+ ? explicitHostsFromFlags
888
+ : explicitHostsFromList.length > 0
889
+ ? explicitHostsFromList
890
+ : [...ALL_HOST_NAMES];
891
+
892
+ if (!options.yes) {
893
+ if (!hasExplicitScope) {
894
+ log.blank();
895
+ const selectedScope = await promptUninstallScope(scope);
896
+ if (!selectedScope) {
897
+ log.warn("Uninstall cancelled");
898
+ return null;
899
+ }
900
+ scope = selectedScope;
901
+ }
902
+
903
+ if (!hasExplicitHosts) {
904
+ const selectedHosts = await promptUninstallHosts(
905
+ scope,
906
+ serverName,
907
+ );
908
+ if (!selectedHosts) {
909
+ log.warn("Uninstall cancelled");
910
+ return null;
911
+ }
912
+ hosts = selectedHosts;
913
+ }
914
+ }
915
+
916
+ return { scope, hosts };
917
+ }
918
+
919
+ async function removeHostConfig(hostName, scope, serverName) {
920
+ const host = getHost(hostName);
921
+ const filePath = await resolveMcpPath(
922
+ mcpCandidatesForScope(host, scope),
923
+ );
924
+
925
+ if (host.mcp.configType === "toml") {
926
+ const result = await removeCodexTomlServer({
927
+ filePath,
928
+ serverName,
929
+ });
930
+ return {
931
+ host: host.displayName,
932
+ scope,
933
+ filePath,
934
+ status: result.changed ? "removed" : "not present",
935
+ };
936
+ }
937
+
938
+ const current =
939
+ host.mcp.configType === "yaml"
940
+ ? await readYamlConfig(filePath)
941
+ : await readJsonConfig(filePath);
942
+ const next = removeJsonServerEntry({
943
+ config: current,
944
+ configKey: host.mcp.configKey,
945
+ serverName,
946
+ });
947
+ if (next.changed) {
948
+ if (host.mcp.configType === "yaml") {
949
+ await writeYamlConfig(filePath, next.config);
950
+ } else {
951
+ await writeJsonConfig(filePath, next.config);
952
+ }
953
+ }
954
+ return {
955
+ host: host.displayName,
956
+ scope,
957
+ filePath,
958
+ status: next.changed ? "removed" : "not present",
959
+ };
960
+ }
961
+
962
+ async function runUninstallCommand(options) {
963
+ const serverName = normalizeServerName(options.name || SERVER_NAME);
964
+ const resolved = await resolveUninstallTargets(options, serverName);
965
+ if (!resolved) return;
966
+ const { scope, hosts } = resolved;
967
+ const scopes = scopesFromUninstallScope(scope);
968
+
969
+ const spinner = ora("Removing Thesis MCP entries...").start();
970
+ const results = [];
971
+ for (const singleScope of scopes) {
972
+ for (const host of hosts) {
973
+ spinner.text = `Removing from ${getHost(host).displayName} (${singleScope})...`;
974
+ // eslint-disable-next-line no-await-in-loop
975
+ results.push(
976
+ await removeHostConfig(host, singleScope, serverName),
977
+ );
978
+ }
979
+ }
980
+ spinner.succeed("Uninstall complete");
981
+
982
+ log.blank();
983
+ log.plain(pc.green("Thesis uninstall complete"));
984
+ log.blank();
985
+ for (const result of results) {
986
+ const icon =
987
+ result.status === "removed" ? pc.green("-") : pc.dim("~");
988
+ log.plain(
989
+ ` ${pc.bold(result.host)} ${pc.dim(`(${result.scope})`)}`,
990
+ );
991
+ log.plain(` ${icon} ${result.status}`);
992
+ log.plain(` ${pc.dim(result.filePath)}`);
993
+ }
994
+ log.blank();
995
+ }
996
+
997
+ // ---------------------------------------------------------------------------
998
+ // Program
999
+ // ---------------------------------------------------------------------------
1000
+
1001
+ function buildProgram() {
1002
+ const program = new Command();
1003
+ program
1004
+ .name("thesis")
1005
+ .description("Thesis by Synthetic Sciences -- CLI setup tool")
1006
+ .addHelpText(
1007
+ "after",
1008
+ `
1009
+ Examples:
1010
+ ${pc.green("npx @synsci/thesis setup")}
1011
+ ${pc.green("npx @synsci/thesis setup --install-skill")}
1012
+ ${pc.green("npx @synsci/thesis setup --skip-skill")}
1013
+ ${pc.green("npx @synsci/thesis setup --auth-mode device --codex --project")}
1014
+ ${pc.green("npx @synsci/thesis setup --codex --project")}
1015
+ ${pc.green("npx @synsci/thesis setup --yes --codex --claude --cursor")}
1016
+ ${pc.green(
1017
+ "npx @synsci/thesis uninstall --scope all --hosts codex,claude,opencode,cursor,pi-mono,hermes-agent,openclaw",
1018
+ )}
1019
+ `,
1020
+ );
1021
+
1022
+ program
1023
+ .command("setup")
1024
+ .description("Set up Thesis MCP for your AI coding host")
1025
+ .option("--claude", "Set up for Claude Code")
1026
+ .option("--opencode", "Set up for OpenCode")
1027
+ .option("--codex", "Set up for Codex")
1028
+ .option("--cursor", "Set up for Cursor")
1029
+ .option("--pi-mono", "Set up for Pi (pi-mono)")
1030
+ .option("--hermes-agent", "Set up for Hermes Agent")
1031
+ .option("--openclaw", "Set up for OpenClaw")
1032
+ .option(
1033
+ "-p, --project",
1034
+ "Configure for current project instead of globally",
1035
+ )
1036
+ .option("-y, --yes", "Skip host selection prompts")
1037
+ .option("--api-key <key>", "Use API key authentication")
1038
+ .option(
1039
+ "--auth-mode <mode>",
1040
+ "Authentication mode: auto | loopback | device (default: auto)",
1041
+ normalizeAuthMode,
1042
+ "auto",
1043
+ )
1044
+ .option(
1045
+ "--install-skill",
1046
+ "Install or refresh the bundled Thesis skills",
1047
+ )
1048
+ .option(
1049
+ "--skip-skill",
1050
+ "Skip bundled skill installation (MCP-only setup)",
1051
+ )
1052
+ .option(
1053
+ "--base-url <url>",
1054
+ `Public Thesis origin used for setup and MCP config (default: ${DEFAULT_BASE_URL})`,
1055
+ )
1056
+ .option(
1057
+ "--name <name>",
1058
+ `MCP server name (default: ${SERVER_NAME})`,
1059
+ )
1060
+ .action(async (options) => {
1061
+ await runSetupCommand(options);
1062
+ });
1063
+
1064
+ program
1065
+ .command("uninstall")
1066
+ .description("Remove Thesis MCP entries from host configs")
1067
+ .option("--claude", "Uninstall for Claude Code")
1068
+ .option("--opencode", "Uninstall for OpenCode")
1069
+ .option("--codex", "Uninstall for Codex")
1070
+ .option("--cursor", "Uninstall for Cursor")
1071
+ .option("--pi-mono", "Uninstall for Pi (pi-mono)")
1072
+ .option("--hermes-agent", "Uninstall for Hermes Agent")
1073
+ .option("--openclaw", "Uninstall for OpenClaw")
1074
+ .option(
1075
+ "--hosts <list>",
1076
+ "Comma-separated: codex,claude,opencode,cursor,pi-mono,hermes-agent,openclaw",
1077
+ )
1078
+ .option("--scope <scope>", "all | global | project")
1079
+ .option(
1080
+ "--name <name>",
1081
+ `MCP server name to remove (default: ${SERVER_NAME})`,
1082
+ )
1083
+ .option("-y, --yes", "Skip uninstall selection prompts")
1084
+ .action(async (options) => {
1085
+ await runUninstallCommand(options);
1086
+ });
1087
+
1088
+ return program;
1089
+ }
1090
+
1091
+ export async function run(argv = process.argv) {
1092
+ try {
1093
+ const program = buildProgram();
1094
+ await program.parseAsync(argv);
1095
+ } catch (error) {
1096
+ if (
1097
+ error instanceof Error &&
1098
+ error.name === "ExitPromptError"
1099
+ ) {
1100
+ process.exit(0);
1101
+ }
1102
+ if (
1103
+ error instanceof Error &&
1104
+ /cancelled/i.test(error.message)
1105
+ ) {
1106
+ log.warn(error.message);
1107
+ process.exit(0);
1108
+ }
1109
+ throw error;
1110
+ }
1111
+ }