carrick 0.3.65 → 0.3.67

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 (42) hide show
  1. package/README.md +34 -0
  2. package/bin/carrick.mjs +9 -1
  3. package/dist/cli.d.ts +20 -2
  4. package/dist/cli.js +24 -2
  5. package/dist/cli.js.map +1 -1
  6. package/dist/contract.d.ts +40 -0
  7. package/dist/contract.js +4 -0
  8. package/dist/contract.js.map +1 -1
  9. package/dist/diagnostics.js +21 -2
  10. package/dist/diagnostics.js.map +1 -1
  11. package/dist/hook/post-edit.js +5 -1
  12. package/dist/hook/post-edit.js.map +1 -1
  13. package/dist/init/connect.js +10 -8
  14. package/dist/init/connect.js.map +1 -1
  15. package/dist/init/hosted.d.ts +23 -1
  16. package/dist/init/hosted.js +41 -32
  17. package/dist/init/hosted.js.map +1 -1
  18. package/dist/init/mcp.d.ts +35 -2
  19. package/dist/init/mcp.js +142 -15
  20. package/dist/init/mcp.js.map +1 -1
  21. package/dist/init/output.d.ts +71 -0
  22. package/dist/init/output.js +173 -0
  23. package/dist/init/output.js.map +1 -0
  24. package/dist/init/remove.d.ts +41 -0
  25. package/dist/init/remove.js +264 -0
  26. package/dist/init/remove.js.map +1 -0
  27. package/dist/init/repos.js +2 -2
  28. package/dist/init/repos.js.map +1 -1
  29. package/dist/init/run.d.ts +44 -30
  30. package/dist/init/run.js +160 -218
  31. package/dist/init/run.js.map +1 -1
  32. package/dist/init/settings.d.ts +15 -0
  33. package/dist/init/settings.js +38 -0
  34. package/dist/init/settings.js.map +1 -1
  35. package/dist/render.d.ts +24 -0
  36. package/dist/render.js +80 -1
  37. package/dist/render.js.map +1 -1
  38. package/package.json +8 -6
  39. package/sidecar/dist/src/capture/machinery.d.ts +10 -0
  40. package/sidecar/dist/src/capture/machinery.js +19 -9
  41. package/sidecar/dist/src/type-inferrer.d.ts +66 -4
  42. package/sidecar/dist/src/type-inferrer.js +249 -18
package/dist/init/run.js CHANGED
@@ -1,64 +1,42 @@
1
1
  // Authenticated setup; Rust owns the workspace and service proposal.
2
+ //
3
+ // What this prints is one line per thing that happened (carrick#1026). The
4
+ // reasoning behind each step — how the hooks deliver, which editor extension
5
+ // to install, why the scan is left to CI, what a hand-written carrick.json
6
+ // looks like — is the quickstart's, at `output.ts`'s `DOCS`, and this command
7
+ // links to it rather than repeating it. The record of the run stays here,
8
+ // because nothing else can state it: this login, this project, these repos,
9
+ // this many packages, this index.
2
10
  import fs from "node:fs";
3
11
  import path from "node:path";
4
- import readline from "node:readline/promises";
5
12
  import { spawnSync } from "node:child_process";
6
13
  import { readCredential } from "../auth/credentials.js";
7
14
  import { signIn } from "../auth/run.js";
8
15
  import { resolveRepos } from "../auth/read.js";
