fapony 0.7.0 → 0.7.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/README.md +144 -156
- package/package.json +5 -6
- package/skill/define-convention/SKILL.md +3 -3
- package/skill/lookup-before-edit/SKILL.md +2 -1
- package/skill/move-to-done/SKILL.md +18 -22
- package/skill/plan-with-pony/SKILL.md +4 -4
- package/skill/review-pony/SKILL.md +10 -12
- package/src/adapters/cli.ts +11 -52
- package/src/adapters/hooks/index.ts +0 -67
- package/src/adapters/hooks/mv-guard.ts +2 -2
- package/src/analyze/discover.ts +1 -1
- package/src/analyze/index.ts +0 -1
- package/src/commands.ts +8 -39
- package/src/core/config.ts +0 -8
- package/src/core/fapony-dir.ts +71 -0
- package/src/core/hook-helpers.ts +2 -11
- package/src/debt/load.ts +3 -5
- package/src/debt/promotion.ts +2 -2
- package/src/debt/scan.ts +1 -1
- package/src/digest/collect.ts +6 -7
- package/src/fael.ts +139 -0
- package/src/hook.ts +1 -81
- package/src/init.ts +30 -90
- package/src/install/antigravity.ts +8 -99
- package/src/install/claude.ts +66 -200
- package/src/install/codex.ts +6 -170
- package/src/install/detect.ts +4 -14
- package/src/install/opencode.ts +50 -486
- package/src/install/types.ts +1 -39
- package/src/install/zcode.ts +5 -91
- package/src/install.ts +9 -35
- package/src/memory.ts +10 -69
- package/src/plan/index.ts +34 -0
- package/src/plan/next.ts +181 -0
- package/src/plan/store.ts +41 -0
- package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
- package/src/seed/plan-seed.ts +10 -9
- package/src/setup.ts +4 -22
- package/src/update.ts +1 -1
- package/templates/PLAN.md +1 -1
- package/src/adapters/hooks/bug-markers.ts +0 -60
- package/src/adapters/hooks/context-data.ts +0 -211
- package/src/adapters/hooks/edit-hint.ts +0 -215
- package/src/adapters/hooks/read-hint.ts +0 -400
- package/src/adapters/hooks/session-start.ts +0 -112
- package/src/adapters/hooks/stop.ts +0 -768
- package/src/adapters/mcp/tools/index.ts +0 -149
- package/src/adapters/mcp/tools/mem.ts +0 -258
- package/src/adapters/mcp/transport.ts +0 -147
- package/src/analyze/cache.ts +0 -162
- package/src/core/mem-log.ts +0 -394
- package/src/init-mem.ts +0 -143
- package/src/install/cursor.ts +0 -167
- package/src/install/utils.ts +0 -29
- package/src/mem/commands/read.ts +0 -792
- package/src/mem/commands/rotate.ts +0 -59
- package/src/mem/commands/where.ts +0 -56
- package/src/mem/commands/write.ts +0 -284
- package/src/mem/engine.ts +0 -329
- package/src/mem/index.ts +0 -201
- package/src/mem/key-registry.ts +0 -116
- package/src/mem/render.ts +0 -66
- package/src/mem/selectors.ts +0 -227
- package/src/mem/store.ts +0 -356
package/src/install/claude.ts
CHANGED
|
@@ -1,160 +1,89 @@
|
|
|
1
1
|
// src/install/claude.ts — Claude Code install provider
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Skills symlink + the PreToolUse hooks fapony still owns. Memory (MCP, Stop,
|
|
4
|
+
// SessionStart, per-file read context) moved to fael — `fael install` wires it.
|
|
4
5
|
|
|
5
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
8
|
-
import { assertSafe } from "../safety.js";
|
|
9
9
|
import { claudeSkillsDir, linkSkills, reportSkills } from "./skills.js";
|
|
10
|
-
import {
|
|
11
|
-
type ClaudeRunResult,
|
|
12
|
-
defaultExit,
|
|
13
|
-
INSTALL_ROOT,
|
|
14
|
-
type InstallDeps,
|
|
15
|
-
} from "./types.js";
|
|
10
|
+
import { INSTALL_ROOT, type InstallDeps } from "./types.js";
|
|
16
11
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
exitCode: proc.exitCode,
|
|
26
|
-
stdout: proc.stdout.toString(),
|
|
27
|
-
stderr: proc.stderr.toString(),
|
|
28
|
-
};
|
|
29
|
-
} catch (e) {
|
|
30
|
-
// `claude` binary missing (ENOENT) or spawn failed outright.
|
|
31
|
-
return { exitCode: 127, stdout: "", stderr: (e as Error).message };
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** `claude mcp get fapony` — read-only probe. exit 0 = an entry named fapony exists. */
|
|
36
|
-
export function claudeGetArgs(): string[] {
|
|
37
|
-
return ["claude", "mcp", "get", "fapony"];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Absolute-path add command — works even before `bun link` puts `fapony` on PATH. */
|
|
41
|
-
export function claudeAddArgs(): string[] {
|
|
42
|
-
return [
|
|
43
|
-
"claude",
|
|
44
|
-
"mcp",
|
|
45
|
-
"add",
|
|
46
|
-
"fapony",
|
|
47
|
-
"-s",
|
|
48
|
-
"user",
|
|
49
|
-
"--",
|
|
50
|
-
"bun",
|
|
51
|
-
join(INSTALL_ROOT, "fapony.ts"),
|
|
52
|
-
"mcp",
|
|
53
|
-
];
|
|
12
|
+
export function cmdInstallClaude(
|
|
13
|
+
dryRun: boolean,
|
|
14
|
+
deps: InstallDeps = {},
|
|
15
|
+
): void {
|
|
16
|
+
const dir = claudeSkillsDir(deps.homedir ?? homedir);
|
|
17
|
+
reportSkills(linkSkills(dir, dryRun), dir, dryRun);
|
|
18
|
+
removeRetiredClaudeHooks(dryRun, deps);
|
|
19
|
+
installClaudeHooks(dryRun, deps);
|
|
54
20
|
}
|
|
55
21
|
|
|
56
|
-
|
|
22
|
+
/** Hook subcommands fapony used to register — memory hooks moved to fael,
|
|
23
|
+
* the edit hint was cut 2026-09-26 (never moved an agent to migrate). The
|
|
24
|
+
* commands are gone, so a leftover entry would error on every tool call. */
|
|
25
|
+
const RETIRED_SUBCOMMANDS = [
|
|
26
|
+
"hook-read-hint",
|
|
27
|
+
"hook-stop",
|
|
28
|
+
"hook-session-start",
|
|
29
|
+
"hook-edit-hint",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function isRetired(command: unknown): boolean {
|
|
57
33
|
return (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
34
|
+
typeof command === "string" &&
|
|
35
|
+
command.includes("fapony") &&
|
|
36
|
+
RETIRED_SUBCOMMANDS.some((sub) => command.endsWith(` ${sub}`))
|
|
61
37
|
);
|
|
62
38
|
}
|
|
63
39
|
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.split("\n")
|
|
74
|
-
.map((l) => l.trim())
|
|
75
|
-
.filter((l) => l.startsWith("Command:") || l.startsWith("Args:"));
|
|
76
|
-
return cmdLines.join("\n").includes("fapony");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function cmdInstallClaude(
|
|
80
|
-
dryRun: boolean,
|
|
81
|
-
deps: InstallDeps = {},
|
|
82
|
-
): void {
|
|
83
|
-
const run = deps.run ?? defaultRun;
|
|
84
|
-
const exitFn = deps.exit ?? defaultExit;
|
|
85
|
-
|
|
86
|
-
const getArgs = claudeGetArgs();
|
|
87
|
-
assertSafe(getArgs);
|
|
88
|
-
const get = run(getArgs);
|
|
89
|
-
if (isClaudeMissing(get)) {
|
|
90
|
-
console.error(`claude CLI not found — install Claude Code first`);
|
|
91
|
-
exitFn(1);
|
|
40
|
+
/** Drop fapony's own retired hook entries from settings.json; foreign entries
|
|
41
|
+
* are never touched. An unreadable settings.json is left alone. */
|
|
42
|
+
function removeRetiredClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
|
|
43
|
+
const home = deps.homedir ? deps.homedir() : homedir();
|
|
44
|
+
const settingsPath = join(home, ".claude", "settings.json");
|
|
45
|
+
let settings: { hooks?: Record<string, unknown> };
|
|
46
|
+
try {
|
|
47
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
48
|
+
} catch {
|
|
92
49
|
return;
|
|
93
50
|
}
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
51
|
+
const hooks = settings.hooks;
|
|
52
|
+
if (!hooks || typeof hooks !== "object") return;
|
|
53
|
+
let removed = 0;
|
|
54
|
+
for (const [event, list] of Object.entries(hooks)) {
|
|
55
|
+
if (!Array.isArray(list)) continue;
|
|
56
|
+
const kept = list.flatMap((group: { hooks?: { command?: unknown }[] }) => {
|
|
57
|
+
if (!Array.isArray(group?.hooks)) return [group];
|
|
58
|
+
const inner = group.hooks.filter((h) => !isRetired(h?.command));
|
|
59
|
+
removed += group.hooks.length - inner.length;
|
|
60
|
+
return inner.length === 0 ? [] : [{ ...group, hooks: inner }];
|
|
61
|
+
});
|
|
62
|
+
if (kept.length === 0) delete hooks[event];
|
|
63
|
+
else hooks[event] = kept;
|
|
64
|
+
}
|
|
65
|
+
if (removed === 0) return;
|
|
66
|
+
if (!dryRun) {
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(
|
|
69
|
+
settingsPath,
|
|
70
|
+
`${JSON.stringify(settings, null, 2)}\n`,
|
|
71
|
+
"utf-8",
|
|
72
|
+
);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error(
|
|
75
|
+
` retired hooks: failed to write — ${(e as Error).message}`,
|
|
76
|
+
);
|
|
105
77
|
return;
|
|
106
78
|
}
|
|
107
|
-
console.error(
|
|
108
|
-
`an MCP server named "fapony" exists but points elsewhere — not overwriting.`,
|
|
109
|
-
);
|
|
110
|
-
console.error(` inspect with: claude mcp get fapony`);
|
|
111
|
-
console.error(` then remove it first: claude mcp remove fapony -s user`);
|
|
112
|
-
exitFn(1);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const addArgs = claudeAddArgs();
|
|
117
|
-
if (dryRun) {
|
|
118
|
-
console.error(`── dry-run: would run ──`);
|
|
119
|
-
console.error(` ${addArgs.join(" ")}`);
|
|
120
|
-
// Dry-run shows the hook wiring too — the installers are dry-run-safe
|
|
121
|
-
// ("would write", no writes), so the preview stays truthful.
|
|
122
|
-
installClaudeHooks(dryRun, deps);
|
|
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
79
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 the Stop hook that refuses to end a turn with ungraded commits,
|
|
148
|
-
// and the Read/Edit hints that annotate reads of large files and edits to
|
|
149
|
-
// files with importers (both annotate-only).
|
|
150
|
-
installClaudeHooks(dryRun, deps);
|
|
80
|
+
console.error(
|
|
81
|
+
` retired hooks: ${dryRun ? "would remove" : "removed"} ${removed} fapony entr${removed === 1 ? "y" : "ies"}`,
|
|
82
|
+
);
|
|
151
83
|
}
|
|
152
84
|
|
|
153
85
|
/**
|
|
154
|
-
*
|
|
155
|
-
* here now (Stop since PLAN-mem-mcp, PreToolUse read hint since the
|
|
156
|
-
* large-file annotate feature) — the read/write/idempotence/append shape
|
|
157
|
-
* is one implementation with two callers, not a scaffold.
|
|
86
|
+
* Append-to-settings.json hook installer (caller: plan-mv guard).
|
|
158
87
|
* Never touch a hook someone else registered, never fail the install over it.
|
|
159
88
|
*/
|
|
160
89
|
function ensureClaudeHook(
|
|
@@ -222,64 +151,14 @@ function ensureClaudeHook(
|
|
|
222
151
|
);
|
|
223
152
|
}
|
|
224
153
|
|
|
225
|
-
/**
|
|
226
|
-
* Wire every hook fapony owns: the Stop hook that refuses to end a turn with
|
|
227
|
-
* ungraded commits, and the Read/Edit PreToolUse hints (annotate-only).
|
|
228
|
-
* Idempotent and dry-run-safe. Called on every install, not just a fresh MCP
|
|
229
|
-
* add — an existing install must still pick up a hook added later.
|
|
230
|
-
*/
|
|
154
|
+
/** Wire every hook fapony owns — the plan-mv guard. Idempotent and dry-run-safe. */
|
|
231
155
|
function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
|
|
232
|
-
installStopHook(dryRun, deps);
|
|
233
|
-
installReadHintHook(dryRun, deps);
|
|
234
|
-
installEditHintHook(dryRun, deps);
|
|
235
156
|
installMvGuardHook(dryRun, deps);
|
|
236
|
-
installSessionStartHook(dryRun, deps);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function installStopHook(dryRun: boolean, deps: InstallDeps): void {
|
|
240
|
-
ensureClaudeHook(dryRun, deps, {
|
|
241
|
-
event: "Stop",
|
|
242
|
-
subcommand: "hook-stop",
|
|
243
|
-
label: "stop hook",
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* PreToolUse hook on Read: annotates a full-file read with one factual line —
|
|
249
|
-
* the size + the review-seed command when the file is large, and the re-read
|
|
250
|
-
* line when the same path was already read this session and its mtime has not
|
|
251
|
-
* moved (an unchanged file, so the second read buys nothing; a changed one
|
|
252
|
-
* stays silent). Annotate only — no permissionDecision is ever returned, the
|
|
253
|
-
* read always proceeds. The matcher "Read" keeps the spawn off every other
|
|
254
|
-
* tool call.
|
|
255
|
-
*/
|
|
256
|
-
function installReadHintHook(dryRun: boolean, deps: InstallDeps): void {
|
|
257
|
-
ensureClaudeHook(dryRun, deps, {
|
|
258
|
-
event: "PreToolUse",
|
|
259
|
-
matcher: "Read",
|
|
260
|
-
subcommand: "hook-read-hint",
|
|
261
|
-
label: "read hint",
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* PreToolUse hook on Edit: annotates an edit with the file's importer count
|
|
267
|
-
* plus the review-seed command that lists them, once per (session, file).
|
|
268
|
-
* Annotate only — no permissionDecision is ever returned, the edit always
|
|
269
|
-
* proceeds. The matcher "Edit" keeps the spawn off every other tool call.
|
|
270
|
-
*/
|
|
271
|
-
function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
|
|
272
|
-
ensureClaudeHook(dryRun, deps, {
|
|
273
|
-
event: "PreToolUse",
|
|
274
|
-
matcher: "Edit",
|
|
275
|
-
subcommand: "hook-edit-hint",
|
|
276
|
-
label: "edit hint",
|
|
277
|
-
});
|
|
278
157
|
}
|
|
279
158
|
|
|
280
159
|
/**
|
|
281
160
|
* PreToolUse hook on Bash: denies a raw `git mv` of a plan file into a done/
|
|
282
|
-
* directory, pointing at `fapony
|
|
161
|
+
* directory, pointing at `fapony plan sweep --apply` instead — that
|
|
283
162
|
* command does the link rewrite a plain `git mv` skips. The one fapony hook
|
|
284
163
|
* that blocks besides Stop; matcher "Bash" keeps the spawn off every other
|
|
285
164
|
* tool call.
|
|
@@ -292,16 +171,3 @@ function installMvGuardHook(dryRun: boolean, deps: InstallDeps): void {
|
|
|
292
171
|
label: "plan-mv guard",
|
|
293
172
|
});
|
|
294
173
|
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* SessionStart hook: injects `fapony mem kickoff` as context when the repo has
|
|
298
|
-
* a mem log, and stays silent when it does not. Context only — SessionStart
|
|
299
|
-
* cannot block, and a repo without mem never sees a line.
|
|
300
|
-
*/
|
|
301
|
-
function installSessionStartHook(dryRun: boolean, deps: InstallDeps): void {
|
|
302
|
-
ensureClaudeHook(dryRun, deps, {
|
|
303
|
-
event: "SessionStart",
|
|
304
|
-
subcommand: "hook-session-start",
|
|
305
|
-
label: "session start",
|
|
306
|
-
});
|
|
307
|
-
}
|
package/src/install/codex.ts
CHANGED
|
@@ -1,192 +1,28 @@
|
|
|
1
1
|
// src/install/codex.ts — Codex install provider
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Symlinks skills into ~/.agents/skills/ (same dir as ZCode).
|
|
3
|
+
// Symlinks skills into ~/.agents/skills/ (same dir as ZCode). Memory (MCP,
|
|
4
|
+
// Stop, SessionStart) moved to fael — `fael install` wires it.
|
|
6
5
|
|
|
7
|
-
import { existsSync
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
8
7
|
import { homedir } from "node:os";
|
|
9
8
|
import { join } from "node:path";
|
|
10
9
|
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";
|
|
10
|
+
import { defaultExit, type InstallDeps } from "./types.js";
|
|
17
11
|
|
|
18
12
|
export function findCodexConfig(getHome: () => string): string | null {
|
|
19
13
|
const p = join(getHome(), ".codex", "config.toml");
|
|
20
14
|
return existsSync(p) ? p : null;
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
export function findCodexHooksJson(getHome: () => string): string | null {
|
|
24
|
-
const p = join(getHome(), ".codex", "hooks.json");
|
|
25
|
-
return existsSync(p) ? p : null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isCodexConfigured(content: string): boolean {
|
|
29
|
-
// Check if [mcp_servers.fapony] section exists with our command
|
|
30
|
-
const sectionRegex = /\[mcp_servers\.fapony\]/;
|
|
31
|
-
if (!sectionRegex.test(content)) return false;
|
|
32
|
-
// Verify it points to fapony
|
|
33
|
-
return content.includes("fapony.ts") && content.includes("mcp");
|
|
34
|
-
}
|
|
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 a fapony command hook to one event group in ~/.codex/hooks.json.
|
|
50
|
-
* Never replaces other hook groups or foreign entries within the group.
|
|
51
|
-
* A fapony-owned entry is identified by the subcommand string
|
|
52
|
-
* ("hook-stop" / "hook-session-start") inside its command — per-group
|
|
53
|
-
* idempotence, so an existing install with only Stop still picks up
|
|
54
|
-
* SessionStart on the next run.
|
|
55
|
-
*/
|
|
56
|
-
function installCodexHookGroup(
|
|
57
|
-
dryRun: boolean,
|
|
58
|
-
getHome: () => string,
|
|
59
|
-
opts: { event: "Stop" | "SessionStart"; subcommand: string; label: string },
|
|
60
|
-
): void {
|
|
61
|
-
const hooksPath = join(getHome(), ".codex", "hooks.json");
|
|
62
|
-
let config: Record<string, unknown> = {};
|
|
63
|
-
if (existsSync(hooksPath)) {
|
|
64
|
-
const parsed = readJsonObject(hooksPath);
|
|
65
|
-
if (!parsed) {
|
|
66
|
-
console.error(
|
|
67
|
-
` ${opts.label}: ${hooksPath} is unreadable or malformed — skipping`,
|
|
68
|
-
);
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
config = parsed;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const hookMap = config.hooks;
|
|
75
|
-
if (
|
|
76
|
-
hookMap !== undefined &&
|
|
77
|
-
(typeof hookMap !== "object" || Array.isArray(hookMap))
|
|
78
|
-
) {
|
|
79
|
-
console.error(
|
|
80
|
-
` ${opts.label}: ${hooksPath} has an unexpected "hooks" shape — skipping`,
|
|
81
|
-
);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const map = (hookMap ?? {}) as Record<string, unknown>;
|
|
86
|
-
// Codex event arrays: each element is { matcher?, hooks: [...] }
|
|
87
|
-
const group = Array.isArray(map[opts.event])
|
|
88
|
-
? (map[opts.event] as unknown[])
|
|
89
|
-
: [];
|
|
90
|
-
|
|
91
|
-
// Check if the fapony hook is already present in this group (by subcommand)
|
|
92
|
-
const serialized = JSON.stringify(group);
|
|
93
|
-
if (serialized.includes(opts.subcommand)) {
|
|
94
|
-
console.error(
|
|
95
|
-
` ${opts.label}: already configured in hooks.json — no change`,
|
|
96
|
-
);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} ${opts.subcommand}`;
|
|
101
|
-
const faponyEntry = { hooks: [{ type: "command", command }] };
|
|
102
|
-
const after = {
|
|
103
|
-
...config,
|
|
104
|
-
hooks: { ...map, [opts.event]: [...group, faponyEntry] },
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
if (!dryRun) {
|
|
108
|
-
try {
|
|
109
|
-
writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
|
|
110
|
-
} catch (e) {
|
|
111
|
-
console.error(
|
|
112
|
-
` ${opts.label}: failed to write — ${(e as Error).message}`,
|
|
113
|
-
);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
console.error(
|
|
118
|
-
` ${opts.label}: ${dryRun ? "would write" : "wrote"} hooks.${opts.event} → ${hooksPath}`,
|
|
119
|
-
);
|
|
120
|
-
console.error(
|
|
121
|
-
` review and trust via Codex /hooks before the hook will run`,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function installStopHook(dryRun: boolean, getHome: () => string): void {
|
|
126
|
-
installCodexHookGroup(dryRun, getHome, {
|
|
127
|
-
event: "Stop",
|
|
128
|
-
subcommand: "hook-stop",
|
|
129
|
-
label: "stop hook",
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* SessionStart hook: injects `fapony mem kickoff` as context when the repo
|
|
135
|
-
* has a mem log, silent when it does not (cmdHookSessionStart exits quiet).
|
|
136
|
-
* Same `hookSpecificOutput.additionalContext` contract Claude uses — Codex
|
|
137
|
-
* SessionStart reads that channel too. No matcher: match-all fires on every
|
|
138
|
-
* source including `clear` and on versions that send no source at all, where
|
|
139
|
-
* a `startup|resume` matcher would silently never fire; kickoff is capped at
|
|
140
|
-
* 4k and costs one cheap spawn, so the /clear path stays fast.
|
|
141
|
-
*/
|
|
142
|
-
function installSessionStartHook(dryRun: boolean, getHome: () => string): void {
|
|
143
|
-
installCodexHookGroup(dryRun, getHome, {
|
|
144
|
-
event: "SessionStart",
|
|
145
|
-
subcommand: "hook-session-start",
|
|
146
|
-
label: "session start",
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
17
|
export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
|
|
151
|
-
const exitFn = deps.exit ?? defaultExit;
|
|
152
18
|
const getHome = deps.homedir ?? homedir;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (!configPath) {
|
|
19
|
+
if (!findCodexConfig(getHome)) {
|
|
156
20
|
console.error(
|
|
157
21
|
`Codex config not found — open Codex at least once to create ~/.codex/config.toml`,
|
|
158
22
|
);
|
|
159
|
-
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
let content: string;
|
|
164
|
-
try {
|
|
165
|
-
content = readFileSync(configPath, "utf-8");
|
|
166
|
-
} catch (e) {
|
|
167
|
-
console.error(`failed to read ${configPath}: ${(e as Error).message}`);
|
|
168
|
-
exitFn(1);
|
|
23
|
+
(deps.exit ?? defaultExit)(1);
|
|
169
24
|
return;
|
|
170
25
|
}
|
|
171
|
-
|
|
172
|
-
if (isCodexConfigured(content)) {
|
|
173
|
-
console.error(`✓ mcp_servers.fapony already configured — no change needed`);
|
|
174
|
-
console.error(` (${configPath})`);
|
|
175
|
-
} else if (dryRun) {
|
|
176
|
-
console.error(`── dry-run: would append to ${configPath} ──`);
|
|
177
|
-
console.log(CODEX_MCP_ENTRY);
|
|
178
|
-
} else {
|
|
179
|
-
const newContent = `${content.trimEnd()}\n\n${CODEX_MCP_ENTRY}`;
|
|
180
|
-
writeFileSync(configPath, newContent);
|
|
181
|
-
console.error(`✓ added mcp_servers.fapony to ${configPath}`);
|
|
182
|
-
console.error(` restart Codex to load the MCP server`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// --- Stop + SessionStart hooks (~/.codex/hooks.json) ---
|
|
186
|
-
installStopHook(dryRun, getHome);
|
|
187
|
-
installSessionStartHook(dryRun, getHome);
|
|
188
|
-
|
|
189
|
-
// --- Skills (~/.agents/skills/) ---
|
|
190
26
|
const skillsDir = agentsSkillsDir(getHome);
|
|
191
27
|
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
192
28
|
}
|
package/src/install/detect.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// src/install/detect.ts — detect which
|
|
1
|
+
// src/install/detect.ts — detect which agent clients are installed on this machine.
|
|
2
2
|
//
|
|
3
3
|
// Signal per client:
|
|
4
4
|
// antigravity = ~/.gemini exists (the app creates it on first run) OR `agy` on PATH
|
|
5
5
|
// claude = `command -v claude` (CLI on PATH)
|
|
6
|
-
// cursor = ~/.cursor exists (the app creates it on first run)
|
|
7
6
|
// opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
|
|
8
7
|
// zcode = ~/.zcode/cli/config.json or ~/.agents/mcp.json exists
|
|
9
8
|
// codex = ~/.codex/config.toml exists
|
|
@@ -12,7 +11,6 @@ import { homedir } from "node:os";
|
|
|
12
11
|
import { defaultCheckCmd } from "../setup.js";
|
|
13
12
|
import { findGeminiDir } from "./antigravity.js";
|
|
14
13
|
import { findCodexConfig } from "./codex.js";
|
|
15
|
-
import { findCursorDir } from "./cursor.js";
|
|
16
14
|
import { findOpencodeConfig } from "./opencode.js";
|
|
17
15
|
import type { InstallDeps } from "./types.js";
|
|
18
16
|
import { findZcodeConfig } from "./zcode.js";
|
|
@@ -24,11 +22,11 @@ export interface DetectedClient {
|
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
/**
|
|
27
|
-
* Detect which
|
|
28
|
-
* Returns one entry per platform, ordered: antigravity, claude,
|
|
25
|
+
* Detect which agent clients are available on this machine.
|
|
26
|
+
* Returns one entry per platform, ordered: antigravity, claude, opencode, zcode, codex.
|
|
29
27
|
*
|
|
30
28
|
* Uses resolvers from each provider (file/dir-exists check) for
|
|
31
|
-
* antigravity/
|
|
29
|
+
* antigravity/opencode/zcode/codex, and `command -v claude`/`command -v agy`
|
|
32
30
|
* for claude/antigravity — all through injected deps for testability.
|
|
33
31
|
*/
|
|
34
32
|
export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
|
|
@@ -39,7 +37,6 @@ export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
|
|
|
39
37
|
const agy = checkCmd("agy");
|
|
40
38
|
|
|
41
39
|
const geminiDir = findGeminiDir(getHome);
|
|
42
|
-
const cursorDir = findCursorDir(getHome);
|
|
43
40
|
const opencodePath = findOpencodeConfig(getHome);
|
|
44
41
|
const zcodeResult = findZcodeConfig(getHome);
|
|
45
42
|
const codexPath = findCodexConfig(getHome);
|
|
@@ -59,13 +56,6 @@ export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
|
|
|
59
56
|
installed: claude,
|
|
60
57
|
why: claude ? "claude CLI on PATH" : "claude CLI not found on PATH",
|
|
61
58
|
},
|
|
62
|
-
{
|
|
63
|
-
platform: "cursor",
|
|
64
|
-
installed: cursorDir !== null,
|
|
65
|
-
why: cursorDir
|
|
66
|
-
? `dir at ${cursorDir}`
|
|
67
|
-
: "no ~/.cursor directory (open Cursor once)",
|
|
68
|
-
},
|
|
69
59
|
{
|
|
70
60
|
platform: "opencode",
|
|
71
61
|
installed: opencodePath !== null,
|