fapony 0.1.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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/fapony.ts +78 -0
  4. package/package.json +42 -0
  5. package/skill/git-commit-conventional/SKILL.md +68 -0
  6. package/skill/git-ship/SKILL.md +144 -0
  7. package/skill/move-to-done/SKILL.md +126 -0
  8. package/skill/plan-with-pony/SKILL.md +263 -0
  9. package/skill/review-pony/SKILL.md +254 -0
  10. package/src/analyze.ts +517 -0
  11. package/src/context/index.ts +11 -0
  12. package/src/context/projectHealth.ts +359 -0
  13. package/src/conventions-seed.ts +420 -0
  14. package/src/db/defaults.ts +26 -0
  15. package/src/db/getters.ts +33 -0
  16. package/src/db/index.ts +7 -0
  17. package/src/db/load.ts +57 -0
  18. package/src/db/store.ts +286 -0
  19. package/src/db/types.ts +79 -0
  20. package/src/debt.ts +667 -0
  21. package/src/digest/cli.ts +75 -0
  22. package/src/digest/collect.ts +625 -0
  23. package/src/digest/html.ts +208 -0
  24. package/src/digest/text.ts +191 -0
  25. package/src/gate.ts +153 -0
  26. package/src/gates.ts +194 -0
  27. package/src/hook.ts +436 -0
  28. package/src/init-mem.ts +71 -0
  29. package/src/init.ts +237 -0
  30. package/src/install/claude.ts +361 -0
  31. package/src/install/codex.ts +61 -0
  32. package/src/install/cursor.ts +167 -0
  33. package/src/install/detect.ts +78 -0
  34. package/src/install/opencode.ts +234 -0
  35. package/src/install/skills.ts +106 -0
  36. package/src/install/types.ts +69 -0
  37. package/src/install/utils.ts +29 -0
  38. package/src/install/zcode.ts +120 -0
  39. package/src/install.ts +176 -0
  40. package/src/lint-baseline.ts +260 -0
  41. package/src/map.ts +320 -0
  42. package/src/math.ts +13 -0
  43. package/src/mcp/evidence.ts +332 -0
  44. package/src/mcp/primitives.ts +316 -0
  45. package/src/mcp/tools/check.ts +243 -0
  46. package/src/mcp/tools/collect.ts +157 -0
  47. package/src/mcp/tools/context.ts +66 -0
  48. package/src/mcp/tools/index.ts +309 -0
  49. package/src/mcp/tools/mem.ts +95 -0
  50. package/src/mcp/tools/plans.ts +255 -0
  51. package/src/mcp/tools/report.ts +285 -0
  52. package/src/mcp/tools/stats.ts +96 -0
  53. package/src/mcp/tools/usage.ts +211 -0
  54. package/src/mcp/tools/verdict.ts +148 -0
  55. package/src/mcp/transport.ts +241 -0
  56. package/src/mcp/types.ts +54 -0
  57. package/src/mcp/worktree.ts +27 -0
  58. package/src/memory.ts +264 -0
  59. package/src/parse.ts +71 -0
  60. package/src/plan-seed.ts +599 -0
  61. package/src/price/fetch.ts +146 -0
  62. package/src/price/index.ts +8 -0
  63. package/src/price/resolve.ts +213 -0
  64. package/src/report/cli.ts +92 -0
  65. package/src/report/format.ts +37 -0
  66. package/src/report/index.ts +4 -0
  67. package/src/report/render.ts +206 -0
  68. package/src/review-seed.ts +932 -0
  69. package/src/safety.ts +18 -0
  70. package/src/session/activeSession.ts +153 -0
  71. package/src/session/claude-code.ts +412 -0
  72. package/src/session/codex.ts +347 -0
  73. package/src/session/findModel.ts +376 -0
  74. package/src/session/helpers.ts +640 -0
  75. package/src/session/index.ts +31 -0
  76. package/src/session/opencode.ts +167 -0
  77. package/src/session/registry.ts +45 -0
  78. package/src/session/types.ts +128 -0
  79. package/src/session/zcode.ts +151 -0
  80. package/src/setup.ts +242 -0
  81. package/src/stats/cli.ts +44 -0
  82. package/src/stats/data.ts +1019 -0
  83. package/src/stats/format.ts +584 -0
  84. package/src/stats/index.ts +19 -0
  85. package/src/telemetry.ts +364 -0
  86. package/src/test.ts +2 -0
  87. package/src/update.ts +212 -0
  88. package/src/usage/cache.ts +125 -0
  89. package/src/usage/cli.ts +120 -0
  90. package/src/usage/format.ts +29 -0
  91. package/src/usage/index.ts +4 -0
  92. package/src/usage/render.ts +523 -0
  93. package/src/usage/scan.ts +161 -0
  94. package/src/util.ts +32 -0
  95. package/src/web/html.ts +33 -0
  96. package/templates/PLAN.md +90 -0
  97. package/templates/SPEC.md +30 -0
  98. package/templates/mem/commands/plan.ts +360 -0
  99. package/templates/mem/commands/read.ts +194 -0
  100. package/templates/mem/commands/rotate.ts +59 -0
  101. package/templates/mem/commands/selftest.ts +450 -0
  102. package/templates/mem/commands/write.ts +214 -0
  103. package/templates/mem/mem.ts +68 -0
  104. package/templates/mem/render.ts +63 -0
  105. package/templates/mem/selectors.ts +144 -0
  106. package/templates/mem/store.ts +285 -0
