fapony 0.2.0 → 0.2.1

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/hook.ts CHANGED
@@ -26,8 +26,8 @@ import {
26
26
  statSync,
27
27
  } from "node:fs";
28
28
  import { homedir } from "node:os";
29
- import { basename, join, relative, resolve } from "node:path";
30
- import { collectSourceFiles, SCAN_EXTS } from "./analyze.js";
29
+ import { basename, join, relative, resolve, sep } from "node:path";
30
+ import { buildGraphCached, collectSourceFiles, SCAN_EXTS } from "./analyze.js";
31
31
  import { openDb } from "./db/index.js";
32
32
  import { debtForFile, loadConventions } from "./debt.js";
33
33
  import { readMemLog } from "./memory.js";
@@ -63,7 +63,7 @@ export function hintLogPath(worktree: string): string {
63
63
  export interface HintFireRow {
64
64
  ts: string;
65
65
  worktree: string;
66
- surface: "read" | "debt" | "mem" | "commit";
66
+ surface: "read" | "debt" | "mem" | "commit" | "edit";
67
67
  file: string | null;
68
68
  count: number;
69
69
  ids?: string[];
@@ -96,7 +96,13 @@ export function recordHintFire(row: HintFireRow): void {
96
96
 
97
97
  export interface HintImpact {
98
98
  fired: number;
99
- by_surface: { read: number; debt: number; mem: number; commit: number };
99
+ by_surface: {
100
+ read: number;
101
+ debt: number;
102
+ mem: number;
103
+ commit: number;
104
+ edit: number;
105
+ };
100
106
  debt: { shown: number; resolved: number; unknown: number };
101
107
  window: string | null;
102
108
  }
@@ -115,7 +121,7 @@ export function computeHintImpact(
115
121
  const dir = hintLogDir();
116
122
  const impact: HintImpact = {
117
123
  fired: 0,
118
- by_surface: { read: 0, debt: 0, mem: 0, commit: 0 },
124
+ by_surface: { read: 0, debt: 0, mem: 0, commit: 0, edit: 0 },
119
125
  debt: { shown: 0, resolved: 0, unknown: 0 },
120
126
  window: since ?? null,
121
127
  };
@@ -212,9 +218,13 @@ export interface RawStopPayload {
212
218
  conversation_id?: string;
213
219
  loop_count?: number;
214
220
  status?: string;
221
+ // Codex — hooks contract (https://learn.chatgpt.com/docs/hooks)
222
+ session_id?: string;
223
+ model?: string;
224
+ permission_mode?: string;
215
225
  }
216
226
 
217
- export type StopClient = "claude" | "cursor";
227
+ export type StopClient = "claude" | "cursor" | "codex";
218
228
 
219
229
  export interface NormalizedStopInput {
220
230
  client: StopClient;
@@ -319,11 +329,32 @@ export function isCursorPayload(raw: RawStopPayload): boolean {
319
329
  );
320
330
  }
321
331
 
322
- /** Field-mapping only — both clients feed the same decideStop below. */
332
+ /** Codex sends permission_mode and/or model — fields neither Claude nor Cursor include in Stop. */
333
+ export function isCodexPayload(raw: RawStopPayload): boolean {
334
+ return (
335
+ typeof raw.permission_mode === "string" ||
336
+ (typeof raw.model === "string" && !isCursorPayload(raw))
337
+ );
338
+ }
339
+
340
+ /** Field-mapping only — all three clients feed the same decideStop below. */
323
341
  export function normalizeStopInput(
324
342
  raw: RawStopPayload,
325
343
  home: string,
326
344
  ): NormalizedStopInput {
345
+ if (isCodexPayload(raw)) {
346
+ // Codex: cwd is the session working directory; stop_hook_active means
347
+ // the hook already fired once (same semantics as Claude).
348
+ return {
349
+ client: "codex",
350
+ cwd: raw.cwd ?? process.cwd(),
351
+ transcriptPath:
352
+ typeof raw.transcript_path === "string" && raw.transcript_path
353
+ ? raw.transcript_path
354
+ : null,
355
+ stopHookActive: raw.stop_hook_active === true,
356
+ };
357
+ }
327
358
  if (isCursorPayload(raw)) {
328
359
  const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
329
360
  let transcriptPath =
@@ -350,12 +381,13 @@ export function normalizeStopInput(
350
381
  };
351
382
  }
352
383
 
353
- /** Claude blocks with decision:block; Cursor's stop hook "blocks" by
354
- * auto-submitting the reason as the next user message. */
384
+ /** Claude blocks with decision:block; Cursor auto-submits as followup_message;
385
+ * Codex continues with decision:block + reason (continue:false would take
386
+ * precedence and end the turn instead — Codex Hooks, Stop section). */
355
387
  export function stopOutput(client: StopClient, reason: string): string {
356
- return client === "cursor"
357
- ? JSON.stringify({ followup_message: reason })
358
- : JSON.stringify({ decision: "block", reason });
388
+ if (client === "cursor") return JSON.stringify({ followup_message: reason });
389
+ if (client === "codex") return JSON.stringify({ decision: "block", reason });
390
+ return JSON.stringify({ decision: "block", reason });
359
391
  }
360
392
 
361
393
  /** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
@@ -370,6 +402,7 @@ export async function cmdHookStop(): Promise<void> {
370
402
  // died — commits from those turns are still caught at the next completed
371
403
  // stop (the window is the conversation transcript's birthtime).
372
404
  if (client === "cursor" && raw.status !== "completed") return;
405
+ // Codex: no status guard needed — Stop fires at turn end unconditionally.
373
406
 
374
407
  const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
375
408
 
@@ -633,6 +666,132 @@ export function rereadHintFor(opts: RereadHintInput): string | null {
633
666
  }
634
667
  }
635
668
 
669
+ // --- Edit hint (PreToolUse annotate — importer count + once-per-session dedupe) ---
670
+ //
671
+ // Editing a file that has importers can silently break its consumers (measured:
672
+ // 17.9% of changed nodes over 30 commits had a 1-hop blast radius; ~12-16% of
673
+ // all-time fail rows were producer/consumer mismatches). The hint is a fact —
674
+ // the importer count plus the review-seed command that lists them — never a
675
+ // judgment about whether the edit is safe, and never a block.
676
+ //
677
+ // Dedupe is per (session, file): the first edit to a file fires, repeats stay
678
+ // silent. The track log reuses the read-track session mechanism (sessionKey,
679
+ // one jsonl per session) but lives in its own dir — sharing read-track's file
680
+ // would make an Edit look like a Read and falsely trip the re-read hint. Like
681
+ // rereadHintFor the track write happens inside this function (the caller-side
682
+ // rule covers recordHintFire, not dedupe state); unlike it there is no mtime
683
+ // comparison — an edit that moves mtime is still the same file in the same
684
+ // session, and repeating the count buys nothing.
685
+
686
+ const EDIT_TRACK_DIR = "edit-track";
687
+
688
+ export interface EditTrackRow {
689
+ ts: string;
690
+ path: string;
691
+ }
692
+
693
+ /** Directory holding one edit log per session. */
694
+ function editTrackDir(): string {
695
+ const base =
696
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
697
+ return join(base, EDIT_TRACK_DIR);
698
+ }
699
+
700
+ /** Absolute path of a session's edit log — may not exist. */
701
+ export function editTrackPath(session: string): string {
702
+ return join(editTrackDir(), `${sessionKey(session)}.jsonl`);
703
+ }
704
+
705
+ function editTrackPaths(session: string): Set<string> {
706
+ const p = editTrackPath(session);
707
+ if (!existsSync(p)) return new Set();
708
+ const out = new Set<string>();
709
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
710
+ if (!line) continue;
711
+ try {
712
+ const r = JSON.parse(line) as EditTrackRow;
713
+ if (typeof r.path === "string") out.add(r.path);
714
+ } catch {
715
+ // a torn line must not lose the rest of the log
716
+ }
717
+ }
718
+ return out;
719
+ }
720
+
721
+ function appendEditTrackRow(session: string, row: EditTrackRow): void {
722
+ const dir = editTrackDir();
723
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
724
+ appendFileSync(editTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
725
+ }
726
+
727
+ export interface EditHintInput {
728
+ filePath: unknown;
729
+ cwd: string;
730
+ /**
731
+ * Session identity — transcript path (Claude) or session id. Without it the
732
+ * hint still fires (the importer fact holds) but cannot dedupe.
733
+ */
734
+ session?: unknown;
735
+ }
736
+
737
+ /**
738
+ * Factual one-liner for editing a source file that has importers, or null.
739
+ * Every unknown (no path, non-source ext, new/unsaved file, outside the
740
+ * worktree, no git repo, graph failure) resolves to null — a hint must never
741
+ * fire on a guess. Files with importers are checked before the dedupe log is
742
+ * touched, so a file nobody imports never writes a track row.
743
+ */
744
+ export function editHintFor(opts: EditHintInput): string | null {
745
+ try {
746
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
747
+ const dot = opts.filePath.lastIndexOf(".");
748
+ // SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
749
+ if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
750
+ // review-seed is a git command — outside a repo the hint would lie.
751
+ const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
752
+ cwd: opts.cwd,
753
+ stdout: "pipe",
754
+ stderr: "pipe",
755
+ });
756
+ if (git.exitCode !== 0) return null;
757
+ // macOS /var → /private/var: normalize both sides before comparing.
758
+ const worktree = realpathSync(git.stdout.toString().trim());
759
+ const abs = (() => {
760
+ const p = opts.filePath.startsWith("/")
761
+ ? opts.filePath
762
+ : join(opts.cwd, opts.filePath);
763
+ try {
764
+ return realpathSync(p);
765
+ } catch {
766
+ return null; // new file — nothing imports it yet
767
+ }
768
+ })();
769
+ if (!abs) return null;
770
+ const rel = relative(worktree, abs).split(sep).join("/");
771
+ if (rel.startsWith("..") || rel === "") return null;
772
+
773
+ const importers = buildGraphCached(worktree).dependents.get(rel);
774
+ if (!importers || importers.size === 0) return null;
775
+
776
+ if (typeof opts.session === "string" && opts.session !== "") {
777
+ if (editTrackPaths(opts.session).has(abs)) return null;
778
+ appendEditTrackRow(opts.session, {
779
+ ts: new Date().toISOString(),
780
+ path: abs,
781
+ });
782
+ }
783
+
784
+ const n = importers.size;
785
+ return (
786
+ `fapony: ${rel} has ${n} importer${n === 1 ? "" : "s"} — ` +
787
+ `review-seed --files ${rel} lists them (add --callers <export> for one ` +
788
+ `export's callers); check before changing its shape`
789
+ );
790
+ } catch {
791
+ return null;
792
+ }
793
+ }
794
+
636
795
  // --- Commit hint (tool.execute.after — annotate only, never block) ---
637
796
  //
638
797
  // OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
@@ -835,6 +994,80 @@ export async function cmdHookReadHint(): Promise<void> {
835
994
  }
836
995
  }
837
996
 
997
+ /** Claude Code PreToolUse (matcher Edit): stdin JSON in, additionalContext out.
998
+ * No permissionDecision ever — the edit always proceeds. Fires once per
999
+ * (session, file); the dedupe lives inside editHintFor. */
1000
+ export async function cmdHookEditHint(): Promise<void> {
1001
+ try {
1002
+ const raw = JSON.parse(await Bun.stdin.text()) as {
1003
+ cwd?: string;
1004
+ transcript_path?: string;
1005
+ session_id?: string;
1006
+ tool_input?: {
1007
+ file_path?: unknown;
1008
+ };
1009
+ };
1010
+ const cwd = raw.cwd ?? process.cwd();
1011
+ const filePath = raw.tool_input?.file_path;
1012
+ // One edit log per session — same identity as the read hint.
1013
+ const session = raw.transcript_path ?? raw.session_id;
1014
+ const hint = editHintFor({ filePath, cwd, session });
1015
+ if (hint) {
1016
+ console.log(
1017
+ JSON.stringify({
1018
+ hookSpecificOutput: {
1019
+ hookEventName: "PreToolUse",
1020
+ additionalContext: hint,
1021
+ },
1022
+ }),
1023
+ );
1024
+ }
1025
+
1026
+ // --- hint-fire log (PLAN-edit-importer-hint chunk 3) ---
1027
+ // After output — best-effort, never block the hint.
1028
+ if (hint) {
1029
+ try {
1030
+ const g = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
1031
+ cwd,
1032
+ stdout: "pipe",
1033
+ stderr: "pipe",
1034
+ });
1035
+ if (g.exitCode === 0) {
1036
+ const worktree = realpathSync(g.stdout.toString().trim());
1037
+ const abs =
1038
+ typeof filePath === "string"
1039
+ ? (() => {
1040
+ try {
1041
+ return realpathSync(
1042
+ filePath.startsWith("/")
1043
+ ? filePath
1044
+ : join(worktree, filePath),
1045
+ );
1046
+ } catch {
1047
+ return null;
1048
+ }
1049
+ })()
1050
+ : null;
1051
+ const rel = abs
1052
+ ? relative(worktree, abs).split("\\").join("/")
1053
+ : null;
1054
+ recordHintFire({
1055
+ ts: new Date().toISOString(),
1056
+ worktree,
1057
+ surface: "edit",
1058
+ file: rel && !rel.startsWith("..") ? rel : null,
1059
+ count: 1,
1060
+ });
1061
+ }
1062
+ } catch {
1063
+ // best-effort — swallow
1064
+ }
1065
+ }
1066
+ } catch {
1067
+ // any failure = no hint; a hook must never block an edit over a hint
1068
+ }
1069
+ }
1070
+
838
1071
  // --- Debt + mem context (PLAN-convention-debt chunk 4) ---
