carrick 0.3.64 → 0.3.66

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