carrick 0.3.90 → 0.3.91

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 (37) hide show
  1. package/dist/auth/oauth.d.ts +8 -0
  2. package/dist/auth/oauth.js +65 -11
  3. package/dist/auth/oauth.js.map +1 -1
  4. package/dist/init/connect.d.ts +42 -3
  5. package/dist/init/connect.js +172 -113
  6. package/dist/init/connect.js.map +1 -1
  7. package/dist/init/mcp.d.ts +11 -0
  8. package/dist/init/mcp.js +14 -8
  9. package/dist/init/mcp.js.map +1 -1
  10. package/dist/init/output.d.ts +2 -0
  11. package/dist/init/output.js +33 -2
  12. package/dist/init/output.js.map +1 -1
  13. package/dist/init/projects.d.ts +56 -5
  14. package/dist/init/projects.js +124 -17
  15. package/dist/init/projects.js.map +1 -1
  16. package/dist/init/repos.d.ts +33 -0
  17. package/dist/init/repos.js +8 -1
  18. package/dist/init/repos.js.map +1 -1
  19. package/dist/init/run.d.ts +64 -36
  20. package/dist/init/run.js +263 -163
  21. package/dist/init/run.js.map +1 -1
  22. package/package.json +6 -6
  23. package/sidecar/dist/src/capture/api.d.ts +13 -1
  24. package/sidecar/dist/src/capture/check-classify.js +12 -7
  25. package/sidecar/dist/src/capture/check-probe.d.ts +10 -0
  26. package/sidecar/dist/src/capture/check-probe.js +21 -3
  27. package/sidecar/dist/src/capture/check.js +1 -0
  28. package/sidecar/dist/src/capture/index.d.ts +1 -0
  29. package/sidecar/dist/src/capture/index.js +1 -0
  30. package/sidecar/dist/src/index.js +37 -9
  31. package/sidecar/dist/src/retype.d.ts +67 -0
  32. package/sidecar/dist/src/retype.js +813 -0
  33. package/sidecar/dist/src/type-inferrer.d.ts +85 -1
  34. package/sidecar/dist/src/type-inferrer.js +384 -1
  35. package/sidecar/dist/src/types.d.ts +58 -2
  36. package/sidecar/dist/src/validators.d.ts +153 -20
  37. package/sidecar/dist/src/validators.js +17 -0
package/dist/init/run.js CHANGED
@@ -8,15 +8,17 @@
8
8
  // because nothing else can state it: this login, this project, these repos,
9
9
  // this many packages, this index.
10
10
  import fs from "node:fs";
11
+ import os from "node:os";
11
12
  import path from "node:path";
12
13
  import { readCredential } from "../auth/credentials.js";
14
+ import { openBrowser } from "../auth/oauth.js";
13
15
  import { signIn } from "../auth/run.js";
14
16
  import { resolveRepos } from "../auth/read.js";
