@mycadet/cli 0.12.6-dev.202610082233.54244ee → 0.12.7

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 (89) hide show
  1. package/eslint.config.mjs +7 -0
  2. package/node_modules/@mycadet/local-mode/src/__tests__/environment.test.ts +1 -4
  3. package/node_modules/@mycadet/local-mode/src/config.ts +14 -19
  4. package/node_modules/@mycadet/local-mode/src/index.ts +5 -0
  5. package/node_modules/@mycadet/local-mode/src/local-signing-key.ts +73 -0
  6. package/node_modules/@mycadet/local-mode/src/lockfile.ts +18 -3
  7. package/package.json +4 -3
  8. package/src/__tests__/assistant-config.test.ts +30 -28
  9. package/src/__tests__/debug.test.ts +8 -8
  10. package/src/__tests__/default-main-screen-formatting.test.ts +5 -4
  11. package/src/__tests__/flags.test.ts +2 -2
  12. package/src/__tests__/hatch-provider-secrets.test.ts +3 -3
  13. package/src/__tests__/ingress-config.test.ts +2 -2
  14. package/src/__tests__/login-loopback.test.ts +1 -1
  15. package/src/__tests__/nginx-ingress.test.ts +68 -76
  16. package/src/__tests__/pair.test.ts +135 -121
  17. package/src/__tests__/platform-client.test.ts +2 -2
  18. package/src/__tests__/recover.test.ts +96 -3
  19. package/src/__tests__/retire.test.ts +5 -3
  20. package/src/__tests__/signing-key-continuity.test.ts +261 -0
  21. package/src/__tests__/sleep.test.ts +189 -6
  22. package/src/__tests__/tailscale-tunnel.test.ts +1 -1
  23. package/src/__tests__/uninstall.test.ts +171 -0
  24. package/src/__tests__/upgrade-local.test.ts +16 -3
  25. package/src/__tests__/use.test.ts +2 -2
  26. package/src/__tests__/wake-error.test.ts +76 -0
  27. package/src/__tests__/wake.test.ts +309 -36
  28. package/src/commands/backup.ts +6 -2
  29. package/src/commands/clean.ts +6 -2
  30. package/src/commands/client.ts +91 -41
  31. package/src/commands/confirm.ts +6 -2
  32. package/src/commands/connect.ts +6 -2
  33. package/src/commands/debug.ts +6 -2
  34. package/src/commands/devices.ts +6 -2
  35. package/src/commands/events.ts +6 -2
  36. package/src/commands/exec.ts +50 -12
  37. package/src/commands/flags.ts +6 -2
  38. package/src/commands/gateway.ts +6 -2
  39. package/src/commands/hatch.ts +6 -2
  40. package/src/commands/login.ts +26 -356
  41. package/src/commands/logs.ts +6 -2
  42. package/src/commands/message.ts +6 -2
  43. package/src/commands/pair.ts +6 -2
  44. package/src/commands/ps.ts +7 -2
  45. package/src/commands/recover.ts +21 -16
  46. package/src/commands/restore.ts +6 -2
  47. package/src/commands/retire.ts +6 -2
  48. package/src/commands/roadmap.ts +6 -2
  49. package/src/commands/rollback.ts +6 -2
  50. package/src/commands/setup.ts +76 -9
  51. package/src/commands/sleep.ts +131 -31
  52. package/src/commands/ssh.ts +6 -2
  53. package/src/commands/teleport.ts +6 -2
  54. package/src/commands/terminal.ts +6 -2
  55. package/src/commands/tunnel.ts +6 -2
  56. package/src/commands/uninstall.ts +228 -0
  57. package/src/commands/unpair.ts +6 -2
  58. package/src/commands/upgrade.ts +13 -8
  59. package/src/commands/use.ts +6 -2
  60. package/src/commands/voice.ts +6 -2
  61. package/src/commands/wake.ts +69 -37
  62. package/src/commands/workflows.ts +6 -2
  63. package/src/components/DefaultMainScreen.tsx +250 -36
  64. package/src/index.ts +23 -55
  65. package/src/lib/assistant-config.ts +27 -13
  66. package/src/lib/backup-ops.ts +3 -4
  67. package/src/lib/cli-error.ts +5 -0
  68. package/src/lib/client-identity.ts +8 -10
  69. package/src/lib/cloud-login.ts +422 -0
  70. package/src/lib/color.ts +16 -0
  71. package/src/lib/default-main-screen-formatting.ts +32 -8
  72. package/src/lib/environments/__tests__/paths.test.ts +9 -11
  73. package/src/lib/environments/paths.ts +2 -20
  74. package/src/lib/guardian-token.ts +72 -18
  75. package/src/lib/hatch-local.ts +52 -1
  76. package/src/lib/health-check.ts +24 -15
  77. package/src/lib/lifecycle-reporter.ts +9 -1
  78. package/src/lib/local-hatch-lock.ts +19 -27
  79. package/src/lib/local-runtime-client.ts +25 -14
  80. package/src/lib/local.ts +17 -43
  81. package/src/lib/ngrok.ts +48 -16
  82. package/src/lib/platform-client.ts +141 -72
  83. package/src/lib/retire-archive.ts +4 -4
  84. package/src/lib/retire-local.ts +2 -25
  85. package/src/lib/teleport-backup.ts +23 -9
  86. package/src/lib/terminal-client.ts +21 -9
  87. package/src/lib/tunnel-edge.ts +11 -13
  88. package/src/lib/upgrade-lifecycle.ts +16 -4
  89. package/src/lib/workos-pkce.ts +52 -21
