create-pathfinder 1.4.0 → 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.
Files changed (42) hide show
  1. package/AGENTS.md +4 -0
  2. package/CLAUDE.md +7 -1
  3. package/README.md +97 -9
  4. package/bin/create-pathfinder.mjs +32 -4
  5. package/context/features/example-feature-spec.md +5 -1
  6. package/context/project-overview.md +16 -3
  7. package/copy-list.json +0 -1
  8. package/package.json +2 -2
  9. package/skills/reflect/SKILL.md +46 -46
  10. package/skills/reverse-engineer/SKILL.md +2 -0
  11. package/skills/to-specs/SKILL.md +1 -1
  12. package/src/cli.mjs +765 -34
  13. package/src/clipboard.mjs +134 -0
  14. package/src/detect.mjs +183 -0
  15. package/src/editor.mjs +136 -0
  16. package/src/git.mjs +58 -0
  17. package/src/harnesses/adapter.mjs +288 -0
  18. package/src/harnesses/index.mjs +122 -0
  19. package/src/install.mjs +209 -1
  20. package/src/kickstart-prompt.mjs +81 -0
  21. package/src/prompt.mjs +307 -0
  22. package/templates/project-overview.template.md +16 -3
  23. package/prompts/01-kickstart-project.md +0 -1
  24. package/prompts/01-teach-current-feature.md +0 -9
  25. package/prompts/02-debate-me.md +0 -1
  26. package/prompts/02-quiz-current-feature.md +0 -7
  27. package/prompts/03-challenge-current-feature.md +0 -7
  28. package/prompts/03-prototype.md +0 -1
  29. package/prompts/04-teach-current-architecture.md +0 -7
  30. package/prompts/04-to-specs.md +0 -1
  31. package/prompts/05-learning-review.md +0 -5
  32. package/prompts/05-load-feature.md +0 -1
  33. package/prompts/06-start-feature.md +0 -1
  34. package/prompts/07-review-feature.md +0 -1
  35. package/prompts/08-complete-feature.md +0 -1
  36. package/prompts/09-learn-feature.md +0 -1
  37. package/prompts/10-learn-codebase.md +0 -1
  38. package/prompts/11-handoff.md +0 -1
  39. package/prompts/12-skillsmith.md +0 -1
  40. package/prompts/13-reverse-engineer.md +0 -18
  41. package/prompts/14-reflect.md +0 -13
  42. package/prompts/15-debug-issue.md +0 -15
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Writing one string to the system clipboard, and never reading it back.
3
+ *
4
+ * Three rules shape this file.
5
+ *
6
+ * **It is a convenience, so it may not fail.** Every way this can go wrong — no
7
+ * tool installed, no display to talk to, a tool that exits non-zero, a tool that
8
+ * hangs — degrades to "not copied" and a printed prompt the user can select the
9
+ * old way. Nothing here can change an install's exit code.
10
+ *
11
+ * **No dependency.** A clipboard package would be the first runtime dependency
12
+ * in a project whose identity is "not a framework," bought for a feature that
13
+ * has to be optional anyway. Platform-native commands cost a table.
14
+ *
15
+ * **Write only.** There is no read function, and there should never be one. The
16
+ * clipboard is where people keep passwords for thirty seconds.
17
+ */
18
+
19
+ import { spawnSync } from "node:child_process";
20
+
21
+ import { onPath } from "./detect.mjs";
22
+
23
+ /** Long enough for a real tool, short enough that a wedged one is not the install. */
24
+ const TIMEOUT_MS = 3000;
25
+
26
+ /**
27
+ * The clipboard writers worth trying, in the order they are tried.
28
+ *
29
+ * Ordered per platform, but *selected* by availability, which is what makes WSL
30
+ * work without a special case: it reports `linux`, has neither Wayland nor X,
31
+ * and does have `clip.exe`, so it falls through the three Linux entries and
32
+ * lands on the fourth. Probing the platform string alone would have told it to
33
+ * give up after `xsel`.
34
+ *
35
+ * Every command takes its text on **stdin**. None of them takes it as an
36
+ * argument, which is not a coincidence — it is the property that makes this
37
+ * safe, because the prompt never becomes part of a command line.
38
+ */
39
+ const WRITERS = Object.freeze({
40
+ darwin: [{ command: "pbcopy", args: [] }],
41
+ win32: [{ command: "clip", args: [] }],
42
+ default: [
43
+ { command: "wl-copy", args: [] },
44
+ { command: "xclip", args: ["-selection", "clipboard"] },
45
+ { command: "xsel", args: ["--clipboard", "--input"] },
46
+ // WSL, and any Linux that can reach a Windows host's clipboard.
47
+ { command: "clip.exe", args: [] },
48
+ ],
49
+ });
50
+
51
+ /**
52
+ * The clipboard command this machine can actually run, or null.
53
+ *
54
+ * Null is an ordinary answer, not an error: a headless server has no clipboard,
55
+ * and saying so is more useful than trying four commands and reporting the
56
+ * fourth one's stderr.
57
+ *
58
+ * @returns {{command: string, args: string[]} | null}
59
+ */
60
+ export function clipboardCommand({ env = {}, platform = process.platform } = {}) {
61
+ const candidates = WRITERS[platform] ?? WRITERS.default;
62
+ return candidates.find((writer) => onPath(writer.command, env, platform)) ?? null;
63
+ }
64
+
65
+ /**
66
+ * Put `text` on the clipboard. Say whether it worked.
67
+ *
68
+ * Never throws. A spawn that fails, a tool that exits non-zero, a timeout, and
69
+ * a machine with no clipboard tool at all are four different reasons and one
70
+ * outcome, and the caller prints a single line about it either way.
71
+ *
72
+ * @param {string} text
73
+ * @returns {{ok: true, command: string} | {ok: false, reason: string}}
74
+ */
75
+ export function copyToClipboard(text, { env = {}, platform = process.platform } = {}) {
76
+ const writer = clipboardCommand({ env, platform });
77
+ if (writer === null) return { ok: false, reason: "no clipboard tool is available here" };
78
+
79
+ let result;
80
+ try {
81
+ result = spawnSync(writer.command, writer.args, {
82
+ // An array, never a shell string. The text goes down stdin and is never
83
+ // interpolated into a command line, so a prompt containing a backtick, a
84
+ // semicolon, or a newline is data on every platform.
85
+ shell: false,
86
+ // The same environment the command was *found* in. Resolving `pbcopy`
87
+ // against one PATH and then running it against another is how a probe
88
+ // ends up disagreeing with the thing it probed for.
89
+ env,
90
+ input: text,
91
+ timeout: TIMEOUT_MS,
92
+ // Captured, not inherited: xclip's complaint about a missing DISPLAY must
93
+ // not appear to be something Pathfinder said.
94
+ stdio: ["pipe", "pipe", "pipe"],
95
+ encoding: "utf8",
96
+ });
97
+ } catch (error) {
98
+ // spawnSync generally reports rather than throws, but a bad argv or an
99
+ // exhausted process table can still land here, and a convenience feature
100
+ // is not allowed to take the install down with it.
101
+ return { ok: false, reason: describe(writer.command, error?.message) };
102
+ }
103
+
104
+ if (result.error) {
105
+ const timedOut = result.error.code === "ETIMEDOUT" || result.signal !== null;
106
+ return {
107
+ ok: false,
108
+ reason: timedOut
109
+ ? `${writer.command} did not finish`
110
+ : describe(writer.command, result.error.message),
111
+ };
112
+ }
113
+
114
+ if (result.status !== 0) {
115
+ // The tool's own first line of stderr is the most useful thing available —
116
+ // "Error: Can't open display" is the actual answer — but it is quoted, so
117
+ // it reads as the tool's words rather than Pathfinder's.
118
+ const detail = firstLine(result.stderr);
119
+ return {
120
+ ok: false,
121
+ reason: detail === "" ? `${writer.command} failed` : `${writer.command} said: ${detail}`,
122
+ };
123
+ }
124
+
125
+ return { ok: true, command: writer.command };
126
+ }
127
+
128
+ function describe(command, message) {
129
+ return message ? `${command} could not be run: ${firstLine(message)}` : `${command} could not be run`;
130
+ }
131
+
132
+ function firstLine(text) {
133
+ return String(text ?? "").split("\n")[0].trim();
134
+ }
package/src/detect.mjs ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * What is already here, and what is available to run.
3
+ *
4
+ * Detection is advisory. Everything in this file answers a question of the form
5
+ * "is this present?" and nothing acts on the answer: a finding sets a default
6
+ * or a line in the report, never a file on disk. That boundary is the whole
7
+ * reason this is a separate module — a detector that could also configure
8
+ * something would make every future "it noticed X" into "it did X to me."
9
+ *
10
+ * Nothing here spawns a process. Availability is decided by scanning PATH with
11
+ * existsSync rather than by running `which`/`where` or the tool itself, which
12
+ * buys three things: detection cannot hang, cannot print another program's
13
+ * output over ours, and cannot become the dependency on the `git` binary that
14
+ * findGitRoot exists to avoid. The cost is that a PATH entry which is present
15
+ * but not executable reads as detected. That is the right direction to be
16
+ * wrong in, because a finding only ever offers a default the user can decline.
17
+ *
18
+ * Every probe is wrapped so a failure degrades to "not detected". An unreadable
19
+ * $HOME, a PATH full of directories that do not exist, a permissions error —
20
+ * none of them may abort an install.
21
+ */
22
+
23
+ import { existsSync, readdirSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ import { findGitRoot } from "./kit.mjs";
27
+
28
+ /**
29
+ * The tools worth reporting, in the order they are reported.
30
+ *
31
+ * This table says how to recognize a tool, and deliberately says nothing about
32
+ * what to do with one. Feature 11 owns the harness registry — where each writes
33
+ * and what it generates — and will consume these ids rather than restate them.
34
+ * Two of these four (VS Code, Cursor) are not skill harnesses at all and never
35
+ * will be; they are here because later features act on the editor, not the
36
+ * harness.
37
+ */
38
+ const TOOLS = [
39
+ {
40
+ id: "claude-code",
41
+ label: "Claude Code",
42
+ commands: ["claude"],
43
+ homeMarkers: [".claude"],
44
+ projectMarkers: [".claude"],
45
+ termProgram: [],
46
+ },
47
+ {
48
+ id: "codex",
49
+ label: "Codex",
50
+ commands: ["codex"],
51
+ homeMarkers: [".codex", join(".agents", "skills")],
52
+ projectMarkers: [".agents"],
53
+ termProgram: [],
54
+ },
55
+ {
56
+ id: "vscode",
57
+ label: "VS Code",
58
+ commands: ["code"],
59
+ homeMarkers: [],
60
+ projectMarkers: [],
61
+ termProgram: ["vscode"],
62
+ },
63
+ {
64
+ id: "cursor",
65
+ label: "Cursor",
66
+ commands: ["cursor"],
67
+ homeMarkers: [".cursor"],
68
+ projectMarkers: [".cursor"],
69
+ termProgram: ["cursor"],
70
+ },
71
+ ];
72
+
73
+ /**
74
+ * Everything the CLI knows about its environment before it asks anything.
75
+ *
76
+ * Reads the filesystem and the environment. Writes nothing, spawns nothing.
77
+ *
78
+ * @returns {{
79
+ * git: {repositoryRoot: string|null, insideRepository: boolean, binary: boolean},
80
+ * pathfinder: {installed: boolean, skillCount: number},
81
+ * tools: {id: string, label: string, detected: boolean}[],
82
+ * }}
83
+ */
84
+ export function detect({ cwd, env = {}, platform = process.platform } = {}) {
85
+ const home = homeDirectory(env);
86
+ const repositoryRoot = safe(() => findGitRoot(cwd), null);
87
+
88
+ return {
89
+ git: {
90
+ repositoryRoot,
91
+ insideRepository: repositoryRoot !== null,
92
+ binary: safe(() => onPath("git", env, platform), false),
93
+ },
94
+ pathfinder: safe(() => detectPathfinder(cwd), { installed: false, skillCount: 0 }),
95
+ tools: TOOLS.map((tool) => ({
96
+ id: tool.id,
97
+ label: tool.label,
98
+ detected: safe(() => detectTool(tool, { cwd, home, env, platform }), false),
99
+ })),
100
+ };
101
+ }
102
+
103
+ /** The labels of every detected tool, in table order. Convenience for the report. */
104
+ export function detectedToolLabels(findings) {
105
+ return findings.tools.filter((tool) => tool.detected).map((tool) => tool.label);
106
+ }
107
+
108
+ function detectTool(tool, { cwd, home, env, platform }) {
109
+ const term = env.TERM_PROGRAM ?? "";
110
+ if (tool.termProgram.some((name) => name.toLowerCase() === term.toLowerCase())) return true;
111
+ if (tool.commands.some((command) => onPath(command, env, platform))) return true;
112
+ if (home !== null && tool.homeMarkers.some((marker) => existsSync(join(home, marker)))) return true;
113
+ return tool.projectMarkers.some((marker) => existsSync(join(cwd, marker)));
114
+ }
115
+
116
+ /**
117
+ * Does this directory already look like a Pathfinder project?
118
+ *
119
+ * Decided by counting skill directories rather than by testing for `CLAUDE.md`,
120
+ * which any agent-assisted project may have written for its own reasons.
121
+ * A `skills/<name>/SKILL.md` is a far more specific signature, and the count is
122
+ * worth having on its own — it is what makes "already installed (20 skills)"
123
+ * checkable by the person reading it.
124
+ */
125
+ function detectPathfinder(cwd) {
126
+ const skillsDirectory = join(cwd, "skills");
127
+ if (!existsSync(skillsDirectory)) return { installed: false, skillCount: 0 };
128
+
129
+ const skillCount = readdirSync(skillsDirectory, { withFileTypes: true }).filter(
130
+ (entry) => entry.isDirectory() && existsSync(join(skillsDirectory, entry.name, "SKILL.md")),
131
+ ).length;
132
+
133
+ return { installed: skillCount > 0, skillCount };
134
+ }
135
+
136
+ /**
137
+ * Is `command` findable on PATH?
138
+ *
139
+ * The separator is derived from the passed platform rather than read from
140
+ * `node:path`, so a test can synthesize a Windows environment on any host.
141
+ * PATHEXT is honored for the same reason `git` is spelled `git.exe` there.
142
+ */
143
+ export function onPath(command, env, platform) {
144
+ const searchPath = env.PATH ?? env.Path ?? "";
145
+ if (searchPath === "") return false;
146
+
147
+ const separator = platform === "win32" ? ";" : ":";
148
+ const extensions =
149
+ platform === "win32"
150
+ ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
151
+ : [""];
152
+
153
+ for (const directory of searchPath.split(separator)) {
154
+ if (directory === "") continue;
155
+ for (const extension of extensions) {
156
+ if (existsSync(join(directory, command + extension))) return true;
157
+ }
158
+ }
159
+
160
+ return false;
161
+ }
162
+
163
+ /**
164
+ * The user's home directory, or null when the environment does not say.
165
+ *
166
+ * Null rather than a guess. A wrong home directory would make every
167
+ * home-marker probe silently answer about somewhere nobody lives, and "not
168
+ * detected" is the honest result when the environment is that impoverished —
169
+ * which is a real CI configuration, not a hypothetical one.
170
+ */
171
+ function homeDirectory(env) {
172
+ return env.HOME || env.USERPROFILE || null;
173
+ }
174
+
175
+ /** Run a probe; treat any failure as "not detected". */
176
+ function safe(probe, fallback) {
177
+ try {
178
+ const value = probe();
179
+ return value === undefined ? fallback : value;
180
+ } catch {
181
+ return fallback;
182
+ }
183
+ }
package/src/editor.mjs ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Finding the editors this machine can actually launch, and launching one.
3
+ *
4
+ * The same three rules the clipboard follows, for the same reasons.
5
+ *
6
+ * **It is a convenience, so it may not fail.** No editor installed, a binary
7
+ * that cannot be executed, a launch that dies on the spot — all of it degrades
8
+ * to "not opened" and one printed line. The project is installed either way,
9
+ * and nothing here can change an install's exit code.
10
+ *
11
+ * **Only what was detected.** There is no way to name an editor, pass a path,
12
+ * or configure one. An editor Pathfinder did not find on PATH is an editor it
13
+ * does not offer, which is what keeps this from growing into a launcher.
14
+ *
15
+ * **Detached.** The CLI hands the project to the editor and forgets it. It does
16
+ * not wait for the editor to exit, does not hold its streams, and does not
17
+ * print its output as Pathfinder's — an installer that stays alive until you
18
+ * close your editor would be a bug that looks like a hang.
19
+ */
20
+
21
+ import { spawn } from "node:child_process";
22
+ import { once } from "node:events";
23
+
24
+ import { onPath } from "./detect.mjs";
25
+
26
+ /**
27
+ * The editors that can be offered, in the order they are offered.
28
+ *
29
+ * Alphabetical by label, deliberately. Some order has to exist, and every
30
+ * ordering that means something — most popular, best supported, the one we use
31
+ * — would make this table an endorsement. The alphabet endorses nothing, and
32
+ * it puts Cursor above VS Code, which is a useful reminder that neither is a
33
+ * Pathfinder requirement.
34
+ *
35
+ * Detection is by command on PATH and nothing else. `src/detect.mjs` reports
36
+ * these two tools more generously — a `.cursor` directory counts there — and
37
+ * that is right for a finding and wrong for a launch: a marker directory does
38
+ * not give you a binary to run.
39
+ */
40
+ const EDITORS = Object.freeze([
41
+ Object.freeze({ id: "cursor", label: "Cursor", command: "cursor" }),
42
+ Object.freeze({ id: "vscode", label: "VS Code", command: "code" }),
43
+ ]);
44
+
45
+ /**
46
+ * The editors this machine can launch. Possibly none, which is an ordinary
47
+ * answer: a server with no editor installed simply is not asked the question.
48
+ *
49
+ * @returns {ReadonlyArray<{id: string, label: string, command: string}>}
50
+ */
51
+ export function detectEditors({ env = {}, platform = process.platform } = {}) {
52
+ return EDITORS.filter((editor) => onPath(editor.command, env, platform));
53
+ }
54
+
55
+ /**
56
+ * Ask `editor` to open `directory`, and say whether the launch got off the
57
+ * ground. Never throws.
58
+ *
59
+ * "Got off the ground" is the honest limit of what this can report. It resolves
60
+ * as soon as the child process exists — Node's `spawn` event — and deliberately
61
+ * not on its exit, because waiting for an editor to exit is waiting for the
62
+ * user to close their editor. An editor that starts and then fails on its own
63
+ * has its own way of saying so, on its own screen.
64
+ *
65
+ * @param {{label: string, command: string}} editor
66
+ * @param {string} directory
67
+ * @returns {Promise<{ok: true} | {ok: false, reason: string}>}
68
+ */
69
+ export async function openInEditor(
70
+ editor,
71
+ directory,
72
+ { env = {}, platform = process.platform } = {},
73
+ ) {
74
+ const windows = platform === "win32";
75
+
76
+ // `code` and `cursor` are `.cmd` shims on Windows, which Node refuses to
77
+ // execute directly, so there it goes through the command processor. The
78
+ // quoting is ours rather than the shell's, because `windowsVerbatimArguments`
79
+ // hands this line through untouched — and it needs one more pair of quotes
80
+ // than looks right.
81
+ //
82
+ // `cmd /s /c` strips the first and last quote of everything after `/c`,
83
+ // unconditionally. Passing `"code" "C:\my project"` therefore loses the quote
84
+ // before `code` and the one after the path, leaving `code" "C:\my project` —
85
+ // a broken command line, and a silently unopened editor. Wrapping the whole
86
+ // thing in an outer pair spends those two quotes on the wrapper, so what
87
+ // survives the strip is the command line actually meant: the executable
88
+ // quoted, and a project path with spaces in it still a single argument.
89
+ //
90
+ // Everywhere else it is an argument array with no shell at all.
91
+ const command = windows ? env.ComSpec || env.COMSPEC || "cmd.exe" : editor.command;
92
+ const args = windows
93
+ ? ["/d", "/s", "/c", `""${editor.command}" "${directory}""`]
94
+ : [directory];
95
+
96
+ let child;
97
+ try {
98
+ child = spawn(command, args, {
99
+ shell: false,
100
+ // The environment the command was *found* in, for the same reason the
101
+ // clipboard does it: resolving `code` against one PATH and running it
102
+ // against another is how a probe disagrees with the thing it probed for.
103
+ env,
104
+ // Detached and ignored together. Its own process group so it outlives
105
+ // this one, and no inherited streams so the editor's startup chatter is
106
+ // never mistaken for something Pathfinder said.
107
+ detached: true,
108
+ stdio: "ignore",
109
+ windowsVerbatimArguments: windows,
110
+ windowsHide: true,
111
+ });
112
+ } catch (error) {
113
+ return { ok: false, reason: describe(editor.command, error?.message) };
114
+ }
115
+
116
+ try {
117
+ // Resolves on `spawn`, rejects on `error` — a missing or unexecutable
118
+ // binary reports ENOENT or EACCES here, before anything is unreferenced.
119
+ await once(child, "spawn");
120
+ } catch (error) {
121
+ return { ok: false, reason: describe(editor.command, error?.message) };
122
+ }
123
+
124
+ // Anything that goes wrong after a successful spawn belongs to the editor,
125
+ // not to the install — but an unhandled `error` event would still take this
126
+ // process down on its way out, so it is absorbed.
127
+ child.on("error", () => {});
128
+ child.unref();
129
+
130
+ return { ok: true };
131
+ }
132
+
133
+ function describe(command, message) {
134
+ const detail = String(message ?? "").split("\n")[0].trim();
135
+ return detail === "" ? `${command} could not be run` : `${command} could not be run: ${detail}`;
136
+ }
package/src/git.mjs ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The one Git command this tool is allowed to run.
3
+ *
4
+ * `git init`, in the directory it was given, and nothing else. No `add`, no
5
+ * `commit`, no `config`, no branch manipulation, no `init` in a parent. The
6
+ * narrowness is the design: this module has no way to express a destructive
7
+ * operation, so no future edit to a caller can turn one on by passing a
8
+ * different argument.
9
+ *
10
+ * Invoked with an argument array, never a shell string, so a directory whose
11
+ * name contains a space, a quote, or a `;` is data rather than syntax.
12
+ *
13
+ * Output is captured rather than inherited. `git init` prints a `hint:` block
14
+ * about default branch names on many installations, and inheriting stdio would
15
+ * make that paragraph appear to be Pathfinder's own voice, in the middle of
16
+ * Pathfinder's own report. Success is reported in our words; failure hands back
17
+ * git's stderr, because there the detail is the actionable part.
18
+ */
19
+
20
+ import { spawnSync } from "node:child_process";
21
+
22
+ /**
23
+ * Initialize a repository in `cwd`.
24
+ *
25
+ * Never throws. A missing binary, a read-only path, and a permissions error are
26
+ * all outcomes to report, not exceptions to escape into a stack trace over the
27
+ * top of a half-finished install.
28
+ *
29
+ * @param {string} cwd
30
+ * @param {{spawn?: typeof spawnSync}} [options] - seam for tests; production
31
+ * passes nothing.
32
+ * @returns {{ok: true} | {ok: false, message: string}}
33
+ */
34
+ export function initRepository(cwd, { spawn = spawnSync } = {}) {
35
+ let result;
36
+
37
+ try {
38
+ result = spawn("git", ["init"], {
39
+ cwd,
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "pipe"],
42
+ windowsHide: true,
43
+ });
44
+ } catch (error) {
45
+ return { ok: false, message: error.message };
46
+ }
47
+
48
+ // ENOENT arrives here rather than as a throw: the binary vanished between
49
+ // detection and use, or PATH said something that was not true.
50
+ if (result.error) return { ok: false, message: result.error.message };
51
+
52
+ if (result.status !== 0) {
53
+ const stderr = (result.stderr ?? "").trim();
54
+ return { ok: false, message: stderr || `\`git init\` exited with status ${result.status}` };
55
+ }
56
+
57
+ return { ok: true };
58
+ }