15
17
  import { deriveWorkspace, selectedProposal, selectRepos, writeProposal, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
16
- import { connectRepos, reposAreInProject, projectAssignments } from "./connect.js";
18
+ import { connectLine, connectRepos, projectAssignments, reposAreInProject, unconnectedRepos, workspaceUrls, } from "./connect.js";
17
19
  import { downloadHostedIndex, hostedReport, nativeRunner, STEP_LABEL } from "./hosted.js";
18
20
  import { createProject, listProjects, planProject, projectLabel, projectStep, SLUG, } from "./projects.js";
19
- import { connectMcpClients, mcpLine, offeredFileClients } from "./mcp.js";
21
+ import { claudeCodeFound, connectMcpClients, mcpLine, offeredFileClients } from "./mcp.js";
20
22
  import { hookCommand, mergeCarrickHooks, removeCarrickHooks } from "./settings.js";
21
23
  import { CODEX_HOOKS_FILE, writeCodexHooks } from "./codex.js";
22
24
  import { ignoredSkillRoots, taskSkillLines, writeTaskSkills } from "./task-skills.js";
@@ -25,7 +27,7 @@ import { findCarrick, offerGlobalInstall } from "../global-install.js";
25
27
  import { currentVersion } from "../update.js";
26
28
  import { writeIfChanged } from "./files.js";
27
29
  import { excludedRepos, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
28
- import { createOutput, DOCS, DOCS_EDITOR, DOCS_INDEX, DOCS_INIT_FILES, PromptCancelled } from "./output.js";
30
+ import { createOutput, DOCS_EDITOR, DOCS_INDEX, DOCS_INIT_FILES, PromptCancelled } from "./output.js";
29
31
  import { renderTemplate, TEMPLATE_PATHS } from "../templates.js";
30
32
  /**
31
33
  * The repos this run covers that the scaffold tool still has work in.
@@ -228,61 +230,130 @@ export function absentRepos(identity, names, project, label = (slug) => slug) {
228
230
  }
229
231
  return lines;
230
232
  }
231
- /** The repos this run connected, as one line however many there are. */
232
- export function connectedLine(names) {
233
- if (names.length === 1)
234
- return `Repo ${names[0]} connected`;
235
- const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
236
- return `${names.length} repos connected: ${shown}`;
233
+ /** A list as a sentence names it, capped: "a, b and 3 more". */
234
+ function listed(items, cap) {
235
+ if (items.length <= 1)
236
+ return items.join("");
237
+ if (items.length > cap)
238
+ return `${items.slice(0, cap).join(", ")} and ${items.length - cap} more`;
239
+ return `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
237
240
  }
238
241
  /**
239
- * What the derive found, in the words the workspace stated it in.
242
+ * The one line that says what is set up (carrick#1489).
240
243
  *
241
- * The manifest kind is the scanner's own `reason` for each repo — "npm
242
- * workspaces", "pnpm workspaces", "Deno manifests", "carrick.json", "single
243
- * repository", or a `+` join of them (`src/service_derivation.rs`) — and it is
244
- * named only where every repo here agrees on one, because a mixed workspace
245
- * has no one word for what was found.
244
+ * It used to be nine blocks: the login, the connection, the packages and the
245
+ * proposal path, each hook file, the skills, the MCP clients, the index. The
246
+ * files were named in the question that asked to write them, so what is left
247
+ * to say is the state.
246
248
  */
247
- export function packagesFound(plan) {
248
- const count = plan.repos.reduce((total, repo) => total + repo.services.length, 0);
249
- const reasons = plan.repos.map((repo) => repo.reason);
250
- const every = (word) => reasons.length > 0 && reasons.every((reason) => reason.includes(word));
251
- const kind = every("Deno") ? "Deno " : every("pnpm") ? "pnpm " : every("npm") ? "npm " : "";
252
- return `${count} ${kind}package${count === 1 ? "" : "s"}`;
249
+ export function setupLine(state) {
250
+ const subject = state.repos.length === 1 ? state.repos[0] : `${state.repos.length} repos`;
251
+ const where = state.project === null ? state.workspace : `${state.workspace} · project ${state.project}`;
252
+ const parts = [
253
+ ...(state.hooks.length > 0 ? [`${state.hooks.join(" and ")} hooks`] : []),
254
+ ...(state.skills ? ["task skills"] : []),
255
+ ...(state.mcp.length > 0 ? [`MCP in ${state.mcp.join(", ")}`] : []),
256
+ ];
257
+ const head = state.repos.length === 0 ? `Set up in ${where}` : `Set up ${subject} in ${where}`;
258
+ return parts.length === 0 ? head : `${head}: ${parts.join(", ")}`;
253
259
  }
254
- /** The line that states it, and names where the proposal landed. */
255
- export function packagesLine(plan) {
256
- return `${packagesFound(plan)} found, proposal in ${PROPOSAL_FILE}`;
260
+ /**
261
+ * The installs a first scan would be refused over, as commands to run.
262
+ *
263
+ * The scanner states them (`not_installed` on each derived repo, from the same
264
+ * preflight rule the scan refuses on), so this only says where each runs, as
265
+ * a path from the workspace the agent is in (carrick#1489).
266
+ */
267
+ export function installCommands(plan) {
268
+ const commands = [];
269
+ for (const repo of plan.repos) {
270
+ for (const row of repo.not_installed) {
271
+ const where = path.join(path.relative(plan.workspace, repo.path), row.directory);
272
+ const command = where === "." ? `\`${row.command}\`` : `\`${row.command}\` in ${where}`;
273
+ if (!commands.includes(command))
274
+ commands.push(command);
275
+ }
276
+ }
277
+ return commands;
257
278
  }
258
279
  /**
259
- * The one line for the hooks and the MCP connection.
280
+ * The sentence that installs them, when there is anything to install: an
281
+ * instruction to the agent, or a line to the reader when no instruction is
282
+ * printed.
283
+ */
284
+ export function installSentence(commands, to) {
285
+ if (commands.length === 0)
286
+ return null;
287
+ return to === "agent"
288
+ ? `First install dependencies: run ${listed(commands, 5)}.`
289
+ : `Dependencies are not installed. Run ${listed(commands, 5)} before the next scan.`;
290
+ }
291
+ /**
292
+ * One block per project: the repos in it, each once, with what is about to
293
+ * happen to it (carrick#1489).
260
294
  *
261
- * A client is named only where this run changed something for it: an "already
262
- * connected" line is a line about nothing, and a first run prints four of them
263
- * (carrick#1026). The hooks are always this repo's, so they are always the
264
- * subject.
295
+ * This replaced a listing that named every repo twice and restated the project
296
+ * the reader had just confirmed. The block header is the project, and a repo
297
+ * row says only what differs from the header: a package count above one, or
298
+ * the project it moves out of.
265
299
  */
266
- export function configuredLine(mcp) {
267
- // `unstamped` is not `written`: that run changed nothing for Claude Code,
268
- // and the line must not say it did.
269
- const claude = mcp.some((outcome) => outcome.client === "Claude Code" && outcome.state === "written");
270
- return claude
271
- ? "Claude Code hooks and MCP configured (restart the client)"
272
- : "Claude Code hooks configured";
300
+ export function summaryBlocks(input) {
301
+ const groups = new Map();
302
+ const header = (repo) => {
303
+ if (input.project !== null) {
304
+ return input.created !== null ? `New project ${input.created}` : `Project ${input.label(input.project)}`;
305
+ }
306
+ const current = repo.name === null ? null : (input.assigned.get(repo.name) ?? null);
307
+ return current === null ? "No project" : `Project ${input.label(current)}`;
308
+ };
309
+ for (const repo of input.selected) {
310
+ const key = header(repo);
311
+ groups.set(key, [...(groups.get(key) ?? []), repo]);
312
+ }
313
+ const moving = new Set(input.moving.map((name) => name.toLowerCase()));
314
+ const lines = [];
315
+ for (const [title, repos] of groups) {
316
+ lines.push(` ${title}`);
317
+ const width = Math.max(...repos.map((repo) => path.basename(repo.path).length));
318
+ for (const repo of repos.slice(0, 10)) {
319
+ const found = input.plan.repos.find((entry) => entry.path === repo.path);
320
+ const packages = found?.services.length ?? 0;
321
+ const from = repo.name === null ? null : (input.assigned.get(repo.name) ?? null);
322
+ const notes = [
323
+ repo.name ?? "no GitHub repo",
324
+ ...(packages > 1 ? [`${packages} packages`] : []),
325
+ ...(repo.name !== null && moving.has(repo.name.toLowerCase()) && from !== null
326
+ ? [`moves from ${input.label(from)}`]
327
+ : []),
328
+ ];
329
+ lines.push(` ${path.basename(repo.path).padEnd(width)} ${notes.join(" ")}`);
330
+ }
331
+ if (repos.length > 10)
332
+ lines.push(` and ${repos.length - 10} more`);
333
+ }
334
+ return lines;
273
335
  }
274
336
  /**
275
- * One line per other client this run wrote a file for, naming that file.
337
+ * The files the write step creates, by path, for the question that asks to
338
+ * write them (carrick#1489).
276
339
  *
277
- * The path is printed because this command guessed it: a client detected by its
278
- * own data directory gets an entry written into its own config file, and a
279
- * wrong guess has to be one visible line and one entry to delete (`mcp.ts`).
280
- * A client left exactly as it was gets no line at all (carrick#1026).
340
+ * Directories inside the workspace, because each holds several files and the
341
+ * list has to fit one line; every file outside it by its own path, because
342
+ * those belong to somebody's editor. Claude Code's server list is written by
343
+ * its own command, so it is named rather than given a path.
281
344
  */
282
- export function mcpClientLines(mcp) {
283
- return mcp
284
- .filter((outcome) => outcome.state === "written" && outcome.client !== "Claude Code")
285
- .map((outcome) => `MCP added for ${outcome.client}: ${outcome.detail}`);
345
+ export function writesLine(options) {
346
+ const home = options.home ?? os.homedir();
347
+ const outside = options.editorFiles.map((file) => file.startsWith(`${home}${path.sep}`) ? `~${file.slice(home.length)}` : file);
348
+ return `Writes: ${[
349
+ ".carrick/",
350
+ ".claude/",
351
+ ".agents/",
352
+ ".codex/",
353
+ ...(options.workspaceFile ? [WORKSPACE_FILE] : []),
354
+ ...outside,
355
+ ...(options.claudeCode ? ["Claude Code's MCP servers"] : []),
356
+ ].join(", ")}`;
286
357
  }
287
358
  /**
288
359
  * An end to the run with its own exit code, decided before anything is written.
@@ -405,7 +476,7 @@ export async function chooseRepos(plan, candidates, options, out) {
405
476
  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}.`);
406
477
  }
407
478
  const { keep, reasons } = preselectedRepos(candidates, options.identity ?? null, options.label);
408
- const kept = new Set(await out.choose("Which repos does this install cover?", "a repo", repoChoices(plan, candidates, reasons), { initial: keep, required: true }));
479
+ const kept = new Set(await out.choose("Which repos should Carrick index?", "a repo", repoChoices(plan, candidates, reasons), { initial: keep, required: true }));
409
480
  return candidates.filter((repo) => kept.has(repo.path));
410
481
  }
411
482
  /**
@@ -472,31 +543,6 @@ export async function chooseEditors(offered, options, out) {
472
543
  hint: client.installed ? client.file : `${client.file}, not detected on this machine`,
473
544
  })), { initial: offered.filter((client) => client.installed).map((client) => client.name), required: false });
474
545
  }
475
- /** What the proposal question adds when an editor file is part of the answer. */
476
- export function editorClause(offered, chosen) {
477
- const files = offered.filter((client) => chosen.includes(client.name)).map((client) => client.file);
478
- if (files.length === 0)
479
- return "";
480
- return `, and add Carrick to ${files.join(" and ")}`;
481
- }
482
- /** The repos this install covers, one line each, for the proposal. */
483
- export function coverageLines(plan, selected) {
484
- return selected.slice(0, 10).map((repo) => {
485
- const found = plan.repos.find((entry) => entry.path === repo.path);
486
- const packages = found?.services.length ?? 0;
487
- return ` ${repo.name ?? path.basename(repo.path)} ${packages} package${packages === 1 ? "" : "s"}`;
488
- });
489
- }
490
- /**
491
- * What the server would be asked to change, said before it is asked.
492
- *
493
- * A move is named with the project it comes OUT of, display name and slug
494
- * both, because that is the project whose agents stop seeing this repo and
495
- * the one a reader has to find again to undo it (carrick#1338).
496
- */
497
- export function moveLines(moving, from, target, label) {
498
- return moving.map((name) => ` ${name} will move from ${label(from(name))} to ${label(target)}`);
499
- }
500
546
  export async function init(argv) {
501
547
  return initWith(argv, createOutput(), process.stdin.isTTY === true);
502
548
  }
@@ -536,6 +582,10 @@ export async function initWith(argv, out, interactive) {
536
582
  let decision;
537
583
  // The repos this run has permission to take out of another project.
538
584
  const movable = new Set();
585
+ // The ones among them that sit in a project somebody chose, at the read the
586
+ // proposal was built from. Their move is said; a repo the App grant has
587
+ // just put in the default project is not (carrick#1489).
588
+ const announced = new Set();
539
589
  // The directory names this run leaves out, for the workspace file below.
540
590
  let deselected = [];
541
591
  // The editors outside this workspace the answer covers, settled before it.
@@ -668,7 +718,16 @@ export async function initWith(argv, out, interactive) {
668
718
  // would do, and the proposal below is where that is accepted. Without
669
719
  // --project the step reads the assignment the repos already have and
670
720
  // offers the list, rather than doing nothing at all (carrick#987).
671
- const prompts = { say: out.say, ask: out.ask, confirm: out.confirm, interactive, assumeYes: parsed.assumeYes };
721
+ const dashboard = workspaceUrls(initial.workspace.slug).projects;
722
+ const prompts = {
723
+ say: out.say,
724
+ ask: out.ask,
725
+ confirm: out.confirm,
726
+ pick: out.pick,
727
+ dashboard: { url: dashboard, open: (url) => void openBrowser(url).catch(() => false) },
728
+ interactive,
729
+ assumeYes: parsed.assumeYes,
730
+ };
672
731
  decision =
673
732
  parsed.project === null
674
733
  ? await projectStep(projectAssignments(initial, names), projects, prompts)
@@ -707,22 +766,6 @@ export async function initWith(argv, out, interactive) {
707
766
  out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
708
767
  for (const missing of plan.missing)
709
768
  out.warn(`Missing workspace override: ${missing}`);
710
- if (selected.length > 1) {
711
- out.say("This install covers:");
712
- for (const line of coverageLines(plan, selected))
713
- out.say(line);
714
- if (selected.length > 10)
715
- out.say(` and ${selected.length - 10} more`);
716
- }
717
- if (project !== null) {
718
- for (const line of moveLines(moving, (name) => assigned.get(name) ?? "", project, label))
719
- out.say(line);
720
- if (joining.length > 0) {
721
- out.say(` ${joining.join(", ")} will be placed in project ${label(project)} once the browser connects ${joining.length === 1 ? "it" : "them"}`);
722
- }
723
- if (decision.create)
724
- out.say(` Project "${project}" will be created in this workspace`);
725
- }
726
769
  // Which editors get an entry, asked before the yes that covers it. The
727
770
  // files are outside the workspace and belong to editors this reader may not
728
771
  // use, and they used to be written on the strength of a yes to the proposal
@@ -730,17 +773,33 @@ export async function initWith(argv, out, interactive) {
730
773
  // and one this machine has no command for is offered unticked.
731
774
  const offered = offeredFileClients();
732
775
  editors = await chooseEditors(offered, { editors: parsed.editors, assumeYes: parsed.assumeYes, interactive }, out);
733
- const subject = packagesFound(plan);
776
+ // The summary the yes is given to: one block per project, each repo once,
777
+ // the one browser step on its own line with its link, and the files by
778
+ // path (carrick#1489).
779
+ const created = decision.create && project !== null
780
+ ? decision.name !== undefined && decision.name !== project
781
+ ? `${decision.name} (${project})`
782
+ : project
783
+ : null;
784
+ for (const line of summaryBlocks({ plan, selected, project, created, label, assigned, moving }))
785
+ out.say(line);
786
+ const unconnected = unconnectedRepos(initial, names);
787
+ if (unconnected.length > 0) {
788
+ out.warn(connectLine(unconnected, names.length, workspaceUrls(initial.workspace.slug).connect));
789
+ }
790
+ out.say(writesLine({
791
+ workspaceFile: deselected.length > 0,
792
+ editorFiles: offered.filter((client) => editors.includes(client.name)).map((client) => client.file),
793
+ claudeCode: claudeCodeFound(),
794
+ }));
734
795
  if (!parsed.assumeYes) {
735
796
  if (!interactive) {
736
797
  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" : ""}.`);
737
798
  }
738
- // The count is in the question, because the listing that used to stand
739
- // above it is gone: this is where a reader decides (carrick#1026). The
740
- // editor files are named in it too, because a question that does not
741
- // name a file is not consent to write it.
742
- const alsoEditors = editorClause(offered, editors);
743
- if (!(await out.confirm(`Write the proposal for ${subject} and configure hooks${alsoEditors}?`))) {
799
+ // The files are named on the line above it, editor files included,
800
+ // because a question that does not name a file is not consent to write
801
+ // it (carrick#1365, carrick#1489).
802
+ if (!(await out.confirm("Write these files?"))) {
744
803
  out.refuse(NOTHING_WRITTEN);
745
804
  throw new Stop(0);
746
805
  }
@@ -761,6 +820,8 @@ export async function initWith(argv, out, interactive) {
761
820
  }
762
821
  for (const name of [...moving, ...joining])
763
822
  movable.add(name.toLowerCase());
823
+ for (const name of moving)
824
+ announced.add(name.toLowerCase());
764
825
  }
765
826
  catch (error) {
766
827
  // A question the reader ended stops the run where it stands. Nothing above
@@ -782,59 +843,27 @@ export async function initWith(argv, out, interactive) {
782
843
  const plan = derived.plan;
783
844
  const project = decision.slug;
784
845
  const label = (slug) => projectLabel(slug, projects);
846
+ // What the reader still has to do, said once at the end rather than between
847
+ // the steps (carrick#1489).
848
+ const todo = [];
849
+ let workspaceSlug = initial.workspace.slug;
850
+ let projectExists = decision.exists;
785
851
  try {
786
- let projectExists = decision.exists;
787
852
  if (decision.create && project !== null) {
788
- const outcome = await createProject(credential.token, project);
853
+ const outcome = await createProject(credential.token, project, decision.name ?? project);
789
854
  if (outcome.kind === "created") {
790
- out.say(`Created project "${project}".`);
855
+ // Known by its name from here on, the same as a listed one.
856
+ projects = [...(projects ?? []), outcome.project];
857
+ // A workspace's first project takes the repos the GitHub App install
858
+ // staged (carrick-cloud#1359); any other starts empty.
859
+ const holds = outcome.project.repo_count;
860
+ out.done(`Created project ${label(project)}${holds > 0 ? ` with ${holds} repo${holds === 1 ? "" : "s"}` : ""}`);
791
861
  projectExists = true;
792
862
  }
793
863
  else if (outcome.kind === "refused") {
794
- out.say(`Carrick did not create "${project}": ${outcome.message}`);
795
- }
796
- }
797
- const identity = await connectRepos(credential.token, names, initial, {
798
- interactive,
799
- project: project ?? undefined,
800
- projectExists,
801
- movable,
802
- label,
803
- say: out.say,
804
- });
805
- // Who this machine is, and where its answers come from. The workspace slug
806
- // is the login: nothing on the credential or in the workspace read names a
807
- // user (carrick#1026).
808
- out.done(project === null
809
- ? `Signed in as ${identity.workspace.slug}`
810
- : `Signed in as ${identity.workspace.slug} · project ${label(project)}`);
811
- if (project !== null && !reposAreInProject(identity, names, project)) {
812
- // An unverified project does not end the run, whether it was named on
813
- // the command line or picked here. Stopping cost someone their hooks and
814
- // their proposal for a browser step they could only take afterwards, and
815
- // the documented command then needed two runs (carrick#993 row 8).
816
- out.warn(`Finish the browser steps above to put these repos in ${label(project)}, then run carrick init --project ${project} again to verify.`);
817
- }
818
- if (identity.allowance_sentence)
819
- out.say(identity.allowance_sentence);
820
- const connected = [];
821
- for (const repo of identity.repos) {
822
- if (!repo.connected)
823
- out.warn(`${repo.full_name} is not connected to this Carrick workspace.`);
824
- else {
825
- connected.push(repo.full_name);
826
- if (repo.services.length > 0)
827
- hostedIndex.push(repo.full_name);
864
+ out.warn(`Carrick did not create ${label(project)}: ${outcome.message}`);
828
865
  }
829
866
  }
830
- if (connected.length > 0)
831
- out.done(connectedLine(connected));
832
- // The rest of the project, which this machine does not hold. Carrick
833
- // answers across every repo in a project, so a folder holding half of one
834
- // is a partial index and nothing here would otherwise say so
835
- // (carrick#993 row 18).
836
- for (const line of absentRepos(identity, names, project, label))
837
- out.warn(line);
838
867
  }
839
868
  catch (error) {
840
869
  process.stderr.write(`carrick init: ${error.message}\n`);
@@ -845,16 +874,14 @@ export async function initWith(argv, out, interactive) {
845
874
  // without a model is written into the repository, because the first scan
846
875
  // is the paid one and it has to run against a config someone has read
847
876
  // (carrick-cloud#799).
877
+ // Both files were named in the question that asked to write them, so
878
+ // neither gets a line of its own (carrick#1489).
848
879
  writeProposal(plan.workspace, derived);
849
- out.done(packagesLine(plan));
850
880
  // And the selection itself, in the file every later command reads. Without
851
881
  // it the choice lasted one run: `carrick refresh` and `carrick index` walk
852
882
  // the folder, the editor hook answers for a file inside a repo nobody
853
883
  // covers, and the next `init` asks again (carrick#1344).
854
- const selection = writeSelection(plan.workspace, deselected);
855
- if (selection !== null && selection.added.length > 0) {
856
- out.done(`${selection.added.join(", ")} excluded in ${WORKSPACE_FILE}, so the scans, the hooks and the next run leave ${selection.added.length === 1 ? "it" : "them"} alone`);
857
- }
884
+ writeSelection(plan.workspace, deselected);
858
885
  }
859
886
  catch (error) {
860
887
  process.stderr.write(`carrick init: ${error.message}\n`);
@@ -910,10 +937,12 @@ export async function initWith(argv, out, interactive) {
910
937
  // from. Written for both hosts for the same reason the task skills are
911
938
  // (carrick#1335): which agent opens this workspace is not a thing init can
912
939
  // know, and a hook file for a host nobody runs costs nothing.
940
+ const hosts = hooksWritten ? ["Claude Code"] : [];
913
941
  try {
914
942
  if (writeCodexHooks(workspace, command.command) === "written") {
915
- out.done(`Codex hook entries written to ${CODEX_HOOKS_FILE}. Codex asks you to trust them the next time it starts; until you do, they do not run.`);
943
+ todo.push("Codex asks you to trust the Carrick hooks the next time it starts; until you do, they do not run.");
916
944
  }
945
+ hosts.push("Codex");
917
946
  }
918
947
  catch (error) {
919
948
  out.refuse(`Could not configure the Codex hooks: ${error.message}. Fix ${CODEX_HOOKS_FILE} and run carrick init again.`);
@@ -922,12 +951,11 @@ export async function initWith(argv, out, interactive) {
922
951
  // somebody hand-edited into invalid JSON is no reason to withhold the bodies
923
952
  // an agent reads. A file this package did not write, or one somebody has
924
953
  // since changed, is left alone and named.
954
+ let skills = false;
925
955
  try {
926
956
  const { done, warn } = taskSkillLines(writeTaskSkills(workspace, { slug: projectSlug }));
927
- for (const line of done)
928
- out.done(line);
929
- for (const line of warn)
930
- out.warn(line);
957
+ skills = done.length > 0;
958
+ todo.push(...warn);
931
959
  // Which build wrote them, so a hook running an older `carrick` than this
932
960
  // one says so instead of answering from files it does not match
933
961
  // (carrick#1372).
@@ -935,25 +963,88 @@ export async function initWith(argv, out, interactive) {
935
963
  const ignored = ignoredSkillRoots(workspace);
936
964
  if (ignored.length > 0) {
937
965
  const one = ignored.length === 1;
938
- out.warn(`${ignored.join(" and ")} ${one ? "is" : "are"} git-ignored here, so these skills stay on this machine. Track ${one ? "it" : "them"} to give the rest of the team the same answers.`);
966
+ todo.push(`${ignored.join(" and ")} ${one ? "is" : "are"} git-ignored here, so these skills stay on this machine. Track ${one ? "it" : "them"} to give the rest of the team the same answers.`);
939
967
  }
940
968
  }
941
969
  catch (error) {
942
970
  out.refuse(`Could not write the task skills: ${error.message}`);
943
971
  }
944
972
  const mcp = connectMcpClients(editors);
945
- if (hooksWritten)
946
- out.done(configuredLine(mcp));
947
- for (const line of mcpClientLines(mcp))
948
- out.done(line);
949
973
  // Nothing here about `carrick` not being on PATH: the offer above said it, in
950
974
  // front of the decision it changes, and with the command for this machine's
951
975
  // package manager rather than a guess at npm (carrick#1372).
976
+ if (mcp.some((outcome) => outcome.client === "Claude Code" && outcome.state === "written")) {
977
+ todo.push("Restart Claude Code to load the Carrick MCP server.");
978
+ }
952
979
  for (const outcome of mcp.filter((entry) => entry.state === "failed")) {
953
- out.warn(`MCP not configured for ${outcome.client}: ${outcome.detail}`);
980
+ todo.push(`MCP not configured for ${outcome.client}: ${outcome.detail}`);
981
+ }
982
+ // Said as what is missing, not as "no agent client": the hooks above were
983
+ // written for two of them (carrick#1489).
984
+ if (!mcp.some((outcome) => outcome.client === "Claude Code")) {
985
+ todo.push(`No claude command on this machine, so Claude Code has no Carrick MCP server. Once it is installed: ${mcpLine()}`);
954
986
  }
955
- if (mcp.length === 0)
956
- out.warn(`No agent client found on this machine. In Claude Code: ${mcpLine()}`);
987
+ // The connection, last of the steps that talk to Carrick, so a read that
988
+ // fails costs nothing already decided: the files above are written
989
+ // (carrick#1489 review).
990
+ //
991
+ // Read again, now. The read the questions were answered from can be minutes
992
+ // old — the App is often installed in another tab while init waits at a
993
+ // prompt — and a first project may have just taken the repos. The
994
+ // connection lines used to come from the old read, and sent a reader to the
995
+ // grant page for repos that were already connected (carrick#1489).
996
+ try {
997
+ const current = await resolveRepos(credential.token, names);
998
+ const identity = await connectRepos(credential.token, names, current, {
999
+ interactive,
1000
+ project: project ?? undefined,
1001
+ projectExists,
1002
+ movable,
1003
+ announce: announced,
1004
+ label,
1005
+ say: out.say,
1006
+ track: (text, work, report) => out.step(text, work, report),
1007
+ });
1008
+ workspaceSlug = identity.workspace.slug;
1009
+ if (project !== null && !reposAreInProject(identity, names, project)) {
1010
+ // An unverified project does not end the run, whether it was named on
1011
+ // the command line or picked here. Stopping cost someone their hooks and
1012
+ // their proposal for a browser step they could only take afterwards, and
1013
+ // the documented command then needed two runs (carrick#993 row 8).
1014
+ todo.push(`Finish the browser steps above to put these repos in ${label(project)}, then run carrick init --project ${project} again to verify.`);
1015
+ }
1016
+ // After a wait that was stopped, the grant is still the thing to do, and
1017
+ // the line that said so is above the wait.
1018
+ const unconnected = unconnectedRepos(identity, names);
1019
+ if (interactive && unconnected.length > 0) {
1020
+ todo.push(connectLine(unconnected, names.length, workspaceUrls(identity.workspace.slug).connect));
1021
+ }
1022
+ if (identity.allowance_sentence)
1023
+ out.say(identity.allowance_sentence);
1024
+ for (const repo of identity.repos) {
1025
+ if (repo.connected && repo.services.length > 0)
1026
+ hostedIndex.push(repo.full_name);
1027
+ }
1028
+ // The rest of the project, which this machine does not hold. Carrick
1029
+ // answers across every repo in a project, so a folder holding half of one
1030
+ // is a partial index and nothing here would otherwise say so
1031
+ // (carrick#993 row 18).
1032
+ todo.push(...absentRepos(identity, names, project, label));
1033
+ }
1034
+ catch (error) {
1035
+ // The server's own sentence says what failed; this one says what is left.
1036
+ todo.push(`${error.message} The files here are written; run carrick init again to verify the connection and the project.`);
1037
+ }
1038
+ // What is set up, in one line; then the index; then what is left to do;
1039
+ // then the instruction for the agent (carrick#1489).
1040
+ out.done(setupLine({
1041
+ repos: selectedRepos.map((repo) => repo.name ?? path.basename(repo.path)),
1042
+ workspace: workspaceSlug,
1043
+ project: project === null ? null : label(project),
1044
+ hooks: hosts,
1045
+ skills,
1046
+ mcp: mcp.filter((outcome) => outcome.state !== "failed").map((outcome) => outcome.client),
1047
+ }));
957
1048
  // No paid scan ran here, and that is the point (carrick-cloud#799): the one
958
1049
  // scan runs against a config someone has read. Where CI has already built an
959
1050
  // index, this run reads it onto the machine instead — the hosted index is
@@ -971,21 +1062,30 @@ export async function initWith(argv, out, interactive) {
971
1062
  const startedAt = Date.now();
972
1063
  await out.step(STEP_LABEL, (progress) => downloadHostedIndex(plan.workspace, nativeRunner(out.quiet, progress), plan.repos.map((repo) => repo.path), progress), (outcome) => hostedReport(outcome, (Date.now() - startedAt) / 1000));
973
1064
  }
974
- else {
975
- out.done("No index yet: your agent runs the one scan");
976
- }
1065
+ for (const line of todo)
1066
+ out.warn(line);
977
1067
  // The scaffold tool writes the files a repo needs; a repo that has them needs
978
1068
  // nothing pasted anywhere. The line used to close every run, including one
979
1069
  // where every covered repo already had its config and its workflow, and
980
1070
  // "this repo" named nothing in a folder of several (carrick#1365).
1071
+ //
1072
+ // An install the first scan would be refused over goes in front of it: the
1073
+ // agent otherwise found out from the refusal (carrick#1489).
1074
+ const installs = installCommands(plan);
981
1075
  const unscaffolded = reposToScaffold(selectedRepos);
982
1076
  if (unscaffolded.length > 0) {
983
- out.note("Next: paste this to your agent", [scaffoldSentence(unscaffolded)]);
1077
+ const install = installSentence(installs, "agent");
1078
+ out.note("Next: paste this to your agent", [
1079
+ ...(install === null ? [] : [install]),
1080
+ scaffoldSentence(unscaffolded),
1081
+ ]);
984
1082
  }
985
1083
  else {
1084
+ const install = installSentence(installs, "reader");
1085
+ if (install !== null)
1086
+ out.warn(install);
986
1087
  out.done(READY_SENTENCE);
987
1088
  }
988
- out.say(`Docs: ${out.accent(DOCS)}`);
989
1089
  return 0;
990
1090
  }
991
1091
  /** `carrick templates <name>`, so a workflow is one command. */