9
- import { deriveWorkspace, writeProposal, PROPOSAL_FILE, repoIdentity } from "./repos.js";
16
+ import { deriveWorkspace, writeProposal, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
10
17
  import { connectRepos, reposAreInProject, projectAssignments } from "./connect.js";
11
- import { downloadHostedIndex, hostedLines } from "./hosted.js";
18
+ import { downloadHostedIndex, hostedReport, nativeRunner } from "./hosted.js";
12
19
  import { ensureProject, projectStep, SLUG } from "./projects.js";
13
- import { connectMcpClients, mcpLines } from "./mcp.js";
14
- import { hookCommand, mergeCarrickHooks } from "./settings.js";
20
+ import { connectMcpClients, MCP_LINE } from "./mcp.js";
21
+ import { hookCommand, mergeCarrickHooks, removeCarrickHooks } from "./settings.js";
22
+ import { createOutput, DOCS } from "./output.js";
15
23
  import { renderTemplate } from "../templates.js";
16
- import { packageRoot } from "../native.js";
17
- const EXTENSION_ID = "carrick-tools.carrick";
18
24
  /**
19
- * The prompt that makes an agent write this repo's config and setup files.
25
+ * The one sentence the run ends on.
20
26
  *
21
- * A copy of the `scaffold` MCP tool's own instructions and the dashboard copy
22
- * beside them (carrick-cloud#800), whose test checks every filename against
23
- * what the tool returns. It is printed here so the terminal path ends where
24
- * the dashboard path ends, and it states the sequence the ruling in
25
- * carrick-cloud#799 fixed: the agent writes a complete `carrick.json` from the
26
- * proposal this command derived, READS it back against the repo, and then runs
27
- * the one scan. There is no rehearsal in front of it — `carrick index` is the
28
- * only scan in the flow (carrick#1008, cloud#832), and a facts-only pass has
29
- * no user here, so nothing in this copy names one. Keep the two copies in
30
- * step; the tool's instructions, not this text, decide what gets written.
27
+ * The scaffold tool carries the instructions — which files to create, how to
28
+ * seed `carrick.json` from the proposal, whether to run the scan at all
29
+ * (carrick-cloud#832) — so the terminal names the tool and stops. The copy
30
+ * that used to stand here was a second statement of the same instructions, and
31
+ * two copies of a sequence drift.
31
32
  *
32
- * One clause is this side's alone, because only this side knows it: whether
33
- * the workspace read found a hosted index for these repos. Where there is one,
34
- * the scan has already run in CI and a laptop scan from a branch would replace
35
- * that row for everyone in the workspace, so the agent runs no scan at all and
36
- * checks the config by reading it (carrick#993, cloud half carrick-cloud#805,
37
- * cloud#832).
33
+ * The argument is named because the tool's branch turns on it and turns soft
34
+ * without it: `repo` is what resolves the hosted rows, and with no `repo` the
35
+ * response keeps its default ending and tells the agent to run the scan — on a
36
+ * repo CI already indexes, which is the row the whole workspace reads
37
+ * (carrick-cloud `src/tools/scaffold.ts`, cloud#805 item 1).
38
38
  */
39
- export function agentScaffoldPrompt(hosted) {
40
- return ("Run the carrick scaffold tool, passing this repo's owner/repo from " +
41
- "`git remote get-url origin` as `repo`, and follow the instructions it " +
42
- "returns: create each file at its path, write carrick.json by seeding it " +
43
- "from .carrick/proposal.json and reading this repo's source for the env " +
44
- "vars and domains its services call (a nested services config for a " +
45
- "monorepo, with library packages folded into the services that import " +
46
- "them), and add the Carrick section to AGENTS.md if this repo already " +
47
- "has one. " +
48
- (hosted
49
- ? "Then read carrick.json back against the repo. Do not run `carrick " +
50
- "index`: it is the scan, this repo already has a hosted index, and a " +
51
- "laptop scan from a branch replaces the CI row for everyone. "
52
- : // carrick#960/#1003: an agent's shell caps a command well below the
53
- // fifteen minutes a first scan takes, so the scan is detached and the
54
- // agent asks `carrick status` how it went. The three words it polls
55
- // for are carrick#1007 item 4's. Mirrors the scaffold tool's own
56
- // wording (carrick-cloud#821, rewritten in carrick-cloud#832).
57
- "Then read carrick.json back against the repo and run `carrick index " +
58
- "--detach` once for the first scan, checking `carrick status` about " +
59
- "once a minute until it says the scan finished, stopped or failed. ") +
60
- "Answer the closing checklist before you open the PR.");
61
- }
39
+ export const SCAFFOLD_SENTENCE = "Run the carrick scaffold tool for this repo, passing its owner/repo as `repo`, and follow what it returns.";
62
40
  const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
63
41
  export function parseArgs(argv, cwd = process.cwd()) {
64
42
  const options = { workspace: cwd, project: null, repo: null, assumeYes: false };
@@ -121,39 +99,15 @@ function help() {
121
99
  ".carrick directory and the hook settings, and it runs no paid scan: where",
122
100
  "Carrick already holds an index for these repos, it reads that index into",
123
101
  ".carrick so this machine can answer from it.",
124
- "With --project, a project missing from the workspace is offered for",
125
- "creation here, and the repos are put in it from the terminal once the",
126
- "GitHub App grant connects them.",
127
102
  "",
128
103
  " -w, --workspace DIR The folder holding the repos (default: this one)",
129
104
  " --project SLUG Require these repos in this Carrick project",
130
105
  " --repo OWNER/REPO Name the GitHub repo whose origin remote names none",
131
106
  " -y, --yes Take the repo list as proposed",
107
+ "",
108
+ `The editor extension, the hooks, CI and a carrick.json written by hand: ${DOCS}`,
132
109
  ].join("\n");
133
110
  }
134
- function say(line = "") {
135
- process.stdout.write(`${line}\n`);
136
- }
137
- async function confirm(question) {
138
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
139
- try {
140
- const answer = await rl.question(`${question} [Y/n] `);
141
- return answer.trim() === "" || /^y(es)?$/i.test(answer.trim());
142
- }
143
- finally {
144
- rl.close();
145
- }
146
- }
147
- /** A typed answer, for the questions whose answer is not yes or no. */
148
- async function ask(question) {
149
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
150
- try {
151
- return (await rl.question(`${question}\n> `)).trim();
152
- }
153
- finally {
154
- rl.close();
155
- }
156
- }
157
111
  function writeIfChanged(target, body) {
158
112
  const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
159
113
  if (existing === body)
@@ -173,7 +127,7 @@ function onPath(command) {
173
127
  * The repos a project holds that this folder does not, one line per project.
174
128
  *
175
129
  * Carrick answers across every repo in a project, so a machine holding half of
176
- * one gets half the answers, and the connection lines above name only what is
130
+ * one gets half the answers, and the connection line above names only what is
177
131
  * here. `project_repos` is the workspace read's answer to that: the whole
178
132
  * membership of every project the requested repos are in (carrick#993 row 18).
179
133
  * Capped and counted like the identity lines, because a project can hold two
@@ -198,35 +152,59 @@ export function absentRepos(identity, names, project) {
198
152
  }
199
153
  return lines;
200
154
  }
155
+ /** The repos this run connected, as one line however many there are. */
156
+ export function connectedLine(names) {
157
+ if (names.length === 1)
158
+ return `Repo ${names[0]} connected`;
159
+ const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
160
+ return `${names.length} repos connected: ${shown}`;
161
+ }
162
+ /**
163
+ * What the derive found, in the words the workspace stated it in.
164
+ *
165
+ * The manifest kind is the scanner's own `reason` for each repo — "npm
166
+ * workspaces", "pnpm workspaces", "Deno manifests", "carrick.json", "single
167
+ * repository", or a `+` join of them (`src/service_derivation.rs`) — and it is
168
+ * named only where every repo here agrees on one, because a mixed workspace
169
+ * has no one word for what was found.
170
+ */
171
+ export function packagesFound(plan) {
172
+ const count = plan.repos.reduce((total, repo) => total + repo.services.length, 0);
173
+ const reasons = plan.repos.map((repo) => repo.reason);
174
+ const every = (word) => reasons.length > 0 && reasons.every((reason) => reason.includes(word));
175
+ const kind = every("Deno") ? "Deno " : every("pnpm") ? "pnpm " : every("npm") ? "npm " : "";
176
+ return `${count} ${kind}package${count === 1 ? "" : "s"}`;
177
+ }
178
+ /** The line that states it, and names where the proposal landed. */
179
+ export function packagesLine(plan) {
180
+ return `${packagesFound(plan)} found, proposal in ${PROPOSAL_FILE}`;
181
+ }
201
182
  /**
202
- * The editor lines, one line per editor that is on the machine.
183
+ * The one line for the hooks and the MCP connection.
203
184
  *
204
- * `--install-extension <id>` resolves the id against that editor's own gallery,
205
- * and the three editors below read three different ones: VS Code the VS Code
206
- * Marketplace, Cursor and Windsurf each their own. The extension is published
207
- * to all three, so the id is a command every one of them can run
208
- * (carrick#915). Any other editor gets the server's command and no claim about
209
- * the editor: the per-editor results table in `plugin/TEST-PLAN.md` section 6
210
- * is still empty.
185
+ * A client is named only where this run changed something for it: an "already
186
+ * connected" line is a line about nothing, and a first run prints four of them
187
+ * (carrick#1026). The hooks are always this repo's, so they are always the
188
+ * subject.
189
+ */
190
+ export function configuredLine(mcp) {
191
+ const claude = mcp.some((outcome) => outcome.client === "Claude Code" && outcome.state === "written");
192
+ return claude
193
+ ? "Claude Code hooks and MCP configured (restart the client)"
194
+ : "Claude Code hooks configured";
195
+ }
196
+ /**
197
+ * One line per other client this run wrote a file for, naming that file.
211
198
  *
212
- * `onPath` is injected so a test can state each machine.
199
+ * The path is printed because this command guessed it: a client detected by its
200
+ * own data directory gets an entry written into its own config file, and a
201
+ * wrong guess has to be one visible line and one entry to delete (`mcp.ts`).
202
+ * A client left exactly as it was gets no line at all (carrick#1026).
213
203
  */
214
- export function editorLines(onPath) {
215
- const lines = [];
216
- for (const [command, editor] of [
217
- ["code", "VS Code"],
218
- ["cursor", "Cursor"],
219
- ["windsurf", "Windsurf"],
220
- ]) {
221
- if (!onPath(command))
222
- continue;
223
- lines.push(` ${editor}, for diagnostics in the Problems panel:`);
224
- lines.push(` ${command} --install-extension ${EXTENSION_ID}`);
225
- }
226
- if (lines.length === 0) {
227
- lines.push(" Any editor with an LSP client starts the server itself as `carrick lsp --stdio`.");
228
- }
229
- return lines;
204
+ export function mcpClientLines(mcp) {
205
+ return mcp
206
+ .filter((outcome) => outcome.state === "written" && outcome.client !== "Claude Code")
207
+ .map((outcome) => `MCP added for ${outcome.client}: ${outcome.detail}`);
230
208
  }
231
209
  export async function init(argv) {
232
210
  const parsed = parseArgs(argv);
@@ -240,12 +218,11 @@ export async function init(argv) {
240
218
  return 1;
241
219
  }
242
220
  const interactive = process.stdin.isTTY === true;
221
+ const out = createOutput();
243
222
  // Authentication and all derivation validation precede local writes.
244
223
  let derived;
245
- // What the workspace read said about these repos, for the closing lines
246
- // printed long after it: a repo Carrick already holds an index for must not
247
- // be told to run the paid scan again (carrick#993).
248
- let requested = [];
224
+ // The repos the workspace read found an index for: where CI has already built
225
+ // one, this run reads it rather than ordering a scan (carrick#993 row 2).
249
226
  const hostedIndex = [];
250
227
  try {
251
228
  // A machine that has never signed in signs in here rather than being told
@@ -257,8 +234,7 @@ export async function init(argv) {
257
234
  if (!interactive) {
258
235
  throw new Error("carrick init requires a Carrick login. Run carrick login, or set CARRICK_TOKEN.");
259
236
  }
260
- say("This machine is not signed in to Carrick.");
261
- credential = await signIn(say);
237
+ credential = await signIn(out.say);
262
238
  }
263
239
  derived = deriveWorkspace(workspace);
264
240
  const derivedIdentities = derived.plan.repos.map((repo) => repoIdentity(repo.path));
@@ -272,11 +248,11 @@ export async function init(argv) {
272
248
  throw new Error(`--repo names one repository, but ${unnamed.length} repos here have no GitHub identity: ${unnamed.map((repo) => repo.path).join(", ")}. Run carrick init --repo in each of them, or fix their origin remotes.`);
273
249
  }
274
250
  if (unnamed.length === 0) {
275
- say(`--repo ${parsed.repo} was not needed: every repo here names its own GitHub repository.`);
251
+ out.warn(`--repo ${parsed.repo} was not needed: every repo here names its own GitHub repository.`);
276
252
  }
277
253
  else {
278
254
  taken = unnamed[0].path;
279
- say(`Taking ${parsed.repo} as the GitHub repository for ${taken}.`);
255
+ out.done(`${parsed.repo} taken as the GitHub repository for ${taken}`);
280
256
  }
281
257
  }
282
258
  const repoIdentities = derivedIdentities.map((repo) => repo.path === taken ? { ...repo, name: parsed.repo, problem: null } : repo);
@@ -291,14 +267,14 @@ export async function init(argv) {
291
267
  // A folder of clones can hold dozens, so the list is capped and the rest
292
268
  // is counted rather than dropped.
293
269
  for (const repo of missingIdentities.slice(0, 10)) {
294
- say(`${repo.path} contributes no GitHub identity: ${repo.problem}.`);
270
+ out.warn(`${repo.path} contributes no GitHub identity: ${repo.problem}.`);
295
271
  }
296
272
  if (missingIdentities.length > 10) {
297
- say(`${missingIdentities.length - 10} more repos here name no GitHub identity either.`);
273
+ out.warn(`${missingIdentities.length - 10} more repos here name no GitHub identity either.`);
298
274
  }
299
275
  if (missingIdentities.length > 0) {
300
276
  const one = missingIdentities.length === 1;
301
- say(` Carrick has nothing to connect ${one ? "it" : "them"} to. Run carrick init --repo owner/repo${one ? "" : " in each of them"}, or give the alias a HostName github.com line in your ssh config.`);
277
+ out.warn(`Carrick has nothing to connect ${one ? "it" : "them"} to. Run carrick init --repo owner/repo${one ? "" : " in each of them"}, or give the alias a HostName github.com line in your ssh config.`);
302
278
  }
303
279
  if (parsed.project && missingIdentities.length > 0) {
304
280
  throw new Error(`--project ${parsed.project} cannot verify ${missingIdentities.map((repo) => repo.path).join(", ")} because ${missingIdentities.length === 1 ? "it has" : "they have"} no GitHub origin. No project assignment was verified.`);
@@ -306,18 +282,12 @@ export async function init(argv) {
306
282
  if (parsed.project && names.length === 0) {
307
283
  throw new Error(`--project ${parsed.project} found no GitHub repos to verify.`);
308
284
  }
309
- if (parsed.project) {
310
- say(`Repos requested for project "${parsed.project}":`);
311
- for (const name of names)
312
- say(` ${name}`);
313
- }
314
- else if (names.length === 0) {
285
+ if (names.length === 0) {
315
286
  // The sentence that stops the rest of this run from reading as a
316
287
  // complete one: no project is chosen, no connection is checked, and a
317
288
  // later upload has no repo identity to resolve a project from.
318
- say("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
289
+ out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
319
290
  }
320
- requested = names;
321
291
  const initial = await resolveRepos(credential.token, names);
322
292
  // The project half of the browser round trip, where this API can do it
323
293
  // from here: the project is created here, and `connectRepos` puts the
@@ -325,7 +295,7 @@ export async function init(argv) {
325
295
  // the GitHub App grant and nothing else. Without --project the step reads
326
296
  // the assignment the repos already have and offers the list, rather than
327
297
  // doing nothing at all (carrick#987).
328
- const prompts = { say, ask, confirm, interactive, assumeYes: parsed.assumeYes };
298
+ const prompts = { say: out.say, ask: out.ask, confirm: out.confirm, interactive, assumeYes: parsed.assumeYes };
329
299
  let project = parsed.project;
330
300
  let projectExists = false;
331
301
  if (project === null) {
@@ -340,66 +310,72 @@ export async function init(argv) {
340
310
  interactive,
341
311
  project: project ?? undefined,
342
312
  projectExists,
343
- say,
313
+ say: out.say,
344
314
  });
315
+ // Who this machine is, and where its answers come from. The workspace slug
316
+ // is the login: nothing on the credential or in the workspace read names a
317
+ // user (carrick#1026).
318
+ out.done(project === null
319
+ ? `Signed in as ${identity.workspace.slug}`
320
+ : `Signed in as ${identity.workspace.slug} · project ${project}`);
345
321
  if (project !== null && !reposAreInProject(identity, names, project)) {
346
322
  // An unverified project does not end the run, whether it was named on
347
323
  // the command line or picked here. Stopping cost someone their hooks and
348
324
  // their proposal for a browser step they could only take afterwards, and
349
325
  // the documented command then needed two runs (carrick#993 row 8).
350
- say(`Setup continues; finish the browser steps above to put these repos in "${project}", then run carrick init --project ${project} again to verify.`);
326
+ out.warn(`Finish the browser steps above to put these repos in "${project}", then run carrick init --project ${project} again to verify.`);
351
327
  }
352
- say(`Carrick workspace: ${identity.workspace.slug}`);
353
328
  if (identity.allowance_sentence)
354
- say(identity.allowance_sentence);
329
+ out.say(identity.allowance_sentence);
330
+ const connected = [];
355
331
  for (const repo of identity.repos) {
356
332
  if (!repo.connected)
357
- say(`${repo.full_name} is not connected to this Carrick workspace.`);
358
- // How to give it one is the last thing this command prints, once, rather
359
- // than a workflow line per repo before anything is set up.
360
- else if (repo.services.length === 0)
361
- say(`${repo.full_name} is connected and has no hosted index yet.`);
362
- else
363
- hostedIndex.push(repo.full_name);
333
+ out.warn(`${repo.full_name} is not connected to this Carrick workspace.`);
334
+ else {
335
+ connected.push(repo.full_name);
336
+ if (repo.services.length > 0)
337
+ hostedIndex.push(repo.full_name);
338
+ }
364
339
  }
340
+ if (connected.length > 0)
341
+ out.done(connectedLine(connected));
365
342
  // The rest of the project, which this machine does not hold. Carrick
366
343
  // answers across every repo in a project, so a folder holding half of one
367
344
  // is a partial index and nothing here would otherwise say so
368
345
  // (carrick#993 row 18).
369
346
  for (const line of absentRepos(identity, names, project))
370
- say(line);
347
+ out.warn(line);
371
348
  }
372
349
  catch (error) {
373
350
  process.stderr.write(`carrick init: ${error.message}\n`);
374
351
  return 1;
375
352
  }
376
353
  const plan = derived.plan;
377
- say(`Repos to index, in ${plan.workspace} (${plan.repos_detected_by}):`);
378
354
  if (plan.parent_proposal) {
379
355
  const parent = plan.parent_proposal;
380
- // Reads like the scanner's own sentence for the same proposal, capped the
381
- // same way: a folder of scratch checkouts holds dozens, and the folder
382
- // that holds them is already named here.
383
356
  const names = parent.repos.map((repo) => path.basename(repo));
384
357
  const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
385
- say(`The parent folder ${parent.directory} holds ${parent.repos.length} ${parent.repos.length === 1 ? "repo" : "repos"}: ${shown}. Run carrick init .. to initialise that workspace.`);
386
- }
387
- for (const repo of plan.repos) {
388
- say(` ${repo.path} (${repo.reason})`);
389
- for (const service of repo.services) {
390
- say(` ${service.serviceName ?? "<repository>"}: directory ${service.directory ?? "."}, tsconfig ${service.tsconfig ?? "scanner default"}`);
391
- }
392
- for (const warning of repo.warnings)
393
- say(` ${warning}`);
358
+ out.warn(`The parent folder ${parent.directory} holds ${parent.repos.length} ${parent.repos.length === 1 ? "repo" : "repos"}: ${shown}. Run carrick init .. to initialise that workspace.`);
394
359
  }
360
+ // The scanner's own warnings about what it proposed. Capped: the proposal
361
+ // file carries every one of them, and it is named on the line above.
362
+ const warnings = plan.repos.flatMap((repo) => repo.warnings);
363
+ for (const warning of warnings.slice(0, 3))
364
+ out.warn(warning);
365
+ if (warnings.length > 3)
366
+ out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
395
367
  for (const missing of plan.missing)
396
- say(`Missing workspace override: ${missing}`);
368
+ out.warn(`Missing workspace override: ${missing}`);
369
+ const proposed = packagesLine(plan);
370
+ const subject = packagesFound(plan);
397
371
  if (!parsed.assumeYes) {
398
372
  if (!process.stdin.isTTY) {
399
373
  process.stderr.write("carrick init: use --yes to accept this proposal without a terminal.\n");
400
374
  return 1;
401
375
  }
402
- if (!await confirm("Write this proposal and configure hooks?"))
376
+ // The count is in the question, because the listing that used to stand
377
+ // above it is gone: this is where a reader decides (carrick#1026).
378
+ if (!await out.confirm(`Write the proposal for ${subject} and configure hooks?`))
403
379
  return 0;
404
380
  }
405
381
  try {
@@ -407,105 +383,71 @@ export async function init(argv) {
407
383
  // without a model is written into the repository, because the first scan
408
384
  // is the paid one and it has to run against a config someone has read
409
385
  // (carrick-cloud#799).
410
- say(`wrote ${writeProposal(plan.workspace, derived)}`);
386
+ writeProposal(plan.workspace, derived);
387
+ out.done(proposed);
411
388
  }
412
389
  catch (error) {
413
390
  process.stderr.write(`carrick init: ${error.message}\n`);
414
391
  return 1;
415
392
  }
416
- // 3. The agent hooks. Merged by command: this file may already hold a user's
417
- // own hooks, or the hook pack the hosted index installs.
393
+ // The agent hooks, merged by command: this file may already hold a user's
394
+ // own hooks, or the hook pack the hosted index installs. And the MCP
395
+ // connection, for work that crosses repos this machine does not hold,
396
+ // configured for every client this machine has rather than printed for one
397
+ // of them (carrick#955).
418
398
  const command = hookCommand({ onPath });
419
399
  const settingsName = path.join(".claude", command.bare ? "settings.json" : "settings.local.json");
420
400
  const settingsFile = path.join(workspace, settingsName);
401
+ let hooksWritten = true;
421
402
  try {
422
403
  const settings = fs.existsSync(settingsFile) ? fs.readFileSync(settingsFile, "utf8") : null;
423
404
  const otherFile = path.join(workspace, ".claude", command.bare ? "settings.local.json" : "settings.json");
424
405
  const other = fs.existsSync(otherFile) ? fs.readFileSync(otherFile, "utf8") : null;
425
- const cleaned = other === null ? null : mergeCarrickHooks(other, null);
406
+ // The same removal `carrick remove` runs, so a file holding no entry of
407
+ // ours is left byte for byte rather than reformatted on the way past.
408
+ const cleaned = other === null ? null : removeCarrickHooks(other);
426
409
  const hooks = mergeCarrickHooks(settings, command.command);
427
410
  // Validate both documents before migrating our entries between them.
428
411
  if (cleaned?.changed)
429
412
  writeIfChanged(otherFile, cleaned.body);
430
- const wroteHooks = writeIfChanged(settingsFile, hooks.body);
431
- say(`${wroteHooks === "written" ? "wrote" : "unchanged"} ${settingsName}`);
432
- if (!command.bare) {
433
- say(` \`carrick\` is not on PATH here, so those hooks name this install: ${command.command}.`);
434
- say(" An npx run leaves nothing on PATH afterwards, and a hook that cannot find carrick");
435
- say(" says nothing rather than failing your edit. `npm install -g carrick` and run init");
436
- say(" again to write the short command instead.");
437
- }
413
+ writeIfChanged(settingsFile, hooks.body);
438
414
  }
439
415
  catch (error) {
440
- say(`Could not configure Carrick hooks: ${error.message}. Fix the settings files and run carrick init again.`);
416
+ hooksWritten = false;
417
+ out.refuse(`Could not configure Carrick hooks: ${error.message}. Fix ${settingsName} and run carrick init again.`);
441
418
  }
442
- // 4. The MCP connection, for work that crosses repos this machine does not
443
- // hold. Configured here for every client this machine has, rather than
444
- // printed for one of them (carrick#955).
445
- say();
446
- say("Next:");
447
- say();
448
- for (const line of mcpLines(connectMcpClients()))
449
- say(line);
450
- say();
451
- for (const line of editorLines(onPath))
452
- say(line);
453
- say();
454
- say(`Start Claude Code in this folder — the hooks above are ${settingsName} here, and`);
455
- say("the index covers every repo in it. The hooks need no plugin; the language server does:");
456
- const plugin = path.join(packageRoot(), "plugin");
457
- say(` claude --plugin-dir ${fs.existsSync(plugin) ? plugin : "<carrick checkout>/plugin"}`);
419
+ const mcp = connectMcpClients();
420
+ if (hooksWritten)
421
+ out.done(configuredLine(mcp));
422
+ for (const line of mcpClientLines(mcp))
423
+ out.done(line);
458
424
  if (!command.bare) {
459
- say(" That plugin's language server is started as `carrick`, which this machine cannot resolve;");
460
- say(" install it globally first, or the hooks above are the channel.");
425
+ 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.`);
426
+ }
427
+ for (const outcome of mcp.filter((entry) => entry.state === "failed")) {
428
+ out.warn(`MCP not configured for ${outcome.client}: ${outcome.detail}`);
461
429
  }
462
- say("Once the index exists, editing a file with a route or a call in it is what");
463
- say("Carrick answers on; `carrick status` says what the index holds.");
464
- say();
465
- // No scan ran here, and that is the point (carrick-cloud#799): the one scan
466
- // runs against a config someone has read, so the last thing this command
467
- // prints is the prompt that produces that config. Nothing here names a
468
- // rehearsal pass: `carrick index` is the only scan in the flow, and a
469
- // facts-only one has no user (carrick#1008, cloud#832).
470
- //
471
- // Unless CI has already built one. The hosted index is the whole workspace's
472
- // row, and `carrick index` from a laptop on a branch replaces it for
473
- // everyone who queries it, so where the workspace read found services this
474
- // run orders no scan (carrick#993 row 2) and reads that index onto this
475
- // machine instead. Saying "do not run the scan" and leaving `.carrick`
476
- // empty left the reader with no index and no command that would make one,
477
- // which is every second developer on a team (carrick#1020).
430
+ if (mcp.length === 0)
431
+ out.warn(`No agent client found on this machine. In Claude Code: ${MCP_LINE}`);
432
+ // No paid scan ran here, and that is the point (carrick-cloud#799): the one
433
+ // scan runs against a config someone has read. Where CI has already built an
434
+ // index, this run reads it onto the machine instead — the hosted index is
435
+ // the whole workspace's row, and `carrick index` from a laptop on a branch
436
+ // replaces it for everyone who queries it (carrick#993 row 2, carrick#1020).
478
437
  if (hostedIndex.length > 0) {
479
- const subject = hostedIndex.length === requested.length && requested.length === 1
480
- ? "This repo"
481
- : hostedIndex.length > 3
482
- ? `${hostedIndex.slice(0, 3).join(", ")} and ${hostedIndex.length - 3} more`
483
- : hostedIndex.join(", ");
484
- say(` ${subject} already ${hostedIndex.length === 1 ? "has" : "have"} a hosted index, so this run reads it`);
485
- say(" into .carrick/ rather than scanning: no model runs and nothing is uploaded.");
486
- say(" It re-reads the source on this machine once, which on a large workspace is a");
487
- say(" few minutes.");
488
- say();
489
- for (const line of hostedLines(await downloadHostedIndex(plan.workspace)))
490
- say(line);
491
- say(` ${PROPOSAL_FILE} holds the services this run derived, to compare`);
492
- say(" with the carrick.json already committed.");
438
+ // The step reports its own outcome, so the read is one line in a terminal
439
+ // as well as in a pipe: a spinner that stopped with its label spent a
440
+ // second line on the same event (carrick#1032).
441
+ await out.step("Reading the hosted index into .carrick/", () => downloadHostedIndex(plan.workspace, nativeRunner(out.quiet)), hostedReport);
493
442
  }
494
443
  else {
495
- say(` There is no index yet. ${PROPOSAL_FILE} holds the services this run derived;`);
496
- say(" an agent turns it into carrick.json, adds the CI check (which needs no secret),");
497
- say(" reads the config back against the repo, and runs `carrick index --detach` once:");
498
- say(" the single scan that builds the index and asks Carrick to classify the rest.");
499
- say(" By hand instead: `carrick templates workflow > .github/workflows/carrick.yml`, a");
500
- say(" carrick.json per https://docs.carrick.tools/carrick-json, then that command.");
444
+ out.done("No index yet: your agent runs the one scan");
501
445
  }
502
- say();
503
- say(" Paste this to your agent:");
504
- say();
505
- say(` ${agentScaffoldPrompt(hostedIndex.length > 0)}`);
446
+ out.note("Next: paste this to your agent", [SCAFFOLD_SENTENCE]);
447
+ out.say(`Docs: ${out.accent(DOCS)}`);
506
448
  return 0;
507
449
  }
508
- /** `carrick templates <name>`, so the workflow above is one command. */
450
+ /** `carrick templates <name>`, so a workflow is one command. */
509
451
  export function templates(argv) {
510
452
  const [name, ...rest] = argv;
511
453
  const variables = {};