carrick 0.3.82 → 0.3.84
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 +62 -30
- 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 +57 -0
- package/dist/init/doctor.js +132 -3
- package/dist/init/doctor.js.map +1 -1
- package/dist/init/files.d.ts +15 -0
- package/dist/init/files.js +20 -1
- package/dist/init/files.js.map +1 -1
- package/dist/init/hosted.d.ts +30 -5
- package/dist/init/hosted.js +75 -9
- package/dist/init/hosted.js.map +1 -1
- package/dist/init/mcp.d.ts +45 -30
- package/dist/init/mcp.js +47 -88
- package/dist/init/mcp.js.map +1 -1
- 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 +72 -3
- package/dist/init/output.js +187 -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 +61 -15
- 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 +148 -26
- package/dist/init/run.js +577 -125
- 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 +27 -0
- package/dist/init/task-skills.js +64 -1
- package/dist/init/task-skills.js.map +1 -1
- 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/render.d.ts +16 -0
- package/dist/render.js +57 -6
- package/dist/render.js.map +1 -1
- package/package.json +7 -6
- package/plugin/hooks/hooks.json +11 -0
- package/sidecar/dist/src/capture/api.d.ts +23 -1
- package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
- package/sidecar/dist/src/capture/check-classify.js +96 -9
- 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 +112 -0
- package/sidecar/dist/src/capture/check-fields.js +311 -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 +14 -2
- package/sidecar/dist/src/capture/index.js +6 -1
- package/sidecar/dist/src/capture/self-check.d.ts +6 -1
- package/sidecar/dist/src/capture/self-check.js +65 -11
- package/sidecar/dist/src/validators.d.ts +40 -40
- package/sidecar/dist/src/validators.js +6 -1
- package/templates/skills/carrick-census.md +18 -9
- package/templates/skills/carrick-drift.md +7 -1
- package/templates/skills/carrick-impact.md +3 -1
- package/templates/skills/carrick-reuse.md +43 -26
package/dist/init/run.js
CHANGED
|
@@ -13,35 +13,56 @@ 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
|
-
import { downloadHostedIndex, hostedReport, nativeRunner } from "./hosted.js";
|
|
19
|
-
import {
|
|
20
|
-
import { connectMcpClients, mcpLine } from "./mcp.js";
|
|
18
|
+
import { DOWNLOAD_LABEL, downloadHostedIndex, hostedReport, nativeRunner } from "./hosted.js";
|
|
19
|
+
import { createProject, listProjects, planProject, projectLabel, projectStep, SLUG, } from "./projects.js";
|
|
20
|
+
import { connectMcpClients, mcpLine, offeredFileClients } from "./mcp.js";
|
|
21
21
|
import { hookCommand, mergeCarrickHooks, removeCarrickHooks } from "./settings.js";
|
|
22
|
+
import { CODEX_HOOKS_FILE, writeCodexHooks } from "./codex.js";
|
|
22
23
|
import { ignoredSkillRoots, taskSkillLines, writeTaskSkills } from "./task-skills.js";
|
|
23
24
|
import { writeIfChanged } from "./files.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
25
|
+
import { excludedRepos, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
|
|
26
|
+
import { createOutput, DOCS, PromptCancelled } from "./output.js";
|
|
27
|
+
import { renderTemplate, TEMPLATE_PATHS } from "../templates.js";
|
|
26
28
|
/**
|
|
27
|
-
* The
|
|
29
|
+
* The repos this run covers that the scaffold tool still has work in.
|
|
28
30
|
*
|
|
29
|
-
* The
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* two copies of a sequence drift.
|
|
31
|
+
* The two files that tool writes: the `carrick.json` a scan reads, and the
|
|
32
|
+
* workflow that runs the scan. A repo holding both has been scaffolded, and
|
|
33
|
+
* the closing instruction is then an instruction to do nothing — which is what
|
|
34
|
+
* it was on a folder where every repo was already set up (carrick#1365).
|
|
34
35
|
*
|
|
35
|
-
* The
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* repo CI already indexes, which is the row the whole workspace reads
|
|
39
|
-
* (carrick-cloud `src/tools/scaffold.ts`, cloud#805 item 1).
|
|
36
|
+
* The repo root only, because that is where the scanner looks for a config
|
|
37
|
+
* (`Config::load_services`): a `carrick.json` deeper in the tree is a fixture
|
|
38
|
+
* or a nested example, not this repo's.
|
|
40
39
|
*/
|
|
41
|
-
export
|
|
40
|
+
export function reposToScaffold(repos, exists = fs.existsSync) {
|
|
41
|
+
return repos.filter((repo) => !exists(path.join(repo.path, TEMPLATE_PATHS["carrick.json"])) ||
|
|
42
|
+
!exists(path.join(repo.path, TEMPLATE_PATHS.workflow)));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The instruction to paste, naming the repos it is about.
|
|
46
|
+
*
|
|
47
|
+
* The scaffold tool's branch turns on `repo` and turns soft without it: with
|
|
48
|
+
* no `repo` the response keeps its default ending and tells the agent to run
|
|
49
|
+
* the scan — on a repo CI already indexes, which is the row the whole
|
|
50
|
+
* workspace reads (carrick-cloud `src/tools/scaffold.ts`, cloud#805 item 1).
|
|
51
|
+
* So the names are in the sentence, one call per repo.
|
|
52
|
+
*/
|
|
53
|
+
export function scaffoldSentence(repos) {
|
|
54
|
+
const names = repos.map((repo) => repo.name ?? path.basename(repo.path));
|
|
55
|
+
if (names.length === 1) {
|
|
56
|
+
return `Run the carrick scaffold tool for ${names[0]}, passing its owner/repo as \`repo\`, and follow what it returns.`;
|
|
57
|
+
}
|
|
58
|
+
const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
|
|
59
|
+
return `Run the carrick scaffold tool for ${shown}, once each, passing that repo's owner/repo as \`repo\`, and follow what it returns.`;
|
|
60
|
+
}
|
|
61
|
+
/** What a run with nothing left to set up closes on: what this machine can do now. */
|
|
62
|
+
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.";
|
|
42
63
|
const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
43
64
|
export function parseArgs(argv, cwd = process.cwd()) {
|
|
44
|
-
const options = { workspace: cwd, project: null,
|
|
65
|
+
const options = { workspace: cwd, project: null, repos: [], editors: [], assumeYes: false, allowMove: false };
|
|
45
66
|
for (let index = 0; index < argv.length; index += 1) {
|
|
46
67
|
const argument = argv[index];
|
|
47
68
|
switch (argument) {
|
|
@@ -49,6 +70,9 @@ export function parseArgs(argv, cwd = process.cwd()) {
|
|
|
49
70
|
case "-y":
|
|
50
71
|
options.assumeYes = true;
|
|
51
72
|
break;
|
|
73
|
+
case "--allow-move":
|
|
74
|
+
options.allowMove = true;
|
|
75
|
+
break;
|
|
52
76
|
case "--project": {
|
|
53
77
|
const value = argv[index + 1];
|
|
54
78
|
if (!value)
|
|
@@ -60,13 +84,30 @@ export function parseArgs(argv, cwd = process.cwd()) {
|
|
|
60
84
|
index += 1;
|
|
61
85
|
break;
|
|
62
86
|
}
|
|
87
|
+
case "--mcp": {
|
|
88
|
+
const value = argv[index + 1];
|
|
89
|
+
if (!value)
|
|
90
|
+
return "--mcp needs an editor name";
|
|
91
|
+
for (const name of value.split(",")) {
|
|
92
|
+
const trimmed = name.trim();
|
|
93
|
+
if (trimmed === "")
|
|
94
|
+
return `invalid editor "${name}": name it as carrick init prints it`;
|
|
95
|
+
if (!options.editors.includes(trimmed))
|
|
96
|
+
options.editors.push(trimmed);
|
|
97
|
+
}
|
|
98
|
+
index += 1;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
63
101
|
case "--repo": {
|
|
64
102
|
const value = argv[index + 1];
|
|
65
103
|
if (!value)
|
|
66
104
|
return "--repo needs an owner/repo";
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
105
|
+
for (const name of value.split(",")) {
|
|
106
|
+
if (!OWNER_REPO.test(name))
|
|
107
|
+
return `invalid repo "${name}": use owner/repo as GitHub spells it`;
|
|
108
|
+
if (!options.repos.includes(name))
|
|
109
|
+
options.repos.push(name);
|
|
110
|
+
}
|
|
70
111
|
index += 1;
|
|
71
112
|
break;
|
|
72
113
|
}
|
|
@@ -92,20 +133,34 @@ export function parseArgs(argv, cwd = process.cwd()) {
|
|
|
92
133
|
}
|
|
93
134
|
function help() {
|
|
94
135
|
return [
|
|
95
|
-
"carrick init [DIRECTORY] [--project SLUG] [--
|
|
136
|
+
"carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--mcp EDITOR] [--allow-move]",
|
|
96
137
|
"",
|
|
97
138
|
"Sign in (here, or beforehand with carrick login), then set up a repository",
|
|
98
|
-
"or a folder of repos:
|
|
99
|
-
"the MCP connection, and the service proposal
|
|
100
|
-
"carrick.json. It writes nothing into the repository but
|
|
101
|
-
".carrick directory, the hook settings, and the four task skills
|
|
102
|
-
"loads. It runs no analysis: where Carrick already holds an index
|
|
103
|
-
"repos, it reads that index into .carrick so this machine can answer
|
|
139
|
+
"or a folder of repos: which repos this install covers, the project, the repo",
|
|
140
|
+
"connection, the agent hooks, the MCP connection, and the service proposal",
|
|
141
|
+
"your agent turns into carrick.json. It writes nothing into the repository but",
|
|
142
|
+
"the ignored .carrick directory, the hook settings, and the four task skills",
|
|
143
|
+
"your agent loads. It runs no analysis: where Carrick already holds an index",
|
|
144
|
+
"for these repos, it reads that index into .carrick so this machine can answer",
|
|
145
|
+
"from it. Nothing is written, here or in Carrick, until you accept what it",
|
|
146
|
+
"proposes.",
|
|
104
147
|
"",
|
|
105
148
|
" -w, --workspace DIR The folder holding the repos (default: this one)",
|
|
106
|
-
" --
|
|
107
|
-
"
|
|
108
|
-
"
|
|
149
|
+
" --repo OWNER/REPO A repo this install covers: repeatable, or one",
|
|
150
|
+
" comma-separated list. In a folder of repos without a",
|
|
151
|
+
" terminal to choose in, this is required. It also names",
|
|
152
|
+
" the GitHub repo whose origin remote names none",
|
|
153
|
+
" --project SLUG Put those repos in this Carrick project, creating it",
|
|
154
|
+
" if it is not there. A repo that is in another project",
|
|
155
|
+
" is MOVED out of it, which changes what every agent",
|
|
156
|
+
" querying either project can see, so the move is named",
|
|
157
|
+
" and asked about separately",
|
|
158
|
+
" --allow-move Accept those moves without being asked. --yes does not",
|
|
159
|
+
" --mcp EDITOR Also add Carrick to this editor's own MCP configuration,",
|
|
160
|
+
" which is a file outside this workspace: repeatable, or one",
|
|
161
|
+
" comma-separated list. Without a terminal no editor file is",
|
|
162
|
+
" written unless this names one, and --yes does not name one",
|
|
163
|
+
" -y, --yes Take the proposal as printed",
|
|
109
164
|
"",
|
|
110
165
|
`The editor extension, the hooks, CI and a carrick.json written by hand: ${DOCS}`,
|
|
111
166
|
].join("\n");
|
|
@@ -127,7 +182,7 @@ function onPath(command) {
|
|
|
127
182
|
* Capped and counted like the identity lines, because a project can hold two
|
|
128
183
|
* hundred.
|
|
129
184
|
*/
|
|
130
|
-
export function absentRepos(identity, names, project) {
|
|
185
|
+
export function absentRepos(identity, names, project, label = (slug) => slug) {
|
|
131
186
|
const onDisk = new Set(names.map((name) => name.toLowerCase()));
|
|
132
187
|
const sole = identity.project_repos.length === 1;
|
|
133
188
|
const lines = [];
|
|
@@ -141,7 +196,9 @@ export function absentRepos(identity, names, project) {
|
|
|
141
196
|
const shown = absent.length > 10
|
|
142
197
|
? `${absent.slice(0, 10).join(", ")} and ${absent.length - 10} more`
|
|
143
198
|
: absent.join(", ");
|
|
144
|
-
const where = sole || entry.project_slug === project
|
|
199
|
+
const where = sole || entry.project_slug === project
|
|
200
|
+
? "this project"
|
|
201
|
+
: `project ${label(entry.project_slug)}`;
|
|
145
202
|
lines.push(`Also in ${where}, not on this machine: ${shown}.`);
|
|
146
203
|
}
|
|
147
204
|
return lines;
|
|
@@ -203,18 +260,226 @@ export function mcpClientLines(mcp) {
|
|
|
203
260
|
.map((outcome) => `MCP added for ${outcome.client}: ${outcome.detail}`);
|
|
204
261
|
}
|
|
205
262
|
/**
|
|
206
|
-
*
|
|
263
|
+
* An end to the run with its own exit code, decided before anything is written.
|
|
207
264
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
265
|
+
* A refusal ("no terminal and no --yes") and a declined proposal are the same
|
|
266
|
+
* shape — stop here, leave everything alone — and they happen at four points
|
|
267
|
+
* in the decision. Thrown, they all end in one place, beside the cancel, which
|
|
268
|
+
* is the only way to be sure no path between them reaches a write.
|
|
211
269
|
*/
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
270
|
+
class Stop extends Error {
|
|
271
|
+
code;
|
|
272
|
+
constructor(code, message = "") {
|
|
273
|
+
super(message);
|
|
274
|
+
this.name = "Stop";
|
|
275
|
+
this.code = code;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** The line a run that was stopped at a question ends on (carrick#1338). */
|
|
279
|
+
export const CANCELLED = "Cancelled. Nothing was written, here or in Carrick.";
|
|
280
|
+
/**
|
|
281
|
+
* The line a declined proposal ends on.
|
|
282
|
+
*
|
|
283
|
+
* A "no" used to be silent, which beside a cancel that says so reads as two
|
|
284
|
+
* different things having happened. Both leave the machine and the server as
|
|
285
|
+
* they were, and both say it.
|
|
286
|
+
*/
|
|
287
|
+
export const NOTHING_WRITTEN = "Nothing written, here or in Carrick.";
|
|
288
|
+
/**
|
|
289
|
+
* The repos that start selected, and why each of the others does not.
|
|
290
|
+
*
|
|
291
|
+
* A default is a claim about somebody's folder, so only the repos something
|
|
292
|
+
* already says belong here carry one: connected to this workspace, and in the
|
|
293
|
+
* project most of this folder's connected repos are in. Everything else — a
|
|
294
|
+
* repo nobody connected, one sitting in a different project, one with no
|
|
295
|
+
* GitHub identity to ask about — starts out, with the reason on its row, and
|
|
296
|
+
* the reader adds it in one keystroke (carrick#1365).
|
|
297
|
+
*
|
|
298
|
+
* Two cases preselect nothing at all, and neither is in the ruling because
|
|
299
|
+
* neither is a majority: a folder where nothing is connected (a first run), and
|
|
300
|
+
* a tie between two projects. A guess there is a guess about which half of
|
|
301
|
+
* somebody's folder this install is for.
|
|
302
|
+
*
|
|
303
|
+
* Unconnected is not the same as unknown: with no read of the workspace — no
|
|
304
|
+
* login yet, or a read that did not answer — `identity` is null and nothing is
|
|
305
|
+
* preselected, because "not connected" would then be this command's assumption
|
|
306
|
+
* rather than the server's answer.
|
|
307
|
+
*/
|
|
308
|
+
export function preselectedRepos(candidates, identity, label = (slug) => slug) {
|
|
309
|
+
const reasons = new Map();
|
|
310
|
+
const project = new Map();
|
|
311
|
+
for (const repo of candidates) {
|
|
312
|
+
if (repo.name === null) {
|
|
313
|
+
reasons.set(repo.path, "no GitHub identity");
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (identity === null)
|
|
317
|
+
continue;
|
|
318
|
+
const row = identity.repos.find((entry) => entry.full_name.toLowerCase() === repo.name?.toLowerCase());
|
|
319
|
+
if (row === undefined || !row.connected) {
|
|
320
|
+
reasons.set(repo.path, "not connected");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
project.set(repo.path, row.project_slug);
|
|
324
|
+
}
|
|
325
|
+
const counts = new Map();
|
|
326
|
+
for (const slug of project.values()) {
|
|
327
|
+
if (slug !== null)
|
|
328
|
+
counts.set(slug, (counts.get(slug) ?? 0) + 1);
|
|
329
|
+
}
|
|
330
|
+
const ranked = [...counts.entries()].sort((left, right) => right[1] - left[1]);
|
|
331
|
+
const majority = ranked.length > 0 && (ranked.length === 1 || ranked[0][1] > ranked[1][1]) ? ranked[0][0] : null;
|
|
332
|
+
const keep = [];
|
|
333
|
+
for (const [repoPath, slug] of project) {
|
|
334
|
+
if (majority !== null && slug === majority)
|
|
335
|
+
keep.push(repoPath);
|
|
336
|
+
else if (slug === null)
|
|
337
|
+
reasons.set(repoPath, "connected, in no project");
|
|
338
|
+
else
|
|
339
|
+
reasons.set(repoPath, `in ${label(slug)}`);
|
|
340
|
+
}
|
|
341
|
+
return { keep, reasons };
|
|
342
|
+
}
|
|
343
|
+
/** A repo as the picker lists it: what it is called, what is in it, and why it is out. */
|
|
344
|
+
export function repoChoices(plan, candidates, reasons = new Map()) {
|
|
345
|
+
return candidates.map((repo) => {
|
|
346
|
+
const found = plan.repos.find((entry) => entry.path === repo.path);
|
|
347
|
+
const packages = found?.services.length ?? 0;
|
|
348
|
+
const reason = reasons.get(repo.path);
|
|
349
|
+
const hint = [
|
|
350
|
+
`${packages} package${packages === 1 ? "" : "s"}`,
|
|
351
|
+
...(reason === undefined ? [] : [reason]),
|
|
352
|
+
].join(", ");
|
|
353
|
+
return { value: repo.path, label: repo.name ?? path.basename(repo.path), hint };
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Which repos in this folder this install covers, settled before anything is
|
|
358
|
+
* read from Carrick and long before anything is written (carrick#1338).
|
|
359
|
+
*
|
|
360
|
+
* A folder routinely holds a repo that must not be scanned, and the only
|
|
361
|
+
* answer this command used to take was one yes covering every repo in it. The
|
|
362
|
+
* terminal picks; without one, `--repo` names them; with neither, there is no
|
|
363
|
+
* safe default and the run stops here, having written nothing.
|
|
364
|
+
*/
|
|
365
|
+
export async function chooseRepos(plan, candidates, options, out) {
|
|
366
|
+
if (options.repos.length > 0) {
|
|
367
|
+
const chosen = selectRepos(candidates, options.repos);
|
|
368
|
+
if ("problem" in chosen)
|
|
369
|
+
throw new Error(chosen.problem);
|
|
370
|
+
if (chosen.taken !== null) {
|
|
371
|
+
out.done(`${chosen.taken.name} taken as the GitHub repository for ${chosen.taken.path}`);
|
|
372
|
+
}
|
|
373
|
+
return chosen.repos;
|
|
374
|
+
}
|
|
375
|
+
// One repo is not a choice, and `--yes` is a reader stating that the list as
|
|
376
|
+
// derived is the list they want.
|
|
377
|
+
if (candidates.length === 1 || options.assumeYes)
|
|
378
|
+
return candidates;
|
|
379
|
+
if (!options.interactive) {
|
|
380
|
+
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}.`);
|
|
381
|
+
}
|
|
382
|
+
const { keep, reasons } = preselectedRepos(candidates, options.identity ?? null, options.label);
|
|
383
|
+
const kept = new Set(await out.choose("Which repos does this install cover?", "a repo", repoChoices(plan, candidates, reasons), { initial: keep, required: true }));
|
|
384
|
+
return candidates.filter((repo) => kept.has(repo.path));
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The excluded repos a `--repo` flag is asking for, by identity.
|
|
388
|
+
*
|
|
389
|
+
* An excluded repo is invisible to everything downstream — the scanner never
|
|
390
|
+
* derives it, so `selectRepos` sees a value matching nothing on disk and, by
|
|
391
|
+
* carrick#991's rule, attaches it to the one repo here with no GitHub identity
|
|
392
|
+
* of its own. That is a flag quietly covering a different repository, so the
|
|
393
|
+
* question is asked here instead, before any of it.
|
|
394
|
+
*
|
|
395
|
+
* Asked of the directory, not of the spelling: `--repo owner/web` and a
|
|
396
|
+
* directory called `web` are not the same claim, and the repo's own origin
|
|
397
|
+
* remote is what says whether they name one repository. A directory that is
|
|
398
|
+
* gone, or that names no GitHub repo, answers nothing and is not reported —
|
|
399
|
+
* there is no identity to have asked for.
|
|
400
|
+
*/
|
|
401
|
+
export function namedButExcluded(workspace, excluded, requested, identify = repoIdentity) {
|
|
402
|
+
if (requested.length === 0 || excluded.length === 0)
|
|
403
|
+
return [];
|
|
404
|
+
const wanted = new Set(requested.map((name) => name.toLowerCase()));
|
|
405
|
+
return excluded.filter((name) => {
|
|
406
|
+
const directory = path.join(workspace, name);
|
|
407
|
+
if (!fs.existsSync(directory))
|
|
408
|
+
return false;
|
|
409
|
+
const identity = identify(directory);
|
|
410
|
+
return identity.name !== null && wanted.has(identity.name.toLowerCase());
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Which editors outside the workspace get an MCP entry written for them.
|
|
415
|
+
*
|
|
416
|
+
* The answer is a set of names, and the default is the empty one: these are
|
|
417
|
+
* files in somebody's home directory for editors they may not use, so the
|
|
418
|
+
* strongest thing a configuration directory can do is put a row in the
|
|
419
|
+
* question (carrick#1365). An editor whose own command answers on PATH starts
|
|
420
|
+
* ticked; without a terminal, `--mcp` names them and nothing else is written.
|
|
421
|
+
*
|
|
422
|
+
* `--yes` does not cover these. It takes the proposal as printed — a list of
|
|
423
|
+
* packages and a set of files inside this workspace — and an entry in
|
|
424
|
+
* `~/.cursor/mcp.json` is neither.
|
|
425
|
+
*/
|
|
426
|
+
export async function chooseEditors(offered, options, out) {
|
|
427
|
+
if (offered.length === 0)
|
|
428
|
+
return [];
|
|
429
|
+
if (options.editors.length > 0) {
|
|
430
|
+
const known = new Map(offered.map((client) => [client.name.toLowerCase(), client.name]));
|
|
431
|
+
const chosen = [];
|
|
432
|
+
for (const wanted of options.editors) {
|
|
433
|
+
const name = known.get(wanted.toLowerCase());
|
|
434
|
+
if (name === undefined) {
|
|
435
|
+
throw new Error(`--mcp ${wanted} names no editor configured on this machine. The ones here are: ${offered.map((client) => client.name).join(", ")}.`);
|
|
436
|
+
}
|
|
437
|
+
if (!chosen.includes(name))
|
|
438
|
+
chosen.push(name);
|
|
439
|
+
}
|
|
440
|
+
return chosen;
|
|
441
|
+
}
|
|
442
|
+
if (!options.interactive || options.assumeYes)
|
|
443
|
+
return [];
|
|
444
|
+
return out.choose("Which editors should Carrick be added to? These files are outside this workspace.", "an editor", offered.map((client) => ({
|
|
445
|
+
value: client.name,
|
|
446
|
+
label: client.name,
|
|
447
|
+
hint: client.installed ? client.file : `${client.file}, not detected on this machine`,
|
|
448
|
+
})), { initial: offered.filter((client) => client.installed).map((client) => client.name), required: false });
|
|
449
|
+
}
|
|
450
|
+
/** What the proposal question adds when an editor file is part of the answer. */
|
|
451
|
+
export function editorClause(offered, chosen) {
|
|
452
|
+
const files = offered.filter((client) => chosen.includes(client.name)).map((client) => client.file);
|
|
453
|
+
if (files.length === 0)
|
|
454
|
+
return "";
|
|
455
|
+
return `, and add Carrick to ${files.join(" and ")}`;
|
|
456
|
+
}
|
|
457
|
+
/** The repos this install covers, one line each, for the proposal. */
|
|
458
|
+
export function coverageLines(plan, selected) {
|
|
459
|
+
return selected.slice(0, 10).map((repo) => {
|
|
460
|
+
const found = plan.repos.find((entry) => entry.path === repo.path);
|
|
461
|
+
const packages = found?.services.length ?? 0;
|
|
462
|
+
return ` ${repo.name ?? path.basename(repo.path)} ${packages} package${packages === 1 ? "" : "s"}`;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* What the server would be asked to change, said before it is asked.
|
|
467
|
+
*
|
|
468
|
+
* A move is named with the project it comes OUT of, display name and slug
|
|
469
|
+
* both, because that is the project whose agents stop seeing this repo and
|
|
470
|
+
* the one a reader has to find again to undo it (carrick#1338).
|
|
471
|
+
*/
|
|
472
|
+
export function moveLines(moving, from, target, label) {
|
|
473
|
+
return moving.map((name) => ` ${name} will move from ${label(from(name))} to ${label(target)}`);
|
|
216
474
|
}
|
|
217
475
|
export async function init(argv) {
|
|
476
|
+
return initWith(argv, createOutput(), process.stdin.isTTY === true);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* `init` with the terminal stated rather than sniffed, so a test can be the
|
|
480
|
+
* terminal: the cancel path and the picker have no other way in.
|
|
481
|
+
*/
|
|
482
|
+
export async function initWith(argv, out, interactive) {
|
|
218
483
|
const parsed = parseArgs(argv);
|
|
219
484
|
if (typeof parsed === "string") {
|
|
220
485
|
process.stdout.write(`${parsed}\n`);
|
|
@@ -225,10 +490,12 @@ export async function init(argv) {
|
|
|
225
490
|
process.stderr.write(`carrick init: ${workspace} is not a directory on this machine\n`);
|
|
226
491
|
return 1;
|
|
227
492
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
|
|
493
|
+
// Everything this run would change is decided before any of it happens: the
|
|
494
|
+
// repos it covers, the project they belong in, and the moves that would
|
|
495
|
+
// take. `--project` used to move repos on the server as its first act and
|
|
496
|
+
// print the proposal afterwards, so a reader's only chance to drop a repo
|
|
497
|
+
// came after that repo had already been moved (carrick#1338).
|
|
498
|
+
//
|
|
232
499
|
// The repos the workspace read found an index for: where CI has already built
|
|
233
500
|
// one, this run reads it rather than ordering a scan (carrick#993 row 2).
|
|
234
501
|
const hostedIndex = [];
|
|
@@ -236,42 +503,100 @@ export async function init(argv) {
|
|
|
236
503
|
// null where no repo here names a GitHub repository, and the skills then
|
|
237
504
|
// tell the agent to read the scope from the git remote instead.
|
|
238
505
|
let projectSlug = null;
|
|
506
|
+
let derived;
|
|
507
|
+
let credential;
|
|
508
|
+
let names;
|
|
509
|
+
let initial;
|
|
510
|
+
let projects;
|
|
511
|
+
let decision;
|
|
512
|
+
// The repos this run has permission to take out of another project.
|
|
513
|
+
const movable = new Set();
|
|
514
|
+
// The directory names this run leaves out, for the workspace file below.
|
|
515
|
+
let deselected = [];
|
|
516
|
+
// The editors outside this workspace the answer covers, settled before it.
|
|
517
|
+
let editors = [];
|
|
518
|
+
// The repos this install covers, for the closing line that names what is
|
|
519
|
+
// still to set up in them.
|
|
520
|
+
let selectedRepos = [];
|
|
239
521
|
try {
|
|
522
|
+
// What a previous run wrote down, before anything is derived from it: the
|
|
523
|
+
// scanner has already dropped an excluded repo by the time the proposal
|
|
524
|
+
// arrives, so this is the only place that can tell a reader why the repo
|
|
525
|
+
// they just named is not here (carrick#1344).
|
|
526
|
+
const excluded = excludedRepos(workspace);
|
|
527
|
+
const asked = namedButExcluded(workspace, excluded, parsed.repos);
|
|
528
|
+
if (asked.length > 0) {
|
|
529
|
+
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.`);
|
|
530
|
+
}
|
|
531
|
+
if (excluded.length > 0) {
|
|
532
|
+
out.say(`${WORKSPACE_FILE} leaves ${excluded.join(", ")} out of this workspace, so nothing below covers ${excluded.length === 1 ? "it" : "them"}.`);
|
|
533
|
+
}
|
|
534
|
+
// The derivation and the identities are local reads: they name the choice,
|
|
535
|
+
// and a run nobody has chosen in yet must not have signed anything in.
|
|
536
|
+
derived = deriveWorkspace(workspace);
|
|
537
|
+
const candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
|
|
538
|
+
// What Carrick already knows about this folder, before the picker draws
|
|
539
|
+
// its defaults. A read, not a write: the credential has to exist already,
|
|
540
|
+
// so a machine nobody has signed in on still chooses first and signs in
|
|
541
|
+
// afterwards, which is what keeps "nothing written, here or in Carrick"
|
|
542
|
+
// true of a cancelled run (carrick#1338). A read that does not answer
|
|
543
|
+
// preselects nothing rather than guessing, and the authoritative read
|
|
544
|
+
// below then reports the failure in its own words.
|
|
545
|
+
const saved = readCredential();
|
|
546
|
+
let known = null;
|
|
547
|
+
projects = null;
|
|
548
|
+
const knownNames = [...new Set(candidates.map((repo) => repo.name).filter((name) => name !== null))];
|
|
549
|
+
// Only where a picker is actually going to draw. Every other path has its
|
|
550
|
+
// answer already — `--repo` names it, `--yes` takes the list as derived, a
|
|
551
|
+
// single repo is not a choice — and asking about a repo whose name the
|
|
552
|
+
// reader never offered is a request that buys nothing (carrick#1338's
|
|
553
|
+
// test pins it: a repo left out is named in no request at all).
|
|
554
|
+
const picking = parsed.repos.length === 0 && !parsed.assumeYes && interactive && candidates.length > 1;
|
|
555
|
+
if (picking && saved !== null && knownNames.length > 0) {
|
|
556
|
+
try {
|
|
557
|
+
known = await resolveRepos(saved.token, knownNames);
|
|
558
|
+
projects = await listProjects(saved.token);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
known = null;
|
|
562
|
+
projects = null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const selected = await chooseRepos(derived.plan, candidates, {
|
|
566
|
+
repos: parsed.repos,
|
|
567
|
+
assumeYes: parsed.assumeYes,
|
|
568
|
+
interactive,
|
|
569
|
+
identity: known,
|
|
570
|
+
label: (slug) => projectLabel(slug, projects),
|
|
571
|
+
}, out);
|
|
572
|
+
if (selected.length === 0)
|
|
573
|
+
throw new Stop(0);
|
|
574
|
+
selectedRepos = selected;
|
|
575
|
+
derived = selectedProposal(derived, selected.map((repo) => repo.path));
|
|
576
|
+
const kept = new Set(selected.map((repo) => repo.path));
|
|
577
|
+
deselected = candidates
|
|
578
|
+
.filter((repo) => !kept.has(repo.path))
|
|
579
|
+
.map((repo) => path.basename(repo.path));
|
|
580
|
+
const dropped = candidates.length - selected.length;
|
|
581
|
+
if (dropped > 0) {
|
|
582
|
+
out.done(`${selected.length} of ${candidates.length} repos covered; ${dropped} left out of the proposal, the project and the connection`);
|
|
583
|
+
}
|
|
240
584
|
// A machine that has never signed in signs in here rather than being told
|
|
241
585
|
// to run another command: `carrick login` is the same browser round trip,
|
|
242
586
|
// and refusing to take it was the first thing a new install did
|
|
243
587
|
// (carrick#955). Without a terminal there is no browser to hand it to.
|
|
244
|
-
|
|
245
|
-
|
|
588
|
+
if (saved)
|
|
589
|
+
credential = saved;
|
|
590
|
+
else {
|
|
246
591
|
if (!interactive) {
|
|
247
592
|
throw new Error("carrick init requires a Carrick login. Run carrick login, or set CARRICK_TOKEN.");
|
|
248
593
|
}
|
|
249
594
|
credential = await signIn(out.say);
|
|
250
595
|
}
|
|
251
|
-
|
|
252
|
-
const derivedIdentities = derived.plan.repos.map((repo) => repoIdentity(repo.path));
|
|
253
|
-
// `--repo` names what a remote could not: an ssh alias ssh itself cannot
|
|
254
|
-
// resolve, a mirror, a clone with no origin. It names one repository, so
|
|
255
|
-
// it is taken only when exactly one repo here is missing an identity.
|
|
256
|
-
const unnamed = derivedIdentities.filter((repo) => repo.name === null);
|
|
257
|
-
let taken = null;
|
|
258
|
-
if (parsed.repo !== null) {
|
|
259
|
-
if (unnamed.length > 1) {
|
|
260
|
-
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.`);
|
|
261
|
-
}
|
|
262
|
-
if (unnamed.length === 0) {
|
|
263
|
-
out.warn(`--repo ${parsed.repo} was not needed: every repo here names its own GitHub repository.`);
|
|
264
|
-
}
|
|
265
|
-
else {
|
|
266
|
-
taken = unnamed[0].path;
|
|
267
|
-
out.done(`${parsed.repo} taken as the GitHub repository for ${taken}`);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
const repoIdentities = derivedIdentities.map((repo) => repo.path === taken ? { ...repo, name: parsed.repo, problem: null } : repo);
|
|
271
|
-
const names = [...new Set(repoIdentities.map((repo) => repo.name).filter((name) => name !== null))];
|
|
596
|
+
names = [...new Set(selected.map((repo) => repo.name).filter((name) => name !== null))];
|
|
272
597
|
if (names.length > 200)
|
|
273
598
|
throw new Error("This workspace has more than 200 GitHub repos. Initialise smaller workspace groups.");
|
|
274
|
-
const missingIdentities =
|
|
599
|
+
const missingIdentities = selected.filter((repo) => repo.name === null);
|
|
275
600
|
// Said before anything is requested, and said whether or not --project was
|
|
276
601
|
// given: a repo with no identity is left out of the project step, the
|
|
277
602
|
// connection check and the workspace read, and a run that dropped it in
|
|
@@ -300,29 +625,156 @@ export async function init(argv) {
|
|
|
300
625
|
// later upload has no repo identity to resolve a project from.
|
|
301
626
|
out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
|
|
302
627
|
}
|
|
303
|
-
|
|
628
|
+
// The read the rest of this run is decided from. The one the picker's
|
|
629
|
+
// defaults came from is reused only where the selection kept every repo,
|
|
630
|
+
// because `project_repos` answers for the repos that were ASKED about: a
|
|
631
|
+
// wider read would have the absent-repo lines below naming projects this
|
|
632
|
+
// install does not cover (carrick#993 row 18).
|
|
633
|
+
const everyCandidate = known !== null && names.length === knownNames.length;
|
|
634
|
+
initial = everyCandidate && known !== null ? known : await resolveRepos(credential.token, names);
|
|
635
|
+
// Read once, for the names: every project this run prints is printed as
|
|
636
|
+
// the dashboard shows it, with the slug beside it (carrick#1338). A
|
|
637
|
+
// workspace whose API has no such action answers null and the lines carry
|
|
638
|
+
// slugs alone, as they did.
|
|
639
|
+
if (projects === null)
|
|
640
|
+
projects = names.length > 0 ? await listProjects(credential.token) : null;
|
|
304
641
|
// The project half of the browser round trip, where this API can do it
|
|
305
|
-
// from here
|
|
306
|
-
//
|
|
307
|
-
// the
|
|
308
|
-
//
|
|
309
|
-
// doing nothing at all (carrick#987).
|
|
642
|
+
// from here. Nothing is created or moved in it: it settles what this run
|
|
643
|
+
// would do, and the proposal below is where that is accepted. Without
|
|
644
|
+
// --project the step reads the assignment the repos already have and
|
|
645
|
+
// offers the list, rather than doing nothing at all (carrick#987).
|
|
310
646
|
const prompts = { say: out.say, ask: out.ask, confirm: out.confirm, interactive, assumeYes: parsed.assumeYes };
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
647
|
+
decision =
|
|
648
|
+
parsed.project === null
|
|
649
|
+
? await projectStep(projectAssignments(initial, names), projects, prompts)
|
|
650
|
+
: reposAreInProject(initial, names, parsed.project)
|
|
651
|
+
? { slug: parsed.project, exists: true, create: false }
|
|
652
|
+
: await planProject(parsed.project, projects, prompts);
|
|
653
|
+
const project = decision.slug;
|
|
654
|
+
projectSlug = project;
|
|
655
|
+
const label = (slug) => projectLabel(slug, projects);
|
|
656
|
+
// What the server would be asked to change, before it is asked: the repos
|
|
657
|
+
// that would leave a project somebody put them in, and the ones the grant
|
|
658
|
+
// has yet to connect at all.
|
|
659
|
+
const current = projectAssignments(initial, names);
|
|
660
|
+
const assigned = new Map(names.map((name, index) => [name, current[index] ?? null]));
|
|
661
|
+
const moving = project === null ? [] : names.filter((name) => {
|
|
662
|
+
const current = assigned.get(name);
|
|
663
|
+
return current !== null && current !== undefined && current !== project;
|
|
664
|
+
});
|
|
665
|
+
const joining = project === null ? [] : names.filter((name) => assigned.get(name) == null);
|
|
666
|
+
// The proposal: what this run covers, what it would change, and what the
|
|
667
|
+
// scanner said about it. Everything above is a read; everything below the
|
|
668
|
+
// confirm is a write.
|
|
669
|
+
const plan = derived.plan;
|
|
670
|
+
if (plan.parent_proposal) {
|
|
671
|
+
const parent = plan.parent_proposal;
|
|
672
|
+
const folders = parent.repos.map((repo) => path.basename(repo));
|
|
673
|
+
const shown = folders.length > 3 ? `${folders.slice(0, 3).join(", ")} and ${folders.length - 3} more` : folders.join(", ");
|
|
674
|
+
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.`);
|
|
317
675
|
}
|
|
318
|
-
|
|
319
|
-
|
|
676
|
+
// The scanner's own warnings about what it proposed. Capped: the proposal
|
|
677
|
+
// file carries every one of them, and it is named on the line above.
|
|
678
|
+
const warnings = plan.repos.flatMap((repo) => repo.warnings);
|
|
679
|
+
for (const warning of warnings.slice(0, 3))
|
|
680
|
+
out.warn(warning);
|
|
681
|
+
if (warnings.length > 3)
|
|
682
|
+
out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
|
|
683
|
+
for (const missing of plan.missing)
|
|
684
|
+
out.warn(`Missing workspace override: ${missing}`);
|
|
685
|
+
if (selected.length > 1) {
|
|
686
|
+
out.say("This install covers:");
|
|
687
|
+
for (const line of coverageLines(plan, selected))
|
|
688
|
+
out.say(line);
|
|
689
|
+
if (selected.length > 10)
|
|
690
|
+
out.say(` and ${selected.length - 10} more`);
|
|
691
|
+
}
|
|
692
|
+
if (project !== null) {
|
|
693
|
+
for (const line of moveLines(moving, (name) => assigned.get(name) ?? "", project, label))
|
|
694
|
+
out.say(line);
|
|
695
|
+
if (joining.length > 0) {
|
|
696
|
+
out.say(` ${joining.join(", ")} will be placed in project ${label(project)} once the browser connects ${joining.length === 1 ? "it" : "them"}`);
|
|
697
|
+
}
|
|
698
|
+
if (decision.create)
|
|
699
|
+
out.say(` Project "${project}" will be created in this workspace`);
|
|
700
|
+
}
|
|
701
|
+
// Which editors get an entry, asked before the yes that covers it. The
|
|
702
|
+
// files are outside the workspace and belong to editors this reader may not
|
|
703
|
+
// use, and they used to be written on the strength of a yes to the proposal
|
|
704
|
+
// (carrick#1365). An editor with no configuration directory is not offered,
|
|
705
|
+
// and one this machine has no command for is offered unticked.
|
|
706
|
+
const offered = offeredFileClients();
|
|
707
|
+
editors = await chooseEditors(offered, { editors: parsed.editors, assumeYes: parsed.assumeYes, interactive }, out);
|
|
708
|
+
const subject = packagesFound(plan);
|
|
709
|
+
if (!parsed.assumeYes) {
|
|
710
|
+
if (!interactive) {
|
|
711
|
+
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" : ""}.`);
|
|
712
|
+
}
|
|
713
|
+
// The count is in the question, because the listing that used to stand
|
|
714
|
+
// above it is gone: this is where a reader decides (carrick#1026). The
|
|
715
|
+
// editor files are named in it too, because a question that does not
|
|
716
|
+
// name a file is not consent to write it.
|
|
717
|
+
const alsoEditors = editorClause(offered, editors);
|
|
718
|
+
if (!(await out.confirm(`Write the proposal for ${subject} and configure hooks${alsoEditors}?`))) {
|
|
719
|
+
out.refuse(NOTHING_WRITTEN);
|
|
720
|
+
throw new Stop(0);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// A move is its own question. `--yes` accepts a list of packages and a set
|
|
724
|
+
// of local files; taking a repo out of a project changes what every agent
|
|
725
|
+
// querying that project can see, and that is not the same answer
|
|
726
|
+
// (carrick#1338).
|
|
727
|
+
if (moving.length > 0 && project !== null && !parsed.allowMove) {
|
|
728
|
+
const from = [...new Set(moving.map((name) => label(assigned.get(name) ?? "")))].join(" and ");
|
|
729
|
+
if (!interactive) {
|
|
730
|
+
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.`);
|
|
731
|
+
}
|
|
732
|
+
if (!(await out.confirm(`Move ${moving.length === 1 ? moving[0] : `${moving.length} repos`} out of ${from} into ${label(project)}?`))) {
|
|
733
|
+
out.refuse(NOTHING_WRITTEN);
|
|
734
|
+
throw new Stop(0);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const name of [...moving, ...joining])
|
|
738
|
+
movable.add(name.toLowerCase());
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
// A question the reader ended stops the run where it stands. Nothing above
|
|
742
|
+
// this line writes, so there is nothing to undo (carrick#1338).
|
|
743
|
+
if (error instanceof PromptCancelled) {
|
|
744
|
+
out.refuse(CANCELLED);
|
|
745
|
+
return 1;
|
|
746
|
+
}
|
|
747
|
+
if (error instanceof Stop) {
|
|
748
|
+
if (error.message !== "")
|
|
749
|
+
process.stderr.write(`carrick init: ${error.message}\n`);
|
|
750
|
+
return error.code;
|
|
751
|
+
}
|
|
752
|
+
process.stderr.write(`carrick init: ${error.message}\n`);
|
|
753
|
+
return 1;
|
|
754
|
+
}
|
|
755
|
+
// Accepted. From here the run writes: the project, the assignment, the
|
|
756
|
+
// proposal, the hooks, the skills and the MCP entries.
|
|
757
|
+
const plan = derived.plan;
|
|
758
|
+
const project = decision.slug;
|
|
759
|
+
const label = (slug) => projectLabel(slug, projects);
|
|
760
|
+
try {
|
|
761
|
+
let projectExists = decision.exists;
|
|
762
|
+
if (decision.create && project !== null) {
|
|
763
|
+
const outcome = await createProject(credential.token, project);
|
|
764
|
+
if (outcome.kind === "created") {
|
|
765
|
+
out.say(`Created project "${project}".`);
|
|
766
|
+
projectExists = true;
|
|
767
|
+
}
|
|
768
|
+
else if (outcome.kind === "refused") {
|
|
769
|
+
out.say(`Carrick did not create "${project}": ${outcome.message}`);
|
|
770
|
+
}
|
|
320
771
|
}
|
|
321
|
-
projectSlug = project;
|
|
322
772
|
const identity = await connectRepos(credential.token, names, initial, {
|
|
323
773
|
interactive,
|
|
324
774
|
project: project ?? undefined,
|
|
325
775
|
projectExists,
|
|
776
|
+
movable,
|
|
777
|
+
label,
|
|
326
778
|
say: out.say,
|
|
327
779
|
});
|
|
328
780
|
// Who this machine is, and where its answers come from. The workspace slug
|
|
@@ -330,13 +782,13 @@ export async function init(argv) {
|
|
|
330
782
|
// user (carrick#1026).
|
|
331
783
|
out.done(project === null
|
|
332
784
|
? `Signed in as ${identity.workspace.slug}`
|
|
333
|
-
: `Signed in as ${identity.workspace.slug} · project ${project}`);
|
|
785
|
+
: `Signed in as ${identity.workspace.slug} · project ${label(project)}`);
|
|
334
786
|
if (project !== null && !reposAreInProject(identity, names, project)) {
|
|
335
787
|
// An unverified project does not end the run, whether it was named on
|
|
336
788
|
// the command line or picked here. Stopping cost someone their hooks and
|
|
337
789
|
// their proposal for a browser step they could only take afterwards, and
|
|
338
790
|
// the documented command then needed two runs (carrick#993 row 8).
|
|
339
|
-
out.warn(`Finish the browser steps above to put these repos in
|
|
791
|
+
out.warn(`Finish the browser steps above to put these repos in ${label(project)}, then run carrick init --project ${project} again to verify.`);
|
|
340
792
|
}
|
|
341
793
|
if (identity.allowance_sentence)
|
|
342
794
|
out.say(identity.allowance_sentence);
|
|
@@ -356,48 +808,28 @@ export async function init(argv) {
|
|
|
356
808
|
// answers across every repo in a project, so a folder holding half of one
|
|
357
809
|
// is a partial index and nothing here would otherwise say so
|
|
358
810
|
// (carrick#993 row 18).
|
|
359
|
-
for (const line of absentRepos(identity, names, project))
|
|
811
|
+
for (const line of absentRepos(identity, names, project, label))
|
|
360
812
|
out.warn(line);
|
|
361
813
|
}
|
|
362
814
|
catch (error) {
|
|
363
815
|
process.stderr.write(`carrick init: ${error.message}\n`);
|
|
364
816
|
return 1;
|
|
365
817
|
}
|
|
366
|
-
const plan = derived.plan;
|
|
367
|
-
if (plan.parent_proposal) {
|
|
368
|
-
const parent = plan.parent_proposal;
|
|
369
|
-
const names = parent.repos.map((repo) => path.basename(repo));
|
|
370
|
-
const shown = names.length > 3 ? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more` : names.join(", ");
|
|
371
|
-
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.`);
|
|
372
|
-
}
|
|
373
|
-
// The scanner's own warnings about what it proposed. Capped: the proposal
|
|
374
|
-
// file carries every one of them, and it is named on the line above.
|
|
375
|
-
const warnings = plan.repos.flatMap((repo) => repo.warnings);
|
|
376
|
-
for (const warning of warnings.slice(0, 3))
|
|
377
|
-
out.warn(warning);
|
|
378
|
-
if (warnings.length > 3)
|
|
379
|
-
out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
|
|
380
|
-
for (const missing of plan.missing)
|
|
381
|
-
out.warn(`Missing workspace override: ${missing}`);
|
|
382
|
-
const proposed = packagesLine(plan);
|
|
383
|
-
const subject = packagesFound(plan);
|
|
384
|
-
if (!parsed.assumeYes) {
|
|
385
|
-
if (!process.stdin.isTTY) {
|
|
386
|
-
process.stderr.write("carrick init: use --yes to accept this proposal without a terminal.\n");
|
|
387
|
-
return 1;
|
|
388
|
-
}
|
|
389
|
-
// The count is in the question, because the listing that used to stand
|
|
390
|
-
// above it is gone: this is where a reader decides (carrick#1026).
|
|
391
|
-
if (!await out.confirm(`Write the proposal for ${subject} and configure hooks?`))
|
|
392
|
-
return 0;
|
|
393
|
-
}
|
|
394
818
|
try {
|
|
395
819
|
// The proposal is a seed for an agent, not a config: nothing derived
|
|
396
820
|
// without a model is written into the repository, because the first scan
|
|
397
821
|
// is the paid one and it has to run against a config someone has read
|
|
398
822
|
// (carrick-cloud#799).
|
|
399
823
|
writeProposal(plan.workspace, derived);
|
|
400
|
-
out.done(
|
|
824
|
+
out.done(packagesLine(plan));
|
|
825
|
+
// And the selection itself, in the file every later command reads. Without
|
|
826
|
+
// it the choice lasted one run: `carrick refresh` and `carrick index` walk
|
|
827
|
+
// the folder, the editor hook answers for a file inside a repo nobody
|
|
828
|
+
// covers, and the next `init` asks again (carrick#1344).
|
|
829
|
+
const selection = writeSelection(plan.workspace, deselected);
|
|
830
|
+
if (selection !== null && selection.added.length > 0) {
|
|
831
|
+
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`);
|
|
832
|
+
}
|
|
401
833
|
}
|
|
402
834
|
catch (error) {
|
|
403
835
|
process.stderr.write(`carrick init: ${error.message}\n`);
|
|
@@ -429,6 +861,18 @@ export async function init(argv) {
|
|
|
429
861
|
hooksWritten = false;
|
|
430
862
|
out.refuse(`Could not configure Carrick hooks: ${error.message}. Fix ${settingsName} and run carrick init again.`);
|
|
431
863
|
}
|
|
864
|
+
// The same two-part nudge for Codex, in the file Codex reads project hooks
|
|
865
|
+
// from. Written for both hosts for the same reason the task skills are
|
|
866
|
+
// (carrick#1335): which agent opens this workspace is not a thing init can
|
|
867
|
+
// know, and a hook file for a host nobody runs costs nothing.
|
|
868
|
+
try {
|
|
869
|
+
if (writeCodexHooks(workspace, command.command) === "written") {
|
|
870
|
+
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.`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
out.refuse(`Could not configure the Codex hooks: ${error.message}. Fix ${CODEX_HOOKS_FILE} and run carrick init again.`);
|
|
875
|
+
}
|
|
432
876
|
// The task skills, beside the hooks and independent of them: a settings file
|
|
433
877
|
// somebody hand-edited into invalid JSON is no reason to withhold the bodies
|
|
434
878
|
// an agent reads. A file this package did not write, or one somebody has
|
|
@@ -448,7 +892,7 @@ export async function init(argv) {
|
|
|
448
892
|
catch (error) {
|
|
449
893
|
out.refuse(`Could not write the task skills: ${error.message}`);
|
|
450
894
|
}
|
|
451
|
-
const mcp = connectMcpClients();
|
|
895
|
+
const mcp = connectMcpClients(editors);
|
|
452
896
|
if (hooksWritten)
|
|
453
897
|
out.done(configuredLine(mcp));
|
|
454
898
|
for (const line of mcpClientLines(mcp))
|
|
@@ -459,10 +903,6 @@ export async function init(argv) {
|
|
|
459
903
|
for (const outcome of mcp.filter((entry) => entry.state === "failed")) {
|
|
460
904
|
out.warn(`MCP not configured for ${outcome.client}: ${outcome.detail}`);
|
|
461
905
|
}
|
|
462
|
-
// Connected, and older than the install id: nothing was changed on that
|
|
463
|
-
// entry and nothing here failed (carrick-cloud#890, `mcp.ts`).
|
|
464
|
-
for (const line of mcpUnstampedLines(mcp))
|
|
465
|
-
out.warn(line);
|
|
466
906
|
if (mcp.length === 0)
|
|
467
907
|
out.warn(`No agent client found on this machine. In Claude Code: ${mcpLine()}`);
|
|
468
908
|
// No paid scan ran here, and that is the point (carrick-cloud#799): the one
|
|
@@ -473,13 +913,25 @@ export async function init(argv) {
|
|
|
473
913
|
if (hostedIndex.length > 0) {
|
|
474
914
|
// The step reports its own outcome, so the read is one line in a terminal
|
|
475
915
|
// as well as in a pipe: a spinner that stopped with its label spent a
|
|
476
|
-
// second line on the same event (carrick#1032).
|
|
477
|
-
|
|
916
|
+
// second line on the same event (carrick#1032). What it says while it runs
|
|
917
|
+
// is the scanner's own count of where it has got to (carrick#1365).
|
|
918
|
+
const startedAt = Date.now();
|
|
919
|
+
await out.step(DOWNLOAD_LABEL, (progress) => downloadHostedIndex(plan.workspace, nativeRunner(out.quiet, progress)), (outcome) => hostedReport(outcome, (Date.now() - startedAt) / 1000));
|
|
478
920
|
}
|
|
479
921
|
else {
|
|
480
922
|
out.done("No index yet: your agent runs the one scan");
|
|
481
923
|
}
|
|
482
|
-
|
|
924
|
+
// The scaffold tool writes the files a repo needs; a repo that has them needs
|
|
925
|
+
// nothing pasted anywhere. The line used to close every run, including one
|
|
926
|
+
// where every covered repo already had its config and its workflow, and
|
|
927
|
+
// "this repo" named nothing in a folder of several (carrick#1365).
|
|
928
|
+
const unscaffolded = reposToScaffold(selectedRepos);
|
|
929
|
+
if (unscaffolded.length > 0) {
|
|
930
|
+
out.note("Next: paste this to your agent", [scaffoldSentence(unscaffolded)]);
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
out.done(READY_SENTENCE);
|
|
934
|
+
}
|
|
483
935
|
out.say(`Docs: ${out.accent(DOCS)}`);
|
|
484
936
|
return 0;
|
|
485
937
|
}
|