839
1072
  //
840
1073
  // The one moment paying down debt is worth tokens is when the file is already
package/src/init-mem.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  rmSync,
14
14
  } from "node:fs";
15
15
  import { join } from "node:path";
16
- import { loadConfig } from "./db/index.js";
16
+ import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./db/index.js";
17
17
 
18
18
  export function copyDir(src: string, dest: string): string[] {
19
19
  mkdirSync(dest, { recursive: true });
@@ -62,8 +62,8 @@ export function cmdInitMem(args: string[]): void {
62
62
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
63
63
  if (
64
64
  entry.isDirectory() &&
65
- !entry.name.startsWith(".") &&
66
- entry.name !== "node_modules"
65
+ entry.name !== "node_modules" &&
66
+ (!entry.name.startsWith(".") || entry.name === FAPONY_DIR)
67
67
  ) {
68
68
  walk(join(dir, entry.name), depth + 1);
69
69
  }
@@ -95,7 +95,7 @@ export function cmdInitMem(args: string[]): void {
95
95
  `keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
96
96
  );
97
97
  console.log(
98
- ` move them under ${join(d, "..", ".fapony", ".memory")}/, or re-run with --force to delete`,
98
+ ` move them under ${DEFAULT_MEM_DIR}/, or re-run with --force to delete`,
99
99
  );
100
100
  kept++;
101
101
  continue;
@@ -109,7 +109,7 @@ export function cmdInitMem(args: string[]): void {
109
109
  console.log(
110
110
  `\nremoved ${removed} legacy .memory/ director${removed === 1 ? "y" : "ies"}${
111
111
  kept > 0
112
- ? ` · kept ${kept} with logs — move them under .fapony/.memory/, then re-run`
112
+ ? ` · kept ${kept} with logs — move them under ${DEFAULT_MEM_DIR}/, then re-run`
113
113
  : ""
114
114
  }`,
115
115
  );
package/src/init.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type Config,
18
18
  doneDir,
19
19
  evidenceFile,
20
+ FAPONY_DIR,
20
21
  memoryDir,
21
22
  planDir,
22
23
  specDir,
@@ -108,7 +109,7 @@ export function initProject(targetPath: string, config?: Config): void {
108
109
  mkdirSync(targetPath, { recursive: true });
109
110
 
110
111
  // --- .fapony/ marker ---
111
- const faponyDir = join(targetPath, ".fapony");
112
+ const faponyDir = join(targetPath, FAPONY_DIR);
112
113
  if (existsSync(faponyDir)) {
113
114
  throw new Error(
114
115
  `${faponyDir} already exists — delete it first if you want a fresh scaffold.`,
@@ -142,7 +143,7 @@ export function initProject(targetPath: string, config?: Config): void {
142
143
  writeFileSync(evidencePath, EVIDENCE_JSON);
143
144
 
144
145
  // --- plan/ spec/ .memory/ — all under .fapony/ ---
145
- const planDirAbs = join(targetPath, planDir(config));
146
+ const planDirAbs = join(targetPath, planDir());
146
147
  if (existsSync(planDirAbs)) {
147
148
  throw new Error(`${planDirAbs} already exists — not overwriting.`);
148
149
  }
@@ -156,7 +157,7 @@ export function initProject(targetPath: string, config?: Config): void {
156
157
  mkdirSync(doneDirAbs, { recursive: true });
157
158
 
158
159
  // --- spec/ ---
159
- const specDirAbs = join(targetPath, specDir(config));
160
+ const specDirAbs = join(targetPath, specDir());
160
161
  if (existsSync(specDirAbs)) {
161
162
  throw new Error(`${specDirAbs} already exists — not overwriting.`);
162
163
  }
@@ -177,9 +178,9 @@ export function initProject(targetPath: string, config?: Config): void {
177
178
  console.log(
178
179
  ` .fapony/ — project dir (plans, specs, memory, evidence)`,
179
180
  );
180
- console.log(` ${planDir(config)}/ — live plan files`);
181
+ console.log(` ${planDir()}/ — live plan files`);
181
182
  console.log(` ${doneDir(config)}/ — shipped plans (archive)`);
182
- console.log(` ${specDir(config)}/ — spec files`);
183
+ console.log(` ${specDir()}/ — spec files`);
183
184
  console.log(
184
185
  ` ${evidenceFile(config)} — allowlist for 'fapony report' (edit the cmds!)`,
185
186
  );
@@ -105,6 +105,10 @@ export function cmdInstallClaude(
105
105
  console.error(` (Claude Code user scope)`);
106
106
  const dir = claudeSkillsDir(deps.homedir ?? homedir);
107
107
  reportSkills(linkSkills(dir, dryRun), dir, dryRun);
108
+ // MCP is already wired, but a hook can be new since the last install
109
+ // (e.g. the Edit hint) — always ensure the hook wiring, not only on a
110
+ // fresh MCP add. This is the upgrade path for existing installs.
111
+ installClaudeHooks(dryRun, deps);
108
112
  return;
109
113
  }
110
114
  console.error(
@@ -120,6 +124,9 @@ export function cmdInstallClaude(
120
124
  if (dryRun) {
121
125
  console.error(`── dry-run: would run ──`);
122
126
  console.error(` ${addArgs.join(" ")}`);
127
+ // Dry-run shows the hook wiring too — the installers are dry-run-safe
128
+ // ("would write", no writes), so the preview stays truthful.
129
+ installClaudeHooks(dryRun, deps);
123
130
  return;
124
131
  }
125
132
 
@@ -148,9 +155,9 @@ export function cmdInstallClaude(
148
155
  installStatusline(dryRun, deps);
149
156
 
150
157
  // 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);
158
+ // and the Read/Edit hints that annotate reads of large files and edits to
159
+ // files with importers (both annotate-only).
160
+ installClaudeHooks(dryRun, deps);
154
161
  }
155
162
 
156
163
  /**
@@ -336,6 +343,18 @@ function ensureClaudeHook(
336
343
  );
337
344
  }
338
345
 
346
+ /**
347
+ * Wire every hook fapony owns: the Stop hook that refuses to end a turn with
348
+ * ungraded commits, and the Read/Edit PreToolUse hints (annotate-only).
349
+ * Idempotent and dry-run-safe. Called on every install, not just a fresh MCP
350
+ * add — an existing install must still pick up a hook added later.
351
+ */
352
+ function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
353
+ installStopHook(dryRun, deps);
354
+ installReadHintHook(dryRun, deps);
355
+ installEditHintHook(dryRun, deps);
356
+ }
357
+
339
358
  function installStopHook(dryRun: boolean, deps: InstallDeps): void {
340
359
  ensureClaudeHook(dryRun, deps, {
341
360
  event: "Stop",
@@ -361,3 +380,18 @@ function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
361
380
  label: "read hint",
362
381
  });
363
382
  }
383
+
384
+ /**
385
+ * PreToolUse hook on Edit: annotates an edit with the file's importer count
386
+ * plus the review-seed command that lists them, once per (session, file).
387
+ * Annotate only — no permissionDecision is ever returned, the edit always
388
+ * proceeds. The matcher "Edit" keeps the spawn off every other tool call.
389
+ */
390
+ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
391
+ ensureClaudeHook(dryRun, deps, {
392
+ event: "PreToolUse",
393
+ matcher: "Edit",
394
+ subcommand: "hook-edit-hint",
395
+ label: "edit hint",
396
+ });
397
+ }
@@ -1,17 +1,30 @@
1
1
  // src/install/codex.ts — Codex install provider
2
2
  //
3
- // Reads/writes ~/.codex/config.toml directly. Codex has no CLI for MCP config.
3
+ // Reads/writes ~/.codex/config.toml directly for MCP config.
4
+ // Reads/writes ~/.codex/hooks.json for lifecycle hooks (Stop).
5
+ // Symlinks skills into ~/.agents/skills/ (same dir as ZCode).
4
6
 
5
7
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
8
  import { homedir } from "node:os";
7
9
  import { join } from "node:path";
8
- import { CODEX_MCP_ENTRY, defaultExit, type InstallDeps } from "./types.js";
10
+ import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
11
+ import {
12
+ CODEX_MCP_ENTRY,
13
+ defaultExit,
14
+ INSTALL_ROOT,
15
+ type InstallDeps,
16
+ } from "./types.js";
9
17
 
10
18
  export function findCodexConfig(getHome: () => string): string | null {
11
19
  const p = join(getHome(), ".codex", "config.toml");
12
20
  return existsSync(p) ? p : null;
13
21
  }
14
22
 
23
+ export function findCodexHooksJson(getHome: () => string): string | null {
24
+ const p = join(getHome(), ".codex", "hooks.json");
25
+ return existsSync(p) ? p : null;
26
+ }
27
+
15
28
  function isCodexConfigured(content: string): boolean {
16
29
  // Check if [mcp_servers.fapony] section exists with our command
17
30
  const sectionRegex = /\[mcp_servers\.fapony\]/;
@@ -20,9 +33,86 @@ function isCodexConfigured(content: string): boolean {
20
33
  return content.includes("fapony.ts") && content.includes("mcp");
21
34
  }
22
35
 
36
+ function readJsonObject(path: string): Record<string, unknown> | null {
37
+ try {
38
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
39
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
40
+ return null;
41
+ }
42
+ return parsed as Record<string, unknown>;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Append the fapony Stop hook to ~/.codex/hooks.json.
50
+ * Never replaces other hook groups or foreign entries within the Stop group.
51
+ * Fapony-owned entry is identified by the command string containing "hook-stop".
52
+ */
53
+ function installStopHook(dryRun: boolean, getHome: () => string): void {
54
+ const hooksPath = join(getHome(), ".codex", "hooks.json");
55
+ let config: Record<string, unknown> = {};
56
+ if (existsSync(hooksPath)) {
57
+ const parsed = readJsonObject(hooksPath);
58
+ if (!parsed) {
59
+ console.error(
60
+ ` stop hook: ${hooksPath} is unreadable or malformed — skipping`,
61
+ );
62
+ return;
63
+ }
64
+ config = parsed;
65
+ }
66
+
67
+ const hookMap = config.hooks;
68
+ if (
69
+ hookMap !== undefined &&
70
+ (typeof hookMap !== "object" || Array.isArray(hookMap))
71
+ ) {
72
+ console.error(
73
+ ` stop hook: ${hooksPath} has an unexpected "hooks" shape — skipping`,
74
+ );
75
+ return;
76
+ }
77
+
78
+ const map = (hookMap ?? {}) as Record<string, unknown>;
79
+ // Codex Stop array: each element is { matcher?, hooks: [...] }
80
+ const stop = Array.isArray(map.Stop) ? (map.Stop as unknown[]) : [];
81
+
82
+ // Check if fapony stop hook already present (by command string)
83
+ const serialized = JSON.stringify(stop);
84
+ if (serialized.includes("hook-stop")) {
85
+ console.error(` stop hook: already configured in hooks.json — no change`);
86
+ return;
87
+ }
88
+
89
+ const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} hook-stop`;
90
+ const faponyEntry = { hooks: [{ type: "command", command }] };
91
+ const after = {
92
+ ...config,
93
+ hooks: { ...map, Stop: [...stop, faponyEntry] },
94
+ };
95
+
96
+ if (!dryRun) {
97
+ try {
98
+ writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
99
+ } catch (e) {
100
+ console.error(` stop hook: failed to write — ${(e as Error).message}`);
101
+ return;
102
+ }
103
+ }
104
+ console.error(
105
+ ` stop hook: ${dryRun ? "would write" : "wrote"} hooks.Stop → ${hooksPath}`,
106
+ );
107
+ console.error(
108
+ ` review and trust via Codex /hooks before the hook will run`,
109
+ );
110
+ }
111
+
23
112
  export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
24
113
  const exitFn = deps.exit ?? defaultExit;
25
- const configPath = findCodexConfig(deps.homedir ?? homedir);
114
+ const getHome = deps.homedir ?? homedir;
115
+ const configPath = findCodexConfig(getHome);
26
116
 
27
117
  if (!configPath) {
28
118
  console.error(
@@ -44,18 +134,20 @@ export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
44
134
  if (isCodexConfigured(content)) {
45
135
  console.error(`✓ mcp_servers.fapony already configured — no change needed`);
46
136
  console.error(` (${configPath})`);
47
- return;
48
- }
49
-
50
- if (dryRun) {
137
+ } else if (dryRun) {
51
138
  console.error(`── dry-run: would append to ${configPath} ──`);
52
139
  console.log(CODEX_MCP_ENTRY);
53
- return;
140
+ } else {
141
+ const newContent = `${content.trimEnd()}\n\n${CODEX_MCP_ENTRY}`;
142
+ writeFileSync(configPath, newContent);
143
+ console.error(`✓ added mcp_servers.fapony to ${configPath}`);
144
+ console.error(` restart Codex to load the MCP server`);
54
145
  }
55
146
 
56
- // Append the fapony MCP server entry to the end of the config file
57
- const newContent = `${content.trimEnd()}\n\n${CODEX_MCP_ENTRY}`;
58
- writeFileSync(configPath, newContent);
59
- console.error(`✓ added mcp_servers.fapony to ${configPath}`);
60
- console.error(` restart Codex to load the MCP server`);
147
+ // --- Stop hook (~/.codex/hooks.json) ---
148
+ installStopHook(dryRun, getHome);
149
+
150
+ // --- Skills (~/.agents/skills/) ---
151
+ const skillsDir = agentsSkillsDir(getHome);
152
+ reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
61
153
  }