carrick 0.3.83 → 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 +16 -11
- package/dist/init/doctor.d.ts +21 -0
- package/dist/init/doctor.js +35 -1
- package/dist/init/doctor.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/output.d.ts +43 -6
- package/dist/init/output.js +90 -18
- package/dist/init/output.js.map +1 -1
- package/dist/init/run.d.ts +80 -25
- package/dist/init/run.js +236 -47
- package/dist/init/run.js.map +1 -1
- 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/sidecar/dist/src/capture/api.d.ts +23 -1
- package/sidecar/dist/src/capture/check-classify.js +31 -2
- package/sidecar/dist/src/capture/check-fields.d.ts +29 -0
- package/sidecar/dist/src/capture/check-fields.js +58 -6
- package/sidecar/dist/src/capture/check.js +5 -0
- 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/dist/init/run.d.ts
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { type ResolvedRepos } from "../auth/read.ts";
|
|
2
2
|
import { type RepoIdentity, type WorkspaceProposal } from "./repos.ts";
|
|
3
|
-
import { type McpOutcome } from "./mcp.ts";
|
|
3
|
+
import { type McpOutcome, type OfferedClient } from "./mcp.ts";
|
|
4
4
|
import { type Choice, type InitOutput } from "./output.ts";
|
|
5
5
|
/**
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
6
|
+
* The repos this run covers that the scaffold tool still has work in.
|
|
7
|
+
*
|
|
8
|
+
* The two files that tool writes: the `carrick.json` a scan reads, and the
|
|
9
|
+
* workflow that runs the scan. A repo holding both has been scaffolded, and
|
|
10
|
+
* the closing instruction is then an instruction to do nothing — which is what
|
|
11
|
+
* it was on a folder where every repo was already set up (carrick#1365).
|
|
12
|
+
*
|
|
13
|
+
* The repo root only, because that is where the scanner looks for a config
|
|
14
|
+
* (`Config::load_services`): a `carrick.json` deeper in the tree is a fixture
|
|
15
|
+
* or a nested example, not this repo's.
|
|
16
|
+
*/
|
|
17
|
+
export declare function reposToScaffold(repos: RepoIdentity[], exists?: (target: string) => boolean): RepoIdentity[];
|
|
18
|
+
/**
|
|
19
|
+
* The instruction to paste, naming the repos it is about.
|
|
20
|
+
*
|
|
21
|
+
* The scaffold tool's branch turns on `repo` and turns soft without it: with
|
|
22
|
+
* no `repo` the response keeps its default ending and tells the agent to run
|
|
23
|
+
* the scan — on a repo CI already indexes, which is the row the whole
|
|
24
|
+
* workspace reads (carrick-cloud `src/tools/scaffold.ts`, cloud#805 item 1).
|
|
25
|
+
* So the names are in the sentence, one call per repo.
|
|
19
26
|
*/
|
|
20
|
-
export declare
|
|
27
|
+
export declare function scaffoldSentence(repos: RepoIdentity[]): string;
|
|
28
|
+
/** What a run with nothing left to set up closes on: what this machine can do now. */
|
|
29
|
+
export declare 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.";
|
|
21
30
|
export type InitOptions = {
|
|
22
31
|
workspace: string;
|
|
23
32
|
/** Put the selected repos in this project, moving them where `allowMove` says. */
|
|
@@ -31,6 +40,13 @@ export type InitOptions = {
|
|
|
31
40
|
* repo here that has no identity.
|
|
32
41
|
*/
|
|
33
42
|
repos: string[];
|
|
43
|
+
/**
|
|
44
|
+
* The editors outside this workspace to add the MCP entry to, by name.
|
|
45
|
+
*
|
|
46
|
+
* Empty means the terminal asks, and a run with no terminal writes no editor
|
|
47
|
+
* file at all: `--yes` is an answer about this workspace (carrick#1365).
|
|
48
|
+
*/
|
|
49
|
+
editors: string[];
|
|
34
50
|
/** Answer yes to the proposal rather than asking. Never grants a move. */
|
|
35
51
|
assumeYes: boolean;
|
|
36
52
|
/** Take the selected repos out of whatever project they are in now. */
|
|
@@ -80,14 +96,6 @@ export declare function configuredLine(mcp: McpOutcome[]): string;
|
|
|
80
96
|
* A client left exactly as it was gets no line at all (carrick#1026).
|
|
81
97
|
*/
|
|
82
98
|
export declare function mcpClientLines(mcp: McpOutcome[]): string[];
|
|
83
|
-
/**
|
|
84
|
-
* One warning per client connected before the install id existed.
|
|
85
|
-
*
|
|
86
|
-
* Not a failure — the client works, and this run deliberately changed nothing
|
|
87
|
-
* on its entry — so the line is the client, what is missing, and the commands
|
|
88
|
-
* that put it there, which are the owner's to run (`mcp.ts`).
|
|
89
|
-
*/
|
|
90
|
-
export declare function mcpUnstampedLines(mcp: McpOutcome[]): string[];
|
|
91
99
|
/** The line a run that was stopped at a question ends on (carrick#1338). */
|
|
92
100
|
export declare const CANCELLED = "Cancelled. Nothing was written, here or in Carrick.";
|
|
93
101
|
/**
|
|
@@ -98,8 +106,32 @@ export declare const CANCELLED = "Cancelled. Nothing was written, here or in Car
|
|
|
98
106
|
* they were, and both say it.
|
|
99
107
|
*/
|
|
100
108
|
export declare const NOTHING_WRITTEN = "Nothing written, here or in Carrick.";
|
|
101
|
-
/**
|
|
102
|
-
|
|
109
|
+
/**
|
|
110
|
+
* The repos that start selected, and why each of the others does not.
|
|
111
|
+
*
|
|
112
|
+
* A default is a claim about somebody's folder, so only the repos something
|
|
113
|
+
* already says belong here carry one: connected to this workspace, and in the
|
|
114
|
+
* project most of this folder's connected repos are in. Everything else — a
|
|
115
|
+
* repo nobody connected, one sitting in a different project, one with no
|
|
116
|
+
* GitHub identity to ask about — starts out, with the reason on its row, and
|
|
117
|
+
* the reader adds it in one keystroke (carrick#1365).
|
|
118
|
+
*
|
|
119
|
+
* Two cases preselect nothing at all, and neither is in the ruling because
|
|
120
|
+
* neither is a majority: a folder where nothing is connected (a first run), and
|
|
121
|
+
* a tie between two projects. A guess there is a guess about which half of
|
|
122
|
+
* somebody's folder this install is for.
|
|
123
|
+
*
|
|
124
|
+
* Unconnected is not the same as unknown: with no read of the workspace — no
|
|
125
|
+
* login yet, or a read that did not answer — `identity` is null and nothing is
|
|
126
|
+
* preselected, because "not connected" would then be this command's assumption
|
|
127
|
+
* rather than the server's answer.
|
|
128
|
+
*/
|
|
129
|
+
export declare function preselectedRepos(candidates: RepoIdentity[], identity: ResolvedRepos | null, label?: (slug: string) => string): {
|
|
130
|
+
keep: string[];
|
|
131
|
+
reasons: Map<string, string>;
|
|
132
|
+
};
|
|
133
|
+
/** A repo as the picker lists it: what it is called, what is in it, and why it is out. */
|
|
134
|
+
export declare function repoChoices(plan: WorkspaceProposal, candidates: RepoIdentity[], reasons?: Map<string, string>): Choice[];
|
|
103
135
|
/**
|
|
104
136
|
* Which repos in this folder this install covers, settled before anything is
|
|
105
137
|
* read from Carrick and long before anything is written (carrick#1338).
|
|
@@ -113,6 +145,9 @@ export declare function chooseRepos(plan: WorkspaceProposal, candidates: RepoIde
|
|
|
113
145
|
repos: string[];
|
|
114
146
|
assumeYes: boolean;
|
|
115
147
|
interactive: boolean;
|
|
148
|
+
/** The workspace read that decides the defaults, or null when there is none. */
|
|
149
|
+
identity?: ResolvedRepos | null;
|
|
150
|
+
label?: (slug: string) => string;
|
|
116
151
|
}, out: InitOutput): Promise<RepoIdentity[]>;
|
|
117
152
|
/**
|
|
118
153
|
* The excluded repos a `--repo` flag is asking for, by identity.
|
|
@@ -130,6 +165,26 @@ export declare function chooseRepos(plan: WorkspaceProposal, candidates: RepoIde
|
|
|
130
165
|
* there is no identity to have asked for.
|
|
131
166
|
*/
|
|
132
167
|
export declare function namedButExcluded(workspace: string, excluded: string[], requested: string[], identify?: (repo: string) => RepoIdentity): string[];
|
|
168
|
+
/**
|
|
169
|
+
* Which editors outside the workspace get an MCP entry written for them.
|
|
170
|
+
*
|
|
171
|
+
* The answer is a set of names, and the default is the empty one: these are
|
|
172
|
+
* files in somebody's home directory for editors they may not use, so the
|
|
173
|
+
* strongest thing a configuration directory can do is put a row in the
|
|
174
|
+
* question (carrick#1365). An editor whose own command answers on PATH starts
|
|
175
|
+
* ticked; without a terminal, `--mcp` names them and nothing else is written.
|
|
176
|
+
*
|
|
177
|
+
* `--yes` does not cover these. It takes the proposal as printed — a list of
|
|
178
|
+
* packages and a set of files inside this workspace — and an entry in
|
|
179
|
+
* `~/.cursor/mcp.json` is neither.
|
|
180
|
+
*/
|
|
181
|
+
export declare function chooseEditors(offered: OfferedClient[], options: {
|
|
182
|
+
editors: string[];
|
|
183
|
+
assumeYes: boolean;
|
|
184
|
+
interactive: boolean;
|
|
185
|
+
}, out: InitOutput): Promise<string[]>;
|
|
186
|
+
/** What the proposal question adds when an editor file is part of the answer. */
|
|
187
|
+
export declare function editorClause(offered: OfferedClient[], chosen: readonly string[]): string;
|
|
133
188
|
/** The repos this install covers, one line each, for the proposal. */
|
|
134
189
|
export declare function coverageLines(plan: WorkspaceProposal, selected: RepoIdentity[]): string[];
|
|
135
190
|
/**
|
package/dist/init/run.js
CHANGED
|
@@ -15,35 +15,54 @@ import { signIn } from "../auth/run.js";
|
|
|
15
15
|
import { resolveRepos } from "../auth/read.js";
|
|
16
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";
|
|
18
|
+
import { DOWNLOAD_LABEL, downloadHostedIndex, hostedReport, nativeRunner } from "./hosted.js";
|
|
19
19
|
import { createProject, listProjects, planProject, projectLabel, projectStep, SLUG, } from "./projects.js";
|
|
20
|
-
import { connectMcpClients, mcpLine } from "./mcp.js";
|
|
20
|
+
import { connectMcpClients, mcpLine, offeredFileClients } from "./mcp.js";
|
|
21
21
|
import { hookCommand, mergeCarrickHooks, removeCarrickHooks } from "./settings.js";
|
|
22
22
|
import { CODEX_HOOKS_FILE, writeCodexHooks } from "./codex.js";
|
|
23
23
|
import { ignoredSkillRoots, taskSkillLines, writeTaskSkills } from "./task-skills.js";
|
|
24
24
|
import { writeIfChanged } from "./files.js";
|
|
25
25
|
import { excludedRepos, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
|
|
26
26
|
import { createOutput, DOCS, PromptCancelled } from "./output.js";
|
|
27
|
-
import { renderTemplate } from "../templates.js";
|
|
27
|
+
import { renderTemplate, TEMPLATE_PATHS } from "../templates.js";
|
|
28
28
|
/**
|
|
29
|
-
* The
|
|
29
|
+
* The repos this run covers that the scaffold tool still has work in.
|
|
30
30
|
*
|
|
31
|
-
* The
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* 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).
|
|
36
35
|
*
|
|
37
|
-
* The
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* repo CI already indexes, which is the row the whole workspace reads
|
|
41
|
-
* (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.
|
|
42
39
|
*/
|
|
43
|
-
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.";
|
|
44
63
|
const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
45
64
|
export function parseArgs(argv, cwd = process.cwd()) {
|
|
46
|
-
const options = { workspace: cwd, project: null, repos: [], assumeYes: false, allowMove: false };
|
|
65
|
+
const options = { workspace: cwd, project: null, repos: [], editors: [], assumeYes: false, allowMove: false };
|
|
47
66
|
for (let index = 0; index < argv.length; index += 1) {
|
|
48
67
|
const argument = argv[index];
|
|
49
68
|
switch (argument) {
|
|
@@ -65,6 +84,20 @@ export function parseArgs(argv, cwd = process.cwd()) {
|
|
|
65
84
|
index += 1;
|
|
66
85
|
break;
|
|
67
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
|
+
}
|
|
68
101
|
case "--repo": {
|
|
69
102
|
const value = argv[index + 1];
|
|
70
103
|
if (!value)
|
|
@@ -100,7 +133,7 @@ export function parseArgs(argv, cwd = process.cwd()) {
|
|
|
100
133
|
}
|
|
101
134
|
function help() {
|
|
102
135
|
return [
|
|
103
|
-
"carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--allow-move]",
|
|
136
|
+
"carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--mcp EDITOR] [--allow-move]",
|
|
104
137
|
"",
|
|
105
138
|
"Sign in (here, or beforehand with carrick login), then set up a repository",
|
|
106
139
|
"or a folder of repos: which repos this install covers, the project, the repo",
|
|
@@ -123,6 +156,10 @@ function help() {
|
|
|
123
156
|
" querying either project can see, so the move is named",
|
|
124
157
|
" and asked about separately",
|
|
125
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",
|
|
126
163
|
" -y, --yes Take the proposal as printed",
|
|
127
164
|
"",
|
|
128
165
|
`The editor extension, the hooks, CI and a carrick.json written by hand: ${DOCS}`,
|
|
@@ -222,18 +259,6 @@ export function mcpClientLines(mcp) {
|
|
|
222
259
|
.filter((outcome) => outcome.state === "written" && outcome.client !== "Claude Code")
|
|
223
260
|
.map((outcome) => `MCP added for ${outcome.client}: ${outcome.detail}`);
|
|
224
261
|
}
|
|
225
|
-
/**
|
|
226
|
-
* One warning per client connected before the install id existed.
|
|
227
|
-
*
|
|
228
|
-
* Not a failure — the client works, and this run deliberately changed nothing
|
|
229
|
-
* on its entry — so the line is the client, what is missing, and the commands
|
|
230
|
-
* that put it there, which are the owner's to run (`mcp.ts`).
|
|
231
|
-
*/
|
|
232
|
-
export function mcpUnstampedLines(mcp) {
|
|
233
|
-
return mcp
|
|
234
|
-
.filter((outcome) => outcome.state === "unstamped")
|
|
235
|
-
.map((outcome) => `${outcome.client}: ${outcome.detail}`);
|
|
236
|
-
}
|
|
237
262
|
/**
|
|
238
263
|
* An end to the run with its own exit code, decided before anything is written.
|
|
239
264
|
*
|
|
@@ -260,14 +285,70 @@ export const CANCELLED = "Cancelled. Nothing was written, here or in Carrick.";
|
|
|
260
285
|
* they were, and both say it.
|
|
261
286
|
*/
|
|
262
287
|
export const NOTHING_WRITTEN = "Nothing written, here or in Carrick.";
|
|
263
|
-
/**
|
|
264
|
-
|
|
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()) {
|
|
265
345
|
return candidates.map((repo) => {
|
|
266
346
|
const found = plan.repos.find((entry) => entry.path === repo.path);
|
|
267
347
|
const packages = found?.services.length ?? 0;
|
|
348
|
+
const reason = reasons.get(repo.path);
|
|
268
349
|
const hint = [
|
|
269
350
|
`${packages} package${packages === 1 ? "" : "s"}`,
|
|
270
|
-
...(
|
|
351
|
+
...(reason === undefined ? [] : [reason]),
|
|
271
352
|
].join(", ");
|
|
272
353
|
return { value: repo.path, label: repo.name ?? path.basename(repo.path), hint };
|
|
273
354
|
});
|
|
@@ -298,7 +379,8 @@ export async function chooseRepos(plan, candidates, options, out) {
|
|
|
298
379
|
if (!options.interactive) {
|
|
299
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}.`);
|
|
300
381
|
}
|
|
301
|
-
const
|
|
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 }));
|
|
302
384
|
return candidates.filter((repo) => kept.has(repo.path));
|
|
303
385
|
}
|
|
304
386
|
/**
|
|
@@ -328,6 +410,50 @@ export function namedButExcluded(workspace, excluded, requested, identify = repo
|
|
|
328
410
|
return identity.name !== null && wanted.has(identity.name.toLowerCase());
|
|
329
411
|
});
|
|
330
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
|
+
}
|
|
331
457
|
/** The repos this install covers, one line each, for the proposal. */
|
|
332
458
|
export function coverageLines(plan, selected) {
|
|
333
459
|
return selected.slice(0, 10).map((repo) => {
|
|
@@ -387,6 +513,11 @@ export async function initWith(argv, out, interactive) {
|
|
|
387
513
|
const movable = new Set();
|
|
388
514
|
// The directory names this run leaves out, for the workspace file below.
|
|
389
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 = [];
|
|
390
521
|
try {
|
|
391
522
|
// What a previous run wrote down, before anything is derived from it: the
|
|
392
523
|
// scanner has already dropped an excluded repo by the time the proposal
|
|
@@ -404,9 +535,43 @@ export async function initWith(argv, out, interactive) {
|
|
|
404
535
|
// and a run nobody has chosen in yet must not have signed anything in.
|
|
405
536
|
derived = deriveWorkspace(workspace);
|
|
406
537
|
const candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
|
|
407
|
-
|
|
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);
|
|
408
572
|
if (selected.length === 0)
|
|
409
573
|
throw new Stop(0);
|
|
574
|
+
selectedRepos = selected;
|
|
410
575
|
derived = selectedProposal(derived, selected.map((repo) => repo.path));
|
|
411
576
|
const kept = new Set(selected.map((repo) => repo.path));
|
|
412
577
|
deselected = candidates
|
|
@@ -420,7 +585,6 @@ export async function initWith(argv, out, interactive) {
|
|
|
420
585
|
// to run another command: `carrick login` is the same browser round trip,
|
|
421
586
|
// and refusing to take it was the first thing a new install did
|
|
422
587
|
// (carrick#955). Without a terminal there is no browser to hand it to.
|
|
423
|
-
const saved = readCredential();
|
|
424
588
|
if (saved)
|
|
425
589
|
credential = saved;
|
|
426
590
|
else {
|
|
@@ -461,12 +625,19 @@ export async function initWith(argv, out, interactive) {
|
|
|
461
625
|
// later upload has no repo identity to resolve a project from.
|
|
462
626
|
out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
|
|
463
627
|
}
|
|
464
|
-
|
|
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);
|
|
465
635
|
// Read once, for the names: every project this run prints is printed as
|
|
466
636
|
// the dashboard shows it, with the slug beside it (carrick#1338). A
|
|
467
637
|
// workspace whose API has no such action answers null and the lines carry
|
|
468
638
|
// slugs alone, as they did.
|
|
469
|
-
|
|
639
|
+
if (projects === null)
|
|
640
|
+
projects = names.length > 0 ? await listProjects(credential.token) : null;
|
|
470
641
|
// The project half of the browser round trip, where this API can do it
|
|
471
642
|
// from here. Nothing is created or moved in it: it settles what this run
|
|
472
643
|
// would do, and the proposal below is where that is accepted. Without
|
|
@@ -527,14 +698,24 @@ export async function initWith(argv, out, interactive) {
|
|
|
527
698
|
if (decision.create)
|
|
528
699
|
out.say(` Project "${project}" will be created in this workspace`);
|
|
529
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);
|
|
530
708
|
const subject = packagesFound(plan);
|
|
531
709
|
if (!parsed.assumeYes) {
|
|
532
710
|
if (!interactive) {
|
|
533
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" : ""}.`);
|
|
534
712
|
}
|
|
535
713
|
// 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
|
-
|
|
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}?`))) {
|
|
538
719
|
out.refuse(NOTHING_WRITTEN);
|
|
539
720
|
throw new Stop(0);
|
|
540
721
|
}
|
|
@@ -711,7 +892,7 @@ export async function initWith(argv, out, interactive) {
|
|
|
711
892
|
catch (error) {
|
|
712
893
|
out.refuse(`Could not write the task skills: ${error.message}`);
|
|
713
894
|
}
|
|
714
|
-
const mcp = connectMcpClients();
|
|
895
|
+
const mcp = connectMcpClients(editors);
|
|
715
896
|
if (hooksWritten)
|
|
716
897
|
out.done(configuredLine(mcp));
|
|
717
898
|
for (const line of mcpClientLines(mcp))
|
|
@@ -722,10 +903,6 @@ export async function initWith(argv, out, interactive) {
|
|
|
722
903
|
for (const outcome of mcp.filter((entry) => entry.state === "failed")) {
|
|
723
904
|
out.warn(`MCP not configured for ${outcome.client}: ${outcome.detail}`);
|
|
724
905
|
}
|
|
725
|
-
// Connected, and older than the install id: nothing was changed on that
|
|
726
|
-
// entry and nothing here failed (carrick-cloud#890, `mcp.ts`).
|
|
727
|
-
for (const line of mcpUnstampedLines(mcp))
|
|
728
|
-
out.warn(line);
|
|
729
906
|
if (mcp.length === 0)
|
|
730
907
|
out.warn(`No agent client found on this machine. In Claude Code: ${mcpLine()}`);
|
|
731
908
|
// No paid scan ran here, and that is the point (carrick-cloud#799): the one
|
|
@@ -736,13 +913,25 @@ export async function initWith(argv, out, interactive) {
|
|
|
736
913
|
if (hostedIndex.length > 0) {
|
|
737
914
|
// The step reports its own outcome, so the read is one line in a terminal
|
|
738
915
|
// as well as in a pipe: a spinner that stopped with its label spent a
|
|
739
|
-
// second line on the same event (carrick#1032).
|
|
740
|
-
|
|
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));
|
|
741
920
|
}
|
|
742
921
|
else {
|
|
743
922
|
out.done("No index yet: your agent runs the one scan");
|
|
744
923
|
}
|
|
745
|
-
|
|
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
|
+
}
|
|
746
935
|
out.say(`Docs: ${out.accent(DOCS)}`);
|
|
747
936
|
return 0;
|
|
748
937
|
}
|