create-pathfinder 1.4.1 → 1.5.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
@@ -1,13 +1,26 @@
1
1
  /**
2
2
  * Command-line surface: parse arguments, refuse unsafe situations, report.
3
3
  *
4
- * No CLI framework. The flag set is four booleans and the whole parser is a
5
- * loop; a dependency tree for that would be indefensible in a project whose
6
- * identity is "not a framework."
4
+ * No CLI framework. The flag set is a handful of booleans and the whole parser
5
+ * is a loop; a dependency tree for that would be indefensible in a project
6
+ * whose identity is "not a framework."
7
7
  */
8
8
 
9
- import { findGitRoot, findKitRoot, COPY_LIST } from "./kit.mjs";
10
- import { applyPlan, planInstall } from "./install.mjs";
9
+ import { findKitRoot, COPY_LIST } from "./kit.mjs";
10
+ import { applyAdapterPlan, applyPlan, planAdapters, planInstall } from "./install.mjs";
11
+ import { detect, detectedToolLabels } from "./detect.mjs";
12
+ import { initRepository } from "./git.mjs";
13
+ import { nonInteractivePrompter } from "./prompt.mjs";
14
+ import { copyToClipboard } from "./clipboard.mjs";
15
+ import { detectEditors, openInEditor } from "./editor.mjs";
16
+ import { kickstartPrompt, kickstartPromptLines } from "./kickstart-prompt.mjs";
17
+ import {
18
+ HARNESSES,
19
+ HARNESS_IDS,
20
+ detectedHarnesses,
21
+ findHarness,
22
+ harnessNamed,
23
+ } from "./harnesses/index.mjs";
11
24
 
12
25
  const USAGE = `Usage: npx create-pathfinder [options]
13
26
 
@@ -16,12 +29,45 @@ Installs the Pathfinder workflow kit into the current Git repository.
16
29
  Copies: ${COPY_LIST.join(", ")}
17
30
 
18
31
  Options:
19
- --dry-run Report what would be written; change nothing.
20
- --force Overwrite files that already exist. Off by default.
21
- -h, --help Show this message.
32
+ --agents <ids> Generate skill adapters for these tools, comma-separated.
33
+ Valid ids: ${HARNESS_IDS.join(", ")}. Alias: --agent.
34
+ Without it, nothing is configured unless you are asked and
35
+ say so.
36
+ --dry-run Report what would be written, and any \`git init\` that would
37
+ run first; change nothing and ask nothing.
38
+ --force Overwrite files that already exist, and replace a file you
39
+ wrote at a path an adapter would occupy. Off by default.
40
+ --git-init Run \`git init\` here if this is not a repository yet.
41
+ --no-git-init Never run \`git init\`; refuse instead.
42
+ --no-clipboard Never offer to copy the Kickstart prompt. The prompt is
43
+ printed either way.
44
+ --no-open Never offer to open the project in an editor.
45
+ --yes Take the defaults and ask nothing. Alias: --no-input.
46
+ It does not authorize \`git init\` or configure any tool;
47
+ pass --git-init and --agents for those.
48
+ -h, --help Show this message.
49
+
50
+ Adapters are generated files Pathfinder owns and regenerates without --force.
51
+ A file it did not generate is never replaced, at any path, without --force.
52
+
53
+ Without a terminal on both stdin and stdout, nothing is ever asked. In that
54
+ case a directory that is not a Git repository needs --git-init, or the install
55
+ is refused, and neither your clipboard nor an editor is touched — --yes does
56
+ not authorize either one.
22
57
  `;
23
58
 
24
- export function run(argv, { cwd, out, err }) {
59
+ export async function run(
60
+ argv,
61
+ {
62
+ cwd,
63
+ out,
64
+ err,
65
+ env = {},
66
+ platform = process.platform,
67
+ stdoutIsTTY = false,
68
+ prompter = nonInteractivePrompter(),
69
+ },
70
+ ) {
25
71
  const options = parseArguments(argv);
26
72
 
27
73
  if (options.error) {
@@ -34,18 +80,36 @@ export function run(argv, { cwd, out, err }) {
34
80
  return 0;
35
81
  }
36
82
 
83
+ // Detection runs before anything is decided and before anything is asked, so
84
+ // the user reads what the tool found before reading what it wants to do.
85
+ const findings = detect({ cwd, env, platform });
86
+ const unicode = supportsUnicode(env, platform);
87
+ const mark = marks(unicode);
88
+
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 }));
94
+ }
95
+
37
96
  // Refused rather than allowed with a warning: this tool writes several
38
97
  // hundred files, and without version control the user has no way to inspect
