carrick 0.3.96 → 0.3.98

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 (49) hide show
  1. package/README.md +42 -282
  2. package/bin/carrick.mjs +19 -14
  3. package/dist/auth/run.d.ts +1 -1
  4. package/dist/auth/run.js +1 -1
  5. package/dist/contract.d.ts +15 -0
  6. package/dist/contract.js.map +1 -1
  7. package/dist/global-install.js +6 -5
  8. package/dist/global-install.js.map +1 -1
  9. package/dist/hook/reuse.d.ts +8 -3
  10. package/dist/hook/reuse.js +15 -3
  11. package/dist/hook/reuse.js.map +1 -1
  12. package/dist/init/connect.js +1 -1
  13. package/dist/init/connect.js.map +1 -1
  14. package/dist/init/doctor.js +5 -2
  15. package/dist/init/doctor.js.map +1 -1
  16. package/dist/init/hosted.d.ts +18 -1
  17. package/dist/init/hosted.js +84 -16
  18. package/dist/init/hosted.js.map +1 -1
  19. package/dist/init/install-id.d.ts +2 -2
  20. package/dist/init/install-id.js +3 -3
  21. package/dist/init/install-id.js.map +1 -1
  22. package/dist/init/mcp.d.ts +2 -2
  23. package/dist/init/mcp.js +2 -2
  24. package/dist/init/mcp.js.map +1 -1
  25. package/dist/init/outdated.d.ts +1 -1
  26. package/dist/init/outdated.js +1 -1
  27. package/dist/init/outdated.js.map +1 -1
  28. package/dist/init/output.d.ts +18 -1
  29. package/dist/init/output.js +40 -1
  30. package/dist/init/output.js.map +1 -1
  31. package/dist/init/remove.d.ts +71 -2
  32. package/dist/init/remove.js +474 -145
  33. package/dist/init/remove.js.map +1 -1
  34. package/dist/init/repo-copies.d.ts +16 -4
  35. package/dist/init/repo-copies.js +20 -6
  36. package/dist/init/repo-copies.js.map +1 -1
  37. package/dist/init/run.d.ts +12 -16
  38. package/dist/init/run.js +25 -42
  39. package/dist/init/run.js.map +1 -1
  40. package/dist/update.js +1 -0
  41. package/dist/update.js.map +1 -1
  42. package/package.json +7 -7
  43. package/plugin/.claude-plugin/plugin.json +1 -1
  44. package/sidecar/dist/src/client-semantics.d.ts +36 -0
  45. package/sidecar/dist/src/client-semantics.js +1008 -0
  46. package/sidecar/dist/src/index.js +35 -0
  47. package/sidecar/dist/src/types.d.ts +83 -2
  48. package/sidecar/dist/src/validators.d.ts +463 -0
  49. package/sidecar/dist/src/validators.js +50 -0
@@ -1,16 +1,25 @@
1
- // Undoing `carrick init`, on this machine.
1
+ // Undoing `carrick init`, in two halves (carrick#1573).
2
2
  //
3
- // An install that can state its own undo is the courtesy that makes people
4
- // willing to try it (carrick#1034), and init now writes in eleven places: the
5
- // hook entries in a workspace's `.claude` settings, the Codex ones in
6
- // `.codex/hooks.json`, the task skills under `.claude/skills` and
7
- // `.agents/skills`, a copy of those hooks and skills inside each repo of a
8
- // folder with the `.git/info/exclude` lines that keep it out of git
9
- // (`writeRepoCopy` / `removeRepoCopy` in `repo-copies.ts`), the repo
10
- // selection in `carrick-workspace.json`, an MCP
11
- // server entry in each agent client's own configuration, the `.carrick`
12
- // directory, the install id in `~/.carrick`, and the credential in the user's
13
- // configuration directory.
3
+ // `carrick remove` undoes what init wrote in one folder: the hook entries in
4
+ // that folder's `.claude` settings and in `.codex/hooks.json`, the task skills
5
+ // under `.claude/skills` and `.agents/skills`, a copy of those hooks and skills
6
+ // inside each repo of a folder with the `.git/info/exclude` lines that keep it
7
+ // out of git (`writeRepoCopy` / `removeRepoCopy` in `repo-copies.ts`), the repo
8
+ // selection in `carrick-workspace.json`, and the `.carrick` directory.
9
+ //
10
+ // `carrick uninstall` undoes what init wrote on this machine, which every
11
+ // folder set up on it shares: an MCP server entry in each agent client's own
12
+ // configuration, the install id, the session records and the refresh notice in
13
+ // `~/.carrick`, and the credential in the user's configuration directory.
14
+ //
15
+ // They were one command, and running it to reset one folder disconnected the
16
+ // agent in every other folder on the machine and signed the machine out. Each
17
+ // now prints the whole list of what it will delete, as `init` does before "Go
18
+ // ahead?", and deletes nothing until it has a typed answer: the folder's name
19
+ // for `remove`, the word `uninstall` for `uninstall`, as the dashboard's
20
+ // project delete asks for the slug. Without a terminal the answer is
21
+ // `--confirm <answer>`, never `--yes`: to `init`, `--yes` means "take the
22
+ // proposal", and an agent adds it by reflex.
14
23
  //
