create-pathfinder 1.5.1 → 1.7.0
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/AGENTS.md +1 -1
- package/README.md +34 -27
- package/bin/create-pathfinder.mjs +22 -0
- package/package.json +2 -2
- package/src/cells.mjs +324 -0
- package/src/cli.mjs +816 -89
- package/src/install.mjs +30 -2
- package/src/kit.mjs +16 -0
- package/src/progress.mjs +144 -0
- package/src/prompt.mjs +116 -11
- package/src/select.mjs +426 -0
- package/src/theme.mjs +590 -0
package/src/cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* whose identity is "not a framework."
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { findKitRoot, COPY_LIST } from "./kit.mjs";
|
|
9
|
+
import { findKitRoot, COPY_LIST, VERSION } from "./kit.mjs";
|
|
10
10
|
import { applyAdapterPlan, applyPlan, planAdapters, planInstall } from "./install.mjs";
|
|
11
11
|
import { detect, detectedToolLabels } from "./detect.mjs";
|
|
12
12
|
import { initRepository } from "./git.mjs";
|
|
@@ -14,6 +14,8 @@ import { nonInteractivePrompter } from "./prompt.mjs";
|
|
|
14
14
|
import { copyToClipboard } from "./clipboard.mjs";
|
|
15
15
|
import { detectEditors, openInEditor } from "./editor.mjs";
|
|
16
16
|
import { kickstartPrompt, kickstartPromptLines } from "./kickstart-prompt.mjs";
|
|
17
|
+
import { createTheme } from "./theme.mjs";
|
|
18
|
+
import { createProgress } from "./progress.mjs";
|
|
17
19
|
import {
|
|
18
20
|
HARNESSES,
|
|
19
21
|
HARNESS_IDS,
|
|
@@ -50,6 +52,15 @@ Options:
|
|
|
50
52
|
Adapters are generated files Pathfinder owns and regenerates without --force.
|
|
51
53
|
A file it did not generate is never replaced, at any path, without --force.
|
|
52
54
|
|
|
55
|
+
Environment:
|
|
56
|
+
PATHFINDER_PROMPT=classic
|
|
57
|
+
Ask every question as a numbered list and y/n instead of an
|
|
58
|
+
arrow-key selector. Both are supported; use this for screen
|
|
59
|
+
readers, for scripts, or simply if you prefer typing. It is
|
|
60
|
+
also what a terminal narrower than 49 columns and TERM=dumb
|
|
61
|
+
select on their own.
|
|
62
|
+
NO_COLOR Print no colour. It does not disable the selector.
|
|
63
|
+
|
|
53
64
|
Without a terminal on both stdin and stdout, nothing is ever asked. In that
|
|
54
65
|
case a directory that is not a Git repository needs --git-init, or the install
|
|
55
66
|
is refused, and neither your clipboard nor an editor is touched — --yes does
|
|
@@ -65,6 +76,7 @@ export async function run(
|
|
|
65
76
|
env = {},
|
|
66
77
|
platform = process.platform,
|
|
67
78
|
stdoutIsTTY = false,
|
|
79
|
+
theme: injectedTheme = null,
|
|
68
80
|
prompter = nonInteractivePrompter(),
|
|
69
81
|
},
|
|
70
82
|
) {
|
|
@@ -83,14 +95,36 @@ export async function run(
|
|
|
83
95
|
// Detection runs before anything is decided and before anything is asked, so
|
|
84
96
|
// the user reads what the tool found before reading what it wants to do.
|
|
85
97
|
const findings = detect({ cwd, env, platform });
|
|
86
|
-
const unicode = supportsUnicode(env, platform);
|
|
87
|
-
const mark = marks(unicode);
|
|
88
98
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
// Every capability question this run will ask is answered once and threaded
|
|
100
|
+
// downward as an argument rather than reached for: a module-level theme would
|
|
101
|
+
// be a second opinion about the terminal that no test could disagree with.
|
|
102
|
+
//
|
|
103
|
+
// Built here when nobody supplied one, which is every test and every caller
|
|
104
|
+
// that has nothing to say about stdin. `bin/` supplies one because it knows
|
|
105
|
+
// things this function is never handed — whether stdin is a terminal, whether
|
|
106
|
+
// it can be put into raw mode, how wide the window is — and those are exactly
|
|
107
|
+
// the three the selection capability is decided from. Accepting it keeps the
|
|
108
|
+
// property this comment has always claimed: one theme per run, not one per
|
|
109
|
+
// module that wants an opinion.
|
|
110
|
+
const theme = injectedTheme ?? createTheme({ env, platform, isTTY: stdoutIsTTY });
|
|
111
|
+
const mark = theme.glyph;
|
|
112
|
+
|
|
113
|
+
// The one branch in this file that chooses between whole presentations, and
|
|
114
|
+
// the reason the theme exposes `tier` at all.
|
|
115
|
+
//
|
|
116
|
+
// `contract` is a promise, not a fallback: a piped run, a CI log, and
|
|
117
|
+
// `> install.txt` get the bytes 1.4.1 produced, and no amount of ambition in
|
|
118
|
+
// this feature reaches them. Written as a tier test rather than as
|
|
119
|
+
// `if (stdoutIsTTY)` — the two are the same value by construction, but only
|
|
120
|
+
// one of them says why, and the next person to add a decorated block here
|
|
121
|
+
// needs to read the reason and not rediscover it.
|
|
122
|
+
//
|
|
123
|
+
// The identity block rides on the same test. `--help` never reaches this line
|
|
124
|
+
// (it returns above), which is how it keeps its plain reference form.
|
|
125
|
+
if (theme.tier !== "contract") {
|
|
126
|
+
out(formatIdentity({ theme }));
|
|
127
|
+
out(formatFindings(findings, { theme }));
|
|
94
128
|
}
|
|
95
129
|
|
|
96
130
|
// Refused rather than allowed with a warning: this tool writes several
|
|
@@ -156,27 +190,109 @@ export async function run(
|
|
|
156
190
|
// the user before the first file moves. Detection supplies the default and
|
|
157
191
|
// nothing more: a tool being installed on this machine is not permission to
|
|
158
192
|
// write into the project on its behalf.
|
|
159
|
-
const { harnesses, customTools } = await selectHarnesses({
|
|
193
|
+
const { harnesses, customTools } = await selectHarnesses({
|
|
194
|
+
findings,
|
|
195
|
+
options,
|
|
196
|
+
prompter,
|
|
197
|
+
out,
|
|
198
|
+
theme,
|
|
199
|
+
});
|
|
160
200
|
|
|
161
201
|
const plan = planInstall(kitRoot, cwd, { force: options.force });
|
|
162
|
-
const result = applyPlan(plan, { dryRun: options.dryRun });
|
|
163
202
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
203
|
+
// Both plans are computed before anything is written, which is what lets the
|
|
204
|
+
// progress bar state a real denominator instead of discovering its own total
|
|
205
|
+
// as it goes.
|
|
206
|
+
//
|
|
207
|
+
// Planning adapters this early is safe, and specifically because of what the
|
|
208
|
+
// copy list contains: AGENTS.md, CLAUDE.md, context, skills, and templates.
|
|
209
|
+
// No entry writes into `.claude/` or `.agents/`, so the copy cannot change
|
|
210
|
+
// the answer `planAdapters` gives about an adapter path, and the canonical
|
|
211
|
+
// skills it reads come from the kit rather than from the destination. If a
|
|
212
|
+
// future entry ever does write to an adapter path, this has to move back.
|
|
213
|
+
//
|
|
214
|
+
// Applying them stays where it was, after the copy: an adapter delegates to a
|
|
215
|
+
// canonical file, so generating one beside a copy that failed would point the
|
|
216
|
+
// user's tool at a file that is not there.
|
|
217
|
+
const adapterPlan =
|
|
218
|
+
harnesses.length > 0
|
|
219
|
+
? planAdapters(harnesses, { kitRoot, targetRoot: cwd, force: options.force })
|
|
220
|
+
: [];
|
|
221
|
+
|
|
222
|
+
// Zero on a dry run, which disables the bar. A dry run carries nothing out,
|
|
223
|
+
// and a bar filling for work that is not happening would be the exact species
|
|
224
|
+
// of theatre this treatment was designed to avoid.
|
|
225
|
+
const progress = createProgress({
|
|
226
|
+
theme,
|
|
227
|
+
total: options.dryRun ? 0 : plan.length + adapterPlan.length,
|
|
228
|
+
out,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (!options.dryRun && theme.tier !== "contract") {
|
|
232
|
+
out(` ${mark.box} ${theme.bold("INSTALLING")}\n`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const result = applyPlan(plan, {
|
|
236
|
+
dryRun: options.dryRun,
|
|
237
|
+
onProgress: (unit) => progress.advance(unit),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
progress.milestone(
|
|
241
|
+
result.errors.length > 0
|
|
242
|
+
? railed(theme, theme.warn(`${mark.warn} Kit files`) + ` ${mark.dash} ${result.errors.length} could not be written`)
|
|
243
|
+
: railed(
|
|
244
|
+
theme,
|
|
245
|
+
countWritten(result, plan, options) > 0
|
|
246
|
+
? theme.ok(`${mark.ok} Kit files`) + ` ${mark.dash} ${theme.bold(countWritten(result, plan, options))} copied`
|
|
247
|
+
: theme.info(`${mark.info} Kit files`) + ` ${mark.dash} already in place`,
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const adapters = generateAdapters({
|
|
252
|
+
plan: adapterPlan,
|
|
253
|
+
harnesses,
|
|
254
|
+
options,
|
|
255
|
+
result,
|
|
256
|
+
onProgress: (unit) => progress.advance(unit),
|
|
257
|
+
onHarnessDone: (harness, counts) =>
|
|
258
|
+
progress.milestone(
|
|
259
|
+
railed(
|
|
260
|
+
theme,
|
|
261
|
+
counts.conflicts > 0
|
|
262
|
+
? theme.warn(`${mark.warn} ${harness.label}`) +
|
|
263
|
+
` ${mark.dash} ${counts.done} adapters, ${counts.conflicts} left alone`
|
|
264
|
+
: theme.ok(`${mark.ok} ${harness.label}`) + ` ${mark.dash} ${theme.bold(counts.done)} adapters`,
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
progress.finish();
|
|
270
|
+
if (!options.dryRun && theme.tier !== "contract") out("\n");
|
|
168
271
|
|
|
169
|
-
report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err });
|
|
272
|
+
report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme });
|
|
170
273
|
|
|
171
274
|
// After the report, because the first offer is about the prompt the report
|
|
172
275
|
// just printed — and because a question above the summary would make the user
|
|
173
276
|
// answer before seeing what happened. Every run that gets this far printed a
|
|
174
277
|
// prompt, including one that wrote no files, so there is nothing to guard on.
|
|
175
|
-
await offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform });
|
|
278
|
+
await offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform, theme });
|
|
279
|
+
|
|
280
|
+
// The last line of the run, and the reason it is here rather than in the
|
|
281
|
+
// report: the report is followed by two questions, so anything printed there
|
|
282
|
+
// would not be last. Before this, a successful first run ended on
|
|
283
|
+
// "Opening VS Code." — the tool installed several hundred files and then
|
|
284
|
+
// signed off with a subordinate clause about somebody else's editor.
|
|
285
|
+
//
|
|
286
|
+
// Not printed when anything failed. A sign-off over an error is a tool that
|
|
287
|
+
// did not read its own output.
|
|
288
|
+
const failed = result.errors.length > 0 || adapters.result.errors.length > 0;
|
|
289
|
+
if (theme.tier !== "contract" && !failed) {
|
|
290
|
+
out(`\n ${theme.dim(SIGN_OFF)}\n`);
|
|
291
|
+
}
|
|
176
292
|
|
|
177
293
|
// Neither offer can change this. An install that wrote every file succeeded
|
|
178
294
|
// whether or not the machine has `pbcopy` or an editor on it.
|
|
179
|
-
return
|
|
295
|
+
return failed ? 1 : 0;
|
|
180
296
|
}
|
|
181
297
|
|
|
182
298
|
/**
|
|
@@ -188,7 +304,7 @@ export async function run(
|
|
|
188
304
|
* needing to remember to skip it, and one that forgot would generate adapters
|
|
189
305
|
* into a directory named after a tool that cannot read them.
|
|
190
306
|
*/
|
|
191
|
-
const SOMETHING_ELSE = Object.freeze({ label: "Something else
|
|
307
|
+
const SOMETHING_ELSE = Object.freeze({ label: "Something else" });
|
|
192
308
|
|
|
193
309
|
/** How many custom names one run will take before it stops asking. */
|
|
194
310
|
const CUSTOM_TOOL_LIMIT = 10;
|
|
@@ -215,7 +331,7 @@ const CUSTOM_TOOL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,39}$/;
|
|
|
215
331
|
*
|
|
216
332
|
* @returns {Promise<{harnesses: object[], customTools: string[]}>}
|
|
217
333
|
*/
|
|
218
|
-
async function selectHarnesses({ findings, options, prompter, out }) {
|
|
334
|
+
async function selectHarnesses({ findings, options, prompter, out, theme }) {
|
|
219
335
|
if (options.agents !== null) {
|
|
220
336
|
return { harnesses: options.agents.map((id) => findHarness(id)), customTools: [] };
|
|
221
337
|
}
|
|
@@ -223,19 +339,38 @@ async function selectHarnesses({ findings, options, prompter, out }) {
|
|
|
223
339
|
|
|
224
340
|
const detected = detectedHarnesses(findings);
|
|
225
341
|
const entries = [...HARNESSES, SOMETHING_ELSE];
|
|
226
|
-
const width = Math.max(...entries.map((entry) => entry.label.length));
|
|
227
342
|
|
|
343
|
+
// The sentinel's trailing ellipsis is the theme's, not a character baked into
|
|
344
|
+
// the label: an ASCII terminal spends three characters on `...` where a UTF-8
|
|
345
|
+
// one spends one.
|
|
346
|
+
const labelOf = (entry) =>
|
|
347
|
+
entry === SOMETHING_ELSE ? `${entry.label}${theme.glyph.ellipsis}` : entry.label;
|
|
348
|
+
|
|
349
|
+
// Three fields instead of one hand-built string.
|
|
350
|
+
//
|
|
351
|
+
// This used to compute the longest label and `padEnd` every other one to it,
|
|
352
|
+
// so that the `->` arrows lined up — a column of layout, measured in UTF-16
|
|
353
|
+
// units, inside a file whose job is deciding which tools to configure. It was
|
|
354
|
+
// wrong in the way `.length` is always wrong about a terminal, and it was
|
|
355
|
+
// wrong twice over: it produced a *rendering* that only one of the two
|
|
356
|
+
// question implementations could use.
|
|
357
|
+
//
|
|
358
|
+
// Now the call site says what each part *is* and the renderer decides where it
|
|
359
|
+
// goes. Both implementations get the same three fields and lay them out their
|
|
360
|
+
// own way, and neither this function nor any other in this file measures a
|
|
361
|
+
// string in cells.
|
|
228
362
|
const answer = await prompter.chooseMany("Configure Pathfinder for which tools?", {
|
|
229
363
|
options: entries.map((entry) => ({
|
|
230
364
|
value: entry,
|
|
231
|
-
label:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
365
|
+
label: labelOf(entry),
|
|
366
|
+
// The path is shown so nobody has to check a box to find out what it
|
|
367
|
+
// writes. The last entry earns the same courtesy by admitting it writes
|
|
368
|
+
// nothing, in the column where every other row names a file.
|
|
369
|
+
hint: entry === SOMETHING_ELSE ? "nothing is generated" : `${entry.skillsDir}/`,
|
|
370
|
+
// Detection decides the default; this only says so out loud. It is the
|
|
371
|
+
// one part of a row a narrow terminal may drop, because the ENVIRONMENT
|
|
372
|
+
// block above has already reported it.
|
|
373
|
+
note: entry !== SOMETHING_ELSE && detected.includes(entry) ? "(detected)" : undefined,
|
|
239
374
|
})),
|
|
240
375
|
defaultSelection: detected,
|
|
241
376
|
});
|
|
@@ -244,7 +379,9 @@ async function selectHarnesses({ findings, options, prompter, out }) {
|
|
|
244
379
|
|
|
245
380
|
const chosen = answer ?? [];
|
|
246
381
|
const harnesses = chosen.filter((entry) => entry !== SOMETHING_ELSE);
|
|
247
|
-
const customTools = chosen.includes(SOMETHING_ELSE)
|
|
382
|
+
const customTools = chosen.includes(SOMETHING_ELSE)
|
|
383
|
+
? await askCustomTools({ prompter, out, theme })
|
|
384
|
+
: [];
|
|
248
385
|
|
|
249
386
|
return { harnesses, customTools };
|
|
250
387
|
}
|
|
@@ -262,7 +399,7 @@ async function selectHarnesses({ findings, options, prompter, out }) {
|
|
|
262
399
|
* there because a question that repeats itself is a question that can repeat
|
|
263
400
|
* itself forever on a stream that never closes.
|
|
264
401
|
*/
|
|
265
|
-
async function askCustomTools({ prompter, out }) {
|
|
402
|
+
async function askCustomTools({ prompter, out, theme }) {
|
|
266
403
|
out(
|
|
267
404
|
"Pathfinder generates adapters only for tools it can generate them for.\n" +
|
|
268
405
|
"Name the others and the summary will say what does work for them.\n\n",
|
|
@@ -279,7 +416,7 @@ async function askCustomTools({ prompter, out }) {
|
|
|
279
416
|
const supported = harnessNamed(answer);
|
|
280
417
|
if (supported !== null) {
|
|
281
418
|
out(
|
|
282
|
-
` ${supported.label} is supported
|
|
419
|
+
` ${supported.label} is supported ${theme.glyph.dash} it is in the list above, and writes to\n` +
|
|
283
420
|
` ${supported.skillsDir}/. Choose it there, or pass --agents ${supported.id}.\n\n`,
|
|
284
421
|
);
|
|
285
422
|
continue;
|
|
@@ -308,17 +445,121 @@ async function askCustomTools({ prompter, out }) {
|
|
|
308
445
|
* harness was chosen" from "a harness was chosen and produced nothing", which
|
|
309
446
|
* are the same zero and mean opposite things.
|
|
310
447
|
*/
|
|
311
|
-
function generateAdapters({ harnesses,
|
|
448
|
+
function generateAdapters({ plan, harnesses, options, result, onProgress, onHarnessDone }) {
|
|
312
449
|
const none = { plan: [], result: applyAdapterPlan([]), blocked: false };
|
|
313
450
|
|
|
314
451
|
if (harnesses.length === 0) return none;
|
|
315
452
|
|
|
316
453
|
// The copy failed part-way. Reporting adapters as generated on top of that
|
|
317
454
|
// would be a success message about a broken install.
|
|
455
|
+
//
|
|
456
|
+
// The progress bar is deliberately left short here rather than topped up.
|
|
457
|
+
// These units were planned and never carried out, and a bar that reached 100%
|
|
458
|
+
// anyway would be the one thing it must never do — agree with itself while
|
|
459
|
+
// disagreeing with the error the user is about to read.
|
|
318
460
|
if (result.errors.length > 0) return { ...none, blocked: true };
|
|
319
461
|
|
|
320
|
-
|
|
321
|
-
|
|
462
|
+
// A milestone per harness, emitted when that harness's last unit resolves
|
|
463
|
+
// rather than after the whole phase, so the lines appear as the work happens.
|
|
464
|
+
// The plan is grouped by harness because `planAdapters` walks the harnesses in
|
|
465
|
+
// order, so a change of harness is the boundary — no second pass needed.
|
|
466
|
+
let current = null;
|
|
467
|
+
let counts = { done: 0, conflicts: 0 };
|
|
468
|
+
|
|
469
|
+
const flush = () => {
|
|
470
|
+
if (current) onHarnessDone?.(current, counts);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const applied = applyAdapterPlan(plan, {
|
|
474
|
+
dryRun: options.dryRun,
|
|
475
|
+
onProgress: (unit) => {
|
|
476
|
+
if (current && unit.item.harness !== current) {
|
|
477
|
+
flush();
|
|
478
|
+
counts = { done: 0, conflicts: 0 };
|
|
479
|
+
}
|
|
480
|
+
current = unit.item.harness;
|
|
481
|
+
if (unit.item.action === "conflict") counts.conflicts += 1;
|
|
482
|
+
else if (unit.ok) counts.done += 1;
|
|
483
|
+
onProgress?.(unit);
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
flush();
|
|
488
|
+
|
|
489
|
+
return { plan, result: applied, blocked: false };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The one line under the closing headline: what this run actually did.
|
|
494
|
+
*
|
|
495
|
+
* Counts rather than adjectives, because "success" is not information and the
|
|
496
|
+
* person reading has just watched a bar fill. A re-run that wrote nothing says
|
|
497
|
+
* so plainly instead of inventing an achievement.
|
|
498
|
+
*/
|
|
499
|
+
function endingHeadline({ theme, written, adapters, attention, options }) {
|
|
500
|
+
const mark = theme.glyph;
|
|
501
|
+
const built = adapters.result.generated + adapters.result.replaced;
|
|
502
|
+
|
|
503
|
+
// Something wants a human. Still ready — it is — but this is not the moment
|
|
504
|
+
// for confetti over somebody's conflicted file.
|
|
505
|
+
if (attention > 0) return theme.ok(`${mark.ok} READY`);
|
|
506
|
+
|
|
507
|
+
// Nothing to do, and nothing wrong. A tool that throws a party for doing no
|
|
508
|
+
// work is a tool whose party means nothing.
|
|
509
|
+
if (written === 0 && built === 0 && !options.dryRun) {
|
|
510
|
+
return theme.ok(`${mark.ok} ALREADY UP TO DATE`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// The emotional peak, and the only place it is earned.
|
|
514
|
+
return `${mark.party} ${theme.brand(options.dryRun ? "READY WHEN YOU ARE" : "YOU'RE ALL SET")}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The one line under the closing headline: what this run actually did.
|
|
519
|
+
*
|
|
520
|
+
* Counts rather than adjectives, because "success" is not information and the
|
|
521
|
+
* person reading has just watched a bar fill. A run that changed nothing says
|
|
522
|
+
* so plainly instead of inventing an achievement out of the harnesses it did
|
|
523
|
+
* not have to configure.
|
|
524
|
+
*/
|
|
525
|
+
function endingDetail({ written, adapters, harnesses, attention, options }) {
|
|
526
|
+
const parts = [];
|
|
527
|
+
const built = adapters.result.generated + adapters.result.replaced;
|
|
528
|
+
|
|
529
|
+
if (written > 0) parts.push(`${written} file${plural(written)}`);
|
|
530
|
+
if (built > 0) parts.push(`${built} adapter${plural(built)}`);
|
|
531
|
+
if (built > 0 && harnesses.length > 0) {
|
|
532
|
+
parts.push(joinNames(harnesses.map((harness) => harness.label)));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (parts.length === 0) {
|
|
536
|
+
parts.push(options.dryRun ? "nothing to write" : "everything was already in place");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (attention > 0) parts.push(`${attention} thing${plural(attention)} to look at above`);
|
|
540
|
+
|
|
541
|
+
return parts.join(", ");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** `ok` for a count that did something, `info` for one that did not. */
|
|
545
|
+
function tally(theme, count) {
|
|
546
|
+
return count > 0 ? theme.ok : theme.info;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** One line inside a phase block, hung off the gutter. */
|
|
550
|
+
function railed(theme, text) {
|
|
551
|
+
return ` ${theme.dim(theme.glyph.gutter)} ${text}`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* How many files the copy put down, phrased for whichever mode this is.
|
|
556
|
+
*
|
|
557
|
+
* A dry run has no `result.written` worth reporting, so the plan is counted
|
|
558
|
+
* instead — the same number the summary underneath will state.
|
|
559
|
+
*/
|
|
560
|
+
function countWritten(result, plan, options) {
|
|
561
|
+
if (options.dryRun) return plan.filter((item) => item.status === "write").length;
|
|
562
|
+
return result.written + result.overwritten;
|
|
322
563
|
}
|
|
323
564
|
|
|
324
565
|
function parseArguments(argv) {
|
|
@@ -532,6 +773,137 @@ function indent(text) {
|
|
|
532
773
|
.join("\n");
|
|
533
774
|
}
|
|
534
775
|
|
|
776
|
+
/**
|
|
777
|
+
* The Pathfinder mark, transcribed from `assets/logo.svg` into cells.
|
|
778
|
+
*
|
|
779
|
+
* The real mark is four rounded horizontal strokes, centred on one axis and
|
|
780
|
+
* tapering upward — a trail blaze, the paint splash on a rock that tells you
|
|
781
|
+
* you are still on the path. Its widths in the 32-unit grid are 24, 18, 12.8,
|
|
782
|
+
* and 7.2 from the bottom up, all centred on x=16.
|
|
783
|
+
*
|
|
784
|
+
* Those proportions are what is preserved here, not the pixels: scaled to a
|
|
785
|
+
* nine-cell base and rounded, 24:18:12.8:7.2 becomes 9:7:5:3, and centring each
|
|
786
|
+
* row on the base gives the indents 0, 1, 2, 3. The slight rotation on each
|
|
787
|
+
* stroke in the SVG is the one feature that does not survive — a terminal cell
|
|
788
|
+
* grid has no way to express three degrees, and faking it by stepping a row
|
|
789
|
+
* sideways would read as a mistake rather than as a tilt.
|
|
790
|
+
*
|
|
791
|
+
* Every number here is authored, not measured. Nothing in this file asks how
|
|
792
|
+
* wide a rendered string is; these are the constants a designer would hand you,
|
|
793
|
+
* and they are the reason the block is stable under any terminal width.
|
|
794
|
+
*/
|
|
795
|
+
const MARK_ROWS = Object.freeze([
|
|
796
|
+
Object.freeze({ indent: 3, width: 3 }),
|
|
797
|
+
Object.freeze({ indent: 2, width: 5 }),
|
|
798
|
+
Object.freeze({ indent: 1, width: 7 }),
|
|
799
|
+
Object.freeze({ indent: 0, width: 9 }),
|
|
800
|
+
]);
|
|
801
|
+
|
|
802
|
+
/** Where the text column starts, counted in the mark's own authored cells. */
|
|
803
|
+
const TEXT_COLUMN = 13;
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* What the tool is, in five words.
|
|
807
|
+
*
|
|
808
|
+
* Reviewed against the finished run and kept. It earns its place by being the
|
|
809
|
+
* only line that says what Pathfinder *is* rather than what it just did, and
|
|
810
|
+
* "markers" is load-bearing: this project's whole argument is that it is not a
|
|
811
|
+
* framework, and a marker is the least presumptuous thing you can leave on a
|
|
812
|
+
* trail. Somebody still has to walk it.
|
|
813
|
+
*/
|
|
814
|
+
const TAGLINE = "trail markers for AI-assisted work";
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The last thing the run says.
|
|
818
|
+
*
|
|
819
|
+
* Warm, and stops. No exhortation, no link, and specifically no request to star
|
|
820
|
+
* anything — a tool that has just written several hundred files into somebody's
|
|
821
|
+
* repository has taken enough of their attention, and asking for a favour on the
|
|
822
|
+
* way out would spend the goodwill this whole feature exists to build.
|
|
823
|
+
*/
|
|
824
|
+
const SIGN_OFF = "Trail's marked. The rest is yours.";
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Who is running, said once, at the top.
|
|
828
|
+
*
|
|
829
|
+
* The requirement is that a reader recognises this tool before parsing the word
|
|
830
|
+
* "Pathfinder", so recognition is carried by form and colour: the actual mark,
|
|
831
|
+
* drawn, in the actual brand orange when the terminal can render it, beside
|
|
832
|
+
* letterspacing no other scaffolder's output has. Someone who ran three
|
|
833
|
+
* installers this afternoon can tell which one this was from the shape alone —
|
|
834
|
+
* which is the test, and it is why the mark is a transcription of the logo
|
|
835
|
+
* rather than an emoji that merely gestures at the same idea.
|
|
836
|
+
*
|
|
837
|
+
* The colour degrades and the mark does not. At 24-bit the strokes are
|
|
838
|
+
* `#E0611F` exactly; at 256 they are its nearest cube neighbour; at 16 they are
|
|
839
|
+
* the one warm accent ANSI offers; with colour off they are still unmistakably
|
|
840
|
+
* four tapering strokes. That ordering is deliberate — form is the part that
|
|
841
|
+
* survives every terminal, so form is what the identity rests on.
|
|
842
|
+
*
|
|
843
|
+
* Every device here is anchored on the left. There is no border and nothing
|
|
844
|
+
* closes on the right, because that would require knowing the printed width of
|
|
845
|
+
* a decorated string. The prototype that inspired this block had a box, and its
|
|
846
|
+
* right edge did not line up — not a bug in the prototype, just what happens.
|
|
847
|
+
*
|
|
848
|
+
* Not printed for `--help`, which is reference output someone pipes to `less`,
|
|
849
|
+
* and not printed for the `contract` tier, which has a byte promise to keep.
|
|
850
|
+
* Both of those decisions live at the call site, where the tier is known.
|
|
851
|
+
*/
|
|
852
|
+
function markBlock(theme, beside = []) {
|
|
853
|
+
const stroke = theme.glyph.rule;
|
|
854
|
+
|
|
855
|
+
return MARK_ROWS.map((row, index) => {
|
|
856
|
+
const drawn = " ".repeat(row.indent) + stroke.repeat(row.width);
|
|
857
|
+
const text = beside[index] ?? "";
|
|
858
|
+
|
|
859
|
+
// Padding to a constant from two constants. The decorated text is appended
|
|
860
|
+
// after the padding is already decided, so no escape sequence is ever part
|
|
861
|
+
// of a length this function computes.
|
|
862
|
+
const pad = " ".repeat(TEXT_COLUMN - row.indent - row.width);
|
|
863
|
+
return ` ${theme.brand(drawn)}${text ? pad + text : ""}`;
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
export function formatIdentity({ theme = createTheme(), version = VERSION } = {}) {
|
|
868
|
+
// The two text lines sit beside the mark's middle rows, so the wordmark lands
|
|
869
|
+
// level with the widest part of the blaze rather than floating above it.
|
|
870
|
+
return (
|
|
871
|
+
[
|
|
872
|
+
"",
|
|
873
|
+
...markBlock(theme, [
|
|
874
|
+
"",
|
|
875
|
+
`${theme.brand("P A T H F I N D E R")} ${theme.dim(`v${version}`)}`,
|
|
876
|
+
theme.dim(TAGLINE),
|
|
877
|
+
"",
|
|
878
|
+
]),
|
|
879
|
+
].join("\n") + "\n"
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* The end of a successful run, and the one place the mark appears twice.
|
|
885
|
+
*
|
|
886
|
+
* The requirement is that this reads as an arrival rather than a receipt, and
|
|
887
|
+
* the device that does the work is the bookend: the run opens with the blaze
|
|
888
|
+
* and closes with it, and no phase in between draws the mark at all. A reader
|
|
889
|
+
* who sees it a second time knows the run is over before reading a word — the
|
|
890
|
+
* same recognition-before-reading test the startup block has to pass.
|
|
891
|
+
*
|
|
892
|
+
* Three endings, because claiming one of them for another would be a lie:
|
|
893
|
+
*
|
|
894
|
+
* - **Clean.** Nothing was skipped, nothing conflicted, nothing failed. This is
|
|
895
|
+
* the emotional peak of the tool and is allowed to behave like it.
|
|
896
|
+
* - **Ready, with notes.** The install did its job and something above wants a
|
|
897
|
+
* look. It still says you are ready, because you are, and it does not throw
|
|
898
|
+
* confetti over a conflict.
|
|
899
|
+
* - **Nothing at all.** A run with write failures gets no ending block. The
|
|
900
|
+
* error is the ending, and a celebration above it would be the output
|
|
901
|
+
* disagreeing with itself.
|
|
902
|
+
*/
|
|
903
|
+
function readyBlock({ theme, headline, detail }) {
|
|
904
|
+
return markBlock(theme, ["", headline, theme.dim(detail), ""]);
|
|
905
|
+
}
|
|
906
|
+
|
|
535
907
|
/**
|
|
536
908
|
* Say what was found, before saying what will be done.
|
|
537
909
|
*
|
|
@@ -539,75 +911,103 @@ function indent(text) {
|
|
|
539
911
|
* the machine; none of them implies an intention. The parenthetical on the
|
|
540
912
|
* tools line is load-bearing — a bare list of everything installed on someone's
|
|
541
913
|
* laptop reads like an announcement that all of it is about to be configured,
|
|
542
|
-
* which is not true here and
|
|
543
|
-
*
|
|
914
|
+
* which is not true here, and configuring anything requires an answer to a
|
|
915
|
+
* question.
|
|
916
|
+
*
|
|
917
|
+
* This is also the run's first phase, and it is rendered as one: a heading that
|
|
918
|
+
* names it, and a gutter down the left of everything that belongs to it. The
|
|
919
|
+
* gutter is what makes the phase a block rather than a paragraph — it survives
|
|
920
|
+
* with colour off, it survives in ASCII, and it costs no width maths, which is
|
|
921
|
+
* the whole reason it was chosen over a box.
|
|
544
922
|
*/
|
|
545
|
-
export function formatFindings(findings, {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
923
|
+
export function formatFindings(findings, { theme = createTheme() } = {}) {
|
|
924
|
+
// The default is the theme an empty environment produces: ASCII, no colour.
|
|
925
|
+
// Not a convenience — it is the same answer this function gave before it took
|
|
926
|
+
// a theme at all, so a caller that forgets one gets the readable alphabet
|
|
927
|
+
// rather than a guess about a terminal it never described.
|
|
928
|
+
const mark = theme.glyph;
|
|
929
|
+
|
|
930
|
+
// Every finding line hangs off the same gutter, so the block reads as one
|
|
931
|
+
// thing rather than as three sentences that happen to be adjacent.
|
|
932
|
+
const rail = railed(theme, "");
|
|
933
|
+
|
|
934
|
+
// A severity span covers the glyph *and* the words it qualifies, never the
|
|
935
|
+
// glyph alone. Two reasons, and the second is the one that bites: a coloured
|
|
936
|
+
// glyph beside plain text reads as a bullet with a tint rather than as a
|
|
937
|
+
// statement with a level, and painting only the glyph puts a reset in the
|
|
938
|
+
// middle of the sentence — so `✓ Git repository detected` stops existing as
|
|
939
|
+
// contiguous bytes, and every assertion about what this line says has to
|
|
940
|
+
// learn the escape codes to find it. Emphasis inside a line still gets its
|
|
941
|
+
// own span; it just starts after the statement's own words have ended.
|
|
942
|
+
const lines = ["", ` ${mark.scan} ${theme.bold("ENVIRONMENT")}`];
|
|
549
943
|
|
|
550
944
|
if (findings.git.insideRepository) {
|
|
551
|
-
lines.push(
|
|
945
|
+
lines.push(`${rail}${theme.ok(`${mark.ok} Git repository detected`)}`);
|
|
552
946
|
} else if (findings.git.binary) {
|
|
553
|
-
lines.push(
|
|
947
|
+
lines.push(`${rail}${theme.info(`${mark.info} No Git repository here`)}`);
|
|
554
948
|
} else {
|
|
555
|
-
lines.push(
|
|
949
|
+
lines.push(
|
|
950
|
+
`${rail}${theme.bad(`${mark.bad} No Git repository here, and \`git\` is not on your PATH`)}`,
|
|
951
|
+
);
|
|
556
952
|
}
|
|
557
953
|
|
|
558
954
|
if (findings.pathfinder.installed) {
|
|
559
955
|
const { skillCount } = findings.pathfinder;
|
|
560
|
-
lines.push(
|
|
956
|
+
lines.push(
|
|
957
|
+
`${rail}${theme.ok(`${mark.ok} Pathfinder already installed`)} ` +
|
|
958
|
+
`(${theme.bold(skillCount)} skill${plural(skillCount)})`,
|
|
959
|
+
);
|
|
561
960
|
}
|
|
562
961
|
|
|
962
|
+
// The tool names are emphasised and the caveat is dimmed, which is the
|
|
963
|
+
// hierarchy the sentence always had and the flat rendering threw away. What
|
|
964
|
+
// the reader wants from this line is the list; what they need from it is the
|
|
965
|
+
// parenthetical, exactly once.
|
|
563
966
|
const tools = detectedToolLabels(findings);
|
|
564
967
|
lines.push(
|
|
565
968
|
tools.length > 0
|
|
566
|
-
?
|
|
567
|
-
|
|
969
|
+
? `${rail}${theme.ok(`${mark.ok} Tools detected:`)} ${theme.bold(tools.join(", "))} ` +
|
|
970
|
+
`${theme.dim("(noted, not configured)")}`
|
|
971
|
+
: `${rail}${theme.info(`${mark.info} No supported tools detected`)}`,
|
|
568
972
|
);
|
|
569
973
|
|
|
570
974
|
// Trailing blank line: whatever comes next is a different statement — the
|
|
571
|
-
// install summary, a refusal, or
|
|
572
|
-
//
|
|
975
|
+
// install summary, a refusal, or a question — and it must not read as one
|
|
976
|
+
// more finding.
|
|
573
977
|
return lines.join("\n") + "\n\n";
|
|
574
978
|
}
|
|
575
979
|
|
|
576
980
|
/**
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
* One table, so a finding and an action it leads to are marked the same way.
|
|
580
|
-
*/
|
|
581
|
-
function marks(unicode) {
|
|
582
|
-
return unicode
|
|
583
|
-
? { ok: "✓", info: "·", bad: "✗", dash: "—" }
|
|
584
|
-
: { ok: "+", info: "-", bad: "!", dash: "-" };
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Can this terminal be trusted with the decorated marks?
|
|
981
|
+
* Say what happened, in full.
|
|
589
982
|
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* user ever sees from this tool.
|
|
983
|
+
* Skipped files are listed individually, not counted. The whole promise of the
|
|
984
|
+
* default mode is that it left your work alone, and a bare "42 skipped" does
|
|
985
|
+
* not let anyone check that claim.
|
|
594
986
|
*/
|
|
595
|
-
function
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
987
|
+
function report(args) {
|
|
988
|
+
// Two renderings, kept adjacent on purpose.
|
|
989
|
+
//
|
|
990
|
+
// The duplication below is a known, accepted cost rather than an oversight.
|
|
991
|
+
// `contractReport` owes byte-for-byte what 1.4.1 printed, to scripts that
|
|
992
|
+
// parse it; `expressiveReport` owes a person a legible hierarchy. Merging
|
|
993
|
+
// them would mean one function whose every line carries a conditional, and
|
|
994
|
+
// the first wording improvement would silently break somebody's grep.
|
|
995
|
+
//
|
|
996
|
+
// They are written next to each other so that editing one is an obvious
|
|
997
|
+
// prompt to consider the other. Anything that changes what is *reported* —
|
|
998
|
+
// as opposed to how it looks — has to be made twice, and that is the point.
|
|
999
|
+
if (args.theme.tier === "contract") return contractReport(args);
|
|
1000
|
+
return expressiveReport(args);
|
|
601
1001
|
}
|
|
602
1002
|
|
|
603
1003
|
/**
|
|
604
|
-
*
|
|
1004
|
+
* The rendering that is a promise, not a design.
|
|
605
1005
|
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
1006
|
+
* Unchanged since 1.4.1 and deliberately frozen. Every byte here is pinned by
|
|
1007
|
+
* `test/non-interactive.test.mjs` and by a capture-and-compare against the
|
|
1008
|
+
* published package, because a script somewhere is reading it.
|
|
609
1009
|
*/
|
|
610
|
-
function
|
|
1010
|
+
function contractReport({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme }) {
|
|
611
1011
|
const lines = [];
|
|
612
1012
|
const verb = options.dryRun ? "Would install" : "Installed";
|
|
613
1013
|
|
|
@@ -624,7 +1024,7 @@ function report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot,
|
|
|
624
1024
|
lines.push(` ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`);
|
|
625
1025
|
}
|
|
626
1026
|
|
|
627
|
-
lines.push(...
|
|
1027
|
+
lines.push(...contractAdapterLines({ adapters, harnesses, options, theme }));
|
|
628
1028
|
lines.push(...customToolLines(customTools));
|
|
629
1029
|
|
|
630
1030
|
const skipped = plan.filter((item) => item.status === "skip");
|
|
@@ -647,7 +1047,7 @@ function report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot,
|
|
|
647
1047
|
// top of this block rather than the only channel, which is what lets every
|
|
648
1048
|
// clipboard failure be a non-event.
|
|
649
1049
|
lines.push("");
|
|
650
|
-
lines.push(
|
|
1050
|
+
lines.push(`Next step ${theme.glyph.dash} give your agent this prompt:`);
|
|
651
1051
|
lines.push("");
|
|
652
1052
|
lines.push(...kickstartPromptLines(harnesses));
|
|
653
1053
|
|
|
@@ -675,7 +1075,7 @@ function report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot,
|
|
|
675
1075
|
* here that an install should be judged by, so there is no value for the exit
|
|
676
1076
|
* code to be computed from.
|
|
677
1077
|
*/
|
|
678
|
-
async function offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform }) {
|
|
1078
|
+
async function offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform, theme }) {
|
|
679
1079
|
if (!prompter.interactive || options.yes) return;
|
|
680
1080
|
|
|
681
1081
|
const suppressed = options.noClipboard && options.noOpen;
|
|
@@ -692,14 +1092,29 @@ async function offerOnboardingActions({ harnesses, cwd, options, prompter, out,
|
|
|
692
1092
|
return;
|
|
693
1093
|
}
|
|
694
1094
|
|
|
695
|
-
|
|
696
|
-
|
|
1095
|
+
// The report ends on the prompt block, which is the one thing on screen the
|
|
1096
|
+
// user is meant to act on. A question butted straight against its last line
|
|
1097
|
+
// reads as part of that block rather than as something being asked, so the
|
|
1098
|
+
// questions get the same leading blank every other device in this run gets —
|
|
1099
|
+
// separation is led here, never trailed.
|
|
1100
|
+
//
|
|
1101
|
+
// Printed only when a question actually follows, which is why the editors are
|
|
1102
|
+
// detected here rather than inside `offerEditor`: a machine with no editor on
|
|
1103
|
+
// PATH and `--no-clipboard` asks nothing, and must not be given a separator
|
|
1104
|
+
// for it.
|
|
1105
|
+
const editors = options.noOpen ? [] : detectEditors({ env, platform });
|
|
1106
|
+
if (!options.noClipboard || editors.length > 0) out("\n");
|
|
1107
|
+
|
|
1108
|
+
if (!options.noClipboard) {
|
|
1109
|
+
await offerClipboard({ harnesses, options, prompter, out, env, platform, theme });
|
|
1110
|
+
}
|
|
1111
|
+
if (editors.length > 0) await offerEditor({ editors, cwd, prompter, out, env, platform, theme });
|
|
697
1112
|
}
|
|
698
1113
|
|
|
699
1114
|
/**
|
|
700
1115
|
* Offer to put the printed prompt on the clipboard. Never take it.
|
|
701
1116
|
*/
|
|
702
|
-
async function offerClipboard({ harnesses, options, prompter, out, env, platform }) {
|
|
1117
|
+
async function offerClipboard({ harnesses, options, prompter, out, env, platform, theme }) {
|
|
703
1118
|
const answer = await prompter.confirm(
|
|
704
1119
|
"Copy that prompt to your clipboard? This replaces what is on it now.",
|
|
705
1120
|
{ defaultAnswer: true },
|
|
@@ -718,7 +1133,7 @@ async function offerClipboard({ harnesses, options, prompter, out, env, platform
|
|
|
718
1133
|
out(
|
|
719
1134
|
copied.ok
|
|
720
1135
|
? " Copied.\n"
|
|
721
|
-
: ` Not copied
|
|
1136
|
+
: ` Not copied ${theme.glyph.dash} ${copied.reason}. The prompt is printed above.\n`,
|
|
722
1137
|
);
|
|
723
1138
|
}
|
|
724
1139
|
|
|
@@ -728,14 +1143,15 @@ async function offerClipboard({ harnesses, options, prompter, out, env, platform
|
|
|
728
1143
|
* The question exists only when there is something to answer it with. No editor
|
|
729
1144
|
* on PATH means no question at all, rather than a question whose honest answer
|
|
730
1145
|
* is "then don't" — an installer that asks about software you do not have is
|
|
731
|
-
* asking to be told about itself.
|
|
1146
|
+
* asking to be told about itself. That decision is made by the caller and the
|
|
1147
|
+
* detected list handed down, because the caller has to know whether anything
|
|
1148
|
+
* will be asked before it prints the blank line above the questions.
|
|
732
1149
|
*
|
|
733
1150
|
* One editor is a yes/no; several are a numbered list with an explicit way out.
|
|
734
1151
|
* Neither shape can be answered by not answering: a decline, an unanswered
|
|
735
1152
|
* question, and "Don't open" all land on the same nothing.
|
|
736
1153
|
*/
|
|
737
|
-
async function offerEditor({ cwd, prompter, out, env, platform }) {
|
|
738
|
-
const editors = detectEditors({ env, platform });
|
|
1154
|
+
async function offerEditor({ editors, cwd, prompter, out, env, platform, theme }) {
|
|
739
1155
|
if (editors.length === 0) return;
|
|
740
1156
|
|
|
741
1157
|
const chosen =
|
|
@@ -765,7 +1181,7 @@ async function offerEditor({ cwd, prompter, out, env, platform }) {
|
|
|
765
1181
|
out(
|
|
766
1182
|
opened.ok
|
|
767
1183
|
? ` Opening ${chosen.label}.\n`
|
|
768
|
-
: ` Not opened
|
|
1184
|
+
: ` Not opened ${theme.glyph.dash} ${opened.reason}. The install is complete; open ${cwd} yourself.\n`,
|
|
769
1185
|
);
|
|
770
1186
|
}
|
|
771
1187
|
|
|
@@ -781,7 +1197,7 @@ async function offerEditor({ cwd, prompter, out, env, platform }) {
|
|
|
781
1197
|
* Empty when no harness was chosen, which is the default and must stay
|
|
782
1198
|
* invisible: a scripted 1.4.1-era run prints exactly what it always did.
|
|
783
1199
|
*/
|
|
784
|
-
function
|
|
1200
|
+
function contractAdapterLines({ adapters, harnesses, options, theme }) {
|
|
785
1201
|
if (harnesses.length === 0) return [];
|
|
786
1202
|
|
|
787
1203
|
if (adapters.blocked) {
|
|
@@ -829,8 +1245,8 @@ function adapterLines({ adapters, harnesses, options }) {
|
|
|
829
1245
|
lines.push("");
|
|
830
1246
|
lines.push(
|
|
831
1247
|
conflicts.length === 1
|
|
832
|
-
?
|
|
833
|
-
:
|
|
1248
|
+
? ` Re-run with --force to replace it ${theme.glyph.dash} note that --force also overwrites`
|
|
1249
|
+
: ` Re-run with --force to replace them ${theme.glyph.dash} note that --force also overwrites`,
|
|
834
1250
|
);
|
|
835
1251
|
lines.push(" Pathfinder kit files you have edited.",
|
|
836
1252
|
);
|
|
@@ -875,6 +1291,317 @@ function customToolLines(customTools = []) {
|
|
|
875
1291
|
];
|
|
876
1292
|
}
|
|
877
1293
|
|
|
1294
|
+
/**
|
|
1295
|
+
* The rendering a person reads.
|
|
1296
|
+
*
|
|
1297
|
+
* The problem it exists to solve is the re-run screen, which is the screen
|
|
1298
|
+
* experienced users see most and was the weakest thing this tool printed: a
|
|
1299
|
+
* conflict, an orphan, eight skipped files and twenty generated adapters all
|
|
1300
|
+
* arrived as prose at one indent level, so nothing about the shape of the
|
|
1301
|
+
* output told you whether anything needed your attention.
|
|
1302
|
+
*
|
|
1303
|
+
* Three rules hold it together:
|
|
1304
|
+
*
|
|
1305
|
+
* - **Every severity is a colour, a glyph, and a word.** `warn` is never the
|
|
1306
|
+
* only signal — the line also carries `mark.warn` and opens with a category
|
|
1307
|
+
* word, so the hierarchy survives `NO_COLOR`, ASCII, a screen reader, and a
|
|
1308
|
+
* colour-blind reader identically.
|
|
1309
|
+
* - **Summary lines are decorated; payload is not.** Counts sit on the gutter
|
|
1310
|
+
* and get colour. The paths underneath get neither, for the reason below.
|
|
1311
|
+
* - **Diagnostics stay pasteable.** See `pathList`.
|
|
1312
|
+
*/
|
|
1313
|
+
function expressiveReport({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err, theme }) {
|
|
1314
|
+
const mark = theme.glyph;
|
|
1315
|
+
const lines = [];
|
|
1316
|
+
const verb = options.dryRun ? "Would install" : "Installed";
|
|
1317
|
+
|
|
1318
|
+
lines.push(` ${mark.clipboard} ${theme.bold(options.dryRun ? "DRY RUN" : "SUMMARY")}`);
|
|
1319
|
+
lines.push(railed(theme, `${verb} the Pathfinder kit into ${theme.bold(cwd)}`));
|
|
1320
|
+
|
|
1321
|
+
if (gitRoot !== cwd) {
|
|
1322
|
+
lines.push(
|
|
1323
|
+
railed(theme, theme.info(`${mark.info} The repository root is ${gitRoot}, not this directory.`)),
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const written = options.dryRun ? plan.filter((i) => i.status === "write").length : result.written;
|
|
1328
|
+
// A zero is reported, never celebrated. `✓ 0 files written` is a tick over
|
|
1329
|
+
// nothing happening, which is the kind of detail that makes a whole summary
|
|
1330
|
+
// feel automated rather than read.
|
|
1331
|
+
lines.push(
|
|
1332
|
+
railed(
|
|
1333
|
+
theme,
|
|
1334
|
+
tally(theme, written)(
|
|
1335
|
+
`${written > 0 ? mark.ok : mark.info} ${written} file${plural(written)} ${options.dryRun ? "to write" : "written"}`,
|
|
1336
|
+
),
|
|
1337
|
+
),
|
|
1338
|
+
);
|
|
1339
|
+
|
|
1340
|
+
// `--force` overwriting is `info`, not `warn`. It is exactly what the flag
|
|
1341
|
+
// was asked to do, and marking a requested action as a warning is how a tool
|
|
1342
|
+
// teaches people to ignore its warnings. The files it replaced are still
|
|
1343
|
+
// worth stating plainly, which is what `info` is for.
|
|
1344
|
+
if (result.overwritten > 0) {
|
|
1345
|
+
lines.push(
|
|
1346
|
+
railed(
|
|
1347
|
+
theme,
|
|
1348
|
+
theme.info(`${mark.info} ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`),
|
|
1349
|
+
),
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
lines.push(...expressiveAdapterLines({ adapters, harnesses, options, theme }));
|
|
1354
|
+
|
|
1355
|
+
const skipped = plan.filter((item) => item.status === "skip");
|
|
1356
|
+
if (skipped.length > 0) {
|
|
1357
|
+
lines.push(
|
|
1358
|
+
railed(
|
|
1359
|
+
theme,
|
|
1360
|
+
theme.warn(`${mark.warn} ${skipped.length} file${plural(skipped.length)} left untouched`) +
|
|
1361
|
+
theme.dim(" (they already exist)"),
|
|
1362
|
+
),
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (customTools.length > 0) {
|
|
1367
|
+
lines.push(
|
|
1368
|
+
railed(theme, theme.info(`${mark.info} No native integration for ${joinNames(customTools)}`)),
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// The detail blocks, below the summary rather than inside it. A reader who
|
|
1373
|
+
// only wants to know whether anything needs them stops at the gutter; a
|
|
1374
|
+
// reader who needs the paths scrolls once and finds them undecorated.
|
|
1375
|
+
if (skipped.length > 0) {
|
|
1376
|
+
lines.push(
|
|
1377
|
+
...warnBlock({
|
|
1378
|
+
theme,
|
|
1379
|
+
word: "Skipped",
|
|
1380
|
+
summary: `${skipped.length} file${plural(skipped.length)} already exist${skipped.length === 1 ? "s" : ""} and ${skipped.length === 1 ? "was" : "were"} left untouched`,
|
|
1381
|
+
paths: skipped.map((item) => item.relativePath),
|
|
1382
|
+
advice: ["Nothing above was modified. Re-run with --force to replace them."],
|
|
1383
|
+
}),
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
lines.push(...expressiveAdapterBlocks({ adapters, harnesses, theme }));
|
|
1388
|
+
|
|
1389
|
+
if (customTools.length > 0) lines.push(...customToolLines(customTools));
|
|
1390
|
+
|
|
1391
|
+
const failureCount = result.errors.length + adapters.result.errors.length;
|
|
1392
|
+
// What actually wants a human: a contested path, or an adapter pointing at a
|
|
1393
|
+
// skill that is gone. Skipped files are deliberately *not* counted here.
|
|
1394
|
+
// A re-run over an existing install skips every file by design, and calling
|
|
1395
|
+
// thirty-six routine skips "things to look at" would turn the one number that
|
|
1396
|
+
// should mean something into noise nobody reads twice.
|
|
1397
|
+
const attention = adapters.blocked
|
|
1398
|
+
? 0
|
|
1399
|
+
: adapters.plan.filter((item) => item.action === "conflict" || item.action === "orphan").length;
|
|
1400
|
+
|
|
1401
|
+
if (failureCount === 0) {
|
|
1402
|
+
lines.push("");
|
|
1403
|
+
lines.push(
|
|
1404
|
+
...readyBlock({
|
|
1405
|
+
theme,
|
|
1406
|
+
headline: endingHeadline({ theme, written, adapters, attention, options }),
|
|
1407
|
+
detail: endingDetail({ written, adapters, harnesses, attention, options }),
|
|
1408
|
+
}),
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// The prompt is printed here, always — including on a re-run that wrote
|
|
1413
|
+
// nothing, and including a run that ends on an error. A second run is how
|
|
1414
|
+
// someone configures a harness they skipped, or simply comes back for the
|
|
1415
|
+
// invocation they have forgotten, and both of those want the same line. It is
|
|
1416
|
+
// also what makes the clipboard a convenience on top of this block rather
|
|
1417
|
+
// than the only channel, which is what lets every clipboard failure be a
|
|
1418
|
+
// non-event.
|
|
1419
|
+
lines.push("");
|
|
1420
|
+
lines.push(` ${theme.bold("Hand your agent this prompt to begin:")}`);
|
|
1421
|
+
lines.push("");
|
|
1422
|
+
|
|
1423
|
+
// The one piece of text on screen the user is meant to act on, so it gets the
|
|
1424
|
+
// strongest emphasis in the run and its own indent. Colour is safe here in a
|
|
1425
|
+
// way it is not for a diagnostic path: selecting text in a terminal copies
|
|
1426
|
+
// the characters, not the escapes.
|
|
1427
|
+
for (const line of kickstartPromptLines(harnesses)) {
|
|
1428
|
+
lines.push(line.trim() === "" ? line : ` ${theme.info(theme.bold(line.trim()))}`);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
out(lines.join("\n") + "\n");
|
|
1432
|
+
|
|
1433
|
+
const failures = [...result.errors, ...adapters.result.errors];
|
|
1434
|
+
if (failures.length > 0) {
|
|
1435
|
+
// `bad`, not `warn`, and the distinction is the whole point of having both:
|
|
1436
|
+
// everything above is an outcome somebody may want to know about, and this
|
|
1437
|
+
// is the install failing to do what it said it would.
|
|
1438
|
+
const heading = theme.bad(
|
|
1439
|
+
`${mark.bad} ${failures.length} file${plural(failures.length)} could not be written:`,
|
|
1440
|
+
);
|
|
1441
|
+
const detail = failures.map((error) => ` ${error.relativePath}: ${error.message}`).join("\n");
|
|
1442
|
+
err(`\ncreate-pathfinder: ${heading}\n${detail}\n`);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* One warning, as a heading a reader can skim and a payload they can paste.
|
|
1448
|
+
*
|
|
1449
|
+
* The split is the requirement. The heading is decorated — colour, glyph, and
|
|
1450
|
+
* a leading category word — because its job is to be noticed. The paths are
|
|
1451
|
+
* printed plain, one per line, never wrapped, never truncated, never coloured,
|
|
1452
|
+
* and never hung off the gutter, because their job is to survive a
|
|
1453
|
+
* copy-and-paste into a GitHub issue. A box character or an ANSI escape in
|
|
1454
|
+
* that list means somebody has to hand-edit every line they pasted.
|
|
1455
|
+
*/
|
|
1456
|
+
function warnBlock({ theme, word, summary, paths, advice }) {
|
|
1457
|
+
const mark = theme.glyph;
|
|
1458
|
+
|
|
1459
|
+
return [
|
|
1460
|
+
"",
|
|
1461
|
+
` ${theme.warn(`${mark.warn} ${word}`)} ${mark.dash} ${summary}:`,
|
|
1462
|
+
"",
|
|
1463
|
+
...pathList(paths),
|
|
1464
|
+
"",
|
|
1465
|
+
...advice.map((line) => ` ${theme.dim(line)}`),
|
|
1466
|
+
];
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Paths, and nothing else.
|
|
1471
|
+
*
|
|
1472
|
+
* Indented for alignment and otherwise untouched: no glyph, no colour, no
|
|
1473
|
+
* gutter, no truncation, no wrapping at any width. Leading spaces are the only
|
|
1474
|
+
* decoration, and they are the one kind that survives a paste — a reader who
|
|
1475
|
+
* selects these lines gets the paths, and a reader who pastes them into a
|
|
1476
|
+
* Markdown issue body gets a code block for free.
|
|
1477
|
+
*/
|
|
1478
|
+
function pathList(paths) {
|
|
1479
|
+
return paths.map((path) => ` ${path}`);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** The per-harness summary counts, on the gutter, each at its own severity. */
|
|
1483
|
+
function expressiveAdapterLines({ adapters, harnesses, options, theme }) {
|
|
1484
|
+
const mark = theme.glyph;
|
|
1485
|
+
if (harnesses.length === 0) return [];
|
|
1486
|
+
|
|
1487
|
+
if (adapters.blocked) {
|
|
1488
|
+
return [
|
|
1489
|
+
railed(theme, theme.bad(`${mark.bad} No adapters were generated`) + theme.dim(" (the kit copy did not finish)")),
|
|
1490
|
+
];
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
|
|
1494
|
+
const lines = [];
|
|
1495
|
+
|
|
1496
|
+
for (const harness of harnesses) {
|
|
1497
|
+
const mine = adapters.plan.filter(
|
|
1498
|
+
(item) => item.harness === harness && !failed.has(item.relativePath),
|
|
1499
|
+
);
|
|
1500
|
+
const count = (action) => mine.filter((item) => item.action === action).length;
|
|
1501
|
+
|
|
1502
|
+
const generated = count("write");
|
|
1503
|
+
const replaced = count("replace");
|
|
1504
|
+
const unchanged = count("up-to-date");
|
|
1505
|
+
|
|
1506
|
+
lines.push(
|
|
1507
|
+
railed(
|
|
1508
|
+
theme,
|
|
1509
|
+
tally(theme, generated)(
|
|
1510
|
+
`${generated > 0 ? mark.ok : mark.info} ${generated} ${harness.label} skill adapter${plural(generated)} ` +
|
|
1511
|
+
(options.dryRun ? "to generate" : "generated"),
|
|
1512
|
+
),
|
|
1513
|
+
),
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
if (replaced > 0) {
|
|
1517
|
+
lines.push(
|
|
1518
|
+
railed(theme, theme.info(`${mark.info} ${replaced} ${harness.label} adapter${plural(replaced)} replaced (--force)`)),
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (unchanged > 0) {
|
|
1523
|
+
lines.push(
|
|
1524
|
+
railed(theme, theme.dim(`${mark.info} ${unchanged} ${harness.label} adapter${plural(unchanged)} already up to date`)),
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const conflicts = mine.filter((item) => item.action === "conflict");
|
|
1529
|
+
const orphans = mine.filter((item) => item.action === "orphan");
|
|
1530
|
+
|
|
1531
|
+
if (conflicts.length > 0) {
|
|
1532
|
+
lines.push(
|
|
1533
|
+
railed(
|
|
1534
|
+
theme,
|
|
1535
|
+
theme.warn(`${mark.warn} ${conflicts.length} ${harness.label} file${plural(conflicts.length)} left untouched`) +
|
|
1536
|
+
theme.dim(conflicts.length === 1 ? " (Pathfinder did not write it)" : " (Pathfinder did not write them)"),
|
|
1537
|
+
),
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (orphans.length > 0) {
|
|
1542
|
+
lines.push(
|
|
1543
|
+
railed(
|
|
1544
|
+
theme,
|
|
1545
|
+
theme.warn(`${mark.warn} ${orphans.length} ${harness.label} orphan adapter${plural(orphans.length)}`) +
|
|
1546
|
+
theme.dim(orphans.length === 1 ? " (the skill it points at was retired)" : " (the skills they point at were retired)"),
|
|
1547
|
+
),
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
return lines;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** The conflict and orphan detail blocks, with their paths kept pasteable. */
|
|
1556
|
+
function expressiveAdapterBlocks({ adapters, harnesses, theme }) {
|
|
1557
|
+
if (harnesses.length === 0 || adapters.blocked) return [];
|
|
1558
|
+
|
|
1559
|
+
const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
|
|
1560
|
+
const blocks = [];
|
|
1561
|
+
|
|
1562
|
+
for (const harness of harnesses) {
|
|
1563
|
+
const mine = adapters.plan.filter(
|
|
1564
|
+
(item) => item.harness === harness && !failed.has(item.relativePath),
|
|
1565
|
+
);
|
|
1566
|
+
|
|
1567
|
+
const conflicts = mine.filter((item) => item.action === "conflict");
|
|
1568
|
+
const orphans = mine.filter((item) => item.action === "orphan");
|
|
1569
|
+
|
|
1570
|
+
if (conflicts.length > 0) {
|
|
1571
|
+
const one = conflicts.length === 1;
|
|
1572
|
+
blocks.push(
|
|
1573
|
+
...warnBlock({
|
|
1574
|
+
theme,
|
|
1575
|
+
word: "Conflict",
|
|
1576
|
+
summary: `${conflicts.length} ${harness.label} file${plural(conflicts.length)} at ${one ? "a path an adapter wants" : "paths adapters want"}, which Pathfinder did not write`,
|
|
1577
|
+
paths: conflicts.map((item) => item.relativePath),
|
|
1578
|
+
advice: [
|
|
1579
|
+
`Re-run with --force to replace ${one ? "it" : "them"} ${theme.glyph.dash} note that --force also`,
|
|
1580
|
+
"overwrites Pathfinder kit files you have edited.",
|
|
1581
|
+
],
|
|
1582
|
+
}),
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (orphans.length > 0) {
|
|
1587
|
+
const one = orphans.length === 1;
|
|
1588
|
+
blocks.push(
|
|
1589
|
+
...warnBlock({
|
|
1590
|
+
theme,
|
|
1591
|
+
word: "Orphan",
|
|
1592
|
+
summary: `${orphans.length} ${harness.label} adapter${plural(orphans.length)} delegat${one ? "es" : "e"} to a skill this version no longer ships`,
|
|
1593
|
+
paths: orphans.map((item) => item.relativePath),
|
|
1594
|
+
advice: [
|
|
1595
|
+
`Left in place. Delete ${one ? "it" : "them"} yourself if you want ${one ? "it" : "them"} gone.`,
|
|
1596
|
+
],
|
|
1597
|
+
}),
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
return blocks;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
878
1605
|
/** `a`, `a and b`, `a, b, and c`. */
|
|
879
1606
|
function joinNames(names) {
|
|
880
1607
|
if (names.length === 1) return names[0];
|