39
- // or undo what it did.
40
- const gitRoot = findGitRoot(cwd);
98
+ // or undo what it did. What is new in this chunk is that the refusal is no
99
+ // longer the only outcome — the tool may offer to satisfy the requirement.
100
+ let gitRoot = findings.git.repositoryRoot;
101
+ let initializeGit = false;
102
+
41
103
  if (gitRoot === null) {
42
- err(
43
- `create-pathfinder: ${cwd} is not inside a Git repository.\n\n` +
44
- "The kit is installed into version control so you can review the\n" +
45
- "files it adds and undo them if you change your mind. Run `git init`\n" +
46
- "here first, or cd into an existing repository, then run this again.\n",
47
- );
48
- return 1;
104
+ const decision = await decideGitInit({ findings, options, prompter, cwd, out });
105
+ if (!decision.approved) {
106
+ err(decision.message);
107
+ return 1;
108
+ }
109
+ initializeGit = true;
110
+ // The repository we are about to create is this directory, so every check
111
+ // and every message downstream reads the same as if it had always been one.
112
+ gitRoot = cwd;
49
113
  }
50
114
 
51
115
  const kitRoot = findKitRoot();
@@ -67,17 +131,235 @@ export function run(argv, { cwd, out, err }) {
67
131
  return 1;
68
132
  }
69
133
 
134
+ // Deliberately after both kit checks. Approval to initialize is not approval
135
+ // to leave a `.git` behind for an install that was never going to happen:
136
+ // a broken package and the kit repository itself both bail out above, with
137
+ // the directory exactly as they found it.
138
+ if (initializeGit) {
139
+ if (options.dryRun) {
140
+ out(` ${mark.info} Would run \`git init\` in ${cwd}\n\n`);
141
+ } else {
142
+ const initialized = initRepository(cwd);
143
+ if (!initialized.ok) {
144
+ err(
145
+ "create-pathfinder: `git init` failed, so nothing was installed.\n\n" +
146
+ `${indent(initialized.message)}\n\n` +
147
+ "Fix that, or run `git init` yourself, then run this again.\n",
148
+ );
149
+ return 1;
150
+ }
151
+ out(` ${mark.ok} git init ${mark.dash} initialized an empty repository in ${cwd}\n\n`);
152
+ }
153
+ }
154
+
155
+ // Asked before anything is written, so every question this run has is behind
156
+ // the user before the first file moves. Detection supplies the default and
157
+ // nothing more: a tool being installed on this machine is not permission to
158
+ // write into the project on its behalf.
159
+ const { harnesses, customTools } = await selectHarnesses({ findings, options, prompter, out });
160
+
70
161
  const plan = planInstall(kitRoot, cwd, { force: options.force });
71
162
  const result = applyPlan(plan, { dryRun: options.dryRun });
72
163
 