@@ -0,0 +1,71 @@
1
+ // src/init-mem.ts — scaffold the canonical .memory/ system into a new worktree.
2
+ // Source of truth lives in fapony/templates/mem/ — named after the CLI it implements
3
+ // (`mem`), not after where it lands; the destination keeps the .memory name because that
4
+ // is where the log lives.
5
+ // Destination is <worktree>/.fapony/.memory/ (plans/specs/memory all live under
6
+ // .fapony/; run state stays in ~/.config/fapony/state.db, never in the worktree).
7
+ // Re-run to re-sync after editing the template — not automatic, on purpose.
8
+
9
+ import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { loadConfig, memoryEntry } from "./db/index.js";
12
+
13
+ export function copyDir(src: string, dest: string): string[] {
14
+ mkdirSync(dest, { recursive: true });
15
+ const copied: string[] = [];
16
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
17
+ const s = join(src, entry.name);
18
+ const d = join(dest, entry.name);
19
+ if (entry.isDirectory()) {
20
+ copied.push(...copyDir(s, d));
21
+ } else {
22
+ copyFileSync(s, d);
23
+ copied.push(d);
24
+ }
25
+ }
26
+ return copied;
27
+ }
28
+
29
+ export function cmdInitMem(args: string[]): void {
30
+ const update = args.includes("--update");
31
+ const worktreeKey = args.find((x) => !x.startsWith("-"));
32
+ const config = loadConfig();
33
+
34
+ // ไม่ระบุ key = repo ที่ยืนอยู่ตอนนี้ — ทำให้ `fapony init-mem --update` รันในโปรเจกต์ของใครก็ได้
35
+ // โดยไม่ต้องลงทะเบียน worktree ก่อน (loadConfig อ่าน fapony.config.json ของ cwd อยู่แล้ว
36
+ // จึงได้ paths.memoryEntry ของโปรเจกต์นั้นมาเอง)
37
+ const worktree = worktreeKey ? config.worktrees[worktreeKey] : process.cwd();
38
+ if (!worktree) {
39
+ console.error(`unknown worktree key: ${worktreeKey}`);
40
+ console.error(`available: ${Object.keys(config.worktrees).join(", ")}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ const templateDir = join(import.meta.dir, "..", "templates", "mem");
45
+ const memEntry = memoryEntry(config);
46
+ const destDir = join(worktree, dirname(memEntry));
47
+ const destFile = join(worktree, memEntry);
48
+
49
+ if (existsSync(destFile) && !update) {
50
+ console.error(
51
+ `${destFile} already exists — re-running would overwrite local edits.\nRun \`fapony init-mem --update\` to refresh it from the template (log.jsonl is kept).`,
52
+ );
53
+ process.exit(1);
54
+ }
55
+ if (update && !existsSync(destFile)) {
56
+ console.error(`${destFile} not found — run \`fapony init\` first.`);
57
+ process.exit(1);
58
+ }
59
+
60
+ const files = copyDir(templateDir, destDir);
61
+ if (update) {
62
+ console.log(`updated ${files.length} files in ${destDir}`);
63
+ console.log(`(log.jsonl and other data files left untouched)`);
64
+ return;
65
+ }
66
+ console.log(`scaffolded ${files.length} files into ${destDir}`);
67
+ console.log(`\nAdd to fapony.config.json:`);
68
+ console.log(
69
+ ` "memory": {\n "claim": ["bun", "${memEntry}", "claim", "{id}"],\n "close": ["bun", "${memEntry}", "close", "{id}", "{msg}"],\n "add": ["bun", "${memEntry}", "add", "{kind}", "{text}"],\n "kickoff": ["bun", "${memEntry}", "kickoff"]\n }`,
70
+ );
71
+ }
package/src/init.ts ADDED
@@ -0,0 +1,237 @@
1
+ // src/init.ts — scaffold fapony project structure at a target path.
2
+ // Creates .fapony/plan/, .fapony/done/, .fapony/spec/, .fapony/.memory/ (from template).
3
+ // state.db stays in ~/.config/fapony/ by design (security boundary — see db.ts),
4
+ // never inside the worktree where agents have full write access.
5
+
6
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname, join, relative } from "node:path";
8
+ import { createInterface } from "node:readline";
9
+ import { seedConventionsFile } from "./conventions-seed.js";
10
+ import {
11
+ type Config,
12
+ doneDir,
13
+ evidenceFile,
14
+ memoryEntry,
15
+ planDir,
16
+ specDir,
17
+ } from "./db/index.js";
18
+ import { copyDir } from "./init-mem.js";
19
+ import { isAffirmative } from "./util.js";
20
+
21
+ const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
22
+ # plan/ holds live plans, done/ the shipped ones, spec/ every spec (specs are a
23
+ # reference library — they are not archived). done/ sits beside plan/ rather
24
+ # than inside it so archiving never changes a file's depth, and the relative
25
+ # links inside it keep working.
26
+ # memory lives in .fapony/.memory/, the evidence allowlist in .fapony/evidence.json.
27
+ #
28
+ # evidence.json SHOULD be committed — it is the shared allowlist that decides
29
+ # which commands 'fapony report' may run, and the team must run the same
30
+ # set. If your .gitignore ignores .fapony/ wholesale, re-include it:
31
+ # **/.fapony/*
32
+ # !**/.fapony/evidence.json
33
+ # (dir before file — git cannot re-include a file inside an excluded dir;
34
+ # patterns with a mid-string slash anchor at the repo root, so keep the **/).
35
+ #
36
+ # state.db is NOT here by design — it lives in ~/.config/fapony/ where agents
37
+ # running in this worktree cannot rewrite run state / audit trail.
38
+ #
39
+ # Ask your agent for the plan picture instead of listing these by hand:
40
+ # "run plan_list" — what is active, blocked, untouched, archived
41
+ `;
42
+
43
+ // Static template — deliberately NOT derived from the repo (reading package.json
44
+ // etc. to guess commands would produce a fake allowlist, which is worse than none).
45
+ // `fapony report` only runs commands listed here; agent-proposed commands
46
+ // outside the allowlist are reported, never executed.
47
+ const EVIDENCE_JSON = `{
48
+ "commands": [
49
+ { "name": "test", "cmd": "echo 'edit me: the real test command'", "timeout_ms": 30000 },
50
+ { "name": "typecheck", "cmd": "echo 'edit me: the real typecheck command'", "timeout_ms": 30000 }
51
+ ]
52
+ }
53
+ `;
54
+
55
+ // Rules snippet for the user's own agent-rules file. Printed, never written:
56
+ // nothing writes log.<person>.jsonl on its own — an agent does, because the rules
57
+ // file it already reads says to. That file is the user's (CLAUDE.md / AGENTS.md /
58
+ // opencode.json instructions), so fapony hands over the text and stays out of it.
59
+ // Not in SERVER_INSTRUCTIONS either: that reaches every MCP session of every user,
60
+ // and most of them never ran `fapony init` — it would tell them to run a command
61
+ // that does not exist.
62
+ const RULES_SNIPPET = (
63
+ memEntry: string,
64
+ ) => `## Memory: ${dirname(memEntry)}/log.<you>.jsonl (append-only)
65
+
66
+ The log is this project's shared brain — it lives in git, so anyone who clones the
67
+ repo gets every decision, bug and note with it. The filename comes from
68
+ \`git config user.name\`, one file per person, so there is nothing to merge.
69
+
70
+ Log as you work — do not wait to be asked. Nothing writes it for you:
71
+
72
+ bun ${memEntry} kickoff <plan.md> # start a session with this
73
+ bun ${memEntry} add decision "what was locked, and why" --files src/x.ts
74
+ bun ${memEntry} add bug "what is broken" --files src/x.ts
75
+ bun ${memEntry} add note "state the next session needs" --files src/x.ts
76
+ bun ${memEntry} close <id> "fixed in <sha>"
77
+ bun ${memEntry} find "<text>"
78
+
79
+ Write each entry standalone — it is read months later with no chat to refer to.
80
+ --files is required: rows that name no file cannot be recalled when that file is
81
+ touched later (add refuses without it).
82
+
83
+ ## Executing a plan chunk-by-chunk
84
+
85
+ A long plan run in one unbroken session accumulates context with nothing to shrink
86
+ it — token cost and coherence both degrade with session length, not with amount of
87
+ work done. Cut at chunk boundaries instead:
88
+
89
+ Finish a chunk, before starting the next:
90
+ 1. Tick its checkbox + stamp the TL;DR in the plan file
91
+ 2. Commit — separate from other chunks
92
+ 3. \`verdict_submit\` (fapony MCP), grading what actually happened
93
+ 4. \`bun ${memEntry} add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
94
+ — pass the exact same plan path every time; kickoff matches it as a literal string
95
+ 5. Stop. Do not continue to the next chunk in the same session unless told to.
96
+
97
+ Next chunk, new session — open with \`bun ${memEntry} kickoff <path/to/PLAN-x.md>\` (same
98
+ path) instead of carrying the old transcript forward. kickoff already filters to the
99
+ rows written against that exact path.`;
100
+
101
+ export function initProject(targetPath: string, config?: Config): void {
102
+ // Create target root
103
+ mkdirSync(targetPath, { recursive: true });
104
+
105
+ // --- .fapony/ marker ---
106
+ const faponyDir = join(targetPath, ".fapony");
107
+ if (existsSync(faponyDir)) {
108
+ throw new Error(
109
+ `${faponyDir} already exists — delete it first if you want a fresh scaffold.`,
110
+ );
111
+ }
112
+ mkdirSync(faponyDir, { recursive: true });
113
+ writeFileSync(join(faponyDir, "README"), FAPONY_README);
114
+
115
+ // --- evidence.json (verification_report allowlist — see src/mcp/evidence.ts) ---
116
+ const evidencePath = join(targetPath, evidenceFile(config));
117
+ if (existsSync(evidencePath)) {
118
+ throw new Error(`${evidencePath} already exists — not overwriting.`);
119
+ }
120
+ mkdirSync(dirname(evidencePath), { recursive: true });
121
+ writeFileSync(evidencePath, EVIDENCE_JSON);
122
+
123
+ // --- plan/ spec/ .memory/ — all under .fapony/ ---
124
+ const planDirAbs = join(targetPath, planDir(config));
125
+ if (existsSync(planDirAbs)) {
126
+ throw new Error(`${planDirAbs} already exists — not overwriting.`);
127
+ }
128
+ mkdirSync(planDirAbs, { recursive: true });
129
+
130
+ // --- done/ (archive, sibling of plan/) ---
131
+ const doneDirAbs = join(targetPath, doneDir(config));
132
+ if (existsSync(doneDirAbs)) {
133
+ throw new Error(`${doneDirAbs} already exists — not overwriting.`);
134
+ }
135
+ mkdirSync(doneDirAbs, { recursive: true });
136
+
137
+ // --- spec/ ---
138
+ const specDirAbs = join(targetPath, specDir(config));
139
+ if (existsSync(specDirAbs)) {
140
+ throw new Error(`${specDirAbs} already exists — not overwriting.`);
141
+ }
142
+ mkdirSync(specDirAbs, { recursive: true });
143
+
144
+ // --- .memory/ (from template) ---
145
+ const memEntry = memoryEntry(config); // e.g. .fapony/.memory/mem.ts
146
+ const memoryDir = join(
147
+ targetPath,
148
+ memEntry.split("/").slice(0, -1).join("/"),
149
+ );
150
+ if (existsSync(join(targetPath, memEntry))) {
151
+ throw new Error(
152
+ `${join(targetPath, memEntry)} already exists — delete it first if you want a fresh copy.`,
153
+ );
154
+ }
155
+ const templateDir = join(import.meta.dir, "..", "templates", "mem");
156
+ const files = copyDir(templateDir, memoryDir);
157
+
158
+ console.log(`scaffolded ${targetPath}/`);
159
+ console.log(
160
+ ` .fapony/ — project dir (plans, specs, memory, evidence)`,
161
+ );
162
+ console.log(` ${planDir(config)}/ — live plan files`);
163
+ console.log(` ${doneDir(config)}/ — shipped plans (archive)`);
164
+ console.log(` ${specDir(config)}/ — spec files`);
165
+ console.log(
166
+ ` ${evidenceFile(config)} — allowlist for 'fapony report' (edit the cmds!)`,
167
+ );
168
+ console.log(
169
+ ` ${relative(targetPath, memoryDir)}/ — ${files.length} files from template`,
170
+ );
171
+ console.log(`\nNext: add "${targetPath}" to fapony.config.json worktrees`);
172
+ console.log(
173
+ `\nThen paste this into your agent-rules file (CLAUDE.md / AGENTS.md / opencode.json\ninstructions) — the memory log only fills up if the rules your agent already reads\ntell it to write:\n`,
174
+ );
175
+ console.log(RULES_SNIPPET(memEntry));
176
+ }
177
+
178
+ const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
179
+
180
+ function ask(question: string): Promise<string> {
181
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
182
+ return new Promise((resolve) => {
183
+ rl.question(`${question} `, (answer) => {
184
+ rl.close();
185
+ resolve(answer.trim());
186
+ });
187
+ });
188
+ }
189
+
190
+ export async function cmdInit(args: string[]): Promise<void> {
191
+ const targetPath = args[0];
192
+ if (!targetPath) {
193
+ console.error("usage: fapony init <path>");
194
+ process.exit(1);
195
+ }
196
+ try {
197
+ initProject(targetPath);
198
+ } catch (e) {
199
+ console.error((e as Error).message);
200
+ process.exit(1);
201
+ }
202
+
203
+ // --- conventions.json fill-signal (PLAN-convention-debt chunk 2) ---
204
+ // eslint no-restricted-* rows carry their checker; the wrapper detector adds
205
+ // live-migration candidates. Nothing derivable = empty file, never an error.
206
+ const seed = await seedConventionsFile(targetPath);
207
+ if (seed.kept) {
208
+ console.log(
209
+ ` ${relative(targetPath, seed.file)} — already exists, left untouched`,
210
+ );
211
+ } else {
212
+ console.log(
213
+ ` ${relative(targetPath, seed.file)} — ${seed.eslintRows} from eslint, ${seed.wrapperRows} from wrappers`,
214
+ );
215
+ console.log(
216
+ ` 'fapony debt' reads it; commit it (!**/.fapony/conventions.json in .gitignore)`,
217
+ );
218
+ }
219
+ for (const s of seed.skipped) console.log(` ⚠ eslint config ${s}`);
220
+
221
+ const found = AGENT_RULE_FILES.map((f) => join(targetPath, f)).filter(
222
+ existsSync,
223
+ );
224
+ if (found.length === 0) return;
225
+
226
+ const answer = await ask(
227
+ `\nAppend the memory-logging rules above to ${found.map((f) => relative(targetPath, f)).join(" and ")}? [y/N]`,
228
+ );
229
+ if (!isAffirmative(answer)) return;
230
+
231
+ const memEntry = memoryEntry();
232
+ const snippet = `\n\n${RULES_SNIPPET(memEntry)}\n`;
233
+ for (const f of found) {
234
+ appendFileSync(f, snippet);
235
+ console.log(` appended to ${relative(targetPath, f)}`);
236
+ }
237
+ }
@@ -0,0 +1,361 @@
1
+ // src/install/claude.ts — Claude Code install provider
2
+ //
3
+ // Shells out to `claude mcp add` (never parses/writes ~/.claude.json directly).
4
+
5
+ import {
6
+ chmodSync,
7
+ copyFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { assertSafe } from "../safety.js";
16
+ import { claudeSkillsDir, linkSkills, reportSkills } from "./skills.js";
17
+ import {
18
+ type ClaudeRunResult,
19
+ defaultExit,
20
+ INSTALL_ROOT,
21
+ type InstallDeps,
22
+ } from "./types.js";
23
+
24
+ function defaultRun(argv: string[]): ClaudeRunResult {
25
+ assertSafe(argv);
26
+ try {
27
+ const proc = Bun.spawnSync(argv, {
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+ return {
32
+ exitCode: proc.exitCode,
33
+ stdout: proc.stdout.toString(),
34
+ stderr: proc.stderr.toString(),
35
+ };
36
+ } catch (e) {
37
+ // `claude` binary missing (ENOENT) or spawn failed outright.
38
+ return { exitCode: 127, stdout: "", stderr: (e as Error).message };
39
+ }
40
+ }
41
+
42
+ /** `claude mcp get fapony` — read-only probe. exit 0 = an entry named fapony exists. */
43
+ export function claudeGetArgs(): string[] {
44
+ return ["claude", "mcp", "get", "fapony"];
45
+ }
46
+
47
+ /** Absolute-path add command — works even before `bun link` puts `fapony` on PATH. */
48
+ export function claudeAddArgs(): string[] {
49
+ return [
50
+ "claude",
51
+ "mcp",
52
+ "add",
53
+ "fapony",
54
+ "-s",
55
+ "user",
56
+ "--",
57
+ "bun",
58
+ join(INSTALL_ROOT, "fapony.ts"),
59
+ "mcp",
60
+ ];
61
+ }
62
+
63
+ function isClaudeMissing(res: ClaudeRunResult): boolean {
64
+ return (
65
+ res.exitCode === 127 ||
66
+ /ENOENT|command not found|not found/i.test(res.stderr) ||
67
+ /ENOENT|command not found|not found/i.test(res.stdout)
68
+ );
69
+ }
70
+
71
+ /**
72
+ * An existing `fapony` entry counts as ours when its Command:/Args: lines
73
+ * mention fapony (covers both `bun <abs>/fapony.ts mcp` and `fapony mcp`
74
+ * launchers). Only those lines are inspected — the `fapony:` header matches
75
+ * trivially and proves nothing. Anything else under our name is someone
76
+ * else's entry — never overwrite it silently.
77
+ */
78
+ export function claudeGetPointsToFapony(getOutput: string): boolean {
79
+ const cmdLines = getOutput
80
+ .split("\n")
81
+ .map((l) => l.trim())
82
+ .filter((l) => l.startsWith("Command:") || l.startsWith("Args:"));
83
+ return cmdLines.join("\n").includes("fapony");
84
+ }
85
+
86
+ export function cmdInstallClaude(
87
+ dryRun: boolean,
88
+ deps: InstallDeps = {},
89
+ ): void {
90
+ const run = deps.run ?? defaultRun;
91
+ const exitFn = deps.exit ?? defaultExit;
92
+
93
+ const getArgs = claudeGetArgs();
94
+ assertSafe(getArgs);
95
+ const get = run(getArgs);
96
+ if (isClaudeMissing(get)) {
97
+ console.error(`claude CLI not found — install Claude Code first`);
98
+ exitFn(1);
99
+ return;
100
+ }
101
+
102
+ if (get.exitCode === 0) {
103
+ if (claudeGetPointsToFapony(`${get.stdout}\n${get.stderr}`)) {
104
+ console.error(`✓ mcp.fapony already configured — no change needed`);
105
+ console.error(` (Claude Code user scope)`);
106
+ const dir = claudeSkillsDir(deps.homedir ?? homedir);
107
+ reportSkills(linkSkills(dir, dryRun), dir, dryRun);
108
+ return;
109
+ }
110
+ console.error(
111
+ `an MCP server named "fapony" exists but points elsewhere — not overwriting.`,
112
+ );
113
+ console.error(` inspect with: claude mcp get fapony`);
114
+ console.error(` then remove it first: claude mcp remove fapony -s user`);
115
+ exitFn(1);
116
+ return;
117
+ }
118
+
119
+ const addArgs = claudeAddArgs();
120
+ if (dryRun) {
121
+ console.error(`── dry-run: would run ──`);
122
+ console.error(` ${addArgs.join(" ")}`);
123
+ return;
124
+ }
125
+
126
+ assertSafe(addArgs);
127
+ const add = run(addArgs);
128
+ if (isClaudeMissing(add)) {
129
+ console.error(`claude CLI not found — install Claude Code first`);
130
+ exitFn(1);
131
+ return;
132
+ }
133
+ if (add.exitCode !== 0) {
134
+ console.error(
135
+ `failed to add mcp.fapony to Claude Code (exit ${add.exitCode})`,
136
+ );
137
+ const detail = `${add.stdout}\n${add.stderr}`.trim();
138
+ if (detail) console.error(detail);
139
+ console.error(`verify with: claude mcp add --help`);
140
+ exitFn(1);
141
+ return;
142
+ }
143
+ console.error(`✓ mcp.fapony configured for Claude Code (user scope)`);
144
+ const skillsDir = claudeSkillsDir(deps.homedir ?? homedir);
145
+ reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
146
+
147
+ // Wire statusline: copy script + update settings.json.
148
+ installStatusline(dryRun, deps);
149
+
150
+ // Wire the Stop hook that refuses to end a turn with ungraded commits,
151
+ // and the Read hint that annotates large-file reads (annotate-only).
152
+ installStopHook(dryRun, deps);
153
+ installReadHintHook(dryRun, deps);
154
+ }
155
+
156
+ /**
157
+ * Copy the statusline script to ~/.claude/statusline.sh and add the
158
+ * statusLine field to ~/.claude/settings.json. Best-effort — never fails
159
+ * the install if settings.json is unreadable or has unexpected shape.
160
+ */
161
+ function installStatusline(dryRun: boolean, deps: InstallDeps): void {
162
+ const home = deps.homedir ? deps.homedir() : homedir();
163
+ const claudeDir = join(home, ".claude");
164
+ const scriptSrc = join(INSTALL_ROOT, "statusline", "claude-statusline.sh");
165
+ const scriptDest = join(claudeDir, "statusline.sh");
166
+ const settingsPath = join(claudeDir, "settings.json");
167
+
168
+ // 1. Copy the statusline script — never overwrite someone else's.
169
+ // Mirrors the mcp-entry policy above: an existing script that isn't ours
170
+ // is left alone (the settings guard below will also refuse to repoint it).
171
+ if (!existsSync(scriptSrc)) {
172
+ console.error(` statusline: script not found at ${scriptSrc} — skipping`);
173
+ return;
174
+ }
175
+ if (existsSync(scriptDest)) {
176
+ let current = "";
177
+ try {
178
+ current = readFileSync(scriptDest, "utf-8");
179
+ } catch {
180
+ current = "";
181
+ }
182
+ if (!current.includes("fapony")) {
183
+ console.error(
184
+ ` statusline: ${scriptDest} exists but isn't fapony's — not overwriting.`,
185
+ );
186
+ console.error(
187
+ ` inspect it first, then remove it to let fapony install its own.`,
188
+ );
189
+ return;
190
+ }
191
+ }
192
+ try {
193
+ if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
194
+ if (!dryRun) copyFileSync(scriptSrc, scriptDest);
195
+ // Claude Code execs this file — the copy must stay executable.
196
+ if (!dryRun) chmodSync(scriptDest, 0o755);
197
+ console.error(
198
+ ` statusline: ${dryRun ? "would copy" : "copied"} ${scriptDest}`,
199
+ );
200
+ } catch (e) {
201
+ console.error(
202
+ ` statusline: failed to copy script — ${(e as Error).message}`,
203
+ );
204
+ return;
205
+ }
206
+
207
+ // 2. Update settings.json with statusLine field.
208
+ let settings: Record<string, unknown> = {};
209
+ if (existsSync(settingsPath)) {
210
+ try {
211
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<
212
+ string,
213
+ unknown
214
+ >;
215
+ } catch {
216
+ console.error(
217
+ ` statusline: ${settingsPath} is unreadable or malformed — skipping settings update`,
218
+ );
219
+ return;
220
+ }
221
+ }
222
+
223
+ // Don't overwrite if already configured (same command path). A foreign
224
+ // statusLine (someone else's command) is left alone — same policy as the
225
+ // mcp-entry "points elsewhere" refusal above. Match on our exact dest:
226
+ // any *statusline.sh substring (e.g. another plugin's script) is not ours.
227
+ const existing = settings.statusLine as Record<string, unknown> | undefined;
228
+ if (
229
+ existing &&
230
+ existing.type === "command" &&
231
+ typeof existing.command === "string" &&
232
+ existing.command === scriptDest
233
+ ) {
234
+ console.error(
235
+ ` statusline: already configured in settings.json — no change`,
236
+ );
237
+ return;
238
+ }
239
+ if (existing && typeof existing === "object") {
240
+ console.error(
241
+ ` statusline: settings.json already has a statusLine that isn't fapony's — not overwriting.`,
242
+ );
243
+ console.error(
244
+ ` inspect it first, then remove it to let fapony wire its own.`,
245
+ );
246
+ return;
247
+ }
248
+
249
+ settings.statusLine = {
250
+ type: "command",
251
+ command: scriptDest,
252
+ };
253
+
254
+ if (!dryRun) {
255
+ writeFileSync(
256
+ settingsPath,
257
+ `${JSON.stringify(settings, null, 2)}\n`,
258
+ "utf-8",
259
+ );
260
+ }
261
+ console.error(
262
+ ` statusline: ${dryRun ? "would write" : "wrote"} statusLine → ${settingsPath}`,
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Shared append-to-settings.json hook installer. Both fapony hooks live
268
+ * here now (Stop since PLAN-mem-mcp, PreToolUse read hint since the
269
+ * large-file annotate feature) — the read/write/idempotence/append shape
270
+ * is one implementation with two callers, not a scaffold.
271
+ * Same policy as installStatusline: never touch a hook someone else
272
+ * registered, never fail the install over it.
273
+ */
274
+ function ensureClaudeHook(
275
+ dryRun: boolean,
276
+ deps: InstallDeps,
277
+ hook: { event: string; matcher?: string; subcommand: string; label: string },
278
+ ): void {
279
+ const home = deps.homedir ? deps.homedir() : homedir();
280
+ const claudeDir = join(home, ".claude");
281
+ const settingsPath = join(claudeDir, "settings.json");
282
+ const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} ${hook.subcommand}`;
283
+
284
+ let settings: Record<string, unknown> = {};
285
+ if (existsSync(settingsPath)) {
286
+ try {
287
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<
288
+ string,
289
+ unknown
290
+ >;
291
+ } catch {
292
+ console.error(
293
+ ` ${hook.label}: ${settingsPath} is unreadable or malformed — skipping`,
294
+ );
295
+ return;
296
+ }
297
+ }
298
+
299
+ const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
300
+ const list = Array.isArray(hooks[hook.event])
301
+ ? (hooks[hook.event] as unknown[])
302
+ : [];
303
+ if (JSON.stringify(list).includes(hook.subcommand)) {
304
+ console.error(
305
+ ` ${hook.label}: already configured in settings.json — no change`,
306
+ );
307
+ return;
308
+ }
309
+
310
+ // Append rather than replace: other tools register hooks too, and
311
+ // Claude Code runs every entry in the array.
312
+ list.push({
313
+ ...(hook.matcher ? { matcher: hook.matcher } : {}),
314
+ hooks: [{ type: "command", command }],
315
+ });
316
+ hooks[hook.event] = list;
317
+ settings.hooks = hooks;
318
+
319
+ if (!dryRun) {
320
+ try {
321
+ if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
322
+ writeFileSync(
323
+ settingsPath,
324
+ `${JSON.stringify(settings, null, 2)}\n`,
325
+ "utf-8",
326
+ );
327
+ } catch (e) {
328
+ console.error(
329
+ ` ${hook.label}: failed to write — ${(e as Error).message}`,
330
+ );
331
+ return;
332
+ }
333
+ }
334
+ console.error(
335
+ ` ${hook.label}: ${dryRun ? "would write" : "wrote"} hooks.${hook.event} → ${settingsPath}`,
336
+ );
337
+ }
338
+
339
+ function installStopHook(dryRun: boolean, deps: InstallDeps): void {
340
+ ensureClaudeHook(dryRun, deps, {
341
+ event: "Stop",
342
+ subcommand: "hook-stop",
343
+ label: "stop hook",
344
+ });
345
+ }
346
+
347
+ /**
348
+ * PreToolUse hook on Read: annotates a full-file read of a large source file
349
+ * with one factual line (size + the review-seed command). Annotate only —
350
+ * no permissionDecision is ever returned, the read always proceeds; and no
351
+ * "already read" dedupe (context compaction makes that claim false). The
352
+ * matcher "Read" keeps the spawn off every other tool call.
353
+ */
354
+ function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
355
+ ensureClaudeHook(dryRun, deps, {
356
+ event: "PreToolUse",
357
+ matcher: "Read",
358
+ subcommand: "hook-read-hint",
359
+ label: "read hint",
360
+ });
361
+ }