carrick 0.3.91 → 0.3.93

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 +7 -3
  2. package/bin/carrick.mjs +2 -2
  3. package/dist/auth/credentials.d.ts +1 -1
  4. package/dist/auth/credentials.js +1 -1
  5. package/dist/auth/credentials.js.map +1 -1
  6. package/dist/auth/oauth.d.ts +8 -0
  7. package/dist/auth/oauth.js +9 -1
  8. package/dist/auth/oauth.js.map +1 -1
  9. package/dist/auth/read.d.ts +5 -0
  10. package/dist/auth/read.js +11 -1
  11. package/dist/auth/read.js.map +1 -1
  12. package/dist/auth/run.d.ts +29 -1
  13. package/dist/auth/run.js +68 -9
  14. package/dist/auth/run.js.map +1 -1
  15. package/dist/init/connect.d.ts +19 -7
  16. package/dist/init/connect.js +29 -16
  17. package/dist/init/connect.js.map +1 -1
  18. package/dist/init/projects.d.ts +16 -3
  19. package/dist/init/projects.js +27 -18
  20. package/dist/init/projects.js.map +1 -1
  21. package/dist/init/remove.js +28 -5
  22. package/dist/init/remove.js.map +1 -1
  23. package/dist/init/repo-copies.d.ts +56 -0
  24. package/dist/init/repo-copies.js +241 -0
  25. package/dist/init/repo-copies.js.map +1 -0
  26. package/dist/init/repos.d.ts +27 -0
  27. package/dist/init/repos.js +67 -0
  28. package/dist/init/repos.js.map +1 -1
  29. package/dist/init/run.d.ts +109 -48
  30. package/dist/init/run.js +331 -154
  31. package/dist/init/run.js.map +1 -1
  32. package/dist/init/task-skills.d.ts +6 -6
  33. package/dist/init/task-skills.js +9 -9
  34. package/dist/init/task-skills.js.map +1 -1
  35. package/package.json +6 -6
  36. package/sidecar/dist/src/capture/check-probe.d.ts +6 -1
  37. package/sidecar/dist/src/capture/check-probe.js +7 -2
  38. package/sidecar/dist/src/capture/index.d.ts +1 -0
  39. package/sidecar/dist/src/capture/index.js +1 -0
  40. package/sidecar/dist/src/index.js +8 -2
  41. package/sidecar/dist/src/retype.d.ts +41 -4
  42. package/sidecar/dist/src/retype.js +82 -8
package/dist/init/run.js CHANGED
@@ -14,14 +14,15 @@ import { readCredential } from "../auth/credentials.js";
14
14
  import { openBrowser } from "../auth/oauth.js";
15
15
  import { signIn } from "../auth/run.js";
16
16
  import { resolveRepos } from "../auth/read.js";