73
- report({ result, plan, cwd, gitRoot, options, out, err });
74
- return result.errors.length > 0 ? 1 : 0;
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 });
168
+
169
+ report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err });
170
+
171
+ // After the report, because the first offer is about the prompt the report
172
+ // just printed — and because a question above the summary would make the user
173
+ // answer before seeing what happened. Every run that gets this far printed a
174
+ // prompt, including one that wrote no files, so there is nothing to guard on.
175
+ await offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform });
176
+
177
+ // Neither offer can change this. An install that wrote every file succeeded
178
+ // 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;
180
+ }
181
+
182
+ /**
183
+ * The list's last entry, which is not a harness and never becomes one.
184
+ *
185
+ * A sentinel rather than a registry row, because everything about a harness —
186
+ * a path, a detection, a rendered file — is exactly what this option does not
187
+ * have. Putting it in `HARNESSES` would mean every loop that writes files
188
+ * needing to remember to skip it, and one that forgot would generate adapters
189
+ * into a directory named after a tool that cannot read them.
190
+ */
191
+ const SOMETHING_ELSE = Object.freeze({ label: "Something else…" });
192
+
193
+ /** How many custom names one run will take before it stops asking. */
194
+ const CUSTOM_TOOL_LIMIT = 10;
195
+
196
+ /** What a tool may be called here: letters, digits, and the punctuation names use. */
197
+ const CUSTOM_TOOL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._+-]{0,39}$/;
198
+
199
+ /**
200
+ * Which harnesses this run configures, and which tools it was told about but
201
+ * cannot configure. Possibly neither, which is the default.
202
+ *
203
+ * Three ways to answer, in precedence order: `--agents` says it outright, an
204
+ * interactive terminal is asked, and everything else configures nothing. That
205
+ * last one is what keeps a scripted run byte-identical to 1.4.1 — a CI job that
206
+ * has always piped this command sees no new files and no new output.
207
+ *
208
+ * `--agents` cannot name an unsupported tool: an id the registry does not know
209
+ * exits 2 rather than being recorded, because a flag is how a script asks for
210
+ * files and there is no honest way to half-satisfy that. "Something else…"
211
+ * exists in the question, where a person is there to read the answer.
212
+ *
213
+ * An unanswered prompt is read as "none", the same conservative reading the
214
+ * Git question gives it.
215
+ *
216
+ * @returns {Promise<{harnesses: object[], customTools: string[]}>}
217
+ */
218
+ async function selectHarnesses({ findings, options, prompter, out }) {
219
+ if (options.agents !== null) {
220
+ return { harnesses: options.agents.map((id) => findHarness(id)), customTools: [] };
221
+ }
222
+ if (!prompter.interactive || options.yes) return { harnesses: [], customTools: [] };
223
+
224
+ const detected = detectedHarnesses(findings);
225
+ const entries = [...HARNESSES, SOMETHING_ELSE];
226
+ const width = Math.max(...entries.map((entry) => entry.label.length));
227
+
228
+ const answer = await prompter.chooseMany("Configure Pathfinder for which tools?", {
229
+ options: entries.map((entry) => ({
230
+ 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)" : "")),
239
+ })),
240
+ defaultSelection: detected,
241
+ });
242
+
243
+ out("\n");
244
+
245
+ const chosen = answer ?? [];
246
+ const harnesses = chosen.filter((entry) => entry !== SOMETHING_ELSE);
247
+ const customTools = chosen.includes(SOMETHING_ELSE) ? await askCustomTools({ prompter, out }) : [];
248
+
249
+ return { harnesses, customTools };
250
+ }
251
+
252
+ /**
253
+ * Collect the names of tools Pathfinder does not support.
254
+ *
255
+ * Nothing is generated from these and nothing is stored — they exist only so
256
+ * the summary can name what it is declining to do. That is the whole feature:
257
+ * an answer a person can act on beats a directory full of files their tool
258
+ * will never read.
259
+ *
260
+ * The loop is bounded three ways: an empty line, an ended stream, and a hard
261
+ * ceiling. The ceiling is not a guess about how many tools anyone uses; it is
262
+ * there because a question that repeats itself is a question that can repeat
263
+ * itself forever on a stream that never closes.
264
+ */
265
+ async function askCustomTools({ prompter, out }) {
266
+ out(
267
+ "Pathfinder generates adapters only for tools it can generate them for.\n" +
268
+ "Name the others and the summary will say what does work for them.\n\n",
269
+ );
270
+
271
+ const names = [];
272
+
273
+ while (names.length < CUSTOM_TOOL_LIMIT) {
274
+ const answer = await prompter.text("Which tool? (Enter when done)");
275
+
276
+ // "" is done and null is nobody there. Both stop, and neither is an error.
277
+ if (answer === null || answer === "") break;
278
+
279
+ const supported = harnessNamed(answer);
280
+ if (supported !== null) {
281
+ out(
282
+ ` ${supported.label} is supported — it is in the list above, and writes to\n` +
283
+ ` ${supported.skillsDir}/. Choose it there, or pass --agents ${supported.id}.\n\n`,
284
+ );
285
+ continue;
286
+ }
287
+
288
+ if (!CUSTOM_TOOL_PATTERN.test(answer)) {
289
+ // Nothing is built from this name, so the risk is not injection but a
290
+ // summary that says something other than what was typed. A name this
291
+ // tool cannot print back faithfully is one it should not accept.
292
+ out(" Letters, digits, spaces, and . _ + - only, up to 40 characters.\n\n");
293
+ continue;
294
+ }
295
+
296
+ if (names.some((name) => name.toLowerCase() === answer.toLowerCase())) continue;
297
+ names.push(answer);
298
+ }
299
+
300
+ if (names.length > 0) out("\n");
301
+ return names;
302
+ }
303
+
304
+ /**
305
+ * Generate the adapters for the selected harnesses, or explain why not.
306
+ *
307
+ * Returns the plan and the result together so the report can distinguish "no
308
+ * harness was chosen" from "a harness was chosen and produced nothing", which
309
+ * are the same zero and mean opposite things.
310
+ */
311
+ function generateAdapters({ harnesses, kitRoot, cwd, options, result }) {
312
+ const none = { plan: [], result: applyAdapterPlan([]), blocked: false };
313
+
314
+ if (harnesses.length === 0) return none;
315
+
316
+ // The copy failed part-way. Reporting adapters as generated on top of that
317
+ // would be a success message about a broken install.
318
+ if (result.errors.length > 0) return { ...none, blocked: true };
319
+
320
+ const plan = planAdapters(harnesses, { kitRoot, targetRoot: cwd, force: options.force });
321
+ return { plan, result: applyAdapterPlan(plan, { dryRun: options.dryRun }), blocked: false };
75
322
  }
