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/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
- // Printed only to a terminal. The report is for a person, and the acceptance
90
- // criteria require non-interactive output to stay what 1.4.1 produced so a
91
- // piped run, a CI log, and `> install.txt` all keep the old bytes.
92
- if (stdoutIsTTY) {
93
- out(formatFindings(findings, { unicode }));
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({ findings, options, prompter, out });
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
- // Deliberately after the copy. An adapter delegates to a canonical file, so
165
- // generating one beside a copy that failed would point the user's tool at a
166
- // file that is not there.
167
- const adapters = generateAdapters({ harnesses, kitRoot, cwd, options, result });
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 result.errors.length > 0 || adapters.result.errors.length > 0 ? 1 : 0;
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
- `${entry.label.padEnd(width)} -> ` +
233
- // The path is shown so nobody has to check a box to find out what it
234
- // writes. The last entry earns the same courtesy by admitting it
235
- // writes nothing, in the column where every other row names a file.
236
- (entry === SOMETHING_ELSE
237
- ? "nothing is generated"
238
- : `${entry.skillsDir}/` + (detected.includes(entry) ? " (detected)" : "")),
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) ? await askCustomTools({ prompter, out }) : [];
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 it is in the list above, and writes to\n` +
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, kitRoot, cwd, options, result }) {
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
- const plan = planAdapters(harnesses, { kitRoot, targetRoot: cwd, force: options.force });
321
- return { plan, result: applyAdapterPlan(plan, { dryRun: options.dryRun }), blocked: false };
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 will still not be true after Feature 11, where
543
- * configuring anything requires an answer to a question.
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, { unicode = false } = {}) {
546
- const mark = marks(unicode);
547
-
548
- const lines = ["", "Pathfinder", ""];
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(` ${mark.ok} Git repository detected`);
945
+ lines.push(`${rail}${theme.ok(`${mark.ok} Git repository detected`)}`);
552
946
  } else if (findings.git.binary) {
553
- lines.push(` ${mark.info} No Git repository here`);
947
+ lines.push(`${rail}${theme.info(`${mark.info} No Git repository here`)}`);
554
948
  } else {
555
- lines.push(` ${mark.bad} No Git repository here, and \`git\` is not on your PATH`);
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(` ${mark.ok} Pathfinder already installed (${skillCount} skill${plural(skillCount)})`);
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
- ? ` ${mark.ok} Tools detected: ${tools.join(", ")} (noted, not configured)`
567
- : ` ${mark.info} No supported tools detected`,
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 in a later chunk a question — and it must
572
- // not read as a sixth finding.
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
- * The line markers, in whichever alphabet this terminal can be trusted with.
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
- * Answered from the environment rather than attempted and hoped for, and biased
591
- * hard toward "no": an unanswerable environment gets ASCII, which is readable
592
- * everywhere, while a wrong "yes" leaves mojibake in the first output a new
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 supportsUnicode(env, platform) {
596
- if (platform === "win32") {
597
- return Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode";
598
- }
599
- const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
600
- return /utf-?8/i.test(locale);
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
- * Say what happened, in full.
1004
+ * The rendering that is a promise, not a design.
605
1005
  *
606
- * Skipped files are listed individually, not counted. The whole promise of the
607
- * default mode is that it left your work alone, and a bare "42 skipped" does
608
- * not let anyone check that claim.
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 report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err }) {
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(...adapterLines({ adapters, harnesses, options }));
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("Next step give your agent this prompt:");
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
- if (!options.noClipboard) await offerClipboard({ harnesses, options, prompter, out, env, platform });
696
- if (!options.noOpen) await offerEditor({ cwd, prompter, out, env, platform });
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 ${copied.reason}. The prompt is printed above.\n`,
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 ${opened.reason}. The install is complete; open ${cwd} yourself.\n`,
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 adapterLines({ adapters, harnesses, options }) {
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
- ? " Re-run with --force to replace it note that --force also overwrites"
833
- : " Re-run with --force to replace them note that --force also overwrites",
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];