17
- import { deriveWorkspace, selectedProposal, selectRepos, writeProposal, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
17
+ import { deriveWorkspace, selectedProposal, selectRepos, siblingRepos, initFolderAbove, writeProposal, realPath, repoRoots, MAX_SIBLINGS, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
18
18
  import { connectLine, connectRepos, projectAssignments, reposAreInProject, unconnectedRepos, workspaceUrls, } from "./connect.js";
19
19
  import { downloadHostedIndex, hostedReport, nativeRunner, STEP_LABEL } from "./hosted.js";
20
- import { createProject, listProjects, planProject, projectLabel, projectStep, SLUG, } from "./projects.js";
20
+ import { createProject, listProjects, planProject, projectLabel, projectStep, reposPhrase, SLUG, } from "./projects.js";
21
+ import { writeRepoCopy } from "./repo-copies.js";
21
22
  import { claudeCodeFound, connectMcpClients, mcpLine, offeredFileClients } from "./mcp.js";
22
23
  import { hookCommand, mergeCarrickHooks, removeCarrickHooks } from "./settings.js";
23
24
  import { CODEX_HOOKS_FILE, writeCodexHooks } from "./codex.js";
24
- import { ignoredSkillRoots, taskSkillLines, writeTaskSkills } from "./task-skills.js";
25
+ import { ignoredSkillRoots, taskSkillWarnings, writeTaskSkills } from "./task-skills.js";
25
26
  import { recordCliVersion } from "./outdated.js";
26
27
  import { findCarrick, offerGlobalInstall } from "../global-install.js";
27
28
  import { currentVersion } from "../update.js";
@@ -62,6 +63,15 @@ export function scaffoldSentence(repos) {
62
63
  const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
63
64
  return `Run the carrick scaffold tool for ${shown}, once each, passing that repo's owner/repo as \`repo\`, and follow what it returns.`;
64
65
  }
66
+ /** The title of the block a run closes on when there is scaffolding left to do. */
67
+ export const NEXT_TITLE = "Next: paste this into a new agent session";
68
+ /**
69
+ * The line that tells the agent where the proposal is, for a run that set up
70
+ * the folder above the one it started in (carrick#1512).
71
+ */
72
+ export function initFolderSentence(workspace) {
73
+ return `The init folder is ${tilde(workspace)}.`;
74
+ }
65
75
  /** What a run with nothing left to set up closes on: what this machine can do now. */
66
76
  export const READY_SENTENCE = "The index is on this machine: `carrick check <file>` answers for a file, and your agent can ask who calls an operation.";
67
77
  const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -200,7 +210,7 @@ function carrickOnPath() {
200
210
  return findCarrick() !== null;
201
211
  }
202
212
  /**
203
- * The repos a project holds that this folder does not, one line per project.
213
+ * The repos a project holds that this machine does not, one line per project.
204
214
  *
205
215
  * Carrick answers across every repo in a project, so a machine holding half of
206
216
  * one gets half the answers, and the connection line above names only what is
@@ -208,9 +218,13 @@ function carrickOnPath() {
208
218
  * membership of every project the requested repos are in (carrick#993 row 18).
209
219
  * Capped and counted like the identity lines, because a project can hold two
210
220
  * hundred.
221
+ *
222
+ * `onMachine` is every GitHub repo found on this machine, not only the ones
223
+ * this run set up: a repo in the folder beside this one is on the machine, and
224
+ * saying otherwise was the false line in carrick#1512.
211
225
  */
212
- export function absentRepos(identity, names, project, label = (slug) => slug) {
213
- const onDisk = new Set(names.map((name) => name.toLowerCase()));
226
+ export function absentRepos(identity, onMachine, project, label = (slug) => slug) {
227
+ const onDisk = new Set(onMachine.map((name) => name.toLowerCase()));
214
228
  const sole = identity.project_repos.length === 1;
215
229
  const lines = [];
216
230
  for (const entry of identity.project_repos) {
@@ -239,23 +253,20 @@ function listed(items, cap) {
239
253
  return `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
240
254
  }
241
255
  /**
242
- * The one line that says what is set up (carrick#1489).
256
+ * The one line that says what is set up: the project and the repos in it
257
+ * from this run (carrick#1489, carrick#1512).
243
258
  *
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.
259
+ * The line David's mock settled on. What was written was the list the reader
260
+ * said yes to, and a hook file that could not be written has its own line, so
261
+ * what is left to say is where the repos are. Only repos with a GitHub remote
262
+ * are named: a repo with none is in no project.
248
263
  */
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(", ")}`;
264
+ export function summaryLine(project, repos) {
265
+ const sorted = [...repos].sort();
266
+ const names = sorted.length > 10 ? `${sorted.slice(0, 10).join(", ")} and ${sorted.length - 10} more` : sorted.join(", ");
267
+ if (project === null)
268
+ return repos.length === 0 ? "No project" : `No project: ${names}`;
269
+ return repos.length === 0 ? project : `${project}: ${names}`;
259
270
  }
260
271
  /**
261
272
  * The installs a first scan would be refused over, as commands to run.
@@ -288,72 +299,145 @@ export function installSentence(commands, to) {
288
299
  ? `First install dependencies: run ${listed(commands, 5)}.`
289
300
  : `Dependencies are not installed. Run ${listed(commands, 5)} before the next scan.`;
290
301
  }
302
+ /** A path as a reader types it, with the home directory as `~`. */
303
+ export function tilde(target, home = os.homedir()) {
304
+ if (target === home)
305
+ return "~";
306
+ return target.startsWith(`${home}${path.sep}`) ? `~${target.slice(home.length)}` : target;
307
+ }
291
308
  /**
292
- * One block per project: the repos in it, each once, with what is about to
293
- * happen to it (carrick#1489).
309
+ * The files the write step creates, as one line of the list "Go ahead?" is
310
+ * asked about (carrick#1489, carrick#1512).
294
311
  *
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.
312
+ * Directories, because each holds several files and the line has to stay one
313
+ * sentence; every file outside the workspace by its own path, because those
314
+ * belong to somebody's editor. Claude Code's server list is written by its own
315
+ * command, so it is named rather than given a path. It says "add", because
316
+ * that is what happens: every writer here merges, and a line that listed
317
+ * folders read as though it replaced them. And it says where: a folder of
318
+ * repos gets the hooks and skills in the folder and again in each repo, with
319
+ * that repo's `.git/info/exclude` (carrick#1512, option A), so `folder` is the
320
+ * folder as the reader types it, and null for a run in one repo.
299
321
  */
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)}`;
322
+ export function writesLine(options) {
323
+ const mcp = [
324
+ ...(options.claudeCode ? ["Claude Code"] : []),
325
+ ...options.editorFiles.map((file) => tilde(file, options.home)),
326
+ ];
327
+ const where = options.folder === null ? "" : ` in ${options.folder} and in each repo`;
328
+ const parts = [
329
+ `Add Carrick's hooks and skills to .claude/, .agents/ and .codex/${where}`,
330
+ options.workspaceFile ? `create .carrick/ and ${WORKSPACE_FILE}` : "create .carrick/",
331
+ ...(mcp.length > 0 ? [`add Carrick's MCP server to ${listed(mcp, 5)}`] : []),
332
+ ];
333
+ return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`;
334
+ }
335
+ /** The width a line of the "Next:" list wraps at, inside the gutter. */
336
+ export const NEXT_WIDTH = 68;
337
+ /**
338
+ * One line of the list, broken between words so that it stays inside the
339
+ * terminal's gutter: a line the terminal wraps for itself runs back to the
340
+ * first column, under the gutter, which is what the smoke run showed
341
+ * (carrick#1512).
342
+ */
343
+ export function wrapped(text, width = NEXT_WIDTH) {
344
+ const lines = [];
345
+ let current = "";
346
+ for (const word of text.split(" ")) {
347
+ if (current !== "" && current.length + 1 + word.length > width) {
348
+ lines.push(current);
349
+ current = word;
305
350
  }
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]);
351
+ else
352
+ current = current === "" ? word : `${current} ${word}`;
312
353
  }
313
- const moving = new Set(input.moving.map((name) => name.toLowerCase()));
354
+ if (current !== "")
355
+ lines.push(current);
356
+ return lines;
357
+ }
358
+ /** The list as it is printed: a heading, and each line indented and wrapped. */
359
+ export function nextBlock(lines) {
360
+ return ["Next:", ...lines.flatMap((line) => wrapped(line).map((part) => ` ${part}`))].join("\n");
361
+ }
362
+ /**
363
+ * The GitHub App line of that list: which repos still need the App.
364
+ *
365
+ * With a terminal, init opens the page itself once the reader says yes, so the
366
+ * line says that and no address. Without one it cannot, and the line carries
367
+ * the address instead: it is the only place a run with no terminal says it.
368
+ */
369
+ export function connectItem(unconnected, total, interactive, url) {
370
+ const which = unconnected.length === total && total === 2
371
+ ? "both repos"
372
+ : unconnected.length === total && total > 2
373
+ ? `all ${total} repos`
374
+ : reposPhrase(unconnected);
375
+ return interactive
376
+ ? `Open GitHub to install Carrick on ${which}`
377
+ : `Install the Carrick GitHub App on ${which}: ${url}`;
378
+ }
379
+ /**
380
+ * The moves out of other projects, one line per project they come from, in
381
+ * the spelling the move question uses too.
382
+ */
383
+ export function moveLines(moving, project) {
384
+ return [...new Set(moving.map((entry) => entry.from))].map((from) => {
385
+ const repos = moving.filter((entry) => entry.from === from).map((entry) => entry.repo);
386
+ return `Move ${reposPhrase(repos)} from ${from} into ${project}`;
387
+ });
388
+ }
389
+ /**
390
+ * Everything the run will do, one line each, for the one question that covers
391
+ * it (carrick#1512).
392
+ *
393
+ * A project used to be created at its own question, before the reader had seen
394
+ * which repos went into it, and the GitHub App line was printed twice around
395
+ * the files question. Now the project, the App and the files are three lines
396
+ * under "Next:", and "Go ahead?" is asked once, about all of them. A move out
397
+ * of another project is a line here too, and still its own question after this
398
+ * one, because `--yes` never grants it (carrick#1338).
399
+ */
400
+ export function nextLines(input) {
314
401
  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`);
402
+ if (input.project !== null && input.create) {
403
+ lines.push(`Create project ${input.project} with ${reposPhrase(input.repos)}`);
404
+ }
405
+ else if (input.project !== null && input.joining.length > 0) {
406
+ lines.push(`Add ${reposPhrase(input.joining)} to project ${input.project}`);
333
407
  }
408
+ if (input.project !== null) {
409
+ for (const line of moveLines(input.moving, input.project))
410
+ lines.push(line);
411
+ }
412
+ if (input.connect !== null)
413
+ lines.push(input.connect);
414
+ lines.push(input.writes);
334
415
  return lines;
335
416
  }
336
417
  /**
337
- * The files the write step creates, by path, for the question that asks to
338
- * write them (carrick#1489).
418
+ * The line a run inside one repo prints about the others beside it, where it
419
+ * does not ask about them: too many to list, or no terminal to ask in.
420
+ */
421
+ export function thisRepoOnly(repo) {
422
+ return `Setting up ${repo} only. To set up several repos as one system, run carrick init in the folder that holds them.`;
423
+ }
424
+ /**
425
+ * Which of the repos beside this one belong to the same system, asked in the
426
+ * run itself (carrick#1512).
339
427
  *
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.
428
+ * The run used to name the folder above and tell the reader to start again
429
+ * there. This repo starts ticked and the others do not: a sibling folder is
430
+ * not a claim that the repos in it call each other. Each row names the GitHub
431
+ * repo its remote points at, which is what the reader recognises when two
432
+ * folders are called something else.
344
433
  */
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(", ")}`;
434
+ export async function chooseSiblings(current, siblings, directory, out) {
435
+ const rows = [current, ...siblings].map((repo) => ({
436
+ value: repo.path,
437
+ label: repo === current ? `${path.basename(repo.path)} (this repo)` : path.basename(repo.path),
438
+ hint: repo.name ?? "no GitHub remote",
439
+ }));
440
+ return out.choose(`${tilde(directory)} holds other repos. Which belong to the same system as ${path.basename(current.path)}?`, "a repo", rows, { initial: [current.path], required: true });
357
441
  }
358
442
  /**
359
443
  * An end to the run with its own exit code, decided before anything is written.
@@ -556,11 +640,26 @@ export async function initWith(argv, out, interactive) {
556
640
  process.stdout.write(`${parsed}\n`);
557
641
  return parsed.startsWith("carrick init") ? 0 : 2;
558
642
  }
559
- const { workspace } = parsed;
643
+ // Where the run was started, and where it sets up: the folder above, once
644
+ // the reader has said a repo beside this one belongs with it (carrick#1512).
645
+ const startedIn = parsed.workspace;
646
+ let workspace = parsed.workspace;
560
647
  if (!fs.existsSync(workspace)) {
561
648
  process.stderr.write(`carrick init: ${workspace} is not a directory on this machine\n`);
562
649
  return 1;
563
650
  }
651
+ // A repo the folder above has already set up is set up from that folder
652
+ // again, rather than on its own beside it (carrick#1512).
653
+ const above = initFolderAbove(workspace);
654
+ if (above !== null) {
655
+ out.say(`${path.basename(realPath(workspace))} is set up with the repos in ${tilde(above)}, so this run sets up ${tilde(above)}.`);
656
+ workspace = above;
657
+ }
658
+ // How to run this again, naming the folder where the run set up a folder
659
+ // other than the one it started in.
660
+ const again = (flags = "") => realPath(workspace) === realPath(startedIn)
661
+ ? `run carrick init${flags} again`
662
+ : `run carrick init${flags} in ${tilde(workspace)} again`;
564
663
  // Everything this run would change is decided before any of it happens: the
565
664
  // repos it covers, the project they belong in, and the moves that would
566
665
  // take. `--project` used to move repos on the server as its first act and
@@ -609,7 +708,47 @@ export async function initWith(argv, out, interactive) {
609
708
  // The derivation and the identities are local reads: they name the choice,
610
709
  // and a run nobody has chosen in yet must not have signed anything in.
611
710
  derived = deriveWorkspace(workspace);
612
- const candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
711
+ let candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
712
+ // The repos beside this one, asked about here rather than named with an
713
+ // instruction to start again in the folder above (carrick#1512). A yes to
714
+ // any of them carries on as if the run had started there: the same
715
+ // derivation, the same proposal, and the repos left unticked written to
716
+ // the same exclude list a folder run writes.
717
+ let ticked = null;
718
+ const parent = derived.plan.parent_proposal;
719
+ if (parent !== null && candidates.length === 1) {
720
+ const current = candidates[0];
721
+ const siblings = siblingRepos(derived.plan, excludedRepos(parent.directory));
722
+ const asking = siblings.length > 0 &&
723
+ siblings.length <= MAX_SIBLINGS &&
724
+ interactive &&
725
+ !parsed.assumeYes &&
726
+ parsed.repos.length === 0;
727
+ if (asking) {
728
+ const picked = new Set((await chooseSiblings(current, siblings.map((repo) => repoIdentity(repo)), parent.directory, out)).map(realPath));
729
+ if (picked.size > 1 || !picked.has(realPath(current.path))) {
730
+ workspace = parent.directory;
731
+ derived = deriveWorkspace(workspace);
732
+ candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
733
+ const chosen = candidates.filter((repo) => picked.has(realPath(repo.path)));
734
+ // This repo first, where the reader kept it: it is the one they
735
+ // started in, and every sentence below names it first.
736
+ ticked = [
737
+ ...chosen.filter((repo) => realPath(repo.path) === realPath(current.path)),
738
+ ...chosen.filter((repo) => realPath(repo.path) !== realPath(current.path)),
739
+ ];
740
+ }
741
+ }
742
+ else if (siblings.length > 0) {
743
+ out.say(thisRepoOnly(path.basename(current.path)));
744
+ }
745
+ else if (parent.repos.some((repo) => realPath(repo) === realPath(parent.directory))) {
746
+ // Not a folder of repos but a repository this directory sits inside,
747
+ // which is where a scan of it belongs: the one case where "run it in
748
+ // the folder above" is still the answer.
749
+ out.warn(`The parent folder ${parent.directory} is itself a repo. Run carrick init .. to initialise that workspace.`);
750
+ }
751
+ }
613
752
  // What Carrick already knows about this folder, before the picker draws
614
753
  // its defaults. A read, not a write: the credential has to exist already,
615
754
  // so a machine nobody has signed in on still chooses first and signs in
@@ -623,10 +762,11 @@ export async function initWith(argv, out, interactive) {
623
762
  const knownNames = [...new Set(candidates.map((repo) => repo.name).filter((name) => name !== null))];
624
763
  // Only where a picker is actually going to draw. Every other path has its
625
764
  // answer already — `--repo` names it, `--yes` takes the list as derived, a
626
- // single repo is not a choice — and asking about a repo whose name the
627
- // reader never offered is a request that buys nothing (carrick#1338's
628
- // test pins it: a repo left out is named in no request at all).
629
- const picking = parsed.repos.length === 0 && !parsed.assumeYes && interactive && candidates.length > 1;
765
+ // single repo is not a choice, the question about the repos beside this
766
+ // one was the choice — and asking about a repo whose name the reader never
767
+ // offered is a request that buys nothing (carrick#1338's test pins it: a
768
+ // repo left out is named in no request at all).
769
+ const picking = ticked === null && parsed.repos.length === 0 && !parsed.assumeYes && interactive && candidates.length > 1;
630
770
  if (picking && saved !== null && knownNames.length > 0) {
631
771
  try {
632
772
  known = await resolveRepos(saved.token, knownNames);
@@ -637,13 +777,14 @@ export async function initWith(argv, out, interactive) {
637
777
  projects = null;
638
778
  }
639
779
  }
640
- const selected = await chooseRepos(derived.plan, candidates, {
641
- repos: parsed.repos,
642
- assumeYes: parsed.assumeYes,
643
- interactive,
644
- identity: known,
645
- label: (slug) => projectLabel(slug, projects),
646
- }, out);
780
+ const selected = ticked ??
781
+ (await chooseRepos(derived.plan, candidates, {
782
+ repos: parsed.repos,
783
+ assumeYes: parsed.assumeYes,
784
+ interactive,
785
+ identity: known,
786
+ label: (slug) => projectLabel(slug, projects),
787
+ }, out));
647
788
  if (selected.length === 0)
648
789
  throw new Stop(0);
649
790
  selectedRepos = selected;
@@ -653,7 +794,10 @@ export async function initWith(argv, out, interactive) {
653
794
  .filter((repo) => !kept.has(repo.path))
654
795
  .map((repo) => path.basename(repo.path));
655
796
  const dropped = candidates.length - selected.length;
656
- if (dropped > 0) {
797
+ // Not after the question about the repos beside this one: its rows were
798
+ // the whole answer, and a folder repo that had no row there (one with no
799
+ // JavaScript or TypeScript in it) is not news.
800
+ if (dropped > 0 && ticked === null) {
657
801
  out.done(`${selected.length} of ${candidates.length} repos covered; ${dropped} left out of the proposal, the project and the connection`);
658
802
  }
659
803
  // A machine that has never signed in signs in here rather than being told
@@ -719,7 +863,16 @@ export async function initWith(argv, out, interactive) {
719
863
  // --project the step reads the assignment the repos already have and
720
864
  // offers the list, rather than doing nothing at all (carrick#987).
721
865
  const dashboard = workspaceUrls(initial.workspace.slug).projects;
866
+ // The repos by the folder names the reader has on disk, in the order the
867
+ // questions name them. Only those with a GitHub remote: a repo with none
868
+ // goes into no project, so no project line names it.
869
+ const named = selected.filter((repo) => repo.name !== null).map((repo) => path.basename(repo.path));
870
+ const shortName = (fullName) => {
871
+ const repo = selected.find((entry) => entry.name?.toLowerCase() === fullName.toLowerCase());
872
+ return repo === undefined ? fullName : path.basename(repo.path);
873
+ };
722
874
  const prompts = {
875
+ repos: named,
723
876
  say: out.say,
724
877
  ask: out.ask,
725
878
  confirm: out.confirm,
@@ -751,12 +904,6 @@ export async function initWith(argv, out, interactive) {
751
904
  // scanner said about it. Everything above is a read; everything below the
752
905
  // confirm is a write.
753
906
  const plan = derived.plan;
754
- if (plan.parent_proposal) {
755
- const parent = plan.parent_proposal;
756
- const folders = parent.repos.map((repo) => path.basename(repo));
757
- const shown = folders.length > 3 ? `${folders.slice(0, 3).join(", ")} and ${folders.length - 3} more` : folders.join(", ");
758
- 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.`);
759
- }
760
907
  // The scanner's own warnings about what it proposed. Capped: the proposal