@@ -9,9 +9,10 @@ import {
9
9
  ensureProviderApiKey,
10
10
  formatProviderName,
11
11
  } from "../lib/provider-secrets.js";
12
+ import { yellow } from "../lib/color.js";
12
13
 
13
- function parseSetupArgs(args: string[]): { provider: string } {
14
- let provider = "anthropic";
14
+ function parseSetupArgs(args: string[]): { provider?: string } {
15
+ let provider: string | undefined;
15
16
 
16
17
  for (let i = 0; i < args.length; i++) {
17
18
  const arg = args[i];
@@ -32,6 +33,58 @@ function parseSetupArgs(args: string[]): { provider: string } {
32
33
  return { provider };
33
34
  }
34
35
 
36
+ interface DefaultProviderResponse {
37
+ provider?: string;
38
+ }
39
+
40
+ /** Parse the default-provider payload without a type assertion. */
41
+ function parseDefaultProviderResponse(value: unknown): DefaultProviderResponse {
42
+ if (typeof value !== "object" || value === null) return {};
43
+ const provider = "provider" in value ? value.provider : undefined;
44
+ return typeof provider === "string" ? { provider } : {};
45
+ }
46
+
47
+ /**
48
+ * Ask the active assistant which provider it routes by default
49
+ * (`GET /v1/config/llm/default-provider`), so `cadet setup` without
50
+ * `--provider` configures the connection the assistant actually uses —
51
+ * e.g. the keyless Cadet Cloud managed connection picked at hatch —
52
+ * instead of blindly prompting for an API key. Falls back to cadet
53
+ * when the runtime is unreachable or predates the endpoint; the
54
+ * returned warning says why, for the caller to render.
55
+ */
56
+ async function resolveDefaultProvider(
57
+ gatewayUrl: string,
58
+ bearerToken?: string,
59
+ ): Promise<{ provider: string; warning?: string }> {
60
+ try {
61
+ const response = await fetch(
62
+ `${gatewayUrl}/v1/config/llm/default-provider`,
63
+ {
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
67
+ },
68
+ signal: AbortSignal.timeout(5000),
69
+ },
70
+ );
71
+ if (!response.ok) {
72
+ return {
73
+ provider: "cadet",
74
+ warning: `Default provider lookup failed (gateway responded ${response.status}); falling back to Cadet Cloud.`,
75
+ };
76
+ }
77
+ const data = parseDefaultProviderResponse(await response.json());
78
+ const provider = data.provider?.trim().toLowerCase();
79
+ return { provider: provider || "cadet" };
80
+ } catch (error) {
81
+ return {
82
+ provider: "cadet",
83
+ warning: `Default provider lookup failed (${error instanceof Error ? error.message : String(error)}); falling back to Cadet Cloud.`,
84
+ };
85
+ }
86
+ }
87
+
35
88
  function isGuardianAccessTokenUsable(
36
89
  tokenData: GuardianTokenData | null,
37
90
  ): tokenData is GuardianTokenData {
@@ -86,7 +139,8 @@ async function resolveSetupBearerToken(
86
139
  return entry.bearerToken;
87
140
  }
88
141
 
89
- export async function setup(): Promise<void> {
142
+ export const setup = Object.assign(
143
+ async function setup(): Promise<void> {
90
144
  const args = process.argv.slice(3);
91
145
 
92
146
  if (args.includes("--help") || args.includes("-h")) {
@@ -95,9 +149,7 @@ export async function setup(): Promise<void> {
95
149
  console.log("Configure a provider API key on the active assistant.");
96
150
  console.log("");
97
151
  console.log("Options:");
98
- console.log(
99
- " --provider <provider> Provider to configure. Defaults to anthropic.",
100
- );
152
+ console.log(" --provider <provider> Provider to configure. Defaults to Cadet Cloud");
101
153
  console.log("");
102
154
  console.log("Behavior:");
103
155
  console.log(
@@ -113,7 +165,7 @@ export async function setup(): Promise<void> {
113
165
  process.exit(0);
114
166
  }
115
167
 
116
- let parsed: { provider: string };
168
+ let parsed: { provider?: string };
117
169
  try {
118
170
  parsed = parseSetupArgs(args);
119
171
  } catch (error) {
@@ -130,13 +182,25 @@ export async function setup(): Promise<void> {
130
182
  const gatewayUrl = entry.localUrl ?? entry.runtimeUrl;
131
183
  const bearerToken = await resolveSetupBearerToken(entry, gatewayUrl);
132
184
 
185
+ // Respect the assistant's configured default provider (e.g. Cadet Cloud
186
+ // chosen at hatch) unless the caller named one explicitly — the runtime's
187
+ // keyless managed connection should not be greeted by an API-key prompt.
188
+ let provider = parsed.provider;
189
+ if (!provider) {
190
+ const resolved = await resolveDefaultProvider(gatewayUrl, bearerToken);
191
+ provider = resolved.provider;
192
+ if (resolved.warning) {
193
+ console.error(yellow(resolved.warning));
194
+ }
195
+ }
196
+
133
197
  console.log("Cadet Setup");
134
198
  console.log("============\n");
135
199
 
136
200
  try {
137
201
  const result = await ensureProviderApiKey({
138
202
  gatewayUrl,
139
- provider: parsed.provider,
203
+ provider,
140
204
  bearerToken,
141
205
  env: process.env,
142
206
  });
@@ -171,4 +235,7 @@ export async function setup(): Promise<void> {
171
235
  );
172
236
  process.exit(1);
173
237
  }
174
- }
238
+ },
239
+ { description: "Configure API keys interactively" },
240
+ );
241
+
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
 
4
4
  import {
5
5
  getDaemonPidPath,
6
+ loadAllAssistants,
6
7
  resolveTargetAssistant,
7
8
  } from "../lib/assistant-config.js";
8
9
  import type { AssistantEntry } from "../lib/assistant-config.js";
@@ -60,10 +61,11 @@ function readActiveCallLeases(cadetDir: string): ActiveCallLease[] {
60
61
  );
61
62
  }
62
63
 
63
- export async function sleep(): Promise<void> {
64
+ export const sleep = Object.assign(
65
+ async function sleep(): Promise<void> {
64
66
  const args = process.argv.slice(3);
65
67
  if (args.includes("--help") || args.includes("-h")) {
66
- console.log("Usage: cadet sleep [<name>] [--force] [--wait [<duration>]]");
68
+ console.log("Usage: cadet sleep [<name> | --all] [--force] [--wait [<duration>]]");
67
69
  console.log("");
68
70
  console.log("Stop the assistant and gateway processes.");
69
71
  console.log("");
@@ -73,6 +75,9 @@ export async function sleep(): Promise<void> {
73
75
  );
74
76
  console.log("");
75
77
  console.log("Options:");
78
+ console.log(
79
+ " --all Stop every assistant across all remotes instead of one",
80
+ );
76
81
  console.log(
77
82
  " --force Stop the assistant even if a phone call keepalive lease is active",
78
83
  );
@@ -128,7 +133,110 @@ export async function sleep(): Promise<void> {
128
133
  const nameArg = args.find(
129
134
  (a) => !a.startsWith("-") && a !== waitDurationToken,
130
135
  );
136
+ const all = args.includes("--all");
137
+
138
+ if (all && nameArg) {
139
+ console.error("Error: --all cannot be combined with an assistant name.");
140
+ process.exit(1);
141
+ }
142
+
143
+ if (all) {
144
+ await sleepAll({ force, wait, waitMs });
145
+ return;
146
+ }
147
+
131
148
  const entry = resolveTargetAssistant(nameArg);
149
+ try {
150
+ await sleepAssistant(entry, { force, wait, waitMs });
151
+ } catch (err) {
152
+ if (err instanceof SleepExit) {
153
+ console.log(err.message);
154
+ process.exit(err.exitCode);
155
+ }
156
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
157
+ process.exit(1);
158
+ }
159
+ },
160
+ { description: "Stop the assistant process" },
161
+ );
162
+
163
+
164
+ type SleepOptions = {
165
+ force: boolean;
166
+ wait: boolean;
167
+ waitMs: number | null;
168
+ };
169
+
170
+ /**
171
+ * Carries a deliberate exit outcome (a cancelled drain) across the
172
+ * per-assistant boundary so the callers can exit with the right code
173
+ * instead of treating it as a stop failure.
174
+ */
175
+ class SleepExit extends Error {
176
+ constructor(
177
+ readonly exitCode: number,
178
+ message: string,
179
+ ) {
180
+ super(message);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Stop every assistant this CLI's environment knows about — `--all`. The
186
+ * CLI only ever reaches one environment, so this is the current env's
187
+ * lockfile. Same per-assistant stop as the single path, but one
188
+ * assistant's failure (or an unmanageable runtime) never blocks the
189
+ * others; a failure only decides the exit code.
190
+ */
191
+ async function sleepAll(opts: SleepOptions): Promise<void> {
192
+ const entries = loadAllAssistants();
193
+ if (entries.length === 0) {
194
+ console.error("Error: no assistants found. Run 'cadet hatch' first.");
195
+ process.exit(1);
196
+ }
197
+
198
+ let failures = 0;
199
+ const seen = new Set<string>();
200
+ for (const entry of entries) {
201
+ if (seen.has(entry.assistantId)) continue;
202
+ seen.add(entry.assistantId);
203
+ if (entry.cloud === "apple-container" || entry.cloud === "paired") {
204
+ console.log(
205
+ `Skipping '${entry.assistantId}' (${entry.cloud}) — its lifecycle is managed elsewhere.`,
206
+ );
207
+ continue;
208
+ }
209
+ try {
210
+ await sleepAssistant(entry, opts);
211
+ } catch (err) {
212
+ if (err instanceof SleepExit) {
213
+ // A cancelled drain is the user asking to stop — honor it immediately.
214
+ console.log(err.message);
215
+ process.exit(err.exitCode);
216
+ }
217
+ failures++;
218
+ console.error(
219
+ `Error: failed to stop '${entry.assistantId}': ${
220
+ err instanceof Error ? err.message : String(err)
221
+ }`,
222
+ );
223
+ }
224
+ }
225
+ if (failures > 0) {
226
+ process.exit(1);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Stop one assistant. Throws on hard errors (active call leases, missing
232
+ * resources, unmanageable runtimes) so the caller decides between exiting
233
+ * (single-assistant path) and continuing to the next entry (`--all`).
234
+ */
235
+ async function sleepAssistant(
236
+ entry: AssistantEntry,
237
+ opts: SleepOptions,
238
+ ): Promise<void> {
239
+ const { force, wait, waitMs } = opts;
132
240
 
133
241
  if (entry.cloud === "docker") {
134
242
  if (wait) {
@@ -146,31 +254,27 @@ export async function sleep(): Promise<void> {
146
254
  }
147
255
 
148
256
  if (entry.cloud === "apple-container") {
149
- console.error(
150
- `Error: '${entry.assistantId}' uses the Apple Containers runtime. Its lifecycle is managed by the macOS app — use the app to stop it.`,
257
+ throw new Error(
258
+ `'${entry.assistantId}' uses the Apple Containers runtime. Its lifecycle is managed by the macOS app — use the app to stop it.`,
151
259
  );
152
- process.exit(1);
153
260
  }
154
261
 
155
262
  if (entry.cloud === "paired") {
156
- console.error(
157
- `Error: '${entry.assistantId}' is a remote assistant paired from another machine — its lifecycle is managed on its host machine, not here. Use \`cadet client ${entry.assistantId}\` to chat with it.`,
263
+ throw new Error(
264
+ `'${entry.assistantId}' is a remote assistant paired from another machine — its lifecycle is managed on its host machine, not here. Use \`cadet client ${entry.assistantId}\` to chat with it.`,
158
265
  );
159
- process.exit(1);
160
266
  }
161
267
 
162
268
  if (entry.cloud && entry.cloud !== "local") {
163
- console.error(
164
- `Error: 'cadet sleep' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
269
+ throw new Error(
270
+ `'cadet sleep' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
165
271
  );
166
- process.exit(1);
167
272
  }
168
273
 
169
274
  if (!entry.resources) {
170
- console.error(
171
- `Error: Local assistant '${entry.assistantId}' is missing resource configuration. Re-hatch to fix.`,
275
+ throw new Error(
276
+ `Local assistant '${entry.assistantId}' is missing resource configuration. Re-hatch to fix.`,
172
277
  );
173
- process.exit(1);
174
278
  }
175
279
  const resources = entry.resources;
176
280
  const assistantPidFile = getDaemonPidPath(resources);
@@ -180,26 +284,23 @@ export async function sleep(): Promise<void> {
180
284
  if (!force) {
181
285
  const assistantAlive = isProcessAlive(assistantPidFile).alive;
182
286
  if (assistantAlive) {
287
+ let leases: ActiveCallLease[];
183
288
  try {
184
- const activeCallLeases = readActiveCallLeases(cadetDir);
185
- if (activeCallLeases.length > 0) {
186
- const activeIds = activeCallLeases.map(
187
- (lease) => lease.callSessionId,
188
- );
189
- console.error(
190
- `Error: assistant is staying awake for active phone calls (${activeIds.join(
191
- ", ",
192
- )}). Use 'cadet sleep --force' to stop it anyway.`,
193
- );
194
- process.exit(1);
195
- }
289
+ leases = readActiveCallLeases(cadetDir);
196
290
  } catch (err) {
197
- console.error(
198
- `Error: ${
291
+ throw new Error(
292
+ `${
199
293
  err instanceof Error ? err.message : String(err)
200
294
  }. Use 'cadet sleep --force' to override if you want to stop the assistant anyway.`,
201
295
  );
202
- process.exit(1);
296
+ }
297
+ if (leases.length > 0) {
298
+ const activeIds = leases.map((lease) => lease.callSessionId);
299
+ throw new Error(
300
+ `assistant is staying awake for active phone calls (${activeIds.join(
301
+ ", ",
302
+ )}). Use 'cadet sleep --force' to stop it anyway.`,
303
+ );
203
304
  }
204
305
  }
205
306
  }
@@ -207,8 +308,7 @@ export async function sleep(): Promise<void> {
207
308
  if (wait) {
208
309
  const outcome = await runDrainPhase(entry, assistantPidFile, waitMs);
209
310
  if (outcome === "cancelled") {
210
- console.log("Sleep cancelled — the assistant is still running.");
211
- process.exit(130);
311
+ throw new SleepExit(130, "Sleep cancelled — the assistant is still running.");
212
312
  }
213
313
  }
214
314
 
@@ -18,7 +18,8 @@ const SSH_OPTS = [
18
18
  "LogLevel=ERROR",
19
19
  ];
20
20
 
21
- export async function ssh(): Promise<void> {
21
+ export const ssh = Object.assign(
22
+ async function ssh(): Promise<void> {
22
23
  const args = process.argv.slice(3);
23
24
  if (args.includes("--help") || args.includes("-h")) {
24
25
  console.log("Usage: cadet ssh [<name>]");
@@ -137,4 +138,7 @@ export async function ssh(): Promise<void> {
137
138
  });
138
139
  child.on("error", reject);
139
140
  });
140
- }
141
+ },
142
+ { description: "SSH into a remote assistant instance" },
143
+ );
144
+
@@ -1274,7 +1274,8 @@ async function tryInjectPlatformCredentials(
1274
1274
  // Main entry point
1275
1275
  // ---------------------------------------------------------------------------
1276
1276
 
1277
- export async function teleport(): Promise<void> {
1277
+ export const teleport = Object.assign(
1278
+ async function teleport(): Promise<void> {
1278
1279
  const args = process.argv.slice(3);
1279
1280
  const { from, to, targetEnv, targetName, keepSource, dryRun, help } =
1280
1281
  parseArgs(args);
@@ -1664,4 +1665,7 @@ export async function teleport(): Promise<void> {
1664
1665
  }
1665
1666
 
1666
1667
  console.log(`Teleport complete: ${from} → ${toEntry.assistantId}`);
1667
- }
1668
+ },
1669
+ { description: "Transfer assistant data between environments" },
1670
+ );
1671
+
@@ -159,7 +159,8 @@ async function listTmuxSessions(
159
159
  // Main entry point
160
160
  // ---------------------------------------------------------------------------
161
161
 
162
- export async function terminal(): Promise<void> {
162
+ export const terminal = Object.assign(
163
+ async function terminal(): Promise<void> {
163
164
  const args = process.argv.slice(3);
164
165
 
165
166
  if (args.includes("--help") || args.includes("-h")) {
@@ -222,4 +223,7 @@ export async function terminal(): Promise<void> {
222
223
 
223
224
  // Default: interactive shell
224
225
  await interactiveSession(assistant);
225
- }
226
+ },
227
+ { description: "Open a terminal into a managed assistant container" },
228
+ );
229
+
@@ -440,7 +440,8 @@ async function spawnDetachedTunnel(): Promise<void> {
440
440
  }
441
441
  }
442
442
 
443
- export async function tunnel(): Promise<void> {
443
+ export const tunnel = Object.assign(
444
+ async function tunnel(): Promise<void> {
444
445
  const { assistantName, provider, domain, clearDomain, detach } = parseArgs();
445
446
 
446
447
  if (detach) {
@@ -506,4 +507,7 @@ export async function tunnel(): Promise<void> {
506
507
  ...baseTunnelOpts,
507
508
  ...(preserveIngressUrl ? { preserveIngressUrl } : {}),
508
509
  });
509
- }
510
+ },
511
+ { description: "Create a tunnel for a locally hosted assistant" },
512
+ );
513
+
@@ -0,0 +1,228 @@
1
+ import { existsSync, readFileSync, renameSync, rmSync, unlinkSync } from "fs";
2
+ import { basename, dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ import { loadAllAssistants } from "../lib/assistant-config.js";
6
+ import {
7
+ canPromptForConfirmation,
8
+ confirmAction,
9
+ } from "../lib/confirm-action.js";
10
+ import {
11
+ getConfigDirs,
12
+ getInputHistoryPath,
13
+ getLogDir,
14
+ getStateDir,
15
+ } from "../lib/environments/paths.js";
16
+ import { getCurrentEnvironment } from "../lib/environments/resolve.js";
17
+
18
+ /** A directory (or file) scheduled for deletion, with what it holds. */
19
+ interface DeleteTarget {
20
+ path: string;
21
+ description: string;
22
+ }
23
+
24
+ /**
25
+ * Owning packages a global install of the CLI can live under, in priority
26
+ * order — @mycadet/cli always wins over its meta wrapper.
27
+ */
28
+ const OWNED_PACKAGE_NAMES: ReadonlyArray<string> = [
29
+ "@mycadet/cli",
30
+ "@mycadet/meta",
31
+ ];
32
+
33
+ /**
34
+ * Find the package root the CLI is running from (…/node_modules/mycadet),
35
+ * or null when running from a source tree. A `.git` ANYWHERE up the tree
36
+ * (repo root or monorepo parent — a checkout's cli/ subpackage has no .git
37
+ * of its own) means this is source, never an install.
38
+ */
39
+ function findInstalledPackageRoot(startDir: string): string | null {
40
+ // Pass 1: any .git ancestor = a checkout, never an install.
41
+ for (let dir = startDir; ; dir = dirname(dir)) {
42
+ if (existsSync(join(dir, ".git"))) return null;
43
+ const parent = dirname(dir);
44
+ if (parent === dir) break; // Reached the filesystem root.
45
+ }
46
+ // Pass 2: walk up looking for the owned package root, priority order —
47
+ // the whole chain is checked for each name in turn, so @mycadet/cli wins
48
+ // over @mycadet/meta wherever both appear.
49
+ for (const owned of OWNED_PACKAGE_NAMES) {
50
+ for (let dir = startDir; ; dir = dirname(dir)) {
51
+ const pkgPath = join(dir, "package.json");
52
+ if (existsSync(pkgPath)) {
53
+ try {
54
+ const parsed: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
55
+ if (
56
+ typeof parsed === "object" &&
57
+ parsed !== null &&
58
+ "name" in parsed &&
59
+ parsed.name === owned
60
+ ) {
61
+ return dir;
62
+ }
63
+ } catch {
64
+ // Unreadable package.json — keep walking up.
65
+ }
66
+ }
67
+ const parent = dirname(dir);
68
+ if (parent === dir) break;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Remove the running CLI. Handles the two real install shapes:
76
+ * - the compiled `cadet` binary (process.execPath IS the install)
77
+ * - a global package install (bun install -g mycadet / npm install -g)
78
+ * Under a source checkout or a bare runtime (bun/node), nothing is deleted —
79
+ * the runtime binary must never be a deletion target.
80
+ */
81
+ function selfDelete(): void {
82
+ const execPath = process.execPath;
83
+ const execBase = basename(execPath);
84
+ if (execBase === "cadet" || execBase === "cadet.exe") {
85
+ try {
86
+ if (process.platform === "win32") {
87
+ // Windows refuses to unlink a running executable — stage it under a
88
+ // junk name and delete that.
89
+ const stale = `${execPath}.uninstalled`;
90
+ renameSync(execPath, stale);
91
+ rmSync(stale, { force: true, recursive: true });
92
+ } else {
93
+ unlinkSync(execPath);
94
+ }
95
+ console.log(`Removed ${execPath}.`);
96
+ return;
97
+ } catch (err) {
98
+ console.warn(
99
+ `⚠️ Could not remove ${execPath}: ${
100
+ err instanceof Error ? err.message : String(err)
101
+ } — remove it by hand.`,
102
+ );
103
+ return;
104
+ }
105
+ }
106
+
107
+ // Package-manager install: remove the package directory we are running
108
+ // from, best-effort. Bin symlinks the package manager planted (e.g.
109
+ // ~/.bun/bin/cadet, the npm global bin) are left dangling — say so.
110
+ const runningFile = fileURLToPath(import.meta.url);
111
+ const pkgRoot = findInstalledPackageRoot(dirname(runningFile));
112
+ if (!pkgRoot) {
113
+ console.log(
114
+ "Cadet is running from a source checkout — skipping self-removal.",
115
+ );
116
+ console.log(
117
+ "Remove the installed copy with `bun remove -g mycadet` (or `npm uninstall -g mycadet`).",
118
+ );
119
+ return;
120
+ }
121
+ try {
122
+ rmSync(pkgRoot, { recursive: true, force: true });
123
+ console.log(`Removed ${pkgRoot}.`);
124
+ console.log(
125
+ "The `cadet` command on your PATH is now stale — remove it if your package manager didn't (`bun remove -g mycadet` or `npm uninstall -g mycadet`).",
126
+ );
127
+ } catch (err) {
128
+ console.warn(
129
+ `⚠️ Could not remove ${pkgRoot}: ${
130
+ err instanceof Error ? err.message : String(err)
131
+ } — remove it with \`bun remove -g mycadet\` (or \`npm uninstall -g mycadet\`).`,
132
+ );
133
+ }
134
+ }
135
+
136
+ function collectDeleteTargets(env = getCurrentEnvironment()): DeleteTarget[] {
137
+ const targets: DeleteTarget[] = [
138
+ {
139
+ path: getStateDir(env),
140
+ description: "assistant state, retired archives, and the assistants it holds",
141
+ },
142
+ ...getConfigDirs(env).map((dir) => ({
143
+ path: dir,
144
+ description: "config and lockfile",
145
+ })),
146
+ { path: getLogDir(env), description: "logs" },
147
+ // Not environment-scoped: deleting it affects every cadet CLI on the
148
+ // machine — called out in the prompt, not hidden.
149
+ { path: getInputHistoryPath(), description: "shared terminal input history" },
150
+ ];
151
+ // Only targets that exist — the prompt lists what will actually go.
152
+ return targets.filter((t) => existsSync(t.path));
153
+ }
154
+
155
+ function rmrf(target: string): void {
156
+ rmSync(target, { recursive: true, force: true });
157
+ }
158
+
159
+ export const uninstall = Object.assign(
160
+ async function uninstall(): Promise<void> {
161
+ const args = process.argv.slice(3);
162
+ if (args.includes("--help") || args.includes("-h")) {
163
+ console.log("Usage: cadet uninstall [--yes]");
164
+ console.log("");
165
+ console.log(
166
+ "Remove Cadet entirely: delete every location it uses for this environment (assistant state, retired archives, config, logs) and then delete the executable itself.",
167
+ );
168
+ process.exit(0);
169
+ }
170
+ const yes = args.includes("--yes");
171
+
172
+ // Retire tears down an assistant AND archives its data. Uninstall assumes
173
+ // an empty rack: retiring first is the way to keep an archive.
174
+ if (loadAllAssistants().length > 0) {
175
+ console.error(
176
+ "Error: You still have assistants. Retire them first (`cadet retire <name>`) — retiring archives their data before removing it.",
177
+ );
178
+ process.exit(1);
179
+ }
180
+
181
+ const env = getCurrentEnvironment();
182
+ const envLabel = env.name ?? "production";
183
+ const targets = collectDeleteTargets(env);
184
+ const execPath = process.execPath;
185
+
186
+ console.log(
187
+ `This removes Cadet entirely for the '${envLabel}' environment and cannot be undone.`,
188
+ );
189
+ if (targets.length === 0) {
190
+ console.log("No Cadet data directories found to delete.");
191
+ } else {
192
+ console.log("It will permanently delete:");
193
+ for (const t of targets) {
194
+ console.log(` - ${t.path} (${t.description})`);
195
+ }
196
+ }
197
+ const execBase = basename(execPath);
198
+ const isCompiledBinary = execBase === "cadet" || execBase === "cadet.exe";
199
+ if (isCompiledBinary) {
200
+ console.log(`It will then remove the Cadet executable at ${execPath}.`);
201
+ }
202
+
203
+ if (!yes) {
204
+ if (!canPromptForConfirmation()) {
205
+ console.error(
206
+ "Error: Refusing to uninstall without confirmation in a non-interactive terminal.",
207
+ );
208
+ console.error("Re-run with --yes to confirm from automation.");
209
+ process.exit(1);
210
+ }
211
+ const confirmed = await confirmAction(
212
+ "Press Enter to uninstall Cadet, or Esc/q to cancel: ",
213
+ );
214
+ if (!confirmed) {
215
+ console.log("Uninstall cancelled.");
216
+ process.exit(1);
217
+ }
218
+ }
219
+
220
+ for (const t of targets) {
221
+ rmrf(t.path);
222
+ console.log(`Deleted ${t.path}.`);
223
+ }
224
+
225
+ selfDelete();
226
+ },
227
+ { description: "Remove Cadet entirely" },
228
+ );
@@ -46,7 +46,8 @@ EXAMPLES:
46
46
  `);
47
47
  }
48
48
 
49
- export async function unpair(): Promise<void> {
49
+ export const unpair = Object.assign(
50
+ async function unpair(): Promise<void> {
50
51
  const args = process.argv.slice(3);
51
52
 
52
53
  if (args.includes("--help") || args.includes("-h")) {
@@ -115,4 +116,7 @@ export async function unpair(): Promise<void> {
115
116
  console.log(
116
117
  "Note: this only forgets the connection on this machine. The assistant's host can fully revoke this device from its side.",
117
118
  );
118
- }
119
+ },
120
+ { description: "Forget a paired assistant imported from another machine [beta]" },
121
+ );
122
+