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