761
908
  // file carries every one of them, and it is named on the line above.
762
909
  const warnings = plan.repos.flatMap((repo) => repo.warnings);
@@ -773,33 +920,40 @@ export async function initWith(argv, out, interactive) {
773
920
  // and one this machine has no command for is offered unticked.
774
921
  const offered = offeredFileClients();
775
922
  editors = await chooseEditors(offered, { editors: parsed.editors, assumeYes: parsed.assumeYes, interactive }, out);
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);
923
+ // What the yes is given to: every thing this run will do, one line each —
924
+ // the project, the GitHub App, the files — and nothing created before it
925
+ // (carrick#1512). The files are named, editor files included, because a
926
+ // question that does not name a file is not consent to write it
927
+ // (carrick#1365, carrick#1489).
786
928
  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
- }));
929
+ // The project as the list and the move question both spell it: the name
930
+ // typed for one this run creates, the dashboard's label for one that is
931
+ // there.
932
+ const target = project === null ? null : decision.create ? (decision.name ?? project) : label(project);
933
+ const moves = moving.map((name) => ({ repo: shortName(name), from: label(assigned.get(name) ?? "") }));
934
+ const next = nextLines({
935
+ create: decision.create,
936
+ project: target,
937
+ repos: named,
938
+ joining: joining.map(shortName),
939
+ moving: moves,
940
+ connect: unconnected.length === 0
941
+ ? null
942
+ : connectItem(unconnected.map(shortName), names.length, interactive, workspaceUrls(initial.workspace.slug).connect),
943
+ writes: writesLine({
944
+ workspaceFile: deselected.length > 0,
945
+ editorFiles: offered.filter((client) => editors.includes(client.name)).map((client) => client.file),
946
+ claudeCode: claudeCodeFound(),
947
+ // A folder of repos: the copy in each repo is written as well.
948
+ folder: selected.some((repo) => realPath(repo.path) !== realPath(workspace)) ? tilde(realPath(workspace)) : null,
949
+ }),
950
+ });
951
+ out.say(nextBlock(next));
795
952
  if (!parsed.assumeYes) {
796
953
  if (!interactive) {
797
954
  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" : ""}.`);
798
955
  }
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?"))) {
956
+ if (!(await out.confirm("Go ahead?"))) {
803
957
  out.refuse(NOTHING_WRITTEN);
804
958
  throw new Stop(0);
805
959
  }
@@ -808,12 +962,14 @@ export async function initWith(argv, out, interactive) {
808
962
  // of local files; taking a repo out of a project changes what every agent
809
963
  // querying that project can see, and that is not the same answer
810
964
  // (carrick#1338).
811
- if (moving.length > 0 && project !== null && !parsed.allowMove) {
812
- const from = [...new Set(moving.map((name) => label(assigned.get(name) ?? "")))].join(" and ");
965
+ if (moving.length > 0 && target !== null && !parsed.allowMove) {
966
+ const from = [...new Set(moves.map((entry) => entry.from))].join(" and ");
967
+ const repos = reposPhrase(moves.map((entry) => entry.repo));
813
968
  if (!interactive) {
814
- throw new Stop(1, `${moving.join(", ")} ${moving.length === 1 ? "is" : "are"} in project ${from}. Moving ${moving.length === 1 ? "it" : "them"} into ${label(project)} changes what every agent querying either project can see, so it needs --allow-move.`);
969
+ throw new Stop(1, `${repos} ${moving.length === 1 ? "is" : "are"} in project ${from}. Moving ${moving.length === 1 ? "it" : "them"} into ${target} changes what every agent querying either project can see, so it needs --allow-move.`);
815
970
  }
816
- if (!(await out.confirm(`Move ${moving.length === 1 ? moving[0] : `${moving.length} repos`} out of ${from} into ${label(project)}?`))) {
971
+ // The same repo and project spelling as the line in the list above it.
972
+ if (!(await out.confirm(`${moveLines(moves, target).join("; ")}?`))) {
817
973
  out.refuse(NOTHING_WRITTEN);
818
974
  throw new Stop(0);
819
975
  }
@@ -846,18 +1002,16 @@ export async function initWith(argv, out, interactive) {
846
1002
  // What the reader still has to do, said once at the end rather than between
847
1003
  // the steps (carrick#1489).
848
1004
  const todo = [];
849
- let workspaceSlug = initial.workspace.slug;
850
1005
  let projectExists = decision.exists;
851
1006
  try {
852
1007
  if (decision.create && project !== null) {
853
1008
  const outcome = await createProject(credential.token, project, decision.name ?? project);
854
1009
  if (outcome.kind === "created") {
855
- // Known by its name from here on, the same as a listed one.
1010
+ // Known by its name from here on, the same as a listed one. Not said
1011
+ // on a line of its own: the list the reader said yes to named it,
1012
+ // and the line after the connection names it with its repos
1013
+ // (carrick#1512). A create the server refused is still said.
856
1014
  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"}` : ""}`);
861
1015
  projectExists = true;
862
1016
  }
863
1017
  else if (outcome.kind === "refused") {
@@ -915,7 +1069,6 @@ export async function initWith(argv, out, interactive) {
915
1069
  const command = hookCommand({ onPath: carrickOnPath });
916
1070
  const settingsName = path.join(".claude", command.bare ? "settings.json" : "settings.local.json");
917
1071
  const settingsFile = path.join(workspace, settingsName);
918
- let hooksWritten = true;
919
1072
  try {
920
1073
  const settings = fs.existsSync(settingsFile) ? fs.readFileSync(settingsFile, "utf8") : null;
921
1074
  const otherFile = path.join(workspace, ".claude", command.bare ? "settings.local.json" : "settings.json");
@@ -930,32 +1083,26 @@ export async function initWith(argv, out, interactive) {
930
1083
  writeIfChanged(settingsFile, hooks.body);
931
1084
  }
932
1085
  catch (error) {
933
- hooksWritten = false;
934
- out.refuse(`Could not configure Carrick hooks: ${error.message}. Fix ${settingsName} and run carrick init again.`);
1086
+ out.refuse(`Could not configure Carrick hooks: ${error.message}. Fix ${settingsName} and ${again()}.`);
935
1087
  }
936
1088
  // The same two-part nudge for Codex, in the file Codex reads project hooks
937
1089
  // from. Written for both hosts for the same reason the task skills are
938
1090
  // (carrick#1335): which agent opens this workspace is not a thing init can
939
1091
  // know, and a hook file for a host nobody runs costs nothing.
940
- const hosts = hooksWritten ? ["Claude Code"] : [];
941
1092
  try {
942
1093
  if (writeCodexHooks(workspace, command.command) === "written") {
943
1094
  todo.push("Codex asks you to trust the Carrick hooks the next time it starts; until you do, they do not run.");
944
1095
  }
945
- hosts.push("Codex");
946
1096
  }
947
1097
  catch (error) {
948
- out.refuse(`Could not configure the Codex hooks: ${error.message}. Fix ${CODEX_HOOKS_FILE} and run carrick init again.`);
1098
+ out.refuse(`Could not configure the Codex hooks: ${error.message}. Fix ${CODEX_HOOKS_FILE} and ${again()}.`);
949
1099
  }
950
1100
  // The task skills, beside the hooks and independent of them: a settings file
951
1101
  // somebody hand-edited into invalid JSON is no reason to withhold the bodies
952
1102
  // an agent reads. A file this package did not write, or one somebody has
953
1103
  // since changed, is left alone and named.
954
- let skills = false;
955
1104
  try {
956
- const { done, warn } = taskSkillLines(writeTaskSkills(workspace, { slug: projectSlug }));
957
- skills = done.length > 0;
958
- todo.push(...warn);
1105
+ todo.push(...taskSkillWarnings(writeTaskSkills(workspace, { slug: projectSlug })));
959
1106
  // Which build wrote them, so a hook running an older `carrick` than this
960
1107
  // one says so instead of answering from files it does not match
961
1108
  // (carrick#1372).
@@ -969,13 +1116,29 @@ export async function initWith(argv, out, interactive) {
969
1116
  catch (error) {
970
1117
  out.refuse(`Could not write the task skills: ${error.message}`);
971
1118
  }
1119
+ // The same hooks and skills inside each repo of a folder, because an agent
1120
+ // started inside one of them reads neither host's files from the folder
1121
+ // above (carrick#1512, option A as ruled). Kept out of git through each
1122
+ // repo's own exclude file; a repo that is the workspace already has them.
1123
+ for (const repo of selectedRepos) {
1124
+ if (realPath(repo.path) === realPath(workspace))
1125
+ continue;
1126
+ try {
1127
+ // Null for a folder that is not a git repository of its own: its lines
1128
+ // would land in some other repository's exclude file.
1129
+ writeRepoCopy(repo.path, command.command, { slug: projectSlug });
1130
+ }
1131
+ catch (error) {
1132
+ out.refuse(`Could not add Carrick's hooks and skills to ${path.basename(repo.path)}: ${error.message}.`);
1133
+ }
1134
+ }
1135
+ // Nothing here about restarting Claude Code: the instruction this run closes
1136
+ // on is pasted into a new agent session, which loads the MCP server it has
1137
+ // just been given (carrick#1512).
972
1138
  const mcp = connectMcpClients(editors);
973
1139
  // Nothing here about `carrick` not being on PATH: the offer above said it, in
974
1140
  // front of the decision it changes, and with the command for this machine's
975
1141
  // 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
- }
979
1142
  for (const outcome of mcp.filter((entry) => entry.state === "failed")) {
980
1143
  todo.push(`MCP not configured for ${outcome.client}: ${outcome.detail}`);
981
1144
  }
@@ -995,6 +1158,7 @@ export async function initWith(argv, out, interactive) {
995
1158
  // grant page for repos that were already connected (carrick#1489).
996
1159
  try {
997
1160
  const current = await resolveRepos(credential.token, names);
1161
+ const role = current.workspace.role;
998
1162
  const identity = await connectRepos(credential.token, names, current, {
999
1163
  interactive,
1000
1164
  project: project ?? undefined,
@@ -1002,16 +1166,18 @@ export async function initWith(argv, out, interactive) {
1002
1166
  movable,
1003
1167
  announce: announced,
1004
1168
  label,
1169
+ // A role the read does not state is unknown, not a member's.
1170
+ member: role === "member",
1171
+ again: again(),
1005
1172
  say: out.say,
1006
1173
  track: (text, work, report) => out.step(text, work, report),
1007
1174
  });
1008
- workspaceSlug = identity.workspace.slug;
1009
1175
  if (project !== null && !reposAreInProject(identity, names, project)) {
1010
1176
  // An unverified project does not end the run, whether it was named on
1011
1177
  // the command line or picked here. Stopping cost someone their hooks and
1012
1178
  // their proposal for a browser step they could only take afterwards, and
1013
1179
  // 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.`);
1180
+ todo.push(`Finish the browser steps above to put these repos in ${label(project)}, then ${again(` --project ${project}`)} to verify.`);
1015
1181
  }
1016
1182
  // After a wait that was stopped, the grant is still the thing to do, and
1017
1183
  // the line that said so is above the wait.
@@ -1028,23 +1194,29 @@ export async function initWith(argv, out, interactive) {
1028
1194
  // The rest of the project, which this machine does not hold. Carrick
1029
1195
  // answers across every repo in a project, so a folder holding half of one
1030
1196
  // is a partial index and nothing here would otherwise say so
1031
- // (carrick#993 row 18).
1032
- todo.push(...absentRepos(identity, names, project, label));
1197
+ // (carrick#993 row 18). "This machine" is every repo in the folder that
1198
+ // holds this run's repos, set up here or not: a repo in the folder beside
1199
+ // this one is not missing (carrick#1512). Only read where the run's own
1200
+ // repos leave something unaccounted for, because each is a git call.
1201
+ let absent = absentRepos(identity, names, project, label);
1202
+ if (absent.length > 0) {
1203
+ const folder = selectedRepos.some((repo) => realPath(repo.path) === realPath(workspace))
1204
+ ? path.dirname(workspace)
1205
+ : workspace;
1206
+ const here = repoRoots(folder)
1207
+ .map((root) => repoIdentity(root).name)
1208
+ .filter((name) => name !== null);
1209
+ absent = absentRepos(identity, [...names, ...here], project, label);
1210
+ }
1211
+ todo.push(...absent);
1033
1212
  }
1034
1213
  catch (error) {
1035
1214
  // 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.`);
1215
+ todo.push(`${error.message} The files here are written; ${again()} to verify the connection and the project.`);
1037
1216
  }
1038
1217
  // What is set up, in one line; then the index; then what is left to do;
1039
1218
  // 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
- }));
1219
+ out.done(summaryLine(project === null ? null : label(project), selectedRepos.filter((repo) => repo.name !== null).map((repo) => path.basename(repo.path))));
1048
1220
  // No paid scan ran here, and that is the point (carrick-cloud#799): the one
1049
1221
  // scan runs against a config someone has read. Where CI has already built an
1050
1222
  // index, this run reads it onto the machine instead — the hosted index is
@@ -1075,9 +1247,14 @@ export async function initWith(argv, out, interactive) {
1075
1247
  const unscaffolded = reposToScaffold(selectedRepos);
1076
1248
  if (unscaffolded.length > 0) {
1077
1249
  const install = installSentence(installs, "agent");
1078
- out.note("Next: paste this to your agent", [
1250
+ // A new session, because that is the one that loads the MCP server this
1251
+ // run has just added (carrick#1512).
1252
+ out.note(NEXT_TITLE, [
1079
1253
  ...(install === null ? [] : [install]),
1080
1254
  scaffoldSentence(unscaffolded),
1255
+ // The scaffold tool reads the proposal from the folder init ran in, and
1256
+ // a run that moved to the folder above did not run where it started.
1257
+ ...(realPath(workspace) === realPath(startedIn) ? [] : [initFolderSentence(workspace)]),
1081
1258
  ]);
1082
1259
  }
1083
1260
  else {