fapony 0.3.0 → 0.3.4
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 +87 -65
- package/fapony.ts +3 -116
- package/package.json +6 -5
- package/skill/move-to-done/SKILL.md +22 -32
- package/skill/review-pony/SKILL.md +27 -58
- package/src/adapters/cli.ts +123 -0
- package/src/adapters/hooks/compute-hint-impact.ts +107 -0
- package/src/adapters/hooks/context-data.ts +102 -0
- package/src/adapters/hooks/edit-hint.ts +195 -0
- package/src/adapters/hooks/git-autonomy.ts +178 -0
- package/src/adapters/hooks/index.ts +79 -0
- package/src/adapters/hooks/mv-guard.ts +52 -0
- package/src/adapters/hooks/read-hint.ts +383 -0
- package/src/adapters/hooks/session-start.ts +101 -0
- package/src/adapters/hooks/stop.ts +302 -0
- package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
- package/src/{mcp → adapters/mcp}/primitives.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/index.ts +0 -74
- package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
- package/src/{mcp → adapters/mcp}/transport.ts +4 -39
- package/src/adapters/mcp/types.ts +18 -0
- package/src/{mcp → adapters/mcp}/worktree.ts +2 -2
- package/src/analyze.ts +2 -2
- package/src/conventions-seed.ts +1 -1
- package/src/core/config.ts +199 -0
- package/src/core/debt-format.ts +107 -0
- package/src/core/debt-types.ts +79 -0
- package/src/core/defaults.ts +8 -0
- package/src/core/enums.ts +34 -0
- package/src/core/format.ts +33 -0
- package/src/core/hint-log.ts +68 -0
- package/src/core/hook-helpers.ts +31 -0
- package/src/core/mem-log.ts +357 -0
- package/src/core/parse.ts +71 -0
- package/src/core/pricing.ts +217 -0
- package/src/core/safety.ts +18 -0
- package/src/core/types.ts +142 -0
- package/src/core/util.ts +105 -0
- package/src/db/store.ts +2 -2
- package/src/debt/cli.ts +1 -1
- package/src/debt/format.ts +2 -107
- package/src/debt/load.ts +1 -1
- package/src/debt/promotion.ts +1 -1
- package/src/debt/types.ts +14 -79
- package/src/digest/collect.ts +4 -3
- package/src/gate.ts +5 -5
- package/src/gates.ts +1 -1
- package/src/hook.ts +69 -1337
- package/src/init-mem.ts +58 -72
- package/src/init.ts +13 -17
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +19 -123
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +167 -26
- package/src/install.ts +23 -7
- package/src/lint-baseline.ts +1 -2
- package/src/map.ts +29 -8
- package/src/mem/commands/plan.ts +70 -42
- package/src/mem/commands/read.ts +219 -146
- package/src/mem/index.ts +4 -13
- package/src/mem/store.ts +5 -1
- package/src/memory.ts +24 -387
- package/src/parse.ts +9 -71
- package/src/price/fetch.ts +4 -16
- package/src/price/resolve.ts +12 -213
- package/src/report/cli.ts +3 -3
- package/src/safety.ts +2 -18
- package/src/{plan-seed.ts → seed/plan-seed.ts} +18 -32
- package/src/seed/primitives.ts +60 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/session/types.ts +14 -128
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +4 -4
- package/src/telemetry.ts +3 -3
- package/src/usage/cache.ts +1 -2
- package/src/usage/cli.ts +1 -1
- package/src/usage/scan.ts +2 -1
- package/src/util.ts +10 -32
- package/src/web/html.ts +2 -33
- package/templates/SPEC.md +8 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/src/db/defaults.ts +0 -34
- package/src/db/getters.ts +0 -35
- package/src/db/index.ts +0 -7
- package/src/db/load.ts +0 -57
- package/src/db/types.ts +0 -77
- package/src/math.ts +0 -13
- package/src/mcp/tools/verdict.ts +0 -161
- package/src/mcp/types.ts +0 -54
package/src/init-mem.ts
CHANGED
|
@@ -4,32 +4,10 @@
|
|
|
4
4
|
// Now fapony owns the mem code (src/mem/) and calls it directly via `fapony mem`.
|
|
5
5
|
// This command's new job: delete legacy .memory/ dirs + warn about package.json refs.
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
copyFileSync,
|
|
9
|
-
existsSync,
|
|
10
|
-
mkdirSync,
|
|
11
|
-
readdirSync,
|
|
12
|
-
readFileSync,
|
|
13
|
-
rmSync,
|
|
14
|
-
} from "node:fs";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
15
8
|
import { join } from "node:path";
|
|
16
|
-
import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./
|
|
17
|
-
|
|
18
|
-
export function copyDir(src: string, dest: string): string[] {
|
|
19
|
-
mkdirSync(dest, { recursive: true });
|
|
20
|
-
const copied: string[] = [];
|
|
21
|
-
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
22
|
-
const s = join(src, entry.name);
|
|
23
|
-
const d = join(dest, entry.name);
|
|
24
|
-
if (entry.isDirectory()) {
|
|
25
|
-
copied.push(...copyDir(s, d));
|
|
26
|
-
} else {
|
|
27
|
-
copyFileSync(s, d);
|
|
28
|
-
copied.push(d);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return copied;
|
|
32
|
-
}
|
|
9
|
+
import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./core/config.js";
|
|
10
|
+
import { walkDir } from "./util.js";
|
|
33
11
|
|
|
34
12
|
export function cmdInitMem(args: string[]): void {
|
|
35
13
|
const force = args.includes("--force");
|
|
@@ -52,27 +30,18 @@ export function cmdInitMem(args: string[]): void {
|
|
|
52
30
|
const root = gitRoot || worktree;
|
|
53
31
|
|
|
54
32
|
// 1) Find and delete .memory/ directories (legacy layout)
|
|
55
|
-
const memoryDirs
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
) {
|
|
68
|
-
walk(join(dir, entry.name), depth + 1);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
} catch {
|
|
72
|
-
// ignore
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
walk(root);
|
|
33
|
+
const memoryDirs = walkDir(root, {
|
|
34
|
+
includeDotDirs: [FAPONY_DIR],
|
|
35
|
+
predicate: (dir) => existsSync(join(dir, ".memory")),
|
|
36
|
+
}).map((dir) => join(dir, ".memory"));
|
|
37
|
+
|
|
38
|
+
// Legacy filenames that may live alongside live logs inside .fapony/.memory/
|
|
39
|
+
const LEGACY_MEM_FILENAMES = [
|
|
40
|
+
"mem.ts",
|
|
41
|
+
"store.ts",
|
|
42
|
+
"selectors.ts",
|
|
43
|
+
"render.ts",
|
|
44
|
+
];
|
|
76
45
|
|
|
77
46
|
if (memoryDirs.length === 0) {
|
|
78
47
|
console.log("no legacy .memory/ directories found — already clean");
|
|
@@ -90,6 +59,39 @@ export function cmdInitMem(args: string[]): void {
|
|
|
90
59
|
} catch {
|
|
91
60
|
// unreadable dir — fall through and remove
|
|
92
61
|
}
|
|
62
|
+
// Inside .fapony/.memory/: remove known legacy .ts files while keeping
|
|
63
|
+
// live logs. The keep-if-logs guard would preserve the whole directory,
|
|
64
|
+
// but legacy scaffolding (mem.ts, store.ts, ...) is dead code that
|
|
65
|
+
// should not linger beside the active log.
|
|
66
|
+
const isFaponyMemory =
|
|
67
|
+
d.endsWith(`/${FAPONY_DIR}/.memory`) || d === `${FAPONY_DIR}/.memory`;
|
|
68
|
+
if (isFaponyMemory && logs.length > 0) {
|
|
69
|
+
let legacyRemoved = 0;
|
|
70
|
+
let commandsRemoved = 0;
|
|
71
|
+
for (const name of LEGACY_MEM_FILENAMES) {
|
|
72
|
+
const fp = join(d, name);
|
|
73
|
+
if (existsSync(fp)) {
|
|
74
|
+
rmSync(fp);
|
|
75
|
+
legacyRemoved++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const commandsDir = join(d, "commands");
|
|
79
|
+
if (existsSync(commandsDir)) {
|
|
80
|
+
rmSync(commandsDir, { recursive: true, force: true });
|
|
81
|
+
commandsRemoved++;
|
|
82
|
+
}
|
|
83
|
+
if (legacyRemoved || commandsRemoved) {
|
|
84
|
+
console.log(
|
|
85
|
+
`cleaned ${d} — removed ${legacyRemoved} legacy file(s)${
|
|
86
|
+
commandsRemoved ? " + commands/" : ""
|
|
87
|
+
} (kept ${logs.length} log file(s): ${logs.join(", ")})`,
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`${d} — already clean (logs: ${logs.join(", ")})`);
|
|
91
|
+
}
|
|
92
|
+
kept++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
93
95
|
if (logs.length > 0 && !force) {
|
|
94
96
|
console.log(
|
|
95
97
|
`keeping ${d} — has ${logs.length} log file(s): ${logs.join(", ")}`,
|
|
@@ -116,42 +118,26 @@ export function cmdInitMem(args: string[]): void {
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
// 2) Warn about package.json call sites still referencing .memory/mem.ts
|
|
119
|
-
const
|
|
120
|
-
if (depth > 4) return;
|
|
121
|
+
for (const dir of walkDir(root)) {
|
|
121
122
|
const pkg = join(dir, "package.json");
|
|
122
|
-
if (existsSync(pkg))
|
|
123
|
-
try {
|
|
124
|
-
const raw = readFileSync(pkg, "utf8");
|
|
125
|
-
if (raw.includes(".memory/mem.ts")) {
|
|
126
|
-
console.log(`\n⚠ ${pkg} still references .memory/mem.ts`);
|
|
127
|
-
console.log(
|
|
128
|
-
` update to: "fapony mem <sub>" (e.g. "fapony mem add", "fapony mem close")`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
} catch {
|
|
132
|
-
// ignore
|
|
133
|
-
}
|
|
134
|
-
}
|
|
123
|
+
if (!existsSync(pkg)) continue;
|
|
135
124
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
142
|
-
warnAboutCallSites(join(dir, entry.name), depth + 1);
|
|
143
|
-
}
|
|
125
|
+
const raw = readFileSync(pkg, "utf8");
|
|
126
|
+
if (raw.includes(".memory/mem.ts")) {
|
|
127
|
+
console.log(`\n⚠ ${pkg} still references .memory/mem.ts`);
|
|
128
|
+
console.log(
|
|
129
|
+
` update to: "fapony mem <sub>" (e.g. "fapony mem add", "fapony mem close")`,
|
|
130
|
+
);
|
|
144
131
|
}
|
|
145
132
|
} catch {
|
|
146
133
|
// ignore
|
|
147
134
|
}
|
|
148
|
-
}
|
|
149
|
-
warnAboutCallSites(root);
|
|
135
|
+
}
|
|
150
136
|
|
|
151
137
|
console.log("\nmem commands are now built into fapony:");
|
|
152
138
|
console.log(' fapony mem add <kind> "<text>" --files <files>');
|
|
153
139
|
console.log(' fapony mem close <id> "<text>"');
|
|
154
140
|
console.log(" fapony mem find <word>");
|
|
155
141
|
console.log(" fapony mem kickoff [id|spec.md]");
|
|
156
|
-
console.log(" fapony mem
|
|
142
|
+
console.log(" fapony mem done | stale");
|
|
157
143
|
}
|
package/src/init.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
memoryDir,
|
|
22
22
|
planDir,
|
|
23
23
|
specDir,
|
|
24
|
-
} from "./
|
|
24
|
+
} from "./core/config.js";
|
|
25
25
|
import { isAffirmative } from "./util.js";
|
|
26
26
|
|
|
27
27
|
const FAPONY_README = `# .fapony/ — fapony project dir (plans, specs, memory)
|
|
@@ -95,10 +95,9 @@ work done. Cut at chunk boundaries instead:
|
|
|
95
95
|
Finish a chunk, before starting the next:
|
|
96
96
|
1. Tick its checkbox + stamp the TL;DR in the plan file
|
|
97
97
|
2. Commit — separate from other chunks
|
|
98
|
-
3. \`
|
|
99
|
-
4. \`fapony mem add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
|
|
98
|
+
3. \`fapony mem add note "what the next chunk needs" --files f1,f2 <path/to/PLAN-x.md>\`
|
|
100
99
|
— use the same plan path every time
|
|
101
|
-
|
|
100
|
+
4. Stop. Do not continue to the next chunk in the same session unless told to.
|
|
102
101
|
|
|
103
102
|
Next chunk, new session — open with \`fapony mem kickoff <path/to/PLAN-x.md>\` instead
|
|
104
103
|
of carrying the old transcript forward. kickoff already filters to the rows for that plan,
|
|
@@ -196,16 +195,6 @@ export function initProject(targetPath: string, config?: Config): void {
|
|
|
196
195
|
|
|
197
196
|
const AGENT_RULE_FILES = ["CLAUDE.md", "AGENTS.md"];
|
|
198
197
|
|
|
199
|
-
function ask(question: string): Promise<string> {
|
|
200
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
201
|
-
return new Promise((resolve) => {
|
|
202
|
-
rl.question(`${question} `, (answer) => {
|
|
203
|
-
rl.close();
|
|
204
|
-
resolve(answer.trim());
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
198
|
export async function cmdInit(args: string[]): Promise<void> {
|
|
210
199
|
const targetPath = args[0];
|
|
211
200
|
if (!targetPath) {
|
|
@@ -242,9 +231,16 @@ export async function cmdInit(args: string[]): Promise<void> {
|
|
|
242
231
|
);
|
|
243
232
|
if (found.length === 0) return;
|
|
244
233
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
234
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
235
|
+
const answer = await new Promise<string>((resolve) => {
|
|
236
|
+
rl.question(
|
|
237
|
+
`\nAppend the memory-logging rules above to ${found.map((f) => relative(targetPath, f)).join(" and ")}? [y/N] `,
|
|
238
|
+
(a) => {
|
|
239
|
+
rl.close();
|
|
240
|
+
resolve(a.trim());
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
});
|
|
248
244
|
if (!isAffirmative(answer)) return;
|
|
249
245
|
|
|
250
246
|
const snippet = `\n\n${RULES_SNIPPET()}\n`;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/install/antigravity.ts — Google Antigravity install provider
|
|
2
|
+
//
|
|
3
|
+
// Writes mcpServers.fapony to ~/.gemini/config/mcp_config.json (Antigravity's
|
|
4
|
+
// global MCP config) and symlinks skills into ~/.agents/skills/ (Progressive
|
|
5
|
+
// Skills path). No hooks in the first phase — Antigravity's hook surface is
|
|
6
|
+
// still evolving.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
|
|
12
|
+
import {
|
|
13
|
+
CURSOR_MCP_ENTRY,
|
|
14
|
+
defaultExit,
|
|
15
|
+
type InstallDeps,
|
|
16
|
+
MCP_KEY,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
/** ~/.gemini — created on first Antigravity launch. */
|
|
20
|
+
export function findGeminiDir(getHome: () => string): string | null {
|
|
21
|
+
const dir = join(getHome(), ".gemini");
|
|
22
|
+
return existsSync(dir) ? dir : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isFaponyEntry(entry: unknown): boolean {
|
|
26
|
+
if (!entry || typeof entry !== "object") return false;
|
|
27
|
+
const cfg = entry as Record<string, unknown>;
|
|
28
|
+
return (
|
|
29
|
+
cfg.command === CURSOR_MCP_ENTRY.command &&
|
|
30
|
+
JSON.stringify(cfg.args) === JSON.stringify(CURSOR_MCP_ENTRY.args)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJsonObject(path: string): Record<string, unknown> | null {
|
|
35
|
+
try {
|
|
36
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
37
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return parsed as Record<string, unknown>;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function cmdInstallAntigravity(
|
|
47
|
+
dryRun: boolean,
|
|
48
|
+
deps: InstallDeps = {},
|
|
49
|
+
): void {
|
|
50
|
+
const exitFn = deps.exit ?? defaultExit;
|
|
51
|
+
const getHome = deps.homedir ?? homedir;
|
|
52
|
+
const geminiDir = findGeminiDir(getHome);
|
|
53
|
+
|
|
54
|
+
if (!geminiDir) {
|
|
55
|
+
console.error(
|
|
56
|
+
`Antigravity not found — open Antigravity at least once to create ~/.gemini`,
|
|
57
|
+
);
|
|
58
|
+
exitFn(1);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- 1. MCP server (mcpServers.fapony in ~/.gemini/config/mcp_config.json) ---
|
|
63
|
+
const configDir = join(geminiDir, "config");
|
|
64
|
+
const mcpPath = join(configDir, "mcp_config.json");
|
|
65
|
+
let mcp: Record<string, unknown> = {};
|
|
66
|
+
let mcpIsNew = true;
|
|
67
|
+
if (existsSync(mcpPath)) {
|
|
68
|
+
const parsed = readJsonObject(mcpPath);
|
|
69
|
+
if (!parsed) {
|
|
70
|
+
console.error(`failed to parse ${mcpPath} — fix or remove it first`);
|
|
71
|
+
exitFn(1);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
mcp = parsed;
|
|
75
|
+
mcpIsNew = false;
|
|
76
|
+
}
|
|
77
|
+
const servers = (mcp.mcpServers ?? {}) as Record<string, unknown>;
|
|
78
|
+
const existing = servers[MCP_KEY];
|
|
79
|
+
if (existing !== undefined && !isFaponyEntry(existing)) {
|
|
80
|
+
console.error(
|
|
81
|
+
`an MCP server named "${MCP_KEY}" exists but points elsewhere — not overwriting.`,
|
|
82
|
+
);
|
|
83
|
+
console.error(` inspect ${mcpPath} and remove it first`);
|
|
84
|
+
exitFn(1);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (existing !== undefined) {
|
|
88
|
+
console.error(
|
|
89
|
+
`✓ mcpServers.${MCP_KEY} already configured — no change needed`,
|
|
90
|
+
);
|
|
91
|
+
console.error(` (${mcpPath})`);
|
|
92
|
+
} else {
|
|
93
|
+
const after = {
|
|
94
|
+
...mcp,
|
|
95
|
+
mcpServers: { ...servers, [MCP_KEY]: CURSOR_MCP_ENTRY },
|
|
96
|
+
};
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
console.error(
|
|
99
|
+
`── dry-run: would ${mcpIsNew ? "create" : "write"} ${mcpPath}${mcpIsNew ? "" : ` (mcpServers.${MCP_KEY})`} ──`,
|
|
100
|
+
);
|
|
101
|
+
} else {
|
|
102
|
+
writeFileSync(mcpPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
|
|
103
|
+
console.error(`✓ added mcpServers.${MCP_KEY} to ${mcpPath}`);
|
|
104
|
+
console.error(` restart Antigravity to load the MCP server`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- 2. Skills (~/.agents/skills/) ---
|
|
109
|
+
const skillsDir = agentsSkillsDir(getHome);
|
|
110
|
+
const results = linkSkills(skillsDir, dryRun);
|
|
111
|
+
reportSkills(results, skillsDir, dryRun);
|
|
112
|
+
}
|
package/src/install/claude.ts
CHANGED
|
@@ -2,14 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Shells out to `claude mcp add` (never parses/writes ~/.claude.json directly).
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
chmodSync,
|
|
7
|
-
copyFileSync,
|
|
8
|
-
existsSync,
|
|
9
|
-
mkdirSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
6
|
import { homedir } from "node:os";
|
|
14
7
|
import { join } from "node:path";
|
|
15
8
|
import { assertSafe } from "../safety.js";
|
|
@@ -151,132 +144,18 @@ export function cmdInstallClaude(
|
|
|
151
144
|
const skillsDir = claudeSkillsDir(deps.homedir ?? homedir);
|
|
152
145
|
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
153
146
|
|
|
154
|
-
// Wire statusline: copy script + update settings.json.
|
|
155
|
-
installStatusline(dryRun, deps);
|
|
156
|
-
|
|
157
147
|
// Wire the Stop hook that refuses to end a turn with ungraded commits,
|
|
158
148
|
// and the Read/Edit hints that annotate reads of large files and edits to
|
|
159
149
|
// files with importers (both annotate-only).
|
|
160
150
|
installClaudeHooks(dryRun, deps);
|
|
161
151
|
}
|
|
162
152
|
|
|
163
|
-
/**
|
|
164
|
-
* Copy the statusline script to ~/.claude/statusline.sh and add the
|
|
165
|
-
* statusLine field to ~/.claude/settings.json. Best-effort — never fails
|
|
166
|
-
* the install if settings.json is unreadable or has unexpected shape.
|
|
167
|
-
*/
|
|
168
|
-
function installStatusline(dryRun: boolean, deps: InstallDeps): void {
|
|
169
|
-
const home = deps.homedir ? deps.homedir() : homedir();
|
|
170
|
-
const claudeDir = join(home, ".claude");
|
|
171
|
-
const scriptSrc = join(INSTALL_ROOT, "statusline", "claude-statusline.sh");
|
|
172
|
-
const scriptDest = join(claudeDir, "statusline.sh");
|
|
173
|
-
const settingsPath = join(claudeDir, "settings.json");
|
|
174
|
-
|
|
175
|
-
// 1. Copy the statusline script — never overwrite someone else's.
|
|
176
|
-
// Mirrors the mcp-entry policy above: an existing script that isn't ours
|
|
177
|
-
// is left alone (the settings guard below will also refuse to repoint it).
|
|
178
|
-
if (!existsSync(scriptSrc)) {
|
|
179
|
-
console.error(` statusline: script not found at ${scriptSrc} — skipping`);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (existsSync(scriptDest)) {
|
|
183
|
-
let current = "";
|
|
184
|
-
try {
|
|
185
|
-
current = readFileSync(scriptDest, "utf-8");
|
|
186
|
-
} catch {
|
|
187
|
-
current = "";
|
|
188
|
-
}
|
|
189
|
-
if (!current.includes("fapony")) {
|
|
190
|
-
console.error(
|
|
191
|
-
` statusline: ${scriptDest} exists but isn't fapony's — not overwriting.`,
|
|
192
|
-
);
|
|
193
|
-
console.error(
|
|
194
|
-
` inspect it first, then remove it to let fapony install its own.`,
|
|
195
|
-
);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
try {
|
|
200
|
-
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
|
201
|
-
if (!dryRun) copyFileSync(scriptSrc, scriptDest);
|
|
202
|
-
// Claude Code execs this file — the copy must stay executable.
|
|
203
|
-
if (!dryRun) chmodSync(scriptDest, 0o755);
|
|
204
|
-
console.error(
|
|
205
|
-
` statusline: ${dryRun ? "would copy" : "copied"} ${scriptDest}`,
|
|
206
|
-
);
|
|
207
|
-
} catch (e) {
|
|
208
|
-
console.error(
|
|
209
|
-
` statusline: failed to copy script — ${(e as Error).message}`,
|
|
210
|
-
);
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// 2. Update settings.json with statusLine field.
|
|
215
|
-
let settings: Record<string, unknown> = {};
|
|
216
|
-
if (existsSync(settingsPath)) {
|
|
217
|
-
try {
|
|
218
|
-
settings = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<
|
|
219
|
-
string,
|
|
220
|
-
unknown
|
|
221
|
-
>;
|
|
222
|
-
} catch {
|
|
223
|
-
console.error(
|
|
224
|
-
` statusline: ${settingsPath} is unreadable or malformed — skipping settings update`,
|
|
225
|
-
);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Don't overwrite if already configured (same command path). A foreign
|
|
231
|
-
// statusLine (someone else's command) is left alone — same policy as the
|
|
232
|
-
// mcp-entry "points elsewhere" refusal above. Match on our exact dest:
|
|
233
|
-
// any *statusline.sh substring (e.g. another plugin's script) is not ours.
|
|
234
|
-
const existing = settings.statusLine as Record<string, unknown> | undefined;
|
|
235
|
-
if (
|
|
236
|
-
existing &&
|
|
237
|
-
existing.type === "command" &&
|
|
238
|
-
typeof existing.command === "string" &&
|
|
239
|
-
existing.command === scriptDest
|
|
240
|
-
) {
|
|
241
|
-
console.error(
|
|
242
|
-
` statusline: already configured in settings.json — no change`,
|
|
243
|
-
);
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
if (existing && typeof existing === "object") {
|
|
247
|
-
console.error(
|
|
248
|
-
` statusline: settings.json already has a statusLine that isn't fapony's — not overwriting.`,
|
|
249
|
-
);
|
|
250
|
-
console.error(
|
|
251
|
-
` inspect it first, then remove it to let fapony wire its own.`,
|
|
252
|
-
);
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
settings.statusLine = {
|
|
257
|
-
type: "command",
|
|
258
|
-
command: scriptDest,
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
if (!dryRun) {
|
|
262
|
-
writeFileSync(
|
|
263
|
-
settingsPath,
|
|
264
|
-
`${JSON.stringify(settings, null, 2)}\n`,
|
|
265
|
-
"utf-8",
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
console.error(
|
|
269
|
-
` statusline: ${dryRun ? "would write" : "wrote"} statusLine → ${settingsPath}`,
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
153
|
/**
|
|
274
154
|
* Shared append-to-settings.json hook installer. Both fapony hooks live
|
|
275
155
|
* here now (Stop since PLAN-mem-mcp, PreToolUse read hint since the
|
|
276
156
|
* large-file annotate feature) — the read/write/idempotence/append shape
|
|
277
157
|
* is one implementation with two callers, not a scaffold.
|
|
278
|
-
*
|
|
279
|
-
* registered, never fail the install over it.
|
|
158
|
+
* Never touch a hook someone else registered, never fail the install over it.
|
|
280
159
|
*/
|
|
281
160
|
function ensureClaudeHook(
|
|
282
161
|
dryRun: boolean,
|
|
@@ -353,6 +232,7 @@ function installClaudeHooks(dryRun: boolean, deps: InstallDeps): void {
|
|
|
353
232
|
installStopHook(dryRun, deps);
|
|
354
233
|
installReadHintHook(dryRun, deps);
|
|
355
234
|
installEditHintHook(dryRun, deps);
|
|
235
|
+
installMvGuardHook(dryRun, deps);
|
|
356
236
|
installSessionStartHook(dryRun, deps);
|
|
357
237
|
}
|
|
358
238
|
|
|
@@ -397,6 +277,22 @@ function installEditHintHook(dryRun: boolean, deps: InstallDeps): void {
|
|
|
397
277
|
});
|
|
398
278
|
}
|
|
399
279
|
|
|
280
|
+
/**
|
|
281
|
+
* PreToolUse hook on Bash: denies a raw `git mv` of a plan file into a done/
|
|
282
|
+
* directory, pointing at `fapony mem plan-sweep --apply` instead — that
|
|
283
|
+
* command does the link rewrite a plain `git mv` skips. The one fapony hook
|
|
284
|
+
* that blocks besides Stop; matcher "Bash" keeps the spawn off every other
|
|
285
|
+
* tool call.
|
|
286
|
+
*/
|
|
287
|
+
function installMvGuardHook(dryRun: boolean, deps: InstallDeps): void {
|
|
288
|
+
ensureClaudeHook(dryRun, deps, {
|
|
289
|
+
event: "PreToolUse",
|
|
290
|
+
matcher: "Bash",
|
|
291
|
+
subcommand: "hook-mv-guard",
|
|
292
|
+
label: "plan-mv guard",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
400
296
|
/**
|
|
401
297
|
* SessionStart hook: injects `fapony mem kickoff` as context when the repo has
|
|
402
298
|
* a mem log, and stays silent when it does not. Context only — SessionStart
|
package/src/install/detect.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// src/install/detect.ts — detect which MCP clients are installed on this machine.
|
|
2
2
|
//
|
|
3
3
|
// Signal per client:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// antigravity = ~/.gemini exists (the app creates it on first run)
|
|
5
|
+
// claude = `command -v claude` (CLI on PATH)
|
|
6
|
+
// cursor = ~/.cursor exists (the app creates it on first run)
|
|
7
|
+
// opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
|
|
8
|
+
// zcode = ~/.zcode/cli/config.json or ~/.agents/mcp.json exists
|
|
9
|
+
// codex = ~/.codex/config.toml exists
|
|
9
10
|
|
|
10
11
|
import { homedir } from "node:os";
|
|
11
12
|
import { defaultCheckCmd } from "../setup.js";
|
|
13
|
+
import { findGeminiDir } from "./antigravity.js";
|
|
12
14
|
import { findCodexConfig } from "./codex.js";
|
|
13
15
|
import { findCursorDir } from "./cursor.js";
|
|
14
16
|
import { findOpencodeConfig } from "./opencode.js";
|
|
@@ -23,10 +25,10 @@ export interface DetectedClient {
|
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Detect which MCP clients are available on this machine.
|
|
26
|
-
* Returns one entry per platform, ordered: claude, cursor, opencode, zcode, codex.
|
|
28
|
+
* Returns one entry per platform, ordered: antigravity, claude, cursor, opencode, zcode, codex.
|
|
27
29
|
*
|
|
28
30
|
* Uses resolvers from each provider (file/dir-exists check) for
|
|
29
|
-
* cursor/opencode/zcode/codex, and `command -v claude` for claude — all
|
|
31
|
+
* antigravity/cursor/opencode/zcode/codex, and `command -v claude` for claude — all
|
|
30
32
|
* through injected deps for testability.
|
|
31
33
|
*/
|
|
32
34
|
export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
|
|
@@ -35,12 +37,20 @@ export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
|
|
|
35
37
|
|
|
36
38
|
const claude = checkCmd("claude");
|
|
37
39
|
|
|
40
|
+
const geminiDir = findGeminiDir(getHome);
|
|
38
41
|
const cursorDir = findCursorDir(getHome);
|
|
39
42
|
const opencodePath = findOpencodeConfig(getHome);
|
|
40
43
|
const zcodeResult = findZcodeConfig(getHome);
|
|
41
44
|
const codexPath = findCodexConfig(getHome);
|
|
42
45
|
|
|
43
46
|
return [
|
|
47
|
+
{
|
|
48
|
+
platform: "antigravity",
|
|
49
|
+
installed: geminiDir !== null,
|
|
50
|
+
why: geminiDir
|
|
51
|
+
? `dir at ${geminiDir}`
|
|
52
|
+
: "no ~/.gemini directory (open Antigravity once)",
|
|
53
|
+
},
|
|
44
54
|
{
|
|
45
55
|
platform: "claude",
|
|
46
56
|
installed: claude,
|