carrick 0.3.81 → 0.3.83
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/README.md +46 -19
- package/bin/carrick.mjs +45 -2
- package/dist/contract.d.ts +17 -0
- package/dist/contract.js.map +1 -1
- package/dist/hook/apply-patch.d.ts +13 -0
- package/dist/hook/apply-patch.js +100 -0
- package/dist/hook/apply-patch.js.map +1 -0
- package/dist/hook/post-edit.d.ts +30 -1
- package/dist/hook/post-edit.js +95 -24
- package/dist/hook/post-edit.js.map +1 -1
- package/dist/hook/reuse.d.ts +97 -0
- package/dist/hook/reuse.js +245 -0
- package/dist/hook/reuse.js.map +1 -0
- package/dist/hook/stop.d.ts +3 -0
- package/dist/hook/stop.js +76 -0
- package/dist/hook/stop.js.map +1 -0
- package/dist/hook/user-prompt.d.ts +9 -0
- package/dist/hook/user-prompt.js +79 -0
- package/dist/hook/user-prompt.js.map +1 -0
- package/dist/init/codex.d.ts +51 -0
- package/dist/init/codex.js +167 -0
- package/dist/init/codex.js.map +1 -0
- package/dist/init/connect.d.ts +13 -0
- package/dist/init/connect.js +21 -15
- package/dist/init/connect.js.map +1 -1
- package/dist/init/doctor.d.ts +36 -0
- package/dist/init/doctor.js +97 -2
- package/dist/init/doctor.js.map +1 -1
- package/dist/init/files.d.ts +16 -0
- package/dist/init/files.js +35 -0
- package/dist/init/files.js.map +1 -0
- package/dist/init/outdated.d.ts +54 -0
- package/dist/init/outdated.js +175 -0
- package/dist/init/outdated.js.map +1 -0
- package/dist/init/output.d.ts +35 -3
- package/dist/init/output.js +115 -23
- package/dist/init/output.js.map +1 -1
- package/dist/init/projects.d.ts +27 -13
- package/dist/init/projects.js +48 -50
- package/dist/init/projects.js.map +1 -1
- package/dist/init/remove.d.ts +0 -2
- package/dist/init/remove.js +91 -23
- package/dist/init/remove.js.map +1 -1
- package/dist/init/repos.d.ts +34 -0
- package/dist/init/repos.js +77 -0
- package/dist/init/repos.js.map +1 -1
- package/dist/init/run.d.ts +73 -6
- package/dist/init/run.js +382 -101
- package/dist/init/run.js.map +1 -1
- package/dist/init/settings.d.ts +20 -0
- package/dist/init/settings.js +42 -4
- package/dist/init/settings.js.map +1 -1
- package/dist/init/task-skills.d.ts +97 -0
- package/dist/init/task-skills.js +267 -0
- package/dist/init/task-skills.js.map +1 -0
- package/dist/init/workspace-file.d.ts +73 -0
- package/dist/init/workspace-file.js +173 -0
- package/dist/init/workspace-file.js.map +1 -0
- package/dist/scan.d.ts +13 -5
- package/dist/scan.js +21 -10
- package/dist/scan.js.map +1 -1
- package/dist/templates.d.ts +9 -0
- package/dist/templates.js +9 -1
- package/dist/templates.js.map +1 -1
- package/package.json +6 -6
- package/plugin/hooks/hooks.json +11 -0
- package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
- package/sidecar/dist/src/capture/check-classify.js +66 -8
- package/sidecar/dist/src/capture/check-deep.d.ts +16 -4
- package/sidecar/dist/src/capture/check-deep.js +21 -17
- package/sidecar/dist/src/capture/check-fields.d.ts +83 -0
- package/sidecar/dist/src/capture/check-fields.js +259 -0
- package/sidecar/dist/src/capture/check-probe.d.ts +21 -1
- package/sidecar/dist/src/capture/check-probe.js +39 -0
- package/sidecar/dist/src/capture/check.js +9 -2
- package/templates/skills/carrick-census.md +89 -0
- package/templates/skills/carrick-drift.md +107 -0
- package/templates/skills/carrick-impact.md +108 -0
- package/templates/skills/carrick-reuse.md +106 -0
package/dist/init/run.js
CHANGED
|
@@ -13,13 +13,17 @@ import { spawnSync } from "node:child_process";
|
|
|
13
13
|
import { readCredential } from "../auth/credentials.js";
|
|
14
14
|
import { signIn } from "../auth/run.js";
|
|
15
15
|
import { resolveRepos } from "../auth/read.js";
|
|
16
|
-
import { deriveWorkspace, writeProposal, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
|
|
16
|
+
import { deriveWorkspace, selectedProposal, selectRepos, writeProposal, PROPOSAL_FILE, repoIdentity, } from "./repos.js";
|
|
17
17
|
import { connectRepos, reposAreInProject, projectAssignments } from "./connect.js";
|
|
18
18
|
import { downloadHostedIndex, hostedReport, nativeRunner } from "./hosted.js";
|
|
19
|
-
import {
|
|
19
|
+
import { createProject, listProjects, planProject, projectLabel, projectStep, SLUG, } from "./projects.js";
|
|
20
20
|
import { connectMcpClients, mcpLine } from "./mcp.js";
|
|
21
21
|
import { hookCommand, mergeCarrickHooks, removeCarrickHooks } from "./settings.js";
|
|
22
|
-
import {
|
|
22
|
+
import { CODEX_HOOKS_FILE, writeCodexHooks } from "./codex.js";
|
|
23
|
+
import { ignoredSkillRoots, taskSkillLines, writeTaskSkills } from "./task-skills.js";
|
|
24
|
+
import { writeIfChanged } from "./files.js";
|
|
25
|
+
import { excludedRepos, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
|
|
26
|
+
import { createOutput, DOCS, PromptCancelled } from "./output.js";
|
|
23
27
|
import { renderTemplate } from "../templates.js";
|
|
24
28
|
/**
|
|
25
29
|
* The one sentence the run ends on.
|
|
@@ -39,7 +43,7 @@ import { renderTemplate } from "../templates.js";
|
|
|
39
43
|
export const SCAFFOLD_SENTENCE = "Run the carrick scaffold tool for this repo, passing its owner/repo as `repo`, and follow what it returns.";
|
|
40
44
|
const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
41
45
|
export function parseArgs(argv, cwd = process.cwd()) {
|
|
42
|
-
const options = { workspace: cwd, project: null,
|
|
46
|
+
const options = { workspace: cwd, project: null, repos: [], assumeYes: false, allowMove: false };
|
|
43
47
|
for (let index = 0; index < argv.length; index += 1) {
|
|
44
48
|
const argument = argv[index];
|
|
45
49
|
switch (argument) {
|
|
@@ -47,6 +51,9 @@ export function parseArgs(argv, cwd = process.cwd()) {
|
|
|
47
51
|
case "-y":
|
|
48
52
|
options.assumeYes = true;
|
|
49
53
|
break;
|
|
54
|
+
case "--allow-move":
|
|
55
|
+
options.allowMove = true;
|
|
56
|
+
break;
|
|
50
57
|
case "--project": {
|
|
51
58
|
const value = argv[index + 1];
|
|
52
59
|
if (!value)
|
|
@@ -62,9 +69,12 @@ export function parseArgs(argv, cwd = process.cwd()) {
|
|
|
62
69
|
const value = argv[index + 1];
|
|
63
70
|
if (!value)
|
|
64
71
|
return "--repo needs an owner/repo";
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
for (const name of value.split(",")) {
|
|
73
|
+
if (!OWNER_REPO.test(name))
|
|
74
|
+
return `invalid repo "${name}": use owner/repo as GitHub spells it`;
|
|
75
|
+
if (!options.repos.includes(name))
|
|
76
|
+
options.repos.push(name);
|
|
77
|
+
}
|
|
68
78
|
index += 1;
|
|
69
79
|
break;
|
|
70
80
|
}
|
|
@@ -90,32 +100,34 @@ export function parseArgs(argv, cwd = process.cwd()) {
|
|
|
90
100
|
}
|
|
91
101
|
function help() {
|
|
92
102
|
return [
|
|
93
|
-
"carrick init [DIRECTORY] [--project SLUG] [--
|
|
103
|
+
"carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--allow-move]",
|
|
94
104
|
"",
|
|
95
105
|
"Sign in (here, or beforehand with carrick login), then set up a repository",
|
|
96
|
-
"or a folder of repos:
|
|
97
|
-
"the MCP connection, and the service proposal
|
|
98
|
-
"carrick.json. It writes nothing into the repository but
|
|
99
|
-
".carrick directory
|
|
100
|
-
"
|
|
101
|
-
".carrick so this machine can answer
|
|
106
|
+
"or a folder of repos: which repos this install covers, the project, the repo",
|
|
107
|
+
"connection, the agent hooks, the MCP connection, and the service proposal",
|
|
108
|
+
"your agent turns into carrick.json. It writes nothing into the repository but",
|
|
109
|
+
"the ignored .carrick directory, the hook settings, and the four task skills",
|
|
110
|
+
"your agent loads. It runs no analysis: where Carrick already holds an index",
|
|
111
|
+
"for these repos, it reads that index into .carrick so this machine can answer",
|
|
112
|
+
"from it. Nothing is written, here or in Carrick, until you accept what it",
|
|
113
|
+
"proposes.",
|
|
102
114
|
"",
|
|
103
115
|
" -w, --workspace DIR The folder holding the repos (default: this one)",
|
|
104
|
-
" --
|
|
105
|
-
"
|
|
106
|
-
"
|
|
116
|
+
" --repo OWNER/REPO A repo this install covers: repeatable, or one",
|
|
117
|
+
" comma-separated list. In a folder of repos without a",
|
|
118
|
+
" terminal to choose in, this is required. It also names",
|
|
119
|
+
" the GitHub repo whose origin remote names none",
|
|
120
|
+
" --project SLUG Put those repos in this Carrick project, creating it",
|
|
121
|
+
" if it is not there. A repo that is in another project",
|
|
122
|
+
" is MOVED out of it, which changes what every agent",
|
|
123
|
+
" querying either project can see, so the move is named",
|
|
124
|
+
" and asked about separately",
|
|
125
|
+
" --allow-move Accept those moves without being asked. --yes does not",
|
|
126
|
+
" -y, --yes Take the proposal as printed",
|
|
107
127
|
"",
|
|
108
128
|
`The editor extension, the hooks, CI and a carrick.json written by hand: ${DOCS}`,
|
|
109
129
|
].join("\n");
|
|
110
130
|
}
|
|
111
|
-
function writeIfChanged(target, body) {
|
|
112
|
-
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
|
|
113
|
-
if (existing === body)
|
|
114
|
-
return "unchanged";
|
|
115
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
116
|
-
fs.writeFileSync(target, body);
|
|
117
|
-
return "written";
|
|
118
|
-
}
|
|
119
131
|
/** Whether a command answers on this machine, for a line we should not print. */
|
|
120
132
|
function onPath(command) {
|
|
121
133
|
const probe = spawnSync(process.platform === "win32" ? "where" : "which", [command], {
|
|
@@ -133,7 +145,7 @@ function onPath(command) {
|
|
|
133
145
|
* Capped and counted like the identity lines, because a project can hold two
|
|
134
146
|
* hundred.
|
|
135
147
|
*/
|
|
136
|
-
export function absentRepos(identity, names, project) {
|
|
148
|
+
export function absentRepos(identity, names, project, label = (slug) => slug) {
|
|
137
149
|
const onDisk = new Set(names.map((name) => name.toLowerCase()));
|
|
138
150
|
const sole = identity.project_repos.length === 1;
|
|
139
151
|
const lines = [];
|
|
@@ -147,7 +159,9 @@ export function absentRepos(identity, names, project) {
|
|
|
147
159
|
const shown = absent.length > 10
|
|
148
160
|
? `${absent.slice(0, 10).join(", ")} and ${absent.length - 10} more`
|
|
149
161
|
: absent.join(", ");
|
|
150
|
-
const where = sole || entry.project_slug === project
|
|
162
|
+
const where = sole || entry.project_slug === project
|
|
163
|
+
? "this project"
|
|
164
|
+
: `project ${label(entry.project_slug)}`;
|
|
151
165
|
lines.push(`Also in ${where}, not on this machine: ${shown}.`);
|
|
152
166
|
}
|
|
153
167
|
return lines;
|
|
@@ -220,7 +234,126 @@ export function mcpUnstampedLines(mcp) {
|
|
|
220
234
|
.filter((outcome) => outcome.state === "unstamped")
|
|
221
235
|
.map((outcome) => `${outcome.client}: ${outcome.detail}`);
|
|
222
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* An end to the run with its own exit code, decided before anything is written.
|
|
239
|
+
*
|
|
240
|
+
* A refusal ("no terminal and no --yes") and a declined proposal are the same
|
|
241
|
+
* shape — stop here, leave everything alone — and they happen at four points
|
|
242
|
+
* in the decision. Thrown, they all end in one place, beside the cancel, which
|
|
243
|
+
* is the only way to be sure no path between them reaches a write.
|
|
244
|
+
*/
|
|
245
|
+
class Stop extends Error {
|
|
246
|
+
code;
|
|
247
|
+
constructor(code, message = "") {
|
|
248
|
+
super(message);
|
|
249
|
+
this.name = "Stop";
|
|
250
|
+
this.code = code;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** The line a run that was stopped at a question ends on (carrick#1338). */
|
|
254
|
+
export const CANCELLED = "Cancelled. Nothing was written, here or in Carrick.";
|
|
255
|
+
/**
|
|
256
|
+
* The line a declined proposal ends on.
|
|
257
|
+
*
|
|
258
|
+
* A "no" used to be silent, which beside a cancel that says so reads as two
|
|
259
|
+
* different things having happened. Both leave the machine and the server as
|
|
260
|
+
* they were, and both say it.
|
|
261
|
+
*/
|
|
262
|
+
export const NOTHING_WRITTEN = "Nothing written, here or in Carrick.";
|
|
263
|
+
/** A repo as the picker lists it: what it is called, and what is in it. */
|
|
264
|
+
export function repoChoices(plan, candidates) {
|
|
265
|
+
return candidates.map((repo) => {
|
|
266
|
+
const found = plan.repos.find((entry) => entry.path === repo.path);
|
|
267
|
+
const packages = found?.services.length ?? 0;
|
|
268
|
+
const hint = [
|
|
269
|
+
`${packages} package${packages === 1 ? "" : "s"}`,
|
|
270
|
+
...(repo.name === null ? ["no GitHub identity"] : []),
|
|
271
|
+
].join(", ");
|
|
272
|
+
return { value: repo.path, label: repo.name ?? path.basename(repo.path), hint };
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Which repos in this folder this install covers, settled before anything is
|
|
277
|
+
* read from Carrick and long before anything is written (carrick#1338).
|
|
278
|
+
*
|
|
279
|
+
* A folder routinely holds a repo that must not be scanned, and the only
|
|
280
|
+
* answer this command used to take was one yes covering every repo in it. The
|
|
281
|
+
* terminal picks; without one, `--repo` names them; with neither, there is no
|
|
282
|
+
* safe default and the run stops here, having written nothing.
|
|
283
|
+
*/
|
|
284
|
+
export async function chooseRepos(plan, candidates, options, out) {
|
|
285
|
+
if (options.repos.length > 0) {
|
|
286
|
+
const chosen = selectRepos(candidates, options.repos);
|
|
287
|
+
if ("problem" in chosen)
|
|
288
|
+
throw new Error(chosen.problem);
|
|
289
|
+
if (chosen.taken !== null) {
|
|
290
|
+
out.done(`${chosen.taken.name} taken as the GitHub repository for ${chosen.taken.path}`);
|
|
291
|
+
}
|
|
292
|
+
return chosen.repos;
|
|
293
|
+
}
|
|
294
|
+
// One repo is not a choice, and `--yes` is a reader stating that the list as
|
|
295
|
+
// derived is the list they want.
|
|
296
|
+
if (candidates.length === 1 || options.assumeYes)
|
|
297
|
+
return candidates;
|
|
298
|
+
if (!options.interactive) {
|
|
299
|
+
throw new Error(`${plan.workspace} holds ${candidates.length} repos and there is no terminal to choose in. Name the ones this install covers with --repo owner/repo (repeatable), or add --yes to cover all ${candidates.length}.`);
|
|
300
|
+
}
|
|
301
|
+
const kept = new Set(await out.choose("Which repos does this install cover?", repoChoices(plan, candidates)));
|
|
302
|
+
return candidates.filter((repo) => kept.has(repo.path));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The excluded repos a `--repo` flag is asking for, by identity.
|
|
306
|
+
*
|
|
307
|
+
* An excluded repo is invisible to everything downstream — the scanner never
|
|
308
|
+
* derives it, so `selectRepos` sees a value matching nothing on disk and, by
|
|
309
|
+
* carrick#991's rule, attaches it to the one repo here with no GitHub identity
|
|
310
|
+
* of its own. That is a flag quietly covering a different repository, so the
|
|
311
|
+
* question is asked here instead, before any of it.
|
|
312
|
+
*
|
|
313
|
+
* Asked of the directory, not of the spelling: `--repo owner/web` and a
|
|
314
|
+
* directory called `web` are not the same claim, and the repo's own origin
|
|
315
|
+
* remote is what says whether they name one repository. A directory that is
|
|
316
|
+
* gone, or that names no GitHub repo, answers nothing and is not reported —
|
|
317
|
+
* there is no identity to have asked for.
|
|
318
|
+
*/
|
|
319
|
+
export function namedButExcluded(workspace, excluded, requested, identify = repoIdentity) {
|
|
320
|
+
if (requested.length === 0 || excluded.length === 0)
|
|
321
|
+
return [];
|
|
322
|
+
const wanted = new Set(requested.map((name) => name.toLowerCase()));
|
|
323
|
+
return excluded.filter((name) => {
|
|
324
|
+
const directory = path.join(workspace, name);
|
|
325
|
+
if (!fs.existsSync(directory))
|
|
326
|
+
return false;
|
|
327
|
+
const identity = identify(directory);
|
|
328
|
+
return identity.name !== null && wanted.has(identity.name.toLowerCase());
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/** The repos this install covers, one line each, for the proposal. */
|
|
332
|
+
export function coverageLines(plan, selected) {
|
|
333
|
+
return selected.slice(0, 10).map((repo) => {
|
|
334
|
+
const found = plan.repos.find((entry) => entry.path === repo.path);
|
|
335
|
+
const packages = found?.services.length ?? 0;
|
|
336
|
+
return ` ${repo.name ?? path.basename(repo.path)} ${packages} package${packages === 1 ? "" : "s"}`;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* What the server would be asked to change, said before it is asked.
|
|
341
|
+
*
|
|
342
|
+
* A move is named with the project it comes OUT of, display name and slug
|
|
343
|
+
* both, because that is the project whose agents stop seeing this repo and
|
|
344
|
+
* the one a reader has to find again to undo it (carrick#1338).
|
|
345
|
+
*/
|
|
346
|
+
export function moveLines(moving, from, target, label) {
|
|
347
|
+
return moving.map((name) => ` ${name} will move from ${label(from(name))} to ${label(target)}`);
|
|
348
|
+
}
|
|
223
349
|
export async function init(argv) {
|
|
350
|
+
return initWith(argv, createOutput(), process.stdin.isTTY === true);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* `init` with the terminal stated rather than sniffed, so a test can be the
|
|
354
|
+
* terminal: the cancel path and the picker have no other way in.
|
|
355
|
+
*/
|
|
356
|
+
export async function initWith(argv, out, interactive) {
|
|
224
357
|
const parsed = parseArgs(argv);
|
|
225
358
|
if (typeof parsed === "string") {
|
|
226
359
|
process.stdout.write(`${parsed}\n`);
|
|
@@ -231,49 +364,75 @@ export async function init(argv) {
|
|
|
231
364
|
process.stderr.write(`carrick init: ${workspace} is not a directory on this machine\n`);
|
|
232
365
|
return 1;
|
|
233
366
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
|
|
367
|
+
// Everything this run would change is decided before any of it happens: the
|
|
368
|
+
// repos it covers, the project they belong in, and the moves that would
|
|
369
|
+
// take. `--project` used to move repos on the server as its first act and
|
|
370
|
+
// print the proposal afterwards, so a reader's only chance to drop a repo
|
|
371
|
+
// came after that repo had already been moved (carrick#1338).
|
|
372
|
+
//
|
|
238
373
|
// The repos the workspace read found an index for: where CI has already built
|
|
239
374
|
// one, this run reads it rather than ordering a scan (carrick#993 row 2).
|
|
240
375
|
const hostedIndex = [];
|
|
376
|
+
// The project the skills are written against, once it is settled. It stays
|
|
377
|
+
// null where no repo here names a GitHub repository, and the skills then
|
|
378
|
+
// tell the agent to read the scope from the git remote instead.
|
|
379
|
+
let projectSlug = null;
|
|
380
|
+
let derived;
|
|
381
|
+
let credential;
|
|
382
|
+
let names;
|
|
383
|
+
let initial;
|
|
384
|
+
let projects;
|
|
385
|
+
let decision;
|
|
386
|
+
// The repos this run has permission to take out of another project.
|
|
387
|
+
const movable = new Set();
|
|
388
|
+
// The directory names this run leaves out, for the workspace file below.
|
|
389
|
+
let deselected = [];
|
|
241
390
|
try {
|
|
391
|
+
// What a previous run wrote down, before anything is derived from it: the
|
|
392
|
+
// scanner has already dropped an excluded repo by the time the proposal
|
|
393
|
+
// arrives, so this is the only place that can tell a reader why the repo
|
|
394
|
+
// they just named is not here (carrick#1344).
|
|
395
|
+
const excluded = excludedRepos(workspace);
|
|
396
|
+
const asked = namedButExcluded(workspace, excluded, parsed.repos);
|
|
397
|
+
if (asked.length > 0) {
|
|
398
|
+
throw new Error(`${WORKSPACE_FILE} leaves ${asked.join(" and ")} out of this workspace, so --repo cannot cover ${asked.length === 1 ? "it" : "them"}. Take ${asked.length === 1 ? "that name" : "those names"} out of the exclude list there and run carrick init again.`);
|
|
399
|
+
}
|
|
400
|
+
if (excluded.length > 0) {
|
|
401
|
+
out.say(`${WORKSPACE_FILE} leaves ${excluded.join(", ")} out of this workspace, so nothing below covers ${excluded.length === 1 ? "it" : "them"}.`);
|
|
402
|
+
}
|
|
403
|
+
// The derivation and the identities are local reads: they name the choice,
|
|
404
|
+
// and a run nobody has chosen in yet must not have signed anything in.
|
|
405
|
+
derived = deriveWorkspace(workspace);
|
|
406
|
+
const candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
|
|
407
|
+
const selected = await chooseRepos(derived.plan, candidates, { repos: parsed.repos, assumeYes: parsed.assumeYes, interactive }, out);
|
|
408
|
+
if (selected.length === 0)
|
|
409
|
+
throw new Stop(0);
|
|
410
|
+
derived = selectedProposal(derived, selected.map((repo) => repo.path));
|
|
411
|
+
const kept = new Set(selected.map((repo) => repo.path));
|
|
412
|
+
deselected = candidates
|
|
413
|
+
.filter((repo) => !kept.has(repo.path))
|
|
414
|
+
.map((repo) => path.basename(repo.path));
|
|
415
|
+
const dropped = candidates.length - selected.length;
|
|
416
|
+
if (dropped > 0) {
|
|
417
|
+
out.done(`${selected.length} of ${candidates.length} repos covered; ${dropped} left out of the proposal, the project and the connection`);
|
|
418
|
+
}
|
|
242
419
|
// A machine that has never signed in signs in here rather than being told
|
|
243
420
|
// to run another command: `carrick login` is the same browser round trip,
|
|
244
421
|
// and refusing to take it was the first thing a new install did
|
|
245
422
|
// (carrick#955). Without a terminal there is no browser to hand it to.
|
|
246
|
-
|
|
247
|
-
if (
|
|
423
|
+
const saved = readCredential();
|
|
424
|
+
if (saved)
|
|
425
|
+
credential = saved;
|
|
426
|
+
else {
|
|
248
427
|
if (!interactive) {
|
|
249
428
|
throw new Error("carrick init requires a Carrick login. Run carrick login, or set CARRICK_TOKEN.");
|
|
250
429
|
}
|
|
251
430
|
credential = await signIn(out.say);
|
|
252
431
|
}
|
|
253
|
-
|
|
254
|
-
const derivedIdentities = derived.plan.repos.map((repo) => repoIdentity(repo.path));
|
|
255
|
-
// `--repo` names what a remote could not: an ssh alias ssh itself cannot
|
|
256
|
-
// resolve, a mirror, a clone with no origin. It names one repository, so
|
|
257
|
-
// it is taken only when exactly one repo here is missing an identity.
|
|
258
|
-
const unnamed = derivedIdentities.filter((repo) => repo.name === null);
|
|
259
|
-
let taken = null;
|
|
260
|
-
if (parsed.repo !== null) {
|
|
261
|
-
if (unnamed.length > 1) {
|
|
262
|
-
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.`);
|
|
263
|
-
}
|
|
264
|
-
if (unnamed.length === 0) {
|
|
265
|
-
out.warn(`--repo ${parsed.repo} was not needed: every repo here names its own GitHub repository.`);
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
taken = unnamed[0].path;
|
|
269
|
-
out.done(`${parsed.repo} taken as the GitHub repository for ${taken}`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
const repoIdentities = derivedIdentities.map((repo) => repo.path === taken ? { ...repo, name: parsed.repo, problem: null } : repo);
|
|
273
|
-
const names = [...new Set(repoIdentities.map((repo) => repo.name).filter((name) => name !== null))];
|
|
432
|
+
names = [...new Set(selected.map((repo) => repo.name).filter((name) => name !== null))];
|
|
274
433
|
if (names.length > 200)
|
|
275
434
|
throw new Error("This workspace has more than 200 GitHub repos. Initialise smaller workspace groups.");
|
|
276
|
-
const missingIdentities =
|
|
435
|
+
const missingIdentities = selected.filter((repo) => repo.name === null);
|
|
277
436
|
// Said before anything is requested, and said whether or not --project was
|
|
278
437
|
// given: a repo with no identity is left out of the project step, the
|
|
279
438
|
// connection check and the workspace read, and a run that dropped it in
|
|
@@ -302,28 +461,139 @@ export async function init(argv) {
|
|
|
302
461
|
// later upload has no repo identity to resolve a project from.
|
|
303
462
|
out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
|
|
304
463
|
}
|
|
305
|
-
|
|
464
|
+
initial = await resolveRepos(credential.token, names);
|
|
465
|
+
// Read once, for the names: every project this run prints is printed as
|
|
466
|
+
// the dashboard shows it, with the slug beside it (carrick#1338). A
|
|
467
|
+
// workspace whose API has no such action answers null and the lines carry
|
|
468
|
+
// slugs alone, as they did.
|
|
469
|
+
projects = names.length > 0 ? await listProjects(credential.token) : null;
|
|
306
470
|
// The project half of the browser round trip, where this API can do it
|
|
307
|
-
// from here
|
|
308
|
-
//
|
|
309
|
-
// the
|
|
310
|
-
//
|
|
311
|
-
// doing nothing at all (carrick#987).
|
|
471
|
+
// from here. Nothing is created or moved in it: it settles what this run
|
|
472
|
+
// would do, and the proposal below is where that is accepted. Without
|
|
473
|
+
// --project the step reads the assignment the repos already have and
|
|
474
|
+
// offers the list, rather than doing nothing at all (carrick#987).
|
|
312
475
|
const prompts = { say: out.say, ask: out.ask, confirm: out.confirm, interactive, assumeYes: parsed.assumeYes };
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
476
|
+
decision =
|
|
477
|
+
parsed.project === null
|
|
478
|
+
? await projectStep(projectAssignments(initial, names), projects, prompts)
|
|
479
|
+
: reposAreInProject(initial, names, parsed.project)
|
|
480
|
+
? { slug: parsed.project, exists: true, create: false }
|
|
481
|
+
: await planProject(parsed.project, projects, prompts);
|
|
482
|
+
const project = decision.slug;
|
|
483
|
+
projectSlug = project;
|
|
484
|
+
const label = (slug) => projectLabel(slug, projects);
|
|
485
|
+
// What the server would be asked to change, before it is asked: the repos
|
|
486
|
+
// that would leave a project somebody put them in, and the ones the grant
|
|
487
|
+
// has yet to connect at all.
|
|
488
|
+
const current = projectAssignments(initial, names);
|
|
489
|
+
const assigned = new Map(names.map((name, index) => [name, current[index] ?? null]));
|
|
490
|
+
const moving = project === null ? [] : names.filter((name) => {
|
|
491
|
+
const current = assigned.get(name);
|
|
492
|
+
return current !== null && current !== undefined && current !== project;
|
|
493
|
+
});
|
|
494
|
+
const joining = project === null ? [] : names.filter((name) => assigned.get(name) == null);
|
|
495
|
+
// The proposal: what this run covers, what it would change, and what the
|
|
496
|
+
// scanner said about it. Everything above is a read; everything below the
|
|
497
|
+
// confirm is a write.
|
|
498
|
+
const plan = derived.plan;
|
|
499
|
+
if (plan.parent_proposal) {
|
|
500
|
+
const parent = plan.parent_proposal;
|
|
501
|
+
const folders = parent.repos.map((repo) => path.basename(repo));
|
|
502
|
+
const shown = folders.length > 3 ? `${folders.slice(0, 3).join(", ")} and ${folders.length - 3} more` : folders.join(", ");
|
|
503
|
+
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.`);
|
|
504
|
+
}
|
|
505
|
+
// The scanner's own warnings about what it proposed. Capped: the proposal
|
|
506
|
+
// file carries every one of them, and it is named on the line above.
|
|
507
|
+
const warnings = plan.repos.flatMap((repo) => repo.warnings);
|
|
508
|
+
for (const warning of warnings.slice(0, 3))
|
|
509
|
+
out.warn(warning);
|
|
510
|
+
if (warnings.length > 3)
|
|
511
|
+
out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
|
|
512
|
+
for (const missing of plan.missing)
|
|
513
|
+
out.warn(`Missing workspace override: ${missing}`);
|
|
514
|
+
if (selected.length > 1) {
|
|
515
|
+
out.say("This install covers:");
|
|
516
|
+
for (const line of coverageLines(plan, selected))
|
|
517
|
+
out.say(line);
|
|
518
|
+
if (selected.length > 10)
|
|
519
|
+
out.say(` and ${selected.length - 10} more`);
|
|
520
|
+
}
|
|
521
|
+
if (project !== null) {
|
|
522
|
+
for (const line of moveLines(moving, (name) => assigned.get(name) ?? "", project, label))
|
|
523
|
+
out.say(line);
|
|
524
|
+
if (joining.length > 0) {
|
|
525
|
+
out.say(` ${joining.join(", ")} will be placed in project ${label(project)} once the browser connects ${joining.length === 1 ? "it" : "them"}`);
|
|
526
|
+
}
|
|
527
|
+
if (decision.create)
|
|
528
|
+
out.say(` Project "${project}" will be created in this workspace`);
|
|
529
|
+
}
|
|
530
|
+
const subject = packagesFound(plan);
|
|
531
|
+
if (!parsed.assumeYes) {
|
|
532
|
+
if (!interactive) {
|
|
533
|
+
throw new Stop(1, `use --yes to accept this proposal without a terminal${moving.length > 0 && !parsed.allowMove ? ", and --allow-move to accept the move above" : ""}.`);
|
|
534
|
+
}
|
|
535
|
+
// The count is in the question, because the listing that used to stand
|
|
536
|
+
// above it is gone: this is where a reader decides (carrick#1026).
|
|
537
|
+
if (!(await out.confirm(`Write the proposal for ${subject} and configure hooks?`))) {
|
|
538
|
+
out.refuse(NOTHING_WRITTEN);
|
|
539
|
+
throw new Stop(0);
|
|
540
|
+
}
|
|
319
541
|
}
|
|
320
|
-
|
|
321
|
-
|
|
542
|
+
// A move is its own question. `--yes` accepts a list of packages and a set
|
|
543
|
+
// of local files; taking a repo out of a project changes what every agent
|
|
544
|
+
// querying that project can see, and that is not the same answer
|
|
545
|
+
// (carrick#1338).
|
|
546
|
+
if (moving.length > 0 && project !== null && !parsed.allowMove) {
|
|
547
|
+
const from = [...new Set(moving.map((name) => label(assigned.get(name) ?? "")))].join(" and ");
|
|
548
|
+
if (!interactive) {
|
|
549
|
+
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.`);
|
|
550
|
+
}
|
|
551
|
+
if (!(await out.confirm(`Move ${moving.length === 1 ? moving[0] : `${moving.length} repos`} out of ${from} into ${label(project)}?`))) {
|
|
552
|
+
out.refuse(NOTHING_WRITTEN);
|
|
553
|
+
throw new Stop(0);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const name of [...moving, ...joining])
|
|
557
|
+
movable.add(name.toLowerCase());
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
// A question the reader ended stops the run where it stands. Nothing above
|
|
561
|
+
// this line writes, so there is nothing to undo (carrick#1338).
|
|
562
|
+
if (error instanceof PromptCancelled) {
|
|
563
|
+
out.refuse(CANCELLED);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
if (error instanceof Stop) {
|
|
567
|
+
if (error.message !== "")
|
|
568
|
+
process.stderr.write(`carrick init: ${error.message}\n`);
|
|
569
|
+
return error.code;
|
|
570
|
+
}
|
|
571
|
+
process.stderr.write(`carrick init: ${error.message}\n`);
|
|
572
|
+
return 1;
|
|
573
|
+
}
|
|
574
|
+
// Accepted. From here the run writes: the project, the assignment, the
|
|
575
|
+
// proposal, the hooks, the skills and the MCP entries.
|
|
576
|
+
const plan = derived.plan;
|
|
577
|
+
const project = decision.slug;
|
|
578
|
+
const label = (slug) => projectLabel(slug, projects);
|
|
579
|
+
try {
|
|
580
|
+
let projectExists = decision.exists;
|
|
581
|
+
if (decision.create && project !== null) {
|
|
582
|
+
const outcome = await createProject(credential.token, project);
|
|
583
|
+
if (outcome.kind === "created") {
|
|
584
|
+
out.say(`Created project "${project}".`);
|
|
585
|
+
projectExists = true;
|
|
586
|
+
}
|
|
587
|
+
else if (outcome.kind === "refused") {
|
|
588
|
+
out.say(`Carrick did not create "${project}": ${outcome.message}`);
|
|
589
|
+
}
|
|
322
590
|
}
|
|
323
591
|
const identity = await connectRepos(credential.token, names, initial, {
|
|
324
592
|
interactive,
|
|
325
593
|
project: project ?? undefined,
|
|
326
594
|
projectExists,
|
|
595
|
+
movable,
|
|
596
|
+
label,
|
|
327
597
|
say: out.say,
|
|
328
598
|
});
|
|
329
599
|
// Who this machine is, and where its answers come from. The workspace slug
|
|
@@ -331,13 +601,13 @@ export async function init(argv) {
|
|
|
331
601
|
// user (carrick#1026).
|
|
332
602
|
out.done(project === null
|
|
333
603
|
? `Signed in as ${identity.workspace.slug}`
|
|
334
|
-
: `Signed in as ${identity.workspace.slug} · project ${project}`);
|
|
604
|
+
: `Signed in as ${identity.workspace.slug} · project ${label(project)}`);
|
|
335
605
|
if (project !== null && !reposAreInProject(identity, names, project)) {
|
|
336
606
|
// An unverified project does not end the run, whether it was named on
|
|
337
607
|
// the command line or picked here. Stopping cost someone their hooks and
|
|
338
608
|
// their proposal for a browser step they could only take afterwards, and
|
|
339
609
|
// the documented command then needed two runs (carrick#993 row 8).
|
|
340
|
-
out.warn(`Finish the browser steps above to put these repos in
|
|
610
|
+
out.warn(`Finish the browser steps above to put these repos in ${label(project)}, then run carrick init --project ${project} again to verify.`);
|
|
341
611
|
}
|
|
342
612
|
if (identity.allowance_sentence)
|
|
343
613
|
out.say(identity.allowance_sentence);
|
|
@@ -357,48 +627,28 @@ export async function init(argv) {
|
|
|
357
627
|
// answers across every repo in a project, so a folder holding half of one
|
|
358
628
|
// is a partial index and nothing here would otherwise say so
|
|
359
629
|
// (carrick#993 row 18).
|
|
360
|
-
for (const line of absentRepos(identity, names, project))
|
|
630
|
+
for (const line of absentRepos(identity, names, project, label))
|
|
361
631
|
out.warn(line);
|
|
362
632
|
}
|
|
363
633
|
catch (error) {
|
|
364
634
|
process.stderr.write(`carrick init: ${error.message}\n`);
|
|
365
635
|
return 1;
|
|
366
636
|
}
|
|
367
|
-
const plan = derived.plan;
|
|
368
|
-
if (plan.parent_proposal) {
|
|
369
|
-
const parent = plan.parent_proposal;
|
|
370
|
-
const names = parent.repos.map((repo) => path.basename(repo));
|
|
371
|
-
const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
|
|
372
|
-
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.`);
|
|
373
|
-
}
|
|
374
|
-
// The scanner's own warnings about what it proposed. Capped: the proposal
|
|
375
|
-
// file carries every one of them, and it is named on the line above.
|
|
376
|
-
const warnings = plan.repos.flatMap((repo) => repo.warnings);
|
|
377
|
-
for (const warning of warnings.slice(0, 3))
|
|
378
|
-
out.warn(warning);
|
|
379
|
-
if (warnings.length > 3)
|
|
380
|
-
out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
|
|
381
|
-
for (const missing of plan.missing)
|
|
382
|
-
out.warn(`Missing workspace override: ${missing}`);
|
|
383
|
-
const proposed = packagesLine(plan);
|
|
384
|
-
const subject = packagesFound(plan);
|
|
385
|
-
if (!parsed.assumeYes) {
|
|
386
|
-
if (!process.stdin.isTTY) {
|
|
387
|
-
process.stderr.write("carrick init: use --yes to accept this proposal without a terminal.\n");
|
|
388
|
-
return 1;
|
|
389
|
-
}
|
|
390
|
-
// The count is in the question, because the listing that used to stand
|
|
391
|
-
// above it is gone: this is where a reader decides (carrick#1026).
|
|
392
|
-
if (!await out.confirm(`Write the proposal for ${subject} and configure hooks?`))
|
|
393
|
-
return 0;
|
|
394
|
-
}
|
|
395
637
|
try {
|
|
396
638
|
// The proposal is a seed for an agent, not a config: nothing derived
|
|
397
639
|
// without a model is written into the repository, because the first scan
|
|
398
640
|
// is the paid one and it has to run against a config someone has read
|
|
399
641
|
// (carrick-cloud#799).
|
|
400
642
|
writeProposal(plan.workspace, derived);
|
|
401
|
-
out.done(
|
|
643
|
+
out.done(packagesLine(plan));
|
|
644
|
+
// And the selection itself, in the file every later command reads. Without
|
|
645
|
+
// it the choice lasted one run: `carrick refresh` and `carrick index` walk
|
|
646
|
+
// the folder, the editor hook answers for a file inside a repo nobody
|
|
647
|
+
// covers, and the next `init` asks again (carrick#1344).
|
|
648
|
+
const selection = writeSelection(plan.workspace, deselected);
|
|
649
|
+
if (selection !== null && selection.added.length > 0) {
|
|
650
|
+
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`);
|
|
651
|
+
}
|
|
402
652
|
}
|
|
403
653
|
catch (error) {
|
|
404
654
|
process.stderr.write(`carrick init: ${error.message}\n`);
|
|
@@ -430,6 +680,37 @@ export async function init(argv) {
|
|
|
430
680
|
hooksWritten = false;
|
|
431
681
|
out.refuse(`Could not configure Carrick hooks: ${error.message}. Fix ${settingsName} and run carrick init again.`);
|
|
432
682
|
}
|
|
683
|
+
// The same two-part nudge for Codex, in the file Codex reads project hooks
|
|
684
|
+
// from. Written for both hosts for the same reason the task skills are
|
|
685
|
+
// (carrick#1335): which agent opens this workspace is not a thing init can
|
|
686
|
+
// know, and a hook file for a host nobody runs costs nothing.
|
|
687
|
+
try {
|
|
688
|
+
if (writeCodexHooks(workspace, command.command) === "written") {
|
|
689
|
+
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.`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
out.refuse(`Could not configure the Codex hooks: ${error.message}. Fix ${CODEX_HOOKS_FILE} and run carrick init again.`);
|
|
694
|
+
}
|
|
695
|
+
// The task skills, beside the hooks and independent of them: a settings file
|
|
696
|
+
// somebody hand-edited into invalid JSON is no reason to withhold the bodies
|
|
697
|
+
// an agent reads. A file this package did not write, or one somebody has
|
|
698
|
+
// since changed, is left alone and named.
|
|
699
|
+
try {
|
|
700
|
+
const { done, warn } = taskSkillLines(writeTaskSkills(workspace, { slug: projectSlug }));
|
|
701
|
+
for (const line of done)
|
|
702
|
+
out.done(line);
|
|
703
|
+
for (const line of warn)
|
|
704
|
+
out.warn(line);
|
|
705
|
+
const ignored = ignoredSkillRoots(workspace);
|
|
706
|
+
if (ignored.length > 0) {
|
|
707
|
+
const one = ignored.length === 1;
|
|
708
|
+
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.`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
out.refuse(`Could not write the task skills: ${error.message}`);
|
|
713
|
+
}
|
|
433
714
|
const mcp = connectMcpClients();
|
|
434
715
|
if (hooksWritten)
|
|
435
716
|
out.done(configuredLine(mcp));
|