15
24
  // Every step here is the inverse of a writer in this folder, and each pair
16
25
  // lives in one file so the two cannot drift: `mergeCarrickHooks` /
@@ -19,9 +28,12 @@
19
28
  // `removeServerEntry` and `connectMcpClients` / `disconnectMcpClients` in
20
29
  // `mcp.ts`, `ensureInstallId` / `removeInstallId` in `install-id.ts`,
21
30
  // `writeProposal` / the `.carrick` removal below, `saveCredential` /
22
- // `removeCredential` in `../auth/credentials.ts`.
31
+ // `removeCredential` in `../auth/credentials.ts`. The list each command prints
32
+ // is made by the read half of the same pairs (`recordedRepoCopy`,
33
+ // `inspectTaskSkills`, `withoutOurExclusions`, `inspectMcpClients`), and the
34
+ // removal then acts on what the list named and nothing else.
23
35
  //
24
- // What it does NOT do is edit a file whose content it cannot reproduce. The
36
+ // What neither does is edit a file whose content it cannot reproduce. The
25
37
  // scaffold pull request put files in the repository and a repository is
26
38
  // version controlled: those are listed with the `git rm` line that removes
27
39
  // them, and the sections that were merged into files someone else owns are
@@ -34,30 +46,37 @@
34
46
  // nothing of anyone's is in it. One whose stamp no longer matches, and one
35
47
  // carrying no stamp, are left where they are and named.
36
48
  //
37
- // It runs no scan and never asks for the scanner binary: everything it touches
38
- // is a file this package wrote.
49
+ // Neither runs a scan or asks for the scanner binary: everything they touch is
50
+ // a file this package wrote.
39
51
  import fs from "node:fs";
40
52
  import path from "node:path";
41
53
  import { removeCredential, credentialPath } from "../auth/credentials.js";
42
54
  import { installIdPath, removeInstallId } from "./install-id.js";
43
- import { removeSessions, sessionsDir } from "../hook/reuse.js";
44
- import { disconnectMcpClients } from "./mcp.js";
55
+ import { removeSessions, sessionRecords, sessionsDir } from "../hook/reuse.js";
56
+ import { disconnectMcpClients, inspectMcpClients } from "./mcp.js";
45
57
  import { repoRoots } from "./repos.js";
46
58
  import { removeCarrickHooks, SETTINGS_FILES } from "./settings.js";
47
- import { CODEX_HOOKS_FILE, uninstallCodexHooks } from "./codex.js";
48
- import { removeTaskSkills, SKILL_ROOTS } from "./task-skills.js";
49
- import { removeNotice } from "./outdated.js";
50
- import { removeSelection, WORKSPACE_FILE } from "./workspace-file.js";
51
- import { removeRepoCopy } from "./repo-copies.js";
52
- import { createOutput, DOCS_INIT_FILES } from "./output.js";
53
- export function parseArgs(argv, cwd = process.cwd()) {
54
- const options = { workspace: cwd, keepLogin: false };
59
+ import { CODEX_HOOKS_FILE, isEmptyHooksDocument, readHooksFile, uninstallCodexHooks } from "./codex.js";
60
+ import { inspectTaskSkills, removeTaskSkills, SKILL_ROOTS } from "./task-skills.js";
61
+ import { noticeFile, removeNotice } from "./outdated.js";
62
+ import { readWorkspaceFile, removeSelection, withoutOurExclusions, WORKSPACE_FILE } from "./workspace-file.js";
63
+ import { recordedRepoCopy, removeRepoCopy } from "./repo-copies.js";
64
+ import { createOutput, DOCS_INIT_FILES, listBlock, PromptCancelled, tilde } from "./output.js";
65
+ /** The word `carrick uninstall` asks to have typed. */
66
+ export const UNINSTALL_ANSWER = "uninstall";
67
+ export function parseRemoveArgs(argv, cwd = process.cwd()) {
68
+ const options = { workspace: cwd, confirm: null };
55
69
  for (let index = 0; index < argv.length; index += 1) {
56
70
  const argument = argv[index];
57
71
  switch (argument) {
58
- case "--keep-login":
59
- options.keepLogin = true;
72
+ case "--confirm": {
73
+ const value = argv[index + 1];
74
+ if (value === undefined)
75
+ return "--confirm needs this folder's name";
76
+ options.confirm = value;
77
+ index += 1;
60
78
  break;
79
+ }
61
80
  case "--workspace":
62
81
  case "-w": {
63
82
  const value = argv[index + 1];
@@ -69,7 +88,7 @@ export function parseArgs(argv, cwd = process.cwd()) {
69
88
  }
70
89
  case "--help":
71
90
  case "-h":
72
- return help();
91
+ return removeHelp();
73
92
  default:
74
93
  if (argument?.startsWith("-"))
75
94
  return `unknown option for \`carrick remove\`: ${argument}`;
@@ -78,24 +97,74 @@ export function parseArgs(argv, cwd = process.cwd()) {
78
97
  }
79
98
  return options;
80
99
  }
81
- function help() {
100
+ export function parseUninstallArgs(argv) {
101
+ const options = { keepLogin: false, confirm: null };
102
+ for (let index = 0; index < argv.length; index += 1) {
103
+ const argument = argv[index];
104
+ switch (argument) {
105
+ case "--keep-login":
106
+ options.keepLogin = true;
107
+ break;
108
+ case "--confirm": {
109
+ const value = argv[index + 1];
110
+ if (value === undefined)
111
+ return `--confirm needs the word ${UNINSTALL_ANSWER}`;
112
+ options.confirm = value;
113
+ index += 1;
114
+ break;
115
+ }
116
+ case "--help":
117
+ case "-h":
118
+ return uninstallHelp();
119
+ default:
120
+ if (argument?.startsWith("-"))
121
+ return `unknown option for \`carrick uninstall\`: ${argument}`;
122
+ return `\`carrick uninstall\` takes no directory: it acts on this machine. \`carrick remove ${argument}\` acts on one folder.`;
123
+ }
124
+ }
125
+ return options;
126
+ }
127
+ function removeHelp() {
82
128
  return [
83
- "carrick remove [DIRECTORY] [--keep-login]",
129
+ "carrick remove [DIRECTORY] [--confirm NAME]",
84
130
  "",
85
- "Undo what carrick init wrote on this machine: the Carrick hook entries in",
86
- "this folder's .claude settings and in .codex/hooks.json, the hooks and",
87
- "skills it copied into each repo here with their .git/info/exclude lines,",
88
- "the carrick MCP server in each agent client's configuration, the .carrick",
89
- "directory, this machine's install id, and the saved credential.",
90
- "Other hooks, other MCP servers and the settings files themselves are left",
91
- "as they are, and so is anything in carrick-workspace.json that init did",
92
- "not put there. The task skills it wrote are removed where they still match",
93
- "the stamp it wrote them with, and named where they do not. Files the",
94
- "scaffold added to the repository are listed with the git rm line that",
95
- "removes them.",
131
+ "Undo what carrick init wrote in this folder, and nowhere else: the Carrick",
132
+ "hook entries in its .claude settings and in .codex/hooks.json, the hooks and",
133
+ "skills it copied into each repo here with their .git/info/exclude lines, the",
134
+ "repo selection in carrick-workspace.json, and the .carrick directory.",
135
+ "Other hooks and the settings files themselves are left as they are, and so",
136
+ "is anything in carrick-workspace.json that init did not put there. The task",
137
+ "skills it wrote are removed where they still match the stamp it wrote them",
138
+ "with, and named where they do not. Files the scaffold added to the",
139
+ "repository are listed with the git rm line that removes them.",
140
+ "",
141
+ "It lists what it will delete, then asks for this folder's name. The MCP",
142
+ "server, the install id and the sign-in belong to this machine and stay;",
143
+ "carrick uninstall removes them.",
96
144
  "",
97
145
  " -w, --workspace DIR The folder init was run in (default: this one)",
146
+ " --confirm NAME This folder's name, as the answer to the question.",
147
+ " Required where there is no terminal to ask in",
148
+ "",
149
+ `What each of those things is, and what it does: ${DOCS_INIT_FILES}`,
150
+ ].join("\n");
151
+ }
152
+ function uninstallHelp() {
153
+ return [
154
+ `carrick uninstall [--keep-login] [--confirm ${UNINSTALL_ANSWER}]`,
155
+ "",
156
+ "Undo what carrick init wrote on this machine, which every folder set up on",
157
+ "it shares: the carrick MCP server in each agent client's configuration, this",
158
+ "machine's install id, the session records and the refresh notice in",
159
+ "~/.carrick, and the saved credential. Other MCP servers are left as they",
160
+ "are. Each folder keeps its hooks and skills; carrick remove, run in that",
161
+ "folder, takes them out.",
162
+ "",
163
+ `It lists what it will delete, then asks you to type ${UNINSTALL_ANSWER}.`,
164
+ "",
98
165
  " --keep-login Leave the saved credential; remove everything else",
166
+ ` --confirm WORD ${UNINSTALL_ANSWER}, as the answer to the question. Required`,
167
+ " where there is no terminal to ask in",
99
168
  "",
100
169
  `What each of those things is, and what it does: ${DOCS_INIT_FILES}`,
101
170
  ].join("\n");
@@ -182,6 +251,121 @@ export function mcpRemovalLines(removals) {
182
251
  }
183
252
  return { done, warn };
184
253
  }
254
+ /** A repo under the folder as the reader knows it: its path from the folder, or its own name. */
255
+ function repoName(workspace, repo) {
256
+ return path.relative(workspace, repo) || path.basename(repo);
257
+ }
258
+ /** The answer `carrick remove` asks for: the folder's own name. */
259
+ export function folderName(workspace) {
260
+ return path.basename(workspace) || workspace;
261
+ }
262
+ /** A word as it would be typed in a shell, quoted only where it has to be. */
263
+ function typed(word) {
264
+ return /^[\w.@%+=:,/-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
265
+ }
266
+ export function folderPlan(workspace) {
267
+ const plan = {
268
+ workspace,
269
+ copies: [],
270
+ settings: [],
271
+ codex: null,
272
+ skills: [],
273
+ selection: null,
274
+ carrick: false,
275
+ leaves: [],
276
+ };
277
+ // A copy is taken back whole, and first: this folder may itself be one of
278
+ // the repos, and its copy's files are then not listed a second time below
279
+ // (carrick#1512).
280
+ const covered = new Set();
281
+ for (const repo of repoRoots(workspace)) {
282
+ try {
283
+ const copy = recordedRepoCopy(repo);
284
+ if (copy === null)
285
+ continue;
286
+ plan.copies.push(repo);
287
+ for (const relative of copy.recorded)
288
+ covered.add(path.join(repo, relative));
289
+ }
290
+ catch (error) {
291
+ plan.leaves.push(`Could not read the .git/info/exclude of ${repoName(workspace, repo)}: ${error.message}. Its copy of Carrick's hooks and skills stays.`);
292
+ }
293
+ }
294
+ for (const relative of SETTINGS_FILES) {
295
+ const file = path.join(workspace, relative);
296
+ if (covered.has(file) || !fs.existsSync(file))
297
+ continue;
298
+ try {
299
+ if (removeCarrickHooks(fs.readFileSync(file, "utf8")).changed)
300
+ plan.settings.push(relative);
301
+ }
302
+ catch (error) {
303
+ plan.leaves.push(`Could not read ${relative}: ${error.message}. Remove the carrick hook entries there by hand.`);
304
+ }
305
+ }
306
+ const codex = covered.has(path.join(workspace, CODEX_HOOKS_FILE)) ? null : readHooksFile(workspace);
307
+ if (codex !== null) {
308
+ try {
309
+ const cleaned = removeCarrickHooks(codex);
310
+ if (cleaned.changed)
311
+ plan.codex = isEmptyHooksDocument(cleaned.body) ? "file" : "entries";
312
+ }
313
+ catch (error) {
314
+ plan.leaves.push(`Could not read ${CODEX_HOOKS_FILE}: ${error.message}. Remove the carrick hook entries there by hand.`);
315
+ }
316
+ }
317
+ // Only the stamped, unchanged copies. Anything a user has made their own is
318
+ // named rather than deleted, which is the same rule the install follows.
319
+ for (const skill of inspectTaskSkills(workspace)) {
320
+ if (skill.state === "ours") {
321
+ if (!covered.has(path.join(workspace, skill.path)))
322
+ plan.skills.push(skill.path);
323
+ }
324
+ else if (skill.state === "edited") {
325
+ plan.leaves.push(`${skill.path} has been edited since Carrick wrote it, so it stays. Delete it by hand to finish removing it.`);
326
+ }
327
+ else if (skill.state === "theirs") {
328
+ plan.leaves.push(`${skill.path} was not written by Carrick, so it stays.`);
329
+ }
330
+ }
331
+ // The repo selection, which is the one thing init writes into a file a user
332
+ // can also write to (carrick#1344). Only the names init put in the exclude
333
+ // list come out; a repo somebody excluded by hand stays excluded, and the
334
+ // file stays unless init is the whole reason it is there.
335
+ const selection = readWorkspaceFile(workspace);
336
+ if (selection !== null) {
337
+ try {
338
+ const cleaned = withoutOurExclusions(selection);
339
+ if (cleaned.removed.length > 0)
340
+ plan.selection = { names: cleaned.removed, file: cleaned.empty };
341
+ }
342
+ catch (error) {
343
+ plan.leaves.push(`Could not read ${WORKSPACE_FILE}: ${error.message}. Take the carrick exclusions out by hand.`);
344
+ }
345
+ }
346
+ plan.carrick = fs.existsSync(path.join(workspace, ".carrick"));
347
+ return plan;
348
+ }
349
+ /** The list `carrick remove` prints before it asks: one line per thing it deletes. */
350
+ export function folderLines(plan) {
351
+ const lines = plan.copies.map((repo) => `Carrick's hooks and skills in ${repoName(plan.workspace, repo)}, with its .git/info/exclude lines`);
352
+ for (const relative of plan.settings)
353
+ lines.push(`Carrick's hook entries in ${relative}`);
354
+ if (plan.codex === "file")
355
+ lines.push(`${CODEX_HOOKS_FILE}, which holds only Carrick's hook entries`);
356
+ if (plan.codex === "entries")
357
+ lines.push(`Carrick's hook entries in ${CODEX_HOOKS_FILE}`);
358
+ if (plan.skills.length > 0)
359
+ lines.push(`${plan.skills.length} task skill file(s) in ${SKILL_ROOTS.join(" and ")}`);
360
+ if (plan.selection !== null) {
361
+ lines.push(plan.selection.file
362
+ ? `${WORKSPACE_FILE}, which holds only the repo selection init wrote`
363
+ : `${plan.selection.names.join(", ")} from the exclude list in ${WORKSPACE_FILE}`);
364
+ }
365
+ if (plan.carrick)
366
+ lines.push(".carrick, with the proposal and the index in it");
367
+ return lines;
368
+ }
185
369
  /** Strip our hook entries from one settings file. Null when there was nothing. */
186
370
  function removeHooks(file) {
187
371
  if (!fs.existsSync(file))
@@ -193,46 +377,30 @@ function removeHooks(file) {
193
377
  fs.writeFileSync(file, cleaned.body);
194
378
  return file;
195
379
  }
196
- export async function remove(argv, out = createOutput()) {
197
- const parsed = parseArgs(argv);
198
- if (typeof parsed === "string") {
199
- process.stdout.write(`${parsed}\n`);
200
- return parsed.startsWith("carrick remove") ? 0 : 2;
201
- }
202
- const { workspace } = parsed;
203
- if (!fs.existsSync(workspace)) {
204
- process.stderr.write(`carrick remove: ${workspace} is not a directory on this machine\n`);
205
- return 1;
206
- }
207
- // Each step reports its own failure and the run carries on: a settings file
208
- // someone hand-edited into invalid JSON must not leave the credential and
209
- // the MCP entries behind.
210
- let removed = 0;
211
- // The copies init put inside each repo of a folder (carrick#1512): exactly
212
- // the files its lines in that repo's `.git/info/exclude` name, and then the
213
- // lines. A repo with no such lines is one init never copied into. First,
214
- // because this folder may itself be one of those repos: the cleanup below
215
- // would empty its copy's settings file and leave it, and then take away the
216
- // line that kept it out of git.
217
- for (const repo of repoRoots(workspace)) {
218
- const name = path.relative(workspace, repo) || path.basename(repo);
380
+ /**
381
+ * Delete what the list named. Each step reports its own failure and the run
382
+ * carries on: a file that cannot be written must not leave the rest behind.
383
+ */
384
+ function removeFolder(plan, out) {
385
+ const { workspace } = plan;
386
+ // The copies first, for the reason `folderPlan` gives: the cleanup below
387
+ // would otherwise empty a copy's settings file and leave it, and then the
388
+ // copy's removal would take away the line that kept it out of git.
389
+ for (const repo of plan.copies) {
390
+ const name = repoName(workspace, repo);
219
391
  try {
220
392
  if (removeRepoCopy(repo) !== null) {
221
393
  out.done(`Carrick's hooks and skills removed from ${name}, with its .git/info/exclude lines`);
222
- removed += 1;
223
394
  }
224
395
  }
225
396
  catch (error) {
226
397
  out.refuse(`Could not remove Carrick's hooks and skills from ${name}: ${error.message}`);
227
398
  }
228
399
  }
229
- for (const relative of SETTINGS_FILES) {
230
- const file = path.join(workspace, relative);
400
+ for (const relative of plan.settings) {
231
401
  try {
232
- if (removeHooks(file) !== null) {
402
+ if (removeHooks(path.join(workspace, relative)) !== null)
233
403
  out.done(`Carrick hook entries removed from ${relative}`);
234
- removed += 1;
235
- }
236
404
  }
237
405
  catch (error) {
238
406
  out.refuse(`Could not read ${relative}: ${error.message}. Remove the carrick hook entries there by hand.`);
@@ -241,110 +409,232 @@ export async function remove(argv, out = createOutput()) {
241
409
  // Codex's half of the same pair. Unlike a settings file, this one exists
242
410
  // because init wrote it, so a file left holding no hooks at all goes with the
243
411
  // entries (carrick#1335).
244
- try {
245
- const codex = uninstallCodexHooks(workspace);
246
- if (codex !== null) {
247
- out.done(codex === "file removed"
248
- ? `${CODEX_HOOKS_FILE} removed, with the Carrick hook entries that were the whole of it`
249
- : `Carrick hook entries removed from ${CODEX_HOOKS_FILE}`);
250
- removed += 1;
412
+ if (plan.codex !== null) {
413
+ try {
414
+ const codex = uninstallCodexHooks(workspace);
415
+ if (codex !== null) {
416
+ out.done(codex === "file removed"
417
+ ? `${CODEX_HOOKS_FILE} removed, with the Carrick hook entries that were the whole of it`
418
+ : `Carrick hook entries removed from ${CODEX_HOOKS_FILE}`);
419
+ }
420
+ }
421
+ catch (error) {
422
+ out.refuse(`Could not read ${CODEX_HOOKS_FILE}: ${error.message}. Remove the carrick hook entries there by hand.`);
251
423
  }
252
424
  }
253
- catch (error) {
254
- out.refuse(`Could not read ${CODEX_HOOKS_FILE}: ${error.message}. Remove the carrick hook entries there by hand.`);
425
+ if (plan.skills.length > 0) {
426
+ try {
427
+ const skills = removeTaskSkills(workspace, (relative) => plan.skills.includes(relative));
428
+ if (skills.deleted.length > 0) {
429
+ out.done(`${skills.deleted.length} task skill file(s) removed from ${SKILL_ROOTS.join(" and ")}`);
430
+ }
431
+ }
432
+ catch (error) {
433
+ out.refuse(`Could not remove the task skills: ${error.message}`);
434
+ }
435
+ }
436
+ if (plan.selection !== null) {
437
+ try {
438
+ const selection = removeSelection(workspace);
439
+ if (selection !== null) {
440
+ out.done(selection.deleted
441
+ ? `${selection.file} removed, with the repo selection init wrote in it`
442
+ : `${selection.removed.join(", ")} taken out of the exclude list in ${selection.file}; the rest of the file is yours and was left`);
443
+ }
444
+ }
445
+ catch (error) {
446
+ out.refuse(`Could not read ${WORKSPACE_FILE}: ${error.message}. Take the carrick exclusions out by hand.`);
447
+ }
255
448
  }
256
- const { done, warn } = mcpRemovalLines(disconnectMcpClients());
257
- for (const line of done) {
258
- out.done(line);
259
- removed += 1;
449
+ if (plan.carrick) {
450
+ const carrick = path.join(workspace, ".carrick");
451
+ try {
452
+ fs.rmSync(carrick, { recursive: true, force: true });
453
+ out.done(".carrick removed, with the proposal and the index in it");
454
+ }
455
+ catch (error) {
456
+ out.refuse(`Could not remove ${carrick}: ${error.message}`);
457
+ }
458
+ }
459
+ }
460
+ export function machinePlan(keepLogin) {
461
+ const plan = {
462
+ mcp: [],
463
+ sessions: sessionRecords(),
464
+ notice: fs.existsSync(noticeFile()),
465
+ installId: fs.existsSync(installIdPath()),
466
+ credential: null,
467
+ leaves: [],
468
+ };
469
+ // Gated on the URL, never the name: a server called `carrick` pointing at
470
+ // another host was not written by this package, and stays.
471
+ for (const client of inspectMcpClients()) {
472
+ if (client.state === "connected")
473
+ plan.mcp.push(client.client);
474
+ else if (client.state === "elsewhere")
475
+ plan.leaves.push(`${client.client}: ${client.detail}, so it stays.`);
476
+ else if (client.state === "unreadable")
477
+ plan.leaves.push(`${client.client}: ${client.detail}. Remove "carrick" there by hand.`);
478
+ }
479
+ if (!keepLogin) {
480
+ try {
481
+ const file = credentialPath();
482
+ if (fs.existsSync(file))
483
+ plan.credential = file;
484
+ }
485
+ catch (error) {
486
+ plan.leaves.push(error.message);
487
+ }
488
+ if (process.env["CARRICK_TOKEN"] !== undefined) {
489
+ plan.leaves.push("CARRICK_TOKEN still signs this shell in; unset it to finish signing out.");
490
+ }
491
+ }
492
+ return plan;
493
+ }
494
+ /** The list `carrick uninstall` prints before it asks. */
495
+ export function machineLines(plan) {
496
+ const lines = plan.mcp.map((client) => `Carrick's MCP server in ${client}`);
497
+ if (plan.sessions > 0)
498
+ lines.push(`${plan.sessions} session record(s) in ${tilde(sessionsDir())}`);
499
+ if (plan.notice)
500
+ lines.push(`The refresh notice's last-shown date, in ${tilde(noticeFile())}`);
501
+ if (plan.installId)
502
+ lines.push(`This machine's install id, in ${tilde(installIdPath())}`);
503
+ if (plan.credential !== null) {
504
+ lines.push(`The saved credential, in ${tilde(plan.credential)}, which signs this machine out`);
505
+ }
506
+ return lines;
507
+ }
508
+ function removeMachine(plan, out) {
509
+ if (plan.mcp.length > 0) {
510
+ // A `kept` entry was named above the question already.
511
+ const { done, warn } = mcpRemovalLines(disconnectMcpClients().filter((removal) => removal.state !== "kept"));
512
+ for (const line of done)
513
+ out.done(line);
514
+ for (const line of warn)
515
+ out.warn(line);
260
516
  }
261
- for (const line of warn)
262
- out.warn(line);
263
517
  // Before the install id, so the directory both live in can go with it: what
264
518
  // the Stop hook speaks from is a scratch record of conversations that have
265
519
  // ended, and it is no more use than the hook entry that fed it
266
520
  // (carrick#1330).
267
- const sessions = removeSessions();
268
- if (sessions > 0) {
269
- out.done(`${sessions} session record(s) removed from ${sessionsDir()}`);
270
- removed += 1;
521
+ if (plan.sessions > 0) {
522
+ const sessions = removeSessions();
523
+ if (sessions > 0)
524
+ out.done(`${sessions} session record(s) removed from ${tilde(sessionsDir())}`);
271
525
  }
272
526
  // Same directory, same reason, and the same ordering it imposes: the day the
273
527
  // refresh notice was last said means nothing once there is no install to
274
528
  // refresh (carrick#1333).
275
- removeNotice();
529
+ if (plan.notice && removeNotice())
530
+ out.done("The refresh notice's last-shown date removed");
276
531
  // The install id goes with the entries that carried it: what the header
277
532
  // named is this machine's setup, and the setup is what just came off it. A
278
533
  // later `carrick init` mints a new one, which is the reset the file exists
279
534
  // to give (carrick-cloud#890).
280
- try {
281
- if (removeInstallId()) {
282
- out.done("This machine's install id removed");
283
- removed += 1;
284
- }
285
- }
286
- catch (error) {
287
- out.refuse(`${error.message}. Delete ${installIdPath()} by hand.`);
288
- }
289
- // Only the stamped, unchanged copies. Anything a user has made their own is
290
- // named rather than deleted, which is the same rule the install follows.
291
- const skills = removeTaskSkills(workspace);
292
- if (skills.deleted.length > 0) {
293
- out.done(`${skills.deleted.length} task skill file(s) removed from ${SKILL_ROOTS.join(" and ")}`);
294
- removed += 1;
295
- }
296
- for (const row of skills.kept) {
297
- out.warn(row.state === "edited"
298
- ? `${row.path} has been edited since Carrick wrote it, so it was left in place. Delete it by hand to finish removing it.`
299
- : `${row.path} was not written by Carrick, so it was left in place.`);
300
- }
301
- // The repo selection, which is the one thing init writes into a file a user
302
- // can also write to (carrick#1344). Only the names init put in the exclude
303
- // list come out; a repo somebody excluded by hand stays excluded, and the
304
- // file stays unless init is the whole reason it is there.
305
- try {
306
- const selection = removeSelection(workspace);
307
- if (selection !== null) {
308
- out.done(selection.deleted
309
- ? `${selection.file} removed, with the repo selection init wrote in it`
310
- : `${selection.removed.join(", ")} taken out of the exclude list in ${selection.file}; the rest of the file is yours and was left`);
311
- removed += 1;
312
- }
313
- }
314
- catch (error) {
315
- out.refuse(`Could not read ${WORKSPACE_FILE}: ${error.message}. Take the carrick exclusions out by hand.`);
316
- }
317
- const carrick = path.join(workspace, ".carrick");
318
- if (fs.existsSync(carrick)) {
535
+ if (plan.installId) {
319
536
  try {
320
- fs.rmSync(carrick, { recursive: true, force: true });
321
- out.done(".carrick removed, with the proposal and the index in it");
322
- removed += 1;
537
+ if (removeInstallId())
538
+ out.done("This machine's install id removed");
323
539
  }
324
540
  catch (error) {
325
- out.refuse(`Could not remove ${carrick}: ${error.message}`);
541
+ out.refuse(`${error.message}. Delete ${installIdPath()} by hand.`);
326
542
  }
327
543
  }
328
- if (parsed.keepLogin) {
329
- out.say(`This machine stays signed in; the credential is ${credentialPath()}.`);
330
- }
331
- else {
544
+ if (plan.credential !== null) {
332
545
  try {
333
546
  if (removeCredential()) {
334
547
  out.done("Signed out: the saved credential is gone");
335
- removed += 1;
336
548
  out.say("Revoke the key itself at https://app.carrick.tools/account.");
337
549
  }
338
- if (process.env["CARRICK_TOKEN"] !== undefined) {
339
- out.warn("CARRICK_TOKEN still signs this shell in; unset it to finish signing out.");
340
- }
341
550
  }
342
551
  catch (error) {
343
552
  out.refuse(error.message);
344
553
  }
345
554
  }
346
- if (removed === 0)
347
- out.say("Nothing left to remove on this machine.");
555
+ }
556
+ /** The line a run that was stopped at its question ends on. */
557
+ export const NOTHING_REMOVED = "Nothing was removed.";
558
+ /**
559
+ * Print the list, then take the typed answer (carrick#1573).
560
+ *
561
+ * Null to go ahead; otherwise the code the run ends with, having deleted
562
+ * nothing. The answer is compared exactly, case included, as the dashboard
563
+ * compares the slug a project delete is confirmed with. `--confirm` answers
564
+ * the question whether or not there is a terminal to ask it in.
565
+ */
566
+ async function confirmed(ask) {
567
+ const { out } = ask;
568
+ out.say(listBlock(ask.heading, ask.deletes));
569
+ for (const line of ask.leaves)
570
+ out.warn(line);
571
+ let answer = ask.given;
572
+ if (answer === null) {
573
+ if (!ask.interactive) {
574
+ process.stderr.write(`carrick ${ask.command}: use --confirm ${typed(ask.expected)} to remove these without a terminal.\n`);
575
+ return 1;
576
+ }
577
+ try {
578
+ answer = await out.ask(ask.question);
579
+ }
580
+ catch (error) {
581
+ if (!(error instanceof PromptCancelled))
582
+ throw error;
583
+ out.refuse(`Cancelled. ${NOTHING_REMOVED}`);
584
+ return 1;
585
+ }
586
+ }
587
+ if (answer !== ask.expected) {
588
+ out.refuse(`"${answer}" is not ${ask.expected}. ${NOTHING_REMOVED}`);
589
+ return 1;
590
+ }
591
+ return null;
592
+ }
593
+ /** A usage answer: the help text exits 0, anything else it had to say exits 2. */
594
+ function usage(text, help) {
595
+ process.stdout.write(`${text}\n`);
596
+ return text.startsWith(help) ? 0 : 2;
597
+ }
598
+ export async function remove(argv) {
599
+ return removeWith(argv, createOutput(), process.stdin.isTTY === true);
600
+ }
601
+ /**
602
+ * `remove` with the terminal stated rather than sniffed, as `initWith` does,
603
+ * so a test can be the terminal that answers the question.
604
+ */
605
+ export async function removeWith(argv, out, interactive) {
606
+ const parsed = parseRemoveArgs(argv);
607
+ if (typeof parsed === "string")
608
+ return usage(parsed, "carrick remove");
609
+ const { workspace } = parsed;
610
+ if (!fs.existsSync(workspace)) {
611
+ process.stderr.write(`carrick remove: ${workspace} is not a directory on this machine\n`);
612
+ return 1;
613
+ }
614
+ const plan = folderPlan(workspace);
615
+ const deletes = folderLines(plan);
616
+ if (deletes.length === 0) {
617
+ for (const line of plan.leaves)
618
+ out.warn(line);
619
+ out.say("Nothing left to remove in this folder.");
620
+ }
621
+ else {
622
+ const name = folderName(workspace);
623
+ const stop = await confirmed({
624
+ command: "remove",
625
+ heading: `Remove from ${tilde(workspace)}:`,
626
+ deletes,
627
+ leaves: plan.leaves,
628
+ expected: name,
629
+ question: `Type this folder's name, ${name}, to remove these`,
630
+ given: parsed.confirm,
631
+ interactive,
632
+ out,
633
+ });
634
+ if (stop !== null)
635
+ return stop;
636
+ removeFolder(plan, out);
637
+ }
348
638
  const leftovers = repoLeftovers(workspace);
349
639
  if (leftovers.files.length > 0 || leftovers.sections.length > 0) {
350
640
  out.say("");
@@ -354,7 +644,46 @@ export async function remove(argv, out = createOutput()) {
354
644
  for (const section of leftovers.sections)
355
645
  out.say(` by hand — ${section}`);
356
646
  }
357
- out.note("Last step, when nothing else on this machine needs it", ["npm uninstall -g carrick"]);
647
+ out.say("The MCP server, the install id and the sign-in belong to this machine and stay: carrick uninstall removes them.");
648
+ return 0;
649
+ }
650
+ export async function uninstall(argv) {
651
+ return uninstallWith(argv, createOutput(), process.stdin.isTTY === true);
652
+ }
653
+ /** `uninstall` with the terminal stated, as `removeWith`. */
654
+ export async function uninstallWith(argv, out, interactive) {
655
+ const parsed = parseUninstallArgs(argv);
656
+ if (typeof parsed === "string")
657
+ return usage(parsed, "carrick uninstall");
658
+ const plan = machinePlan(parsed.keepLogin);
659
+ const deletes = machineLines(plan);
660
+ if (deletes.length === 0) {
661
+ for (const line of plan.leaves)
662
+ out.warn(line);
663
+ out.say("Nothing left to remove on this machine.");
664
+ }
665
+ else {
666
+ const stop = await confirmed({
667
+ command: "uninstall",
668
+ heading: "Remove from this machine:",
669
+ deletes,
670
+ leaves: plan.leaves,
671
+ expected: UNINSTALL_ANSWER,
672
+ question: `Type ${UNINSTALL_ANSWER} to remove these`,
673
+ given: parsed.confirm,
674
+ interactive,
675
+ out,
676
+ });
677
+ if (stop !== null)
678
+ return stop;
679
+ removeMachine(plan, out);
680
+ }
681
+ if (parsed.keepLogin)
682
+ out.say(`This machine stays signed in; the credential is ${tilde(credentialPath())}.`);
683
+ out.note("Last steps, when nothing else on this machine needs it", [
684
+ "carrick remove, in each folder carrick init set up",
685
+ "npm uninstall -g carrick",
686
+ ]);
358
687
  return 0;
359
688
  }
360
689
  //# sourceMappingURL=remove.js.map