76
323
 
77
324
  function parseArguments(argv) {
78
- const options = { dryRun: false, force: false, help: false, error: null };
325
+ const options = {
326
+ dryRun: false,
327
+ force: false,
328
+ help: false,
329
+ gitInit: false,
330
+ noGitInit: false,
331
+ noClipboard: false,
332
+ noOpen: false,
333
+ yes: false,
334
+ // null means "not said", which is not the same as "none". Only the first
335
+ // suppresses the question.
336
+ agents: null,
337
+ error: null,
338
+ };
339
+
340
+ for (let index = 0; index < argv.length; index += 1) {
341
+ const argument = argv[index];
342
+
343
+ const isAgents =
344
+ argument === "--agents" ||
345
+ argument === "--agent" ||
346
+ argument.startsWith("--agents=") ||
347
+ argument.startsWith("--agent=");
348
+
349
+ if (isAgents) {
350
+ const equals = argument.indexOf("=");
351
+ const value = equals === -1 ? argv[++index] : argument.slice(equals + 1);
352
+ const parsed = parseAgents(value);
353
+
354
+ if (parsed.error) {
355
+ options.error = parsed.error;
356
+ return options;
357
+ }
358
+
359
+ options.agents = [...new Set([...(options.agents ?? []), ...parsed.ids])];
360
+ continue;
361
+ }
79
362
 
80
- for (const argument of argv) {
81
363
  switch (argument) {
82
364
  case "--dry-run":
83
365
  options.dryRun = true;
@@ -85,6 +367,26 @@ function parseArguments(argv) {
85
367
  case "--force":
86
368
  options.force = true;
87
369
  break;
370
+ case "--git-init":
371
+ options.gitInit = true;
372
+ break;
373
+ case "--no-git-init":
374
+ options.noGitInit = true;
375
+ break;
376
+ case "--no-clipboard":
377
+ options.noClipboard = true;
378
+ break;
379
+ case "--no-open":
380
+ options.noOpen = true;
381
+ break;
382
+ // `--yes` silences questions. It does not answer the Git one: authorizing
383
+ // the creation of a repository is the single thing in this tool that has
384
+ // to be said out loud, and "assume yes to everything" is exactly the kind
385
+ // of blanket that should not cover it.
386
+ case "--yes":
387
+ case "--no-input":
388
+ options.yes = true;
389
+ break;
88
390
  case "-h":
89
391
  case "--help":
90
392
  options.help = true;
@@ -95,9 +397,209 @@ function parseArguments(argv) {
95
397
  }
96
398
  }
97
399
 
400
+ if (options.gitInit && options.noGitInit) {
401
+ options.error = "`--git-init` and `--no-git-init` contradict each other";
402
+ }
403
+
98
404
  return options;
99
405
  }
100
406
 
407
+ /**
408
+ * Read `--agents claude-code,codex`.
409
+ *
410
+ * An unknown id is a refusal, not a warning that drops it: someone who typed
411
+ * `--agents claud-code` wants adapters, and quietly installing none while
412
+ * exiting 0 would tell them it worked. The valid ids are named in the message,
413
+ * because the whole list is short enough to be the answer.
414
+ */
415
+ function parseAgents(value) {
416
+ if (value === undefined || value.startsWith("-")) {
417
+ return { error: "`--agents` needs a value, such as `--agents " + HARNESS_IDS[0] + "`" };
418
+ }
419
+
420
+ const ids = value
421
+ .split(",")
422
+ .map((id) => id.trim())
423
+ .filter((id) => id !== "");
424
+
425
+ if (ids.length === 0) {
426
+ return { error: "`--agents` needs a value, such as `--agents " + HARNESS_IDS[0] + "`" };
427
+ }
428
+
429
+ const unknown = ids.find((id) => findHarness(id) === null);
430
+ if (unknown !== undefined) {
431
+ return { error: `unknown agent \`${unknown}\`. Valid ids: ${HARNESS_IDS.join(", ")}` };
432
+ }
433
+
434
+ return { ids };
435
+ }
436
+
437
+ /**
438
+ * May this directory become a repository?
439
+ *
440
+ * The four ways to reach "no" are kept apart because they are four different
441
+ * situations for the person reading the message: they said no, they said never,
442
+ * nobody was there to ask, or the machine cannot do it at all. Only the last of
443
+ * those makes `--git-init` bad advice, which is why it is checked before the
444
+ * flags — offering a flag that cannot work would be worse than the refusal it
445
+ * replaced.
446
+ *
447
+ * Asks nothing unless the answer is genuinely unknown *and* someone is there to
448
+ * answer. Returns rather than exits: the caller owns the exit code.
449
+ *
450
+ * @returns {{approved: true} | {approved: false, message: string}}
451
+ */
452
+ async function decideGitInit({ findings, options, prompter, cwd, out }) {
453
+ if (!findings.git.binary) {
454
+ return {
455
+ approved: false,
456
+ message:
457
+ `create-pathfinder: ${cwd} is not inside a Git repository, and \`git\`\n` +
458
+ "is not available to create one.\n\n" +
459
+ "The kit is installed into version control so you can review the files\n" +
460
+ "it adds and undo them if you change your mind. Install Git\n" +
461
+ "(https://git-scm.com/downloads), or cd into an existing repository,\n" +
462
+ "then run this again.\n",
463
+ };
464
+ }
465
+
466
+ if (options.noGitInit) return { approved: false, message: refusal(cwd) };
467
+ if (options.gitInit) return { approved: true };
468
+
469
+ // `--dry-run` needs no permission, because there is nothing to permit. The
470
+ // spec forbids asking a question whose only purpose is to authorize an action
471
+ // that will not be taken, and this is that question: the file plan is the
472
+ // same whatever the answer, so the prompt would buy a report the tool could
473
+ // have written anyway. Reporting the `git init` it *would* run is the whole
474
+ // point of the mode — telling someone "no" about work nobody was going to do
475
+ // withholds the one thing they asked for.
476
+ if (options.dryRun) return { approved: true };
477
+
478
+ // The TTY guard. Both ends must be a terminal, and `--yes` opts out on the
479
+ // user's behalf. Below this line the tool is scriptable: it asks nothing,
480
+ // prints no question, and refuses the way 1.4.1 did.
481
+ if (!prompter.interactive || options.yes) return { approved: false, message: refusal(cwd) };
482
+
483
+ out(
484
+ "Pathfinder installs into version control so you can review what it wrote\n" +
485
+ "and undo it. It will not touch an existing history.\n\n",
486
+ );
487
+
488
+ const answer = await prompter.confirm("Initialize a Git repository here?", {
489
+ defaultAnswer: true,
490
+ });
491
+
492
+ if (answer === true) return { approved: true };
493
+
494
+ // `false` is a decision and `null` is an unanswerable prompt — a closed
495
+ // stdin, or input that never resolved to a yes or a no. Both land here,
496
+ // because the only safe reading of "no usable approval" is that there is no
497
+ // approval. Declining is not an error, but it is still a refusal to install.
498
+ return {
499
+ approved: false,
500
+ message:
501
+ "\nNothing was installed.\n\n" +
502
+ "Pathfinder writes the whole kit into your project. Without version\n" +
503
+ "control there is no way to review or undo that, so it will not run\n" +
504
+ "outside a repository.\n\n" +
505
+ "Run `git init` here yourself and try again, or cd into an existing\n" +
506
+ "repository.\n",
507
+ };
508
+ }
509
+
510
+ /**
511
+ * The 1.4.1 refusal, plus the one sentence this feature earns the right to add.
512
+ *
513
+ * Byte-identical to what `1.4.1` printed through the final line, so every
514
+ * non-interactive scenario that existed before this feature still reads the
515
+ * same; the flag is named after it rather than woven into it.
516
+ */
517
+ function refusal(cwd) {
518
+ return (
519
+ `create-pathfinder: ${cwd} is not inside a Git repository.\n\n` +
520
+ "The kit is installed into version control so you can review the\n" +
521
+ "files it adds and undo them if you change your mind. Run `git init`\n" +
522
+ "here first, or cd into an existing repository, then run this again.\n" +
523
+ "\nTo have this command run `git init` for you, pass --git-init.\n"
524
+ );
525
+ }
526
+
527
+ /** Prefix every line, so borrowed output is visibly not ours. */
528
+ function indent(text) {
529
+ return text
530
+ .split("\n")
531
+ .map((line) => ` ${line}`)
532
+ .join("\n");
533
+ }
534
+
535
+ /**
536
+ * Say what was found, before saying what will be done.
537
+ *
538
+ * Deliberately short, and deliberately passive. Every line states a fact about
539
+ * the machine; none of them implies an intention. The parenthetical on the
540
+ * tools line is load-bearing — a bare list of everything installed on someone's
541
+ * 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.
544
+ */
545
+ export function formatFindings(findings, { unicode = false } = {}) {
546
+ const mark = marks(unicode);
547
+
548
+ const lines = ["", "Pathfinder", ""];
549
+
550
+ if (findings.git.insideRepository) {
551
+ lines.push(` ${mark.ok} Git repository detected`);
552
+ } else if (findings.git.binary) {
553
+ lines.push(` ${mark.info} No Git repository here`);
554
+ } else {
555
+ lines.push(` ${mark.bad} No Git repository here, and \`git\` is not on your PATH`);
556
+ }
557
+
558
+ if (findings.pathfinder.installed) {
559
+ const { skillCount } = findings.pathfinder;
560
+ lines.push(` ${mark.ok} Pathfinder already installed (${skillCount} skill${plural(skillCount)})`);
561
+ }
562
+
563
+ const tools = detectedToolLabels(findings);
564
+ lines.push(
565
+ tools.length > 0
566
+ ? ` ${mark.ok} Tools detected: ${tools.join(", ")} (noted, not configured)`
567
+ : ` ${mark.info} No supported tools detected`,
568
+ );
569
+
570
+ // 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.
573
+ return lines.join("\n") + "\n\n";
574
+ }
575
+
576
+ /**
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?
589
+ *
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.
594
+ */
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);
601
+ }
602
+
101
603
  /**
102
604
  * Say what happened, in full.
103
605
  *
@@ -105,7 +607,7 @@ function parseArguments(argv) {
105
607
  * default mode is that it left your work alone, and a bare "42 skipped" does
106
608
  * not let anyone check that claim.
107
609
  */
108
- function report({ result, plan, cwd, gitRoot, options, out, err }) {
610
+ function report({ result, plan, adapters, harnesses, customTools, cwd, gitRoot, options, out, err }) {
109
611
  const lines = [];
110
612
  const verb = options.dryRun ? "Would install" : "Installed";
111
613
 
@@ -122,6 +624,9 @@ function report({ result, plan, cwd, gitRoot, options, out, err }) {
122
624
  lines.push(` ${result.overwritten} file${plural(result.overwritten)} overwritten (--force)`);
123
625
  }
124
626
 
627
+ lines.push(...adapterLines({ adapters, harnesses, options }));
628
+ lines.push(...customToolLines(customTools));
629
+
125
630
  const skipped = plan.filter((item) => item.status === "skip");
126
631
  if (skipped.length > 0) {
127
632
  lines.push(` ${skipped.length} file${plural(skipped.length)} left untouched because they already exist:`);
@@ -133,24 +638,250 @@ function report({ result, plan, cwd, gitRoot, options, out, err }) {
133
638
  if (written === 0 && skipped.length === plan.length) {
134
639
  lines.push("");
135
640
  lines.push("The kit is already installed here.");
136
- } else {
137
- lines.push("");
138
- lines.push("Next step — give your agent this prompt:");
139
- lines.push("");
140
- lines.push(" Use skills/kickstart-pathfinder/SKILL.md. Help me initialize this");
141
- lines.push(" project. Do not install packages or write product code yet.");
142
641
  }
143
642
 
643
+ // The prompt is printed here, always — including on a re-run that wrote
644
+ // nothing. A second run is how someone configures a harness they skipped, or
645
+ // simply comes back for the invocation they have forgotten, and both of those
646
+ // want the same line. It is also what makes the clipboard a convenience on
647
+ // top of this block rather than the only channel, which is what lets every
648
+ // clipboard failure be a non-event.
649
+ lines.push("");
650
+ lines.push("Next step — give your agent this prompt:");
651
+ lines.push("");
652
+ lines.push(...kickstartPromptLines(harnesses));
653
+
144
654
  out(lines.join("\n") + "\n");
145
655
 
146
- if (result.errors.length > 0) {
147
- const failures = result.errors
148
- .map((error) => ` ${error.relativePath}: ${error.message}`)
149
- .join("\n");
150
- err(`\ncreate-pathfinder: ${result.errors.length} file${plural(result.errors.length)} could not be written:\n${failures}\n`);
656
+ const failures = [...result.errors, ...adapters.result.errors];
657
+ if (failures.length > 0) {
658
+ const detail = failures.map((error) => ` ${error.relativePath}: ${error.message}`).join("\n");
659
+ err(`\ncreate-pathfinder: ${failures.length} file${plural(failures.length)} could not be written:\n${detail}\n`);
151
660
  }
152
661
  }
153
662
 
663
+ /**
664
+ * The last two lines of a successful install: the clipboard, then the editor.
665
+ *
666
+ * The guards they share live here because they are one rule, not two. Both are
667
+ * skipped without a terminal because nothing may be asked there; both are
668
+ * skipped under `--yes` because silence is not consent to overwrite what
669
+ * someone is carrying around in their clipboard, nor to take over their screen;
670
+ * and both are skipped under `--dry-run` because a mode whose promise is
671
+ * "changes nothing" cannot make an exception for the state that lives outside
672
+ * the directory.
673
+ *
674
+ * Returns nothing and reports nothing upward on purpose. There is no outcome
675
+ * here that an install should be judged by, so there is no value for the exit
676
+ * code to be computed from.
677
+ */
678
+ async function offerOnboardingActions({ harnesses, cwd, options, prompter, out, env, platform }) {
679
+ if (!prompter.interactive || options.yes) return;
680
+
681
+ const suppressed = options.noClipboard && options.noOpen;
682
+
683
+ if (options.dryRun) {
684
+ // Said rather than silently skipped, because the flag's whole job is to
685
+ // describe the run — and "it would have asked about your clipboard" is
686
+ // exactly the kind of thing someone runs a dry run to find out. Nothing is
687
+ // said when both flags already ruled both offers out; there is no run being
688
+ // described at that point.
689
+ if (!suppressed) {
690
+ out("\nOnboarding actions are not offered in a dry run; nothing was copied or opened.\n");
691
+ }
692
+ return;
693
+ }
694
+
695
+ if (!options.noClipboard) await offerClipboard({ harnesses, options, prompter, out, env, platform });
696
+ if (!options.noOpen) await offerEditor({ cwd, prompter, out, env, platform });
697
+ }
698
+
699
+ /**
700
+ * Offer to put the printed prompt on the clipboard. Never take it.
701
+ */
702
+ async function offerClipboard({ harnesses, options, prompter, out, env, platform }) {
703
+ const answer = await prompter.confirm(
704
+ "Copy that prompt to your clipboard? This replaces what is on it now.",
705
+ { defaultAnswer: true },
706
+ );
707
+
708
+ // `false` is a decline and `null` is nobody there. Neither is a yes, and only
709
+ // a yes may touch the clipboard.
710
+ if (answer !== true) return;
711
+
712
+ // No trailing newline, deliberately. Both harnesses treat a pasted newline as
713
+ // Enter, so appending one would submit the prompt the instant it is pasted —
714
+ // a surprise, not a convenience, and the opposite of leaving the user in
715
+ // control of when their session starts.
716
+ const copied = copyToClipboard(kickstartPrompt(harnesses), { env, platform });
717
+
718
+ out(
719
+ copied.ok
720
+ ? " Copied.\n"
721
+ : ` Not copied — ${copied.reason}. The prompt is printed above.\n`,
722
+ );
723
+ }
724
+
725
+ /**
726
+ * Offer to open the project in an editor that is already installed here.
727
+ *
728
+ * The question exists only when there is something to answer it with. No editor
729
+ * on PATH means no question at all, rather than a question whose honest answer
730
+ * is "then don't" — an installer that asks about software you do not have is
731
+ * asking to be told about itself.
732
+ *
733
+ * One editor is a yes/no; several are a numbered list with an explicit way out.
734
+ * Neither shape can be answered by not answering: a decline, an unanswered
735
+ * question, and "Don't open" all land on the same nothing.
736
+ */
737
+ async function offerEditor({ cwd, prompter, out, env, platform }) {
738
+ const editors = detectEditors({ env, platform });
739
+ if (editors.length === 0) return;
740
+
741
+ const chosen =
742
+ editors.length === 1
743
+ ? (await prompter.confirm(`Open this project in ${editors[0].label}?`, {
744
+ defaultAnswer: true,
745
+ })) === true
746
+ ? editors[0]
747
+ : null
748
+ : await prompter.chooseOne("Open this project in an editor?", {
749
+ options: [
750
+ ...editors.map((editor) => ({ value: editor, label: editor.label })),
751
+ // Last, and a real row rather than a rule about the empty answer,
752
+ // so declining costs the same one keystroke as accepting.
753
+ { value: null, label: "Don't open" },
754
+ ],
755
+ defaultValue: editors[0],
756
+ });
757
+
758
+ if (chosen === null) return;
759
+
760
+ const opened = await openInEditor(chosen, cwd, { env, platform });
761
+
762
+ // "Opening", not "Opened". The launch is detached, so what this line can
763
+ // truthfully report is that the editor was started, not that it has drawn a
764
+ // window — and a failure after that point is the editor's to explain.
765
+ out(
766
+ opened.ok
767
+ ? ` Opening ${chosen.label}.\n`
768
+ : ` Not opened — ${opened.reason}. The install is complete; open ${cwd} yourself.\n`,
769
+ );
770
+ }
771
+
772
+ /**
773
+ * The adapter half of the summary.
774
+ *
775
+ * Three counts, kept apart because they answer three different worries: what
776
+ * was generated, what was already right, and what was left alone. The third is
777
+ * the one that matters to someone re-running this over a project they have
778
+ * worked in, so conflicts are listed by name — a bare count would ask them to
779
+ * take it on faith that their file survived.
780
+ *
781
+ * Empty when no harness was chosen, which is the default and must stay
782
+ * invisible: a scripted 1.4.1-era run prints exactly what it always did.
783
+ */
784
+ function adapterLines({ adapters, harnesses, options }) {
785
+ if (harnesses.length === 0) return [];
786
+
787
+ if (adapters.blocked) {
788
+ return [
789
+ "",
790
+ " No adapters were generated, because the kit copy did not finish.",
791
+ " An adapter delegates to a canonical skill file, and pointing your tool",
792
+ " at a file that was not written would be worse than generating nothing.",
793
+ ];
794
+ }
795
+
796
+ const failed = new Set(adapters.result.errors.map((error) => error.relativePath));
797
+ const lines = [];
798
+
799
+ for (const harness of harnesses) {
800
+ const mine = adapters.plan.filter(
801
+ (item) => item.harness === harness && !failed.has(item.relativePath),
802
+ );
803
+ const count = (action) => mine.filter((item) => item.action === action).length;
804
+
805
+ const generated = count("write");
806
+ const replaced = count("replace");
807
+ const unchanged = count("up-to-date");
808
+ const conflicts = mine.filter((item) => item.action === "conflict");
809
+ const orphans = mine.filter((item) => item.action === "orphan");
810
+
811
+ lines.push(
812
+ ` ${generated} ${harness.label} skill adapter${plural(generated)} ` +
813
+ (options.dryRun ? "to generate" : "generated"),
814
+ );
815
+
816
+ if (replaced > 0) {
817
+ lines.push(` ${replaced} ${harness.label} adapter${plural(replaced)} replaced (--force)`);
818
+ }
819
+
820
+ if (unchanged > 0) {
821
+ lines.push(` ${unchanged} ${harness.label} adapter${plural(unchanged)} already up to date`);
822
+ }
823
+
824
+ if (conflicts.length > 0) {
825
+ lines.push(
826
+ ` ${conflicts.length} file${plural(conflicts.length)} left untouched because Pathfinder did not write ${conflicts.length === 1 ? "it" : "them"}:`,
827
+ );
828
+ for (const item of conflicts) lines.push(` ${item.relativePath}`);
829
+ lines.push("");
830
+ lines.push(
831
+ 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",
834
+ );
835
+ lines.push(" Pathfinder kit files you have edited.",
836
+ );
837
+ }
838
+
839
+ for (const item of orphans) {
840
+ lines.push(` ${item.relativePath} delegates to a skill this version no longer`);
841
+ lines.push(" ships. It was left in place; delete it yourself if you want it gone.");
842
+ }
843
+ }
844
+
845
+ return lines;
846
+ }
847
+
848
+ /**
849
+ * The tools this run was told about and cannot configure.
850
+ *
851
+ * The point of the whole option, and the reason it is worth a prompt: it is an
852
+ * answer rather than a gap. Pathfinder could plausibly write `.mdc` files for
853
+ * Cursor or drop a `SKILL.md` into any directory a tool might one day read, and
854
+ * every one of those would be a file the user's tool ignores while their
855
+ * installer summary claims success.
856
+ *
857
+ * So this states three things and stops: what does not exist, and the two
858
+ * things that already work. No apology, because nothing here went wrong, and
859
+ * no "yet", "planned", or "for now", because a summary is not the place to
860
+ * imply a roadmap nobody has committed to.
861
+ */
862
+ function customToolLines(customTools = []) {
863
+ if (customTools.length === 0) return [];
864
+
865
+ return [
866
+ "",
867
+ ` Pathfinder has no native integration for ${joinNames(customTools)}, so`,
868
+ ` nothing is generated for ${customTools.length === 1 ? "it" : "them"}. Two things already work:`,
869
+ "",
870
+ " - The kit installs AGENTS.md at the repository root, which Codex,",
871
+ " Cursor, and several other tools read.",
872
+ " - Any agent can be given the line the adapters delegate to anyway:",
873
+ "",
874
+ " Use skills/<name>/SKILL.md and follow it exactly.",
875
+ ];
876
+ }
877
+
878
+ /** `a`, `a and b`, `a, b, and c`. */
879
+ function joinNames(names) {
880
+ if (names.length === 1) return names[0];
881
+ if (names.length === 2) return `${names[0]} and ${names[1]}`;
882
+ return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
883
+ }
884
+
154
885
  function plural(count) {
155
886
  return count === 1 ? "" : "s";
156
887
  }