carrick 0.3.83 → 0.3.85

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 (68) hide show
  1. package/README.md +31 -11
  2. package/bin/carrick.mjs +89 -0
  3. package/dist/auth/credentials.d.ts +9 -0
  4. package/dist/auth/credentials.js +13 -2
  5. package/dist/auth/credentials.js.map +1 -1
  6. package/dist/auth/read.d.ts +4 -4
  7. package/dist/global-install.d.ts +183 -0
  8. package/dist/global-install.js +393 -0
  9. package/dist/global-install.js.map +1 -0
  10. package/dist/hook/post-edit.js +10 -0
  11. package/dist/hook/post-edit.js.map +1 -1
  12. package/dist/hook/session-start.js +34 -0
  13. package/dist/hook/session-start.js.map +1 -1
  14. package/dist/hook/stop.js +18 -4
  15. package/dist/hook/stop.js.map +1 -1
  16. package/dist/hook/user-prompt.js +16 -4
  17. package/dist/hook/user-prompt.js.map +1 -1
  18. package/dist/init/doctor.d.ts +61 -0
  19. package/dist/init/doctor.js +160 -1
  20. package/dist/init/doctor.js.map +1 -1
  21. package/dist/init/hosted.d.ts +30 -5
  22. package/dist/init/hosted.js +75 -9
  23. package/dist/init/hosted.js.map +1 -1
  24. package/dist/init/mcp.d.ts +45 -30
  25. package/dist/init/mcp.js +47 -88
  26. package/dist/init/mcp.js.map +1 -1
  27. package/dist/init/outdated.d.ts +47 -0
  28. package/dist/init/outdated.js +104 -0
  29. package/dist/init/outdated.js.map +1 -1
  30. package/dist/init/output.d.ts +43 -6
  31. package/dist/init/output.js +90 -18
  32. package/dist/init/output.js.map +1 -1
  33. package/dist/init/projects.d.ts +2 -2
  34. package/dist/init/run.d.ts +89 -25
  35. package/dist/init/run.js +292 -58
  36. package/dist/init/run.js.map +1 -1
  37. package/dist/render.d.ts +16 -0
  38. package/dist/render.js +57 -6
  39. package/dist/render.js.map +1 -1
  40. package/dist/scan.d.ts +26 -0
  41. package/dist/scan.js +92 -0
  42. package/dist/scan.js.map +1 -1
  43. package/dist/update-check.d.ts +1 -0
  44. package/dist/update-check.js +25 -0
  45. package/dist/update-check.js.map +1 -0
  46. package/dist/update.d.ts +128 -0
  47. package/dist/update.js +398 -0
  48. package/dist/update.js.map +1 -0
  49. package/package.json +8 -7
  50. package/sidecar/dist/src/capture/anchors.js +69 -8
  51. package/sidecar/dist/src/capture/api.d.ts +27 -2
  52. package/sidecar/dist/src/capture/check-classify.js +31 -2
  53. package/sidecar/dist/src/capture/check-fields.d.ts +29 -0
  54. package/sidecar/dist/src/capture/check-fields.js +58 -6
  55. package/sidecar/dist/src/capture/check.js +5 -0
  56. package/sidecar/dist/src/capture/deep-walk.js +4 -1
  57. package/sidecar/dist/src/capture/index.js +6 -1
  58. package/sidecar/dist/src/capture/node-builder.d.ts +28 -1
  59. package/sidecar/dist/src/capture/node-builder.js +89 -3
  60. package/sidecar/dist/src/capture/self-check.d.ts +6 -1
  61. package/sidecar/dist/src/capture/self-check.js +65 -11
  62. package/sidecar/dist/src/capture/unresolved.d.ts +7 -0
  63. package/sidecar/dist/src/capture/unresolved.js +1 -1
  64. package/sidecar/dist/src/type-inferrer.d.ts +118 -4
  65. package/sidecar/dist/src/type-inferrer.js +433 -15
  66. package/sidecar/dist/src/validators.d.ts +40 -40
  67. package/sidecar/dist/src/validators.js +6 -1
  68. package/templates/skills/carrick-census.md +3 -1
package/dist/init/run.js CHANGED
@@ -9,41 +9,70 @@
9
9
  // this many packages, this index.
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
- import { spawnSync } from "node:child_process";
13
12
  import { readCredential } from "../auth/credentials.js";
14
13
  import { signIn } from "../auth/run.js";
15
14
  import { resolveRepos } from "../auth/read.js";
16
15
  import { deriveWorkspace, selectedProposal, selectRepos, writeProposal, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
17
16
  import { connectRepos, reposAreInProject, projectAssignments } from "./connect.js";
18
- import { downloadHostedIndex, hostedReport, nativeRunner } from "./hosted.js";
17
+ import { DOWNLOAD_LABEL, downloadHostedIndex, hostedReport, nativeRunner } from "./hosted.js";
19
18
  import { createProject, listProjects, planProject, projectLabel, projectStep, SLUG, } from "./projects.js";
20
- import { connectMcpClients, mcpLine } from "./mcp.js";
19
+ import { connectMcpClients, mcpLine, offeredFileClients } from "./mcp.js";
21
20
  import { hookCommand, mergeCarrickHooks, removeCarrickHooks } from "./settings.js";
22
21
  import { CODEX_HOOKS_FILE, writeCodexHooks } from "./codex.js";
23
22
  import { ignoredSkillRoots, taskSkillLines, writeTaskSkills } from "./task-skills.js";
23
+ import { recordCliVersion } from "./outdated.js";
24
+ import { findCarrick, offerGlobalInstall } from "../global-install.js";
25
+ import { currentVersion } from "../update.js";
24
26
  import { writeIfChanged } from "./files.js";
25
27
  import { excludedRepos, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
26
28
  import { createOutput, DOCS, PromptCancelled } from "./output.js";
27
- import { renderTemplate } from "../templates.js";
29
+ import { renderTemplate, TEMPLATE_PATHS } from "../templates.js";
28
30
  /**
29
- * The one sentence the run ends on.
31
+ * The repos this run covers that the scaffold tool still has work in.
30
32
  *
31
- * The scaffold tool carries the instructions — which files to create, how to
32
- * seed `carrick.json` from the proposal, whether to run the scan at all
33
- * (carrick-cloud#832) — so the terminal names the tool and stops. The copy
34
- * that used to stand here was a second statement of the same instructions, and
35
- * two copies of a sequence drift.
33
+ * The two files that tool writes: the `carrick.json` a scan reads, and the
34
+ * workflow that runs the scan. A repo holding both has been scaffolded, and
35
+ * the closing instruction is then an instruction to do nothing — which is what
36
+ * it was on a folder where every repo was already set up (carrick#1365).
36
37
  *
37
- * The argument is named because the tool's branch turns on it and turns soft
38
- * without it: `repo` is what resolves the hosted rows, and with no `repo` the
39
- * response keeps its default ending and tells the agent to run the scan — on a
40
- * repo CI already indexes, which is the row the whole workspace reads
41
- * (carrick-cloud `src/tools/scaffold.ts`, cloud#805 item 1).
38
+ * The repo root only, because that is where the scanner looks for a config
39
+ * (`Config::load_services`): a `carrick.json` deeper in the tree is a fixture
40
+ * or a nested example, not this repo's.
42
41
  */
43
- export const SCAFFOLD_SENTENCE = "Run the carrick scaffold tool for this repo, passing its owner/repo as `repo`, and follow what it returns.";
42
+ export function reposToScaffold(repos, exists = fs.existsSync) {
43
+ return repos.filter((repo) => !exists(path.join(repo.path, TEMPLATE_PATHS["carrick.json"])) ||
44
+ !exists(path.join(repo.path, TEMPLATE_PATHS.workflow)));
45
+ }
46
+ /**
47
+ * The instruction to paste, naming the repos it is about.
48
+ *
49
+ * The scaffold tool's branch turns on `repo` and turns soft without it: with
50
+ * no `repo` the response keeps its default ending and tells the agent to run
51
+ * the scan — on a repo CI already indexes, which is the row the whole
52
+ * workspace reads (carrick-cloud `src/tools/scaffold.ts`, cloud#805 item 1).
53
+ * So the names are in the sentence, one call per repo.
54
+ */
55
+ export function scaffoldSentence(repos) {
56
+ const names = repos.map((repo) => repo.name ?? path.basename(repo.path));
57
+ if (names.length === 1) {
58
+ return `Run the carrick scaffold tool for ${names[0]}, passing its owner/repo as \`repo\`, and follow what it returns.`;
59
+ }
60
+ const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
61
+ return `Run the carrick scaffold tool for ${shown}, once each, passing that repo's owner/repo as \`repo\`, and follow what it returns.`;
62
+ }
63
+ /** What a run with nothing left to set up closes on: what this machine can do now. */
64
+ export const READY_SENTENCE = "The index is on this machine: `carrick check <file>` answers for a file, and your agent can ask who calls an operation.";
44
65
  const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
45
66
  export function parseArgs(argv, cwd = process.cwd()) {
46
- const options = { workspace: cwd, project: null, repos: [], assumeYes: false, allowMove: false };
67
+ const options = {
68
+ workspace: cwd,
69
+ project: null,
70
+ repos: [],
71
+ editors: [],
72
+ assumeYes: false,
73
+ allowMove: false,
74
+ installGlobal: false,
75
+ };
47
76
  for (let index = 0; index < argv.length; index += 1) {
48
77
  const argument = argv[index];
49
78
  switch (argument) {
@@ -51,6 +80,9 @@ export function parseArgs(argv, cwd = process.cwd()) {
51
80
  case "-y":
52
81
  options.assumeYes = true;
53
82
  break;
83
+ case "--install-global":
84
+ options.installGlobal = true;
85
+ break;
54
86
  case "--allow-move":
55
87
  options.allowMove = true;
56
88
  break;
@@ -65,6 +97,20 @@ export function parseArgs(argv, cwd = process.cwd()) {
65
97
  index += 1;
66
98
  break;
67
99
  }
100
+ case "--mcp": {
101
+ const value = argv[index + 1];
102
+ if (!value)
103
+ return "--mcp needs an editor name";
104
+ for (const name of value.split(",")) {
105
+ const trimmed = name.trim();
106
+ if (trimmed === "")
107
+ return `invalid editor "${name}": name it as carrick init prints it`;
108
+ if (!options.editors.includes(trimmed))
109
+ options.editors.push(trimmed);
110
+ }
111
+ index += 1;
112
+ break;
113
+ }
68
114
  case "--repo": {
69
115
  const value = argv[index + 1];
70
116
  if (!value)
@@ -100,7 +146,7 @@ export function parseArgs(argv, cwd = process.cwd()) {
100
146
  }
101
147
  function help() {
102
148
  return [
103
- "carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--allow-move]",
149
+ "carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--mcp EDITOR] [--allow-move]",
104
150
  "",
105
151
  "Sign in (here, or beforehand with carrick login), then set up a repository",
106
152
  "or a folder of repos: which repos this install covers, the project, the repo",
@@ -123,17 +169,29 @@ function help() {
123
169
  " querying either project can see, so the move is named",
124
170
  " and asked about separately",
125
171
  " --allow-move Accept those moves without being asked. --yes does not",
172
+ " --mcp EDITOR Also add Carrick to this editor's own MCP configuration,",
173
+ " which is a file outside this workspace: repeatable, or one",
174
+ " comma-separated list. Without a terminal no editor file is",
175
+ " written unless this names one, and --yes does not name one",
126
176
  " -y, --yes Take the proposal as printed",
177
+ " --install-global Install carrick on this machine where there is none,",
178
+ " so the agent hooks can run it by name after this run",
179
+ " ends. A terminal is asked instead; --yes is not this",
127
180
  "",
128
181
  `The editor extension, the hooks, CI and a carrick.json written by hand: ${DOCS}`,
129
182
  ].join("\n");
130
183
  }
131
- /** Whether a command answers on this machine, for a line we should not print. */
132
- function onPath(command) {
133
- const probe = spawnSync(process.platform === "win32" ? "where" : "which", [command], {
134
- stdio: "ignore",
135
- });
136
- return probe.status === 0;
184
+ /**
185
+ * Whether `carrick` answers on this machine after this run ends.
186
+ *
187
+ * Not `which`: npm puts the exec tree's own `node_modules/.bin` first on PATH
188
+ * for the child it runs, so inside `npx carrick init` a `which carrick` says
189
+ * yes on a machine with no global — and the hooks were then written as a bare
190
+ * `carrick hook post-edit`, naming a command that stopped existing when npx
191
+ * exited (carrick#1372). `findCarrick` skips the copy doing the looking.
192
+ */
193
+ function carrickOnPath() {
194
+ return findCarrick() !== null;
137
195
  }
138
196
  /**
139
197
  * The repos a project holds that this folder does not, one line per project.
@@ -222,18 +280,6 @@ export function mcpClientLines(mcp) {
222
280
  .filter((outcome) => outcome.state === "written" && outcome.client !== "Claude Code")
223
281
  .map((outcome) => `MCP added for ${outcome.client}: ${outcome.detail}`);
224
282
  }
225
- /**
226
- * One warning per client connected before the install id existed.
227
- *
228
- * Not a failure — the client works, and this run deliberately changed nothing
229
- * on its entry — so the line is the client, what is missing, and the commands
230
- * that put it there, which are the owner's to run (`mcp.ts`).
231
- */
232
- export function mcpUnstampedLines(mcp) {
233
- return mcp
234
- .filter((outcome) => outcome.state === "unstamped")
235
- .map((outcome) => `${outcome.client}: ${outcome.detail}`);
236
- }
237
283
  /**
238
284
  * An end to the run with its own exit code, decided before anything is written.
239
285
  *
@@ -260,14 +306,70 @@ export const CANCELLED = "Cancelled. Nothing was written, here or in Carrick.";
260
306
  * they were, and both say it.
261
307
  */
262
308
  export const NOTHING_WRITTEN = "Nothing written, here or in Carrick.";
263
- /** A repo as the picker lists it: what it is called, and what is in it. */
264
- export function repoChoices(plan, candidates) {
309
+ /**
310
+ * The repos that start selected, and why each of the others does not.
311
+ *
312
+ * A default is a claim about somebody's folder, so only the repos something
313
+ * already says belong here carry one: connected to this workspace, and in the
314
+ * project most of this folder's connected repos are in. Everything else — a
315
+ * repo nobody connected, one sitting in a different project, one with no
316
+ * GitHub identity to ask about — starts out, with the reason on its row, and
317
+ * the reader adds it in one keystroke (carrick#1365).
318
+ *
319
+ * Two cases preselect nothing at all, and neither is in the ruling because
320
+ * neither is a majority: a folder where nothing is connected (a first run), and
321
+ * a tie between two projects. A guess there is a guess about which half of
322
+ * somebody's folder this install is for.
323
+ *
324
+ * Unconnected is not the same as unknown: with no read of the workspace — no
325
+ * login yet, or a read that did not answer — `identity` is null and nothing is
326
+ * preselected, because "not connected" would then be this command's assumption
327
+ * rather than the server's answer.
328
+ */
329
+ export function preselectedRepos(candidates, identity, label = (slug) => slug) {
330
+ const reasons = new Map();
331
+ const project = new Map();
332
+ for (const repo of candidates) {
333
+ if (repo.name === null) {
334
+ reasons.set(repo.path, "no GitHub identity");
335
+ continue;
336
+ }
337
+ if (identity === null)
338
+ continue;
339
+ const row = identity.repos.find((entry) => entry.full_name.toLowerCase() === repo.name?.toLowerCase());
340
+ if (row === undefined || !row.connected) {
341
+ reasons.set(repo.path, "not connected");
342
+ continue;
343
+ }
344
+ project.set(repo.path, row.project_slug);
345
+ }
346
+ const counts = new Map();
347
+ for (const slug of project.values()) {
348
+ if (slug !== null)
349
+ counts.set(slug, (counts.get(slug) ?? 0) + 1);
350
+ }
351
+ const ranked = [...counts.entries()].sort((left, right) => right[1] - left[1]);
352
+ const majority = ranked.length > 0 && (ranked.length === 1 || ranked[0][1] > ranked[1][1]) ? ranked[0][0] : null;
353
+ const keep = [];
354
+ for (const [repoPath, slug] of project) {
355
+ if (majority !== null && slug === majority)
356
+ keep.push(repoPath);
357
+ else if (slug === null)
358
+ reasons.set(repoPath, "connected, in no project");
359
+ else
360
+ reasons.set(repoPath, `in ${label(slug)}`);
361
+ }
362
+ return { keep, reasons };
363
+ }
364
+ /** A repo as the picker lists it: what it is called, what is in it, and why it is out. */
365
+ export function repoChoices(plan, candidates, reasons = new Map()) {
265
366
  return candidates.map((repo) => {
266
367
  const found = plan.repos.find((entry) => entry.path === repo.path);
267
368
  const packages = found?.services.length ?? 0;
369
+ const reason = reasons.get(repo.path);
268
370
  const hint = [
269
371
  `${packages} package${packages === 1 ? "" : "s"}`,
270
- ...(repo.name === null ? ["no GitHub identity"] : []),
372
+ ...(reason === undefined ? [] : [reason]),
271
373
  ].join(", ");
272
374
  return { value: repo.path, label: repo.name ?? path.basename(repo.path), hint };
273
375
  });
@@ -298,7 +400,8 @@ export async function chooseRepos(plan, candidates, options, out) {
298
400
  if (!options.interactive) {
299
401
  throw new Error(`${plan.workspace} holds ${candidates.length} repos and there is no terminal to choose in. Name the ones this install covers with --repo owner/repo (repeatable), or add --yes to cover all ${candidates.length}.`);
300
402
  }
301
- const kept = new Set(await out.choose("Which repos does this install cover?", repoChoices(plan, candidates)));
403
+ const { keep, reasons } = preselectedRepos(candidates, options.identity ?? null, options.label);
404
+ const kept = new Set(await out.choose("Which repos does this install cover?", "a repo", repoChoices(plan, candidates, reasons), { initial: keep, required: true }));
302
405
  return candidates.filter((repo) => kept.has(repo.path));
303
406
  }
304
407
  /**
@@ -328,6 +431,50 @@ export function namedButExcluded(workspace, excluded, requested, identify = repo
328
431
  return identity.name !== null && wanted.has(identity.name.toLowerCase());
329
432
  });
330
433
  }
434
+ /**
435
+ * Which editors outside the workspace get an MCP entry written for them.
436
+ *
437
+ * The answer is a set of names, and the default is the empty one: these are
438
+ * files in somebody's home directory for editors they may not use, so the
439
+ * strongest thing a configuration directory can do is put a row in the
440
+ * question (carrick#1365). An editor whose own command answers on PATH starts
441
+ * ticked; without a terminal, `--mcp` names them and nothing else is written.
442
+ *
443
+ * `--yes` does not cover these. It takes the proposal as printed — a list of
444
+ * packages and a set of files inside this workspace — and an entry in
445
+ * `~/.cursor/mcp.json` is neither.
446
+ */
447
+ export async function chooseEditors(offered, options, out) {
448
+ if (offered.length === 0)
449
+ return [];
450
+ if (options.editors.length > 0) {
451
+ const known = new Map(offered.map((client) => [client.name.toLowerCase(), client.name]));
452
+ const chosen = [];
453
+ for (const wanted of options.editors) {
454
+ const name = known.get(wanted.toLowerCase());
455
+ if (name === undefined) {
456
+ throw new Error(`--mcp ${wanted} names no editor configured on this machine. The ones here are: ${offered.map((client) => client.name).join(", ")}.`);
457
+ }
458
+ if (!chosen.includes(name))
459
+ chosen.push(name);
460
+ }
461
+ return chosen;
462
+ }
463
+ if (!options.interactive || options.assumeYes)
464
+ return [];
465
+ return out.choose("Which editors should Carrick be added to? These files are outside this workspace.", "an editor", offered.map((client) => ({
466
+ value: client.name,
467
+ label: client.name,
468
+ hint: client.installed ? client.file : `${client.file}, not detected on this machine`,
469
+ })), { initial: offered.filter((client) => client.installed).map((client) => client.name), required: false });
470
+ }
471
+ /** What the proposal question adds when an editor file is part of the answer. */
472
+ export function editorClause(offered, chosen) {
473
+ const files = offered.filter((client) => chosen.includes(client.name)).map((client) => client.file);
474
+ if (files.length === 0)
475
+ return "";
476
+ return `, and add Carrick to ${files.join(" and ")}`;
477
+ }
331
478
  /** The repos this install covers, one line each, for the proposal. */
332
479
  export function coverageLines(plan, selected) {
333
480
  return selected.slice(0, 10).map((repo) => {
@@ -387,6 +534,11 @@ export async function initWith(argv, out, interactive) {
387
534
  const movable = new Set();
388
535
  // The directory names this run leaves out, for the workspace file below.
389
536
  let deselected = [];
537
+ // The editors outside this workspace the answer covers, settled before it.
538
+ let editors = [];
539
+ // The repos this install covers, for the closing line that names what is
540
+ // still to set up in them.
541
+ let selectedRepos = [];
390
542
  try {
391
543
  // What a previous run wrote down, before anything is derived from it: the
392
544
  // scanner has already dropped an excluded repo by the time the proposal
@@ -404,9 +556,43 @@ export async function initWith(argv, out, interactive) {
404
556
  // and a run nobody has chosen in yet must not have signed anything in.
405
557
  derived = deriveWorkspace(workspace);
406
558
  const candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
407
- const selected = await chooseRepos(derived.plan, candidates, { repos: parsed.repos, assumeYes: parsed.assumeYes, interactive }, out);
559
+ // What Carrick already knows about this folder, before the picker draws
560
+ // its defaults. A read, not a write: the credential has to exist already,
561
+ // so a machine nobody has signed in on still chooses first and signs in
562
+ // afterwards, which is what keeps "nothing written, here or in Carrick"
563
+ // true of a cancelled run (carrick#1338). A read that does not answer
564
+ // preselects nothing rather than guessing, and the authoritative read
565
+ // below then reports the failure in its own words.
566
+ const saved = readCredential();
567
+ let known = null;
568
+ projects = null;
569
+ const knownNames = [...new Set(candidates.map((repo) => repo.name).filter((name) => name !== null))];
570
+ // Only where a picker is actually going to draw. Every other path has its
571
+ // answer already — `--repo` names it, `--yes` takes the list as derived, a
572
+ // single repo is not a choice — and asking about a repo whose name the
573
+ // reader never offered is a request that buys nothing (carrick#1338's
574
+ // test pins it: a repo left out is named in no request at all).
575
+ const picking = parsed.repos.length === 0 && !parsed.assumeYes && interactive && candidates.length > 1;
576
+ if (picking && saved !== null && knownNames.length > 0) {
577
+ try {
578
+ known = await resolveRepos(saved.token, knownNames);
579
+ projects = await listProjects(saved.token);
580
+ }
581
+ catch {
582
+ known = null;
583
+ projects = null;
584
+ }
585
+ }
586
+ const selected = await chooseRepos(derived.plan, candidates, {
587
+ repos: parsed.repos,
588
+ assumeYes: parsed.assumeYes,
589
+ interactive,
590
+ identity: known,
591
+ label: (slug) => projectLabel(slug, projects),
592
+ }, out);
408
593
  if (selected.length === 0)
409
594
  throw new Stop(0);
595
+ selectedRepos = selected;
410
596
  derived = selectedProposal(derived, selected.map((repo) => repo.path));
411
597
  const kept = new Set(selected.map((repo) => repo.path));
412
598
  deselected = candidates
@@ -420,7 +606,6 @@ export async function initWith(argv, out, interactive) {
420
606
  // to run another command: `carrick login` is the same browser round trip,
421
607
  // and refusing to take it was the first thing a new install did
422
608
  // (carrick#955). Without a terminal there is no browser to hand it to.
423
- const saved = readCredential();
424
609
  if (saved)
425
610
  credential = saved;
426
611
  else {
@@ -461,12 +646,19 @@ export async function initWith(argv, out, interactive) {
461
646
  // later upload has no repo identity to resolve a project from.
462
647
  out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
463
648
  }
464
- initial = await resolveRepos(credential.token, names);
649
+ // The read the rest of this run is decided from. The one the picker's
650
+ // defaults came from is reused only where the selection kept every repo,
651
+ // because `project_repos` answers for the repos that were ASKED about: a
652
+ // wider read would have the absent-repo lines below naming projects this
653
+ // install does not cover (carrick#993 row 18).
654
+ const everyCandidate = known !== null && names.length === knownNames.length;
655
+ initial = everyCandidate && known !== null ? known : await resolveRepos(credential.token, names);
465
656
  // Read once, for the names: every project this run prints is printed as
466
657
  // the dashboard shows it, with the slug beside it (carrick#1338). A
467
658
  // workspace whose API has no such action answers null and the lines carry
468
659
  // slugs alone, as they did.
469
- projects = names.length > 0 ? await listProjects(credential.token) : null;
660
+ if (projects === null)
661
+ projects = names.length > 0 ? await listProjects(credential.token) : null;
470
662
  // The project half of the browser round trip, where this API can do it
471
663
  // from here. Nothing is created or moved in it: it settles what this run
472
664
  // would do, and the proposal below is where that is accepted. Without
@@ -527,14 +719,24 @@ export async function initWith(argv, out, interactive) {
527
719
  if (decision.create)
528
720
  out.say(` Project "${project}" will be created in this workspace`);
529
721
  }
722
+ // Which editors get an entry, asked before the yes that covers it. The
723
+ // files are outside the workspace and belong to editors this reader may not
724
+ // use, and they used to be written on the strength of a yes to the proposal
725
+ // (carrick#1365). An editor with no configuration directory is not offered,
726
+ // and one this machine has no command for is offered unticked.
727
+ const offered = offeredFileClients();
728
+ editors = await chooseEditors(offered, { editors: parsed.editors, assumeYes: parsed.assumeYes, interactive }, out);
530
729
  const subject = packagesFound(plan);
531
730
  if (!parsed.assumeYes) {
532
731
  if (!interactive) {
533
732
  throw new Stop(1, `use --yes to accept this proposal without a terminal${moving.length > 0 && !parsed.allowMove ? ", and --allow-move to accept the move above" : ""}.`);
534
733
  }
535
734
  // The count is in the question, because the listing that used to stand
536
- // above it is gone: this is where a reader decides (carrick#1026).
537
- if (!(await out.confirm(`Write the proposal for ${subject} and configure hooks?`))) {
735
+ // above it is gone: this is where a reader decides (carrick#1026). The
736
+ // editor files are named in it too, because a question that does not
737
+ // name a file is not consent to write it.
738
+ const alsoEditors = editorClause(offered, editors);
739
+ if (!(await out.confirm(`Write the proposal for ${subject} and configure hooks${alsoEditors}?`))) {
538
740
  out.refuse(NOTHING_WRITTEN);
539
741
  throw new Stop(0);
540
742
  }
@@ -659,7 +861,27 @@ export async function initWith(argv, out, interactive) {
659
861
  // connection, for work that crosses repos this machine does not hold,
660
862
  // configured for every client this machine has rather than printed for one
661
863
  // of them (carrick#955).
662
- const command = hookCommand({ onPath });
864
+ // Before the hook commands are decided, because what they say depends on the
865
+ // answer: with a global, they are the bare `carrick`, which every shell and
866
+ // every agent on this machine resolves; without one, they name this install,
867
+ // and an npx cache is cleared (carrick#1372). A machine that already has one
868
+ // was brought level before this command started.
869
+ try {
870
+ await offerGlobalInstall({
871
+ assumeYes: parsed.assumeYes,
872
+ install: parsed.installGlobal,
873
+ confirm: (question) => out.confirm(question),
874
+ say: (line) => out.warn(line),
875
+ });
876
+ }
877
+ catch (error) {
878
+ // Including a cancelled prompt: somebody answering Ctrl-C to an offer is
879
+ // answering the offer, not ending the setup.
880
+ if (!(error instanceof PromptCancelled)) {
881
+ out.warn(`The global install was not offered: ${error.message}`);
882
+ }
883
+ }
884
+ const command = hookCommand({ onPath: carrickOnPath });
663
885
  const settingsName = path.join(".claude", command.bare ? "settings.json" : "settings.local.json");
664
886
  const settingsFile = path.join(workspace, settingsName);
665
887
  let hooksWritten = true;
@@ -702,6 +924,10 @@ export async function initWith(argv, out, interactive) {
702
924
  out.done(line);
703
925
  for (const line of warn)
704
926
  out.warn(line);
927
+ // Which build wrote them, so a hook running an older `carrick` than this
928
+ // one says so instead of answering from files it does not match
929
+ // (carrick#1372).
930
+ recordCliVersion(workspace, currentVersion());
705
931
  const ignored = ignoredSkillRoots(workspace);
706
932
  if (ignored.length > 0) {
707
933
  const one = ignored.length === 1;
@@ -711,21 +937,17 @@ export async function initWith(argv, out, interactive) {
711
937
  catch (error) {
712
938
  out.refuse(`Could not write the task skills: ${error.message}`);
713
939
  }
714
- const mcp = connectMcpClients();
940
+ const mcp = connectMcpClients(editors);
715
941
  if (hooksWritten)
716
942
  out.done(configuredLine(mcp));
717
943
  for (const line of mcpClientLines(mcp))
718
944
  out.done(line);
719
- if (!command.bare) {
720
- out.warn(`\`carrick\` is not on PATH, so the hooks in ${settingsName} name this install. Run \`npm install -g carrick\` and carrick init again for the short command.`);
721
- }
945
+ // Nothing here about `carrick` not being on PATH: the offer above said it, in
946
+ // front of the decision it changes, and with the command for this machine's
947
+ // package manager rather than a guess at npm (carrick#1372).
722
948
  for (const outcome of mcp.filter((entry) => entry.state === "failed")) {
723
949
  out.warn(`MCP not configured for ${outcome.client}: ${outcome.detail}`);
724
950
  }
725
- // Connected, and older than the install id: nothing was changed on that
726
- // entry and nothing here failed (carrick-cloud#890, `mcp.ts`).
727
- for (const line of mcpUnstampedLines(mcp))
728
- out.warn(line);
729
951
  if (mcp.length === 0)
730
952
  out.warn(`No agent client found on this machine. In Claude Code: ${mcpLine()}`);
731
953
  // No paid scan ran here, and that is the point (carrick-cloud#799): the one
@@ -736,13 +958,25 @@ export async function initWith(argv, out, interactive) {
736
958
  if (hostedIndex.length > 0) {
737
959
  // The step reports its own outcome, so the read is one line in a terminal
738
960
  // as well as in a pipe: a spinner that stopped with its label spent a
739
- // second line on the same event (carrick#1032).
740
- await out.step("Reading the hosted index into .carrick/", () => downloadHostedIndex(plan.workspace, nativeRunner(out.quiet)), hostedReport);
961
+ // second line on the same event (carrick#1032). What it says while it runs
962
+ // is the scanner's own count of where it has got to (carrick#1365).
963
+ const startedAt = Date.now();
964
+ await out.step(DOWNLOAD_LABEL, (progress) => downloadHostedIndex(plan.workspace, nativeRunner(out.quiet, progress)), (outcome) => hostedReport(outcome, (Date.now() - startedAt) / 1000));
741
965
  }
742
966
  else {
743
967
  out.done("No index yet: your agent runs the one scan");
744
968
  }
745
- out.note("Next: paste this to your agent", [SCAFFOLD_SENTENCE]);
969
+ // The scaffold tool writes the files a repo needs; a repo that has them needs
970
+ // nothing pasted anywhere. The line used to close every run, including one
971
+ // where every covered repo already had its config and its workflow, and
972
+ // "this repo" named nothing in a folder of several (carrick#1365).
973
+ const unscaffolded = reposToScaffold(selectedRepos);
974
+ if (unscaffolded.length > 0) {
975
+ out.note("Next: paste this to your agent", [scaffoldSentence(unscaffolded)]);
976
+ }
977
+ else {
978
+ out.done(READY_SENTENCE);
979
+ }
746
980
  out.say(`Docs: ${out.accent(DOCS)}`);
747
981
  return 0;
748
982
  }