fapony 0.3.0 → 0.3.3
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 +2 -2
- package/package.json +5 -4
- package/skill/move-to-done/SKILL.md +19 -30
- package/skill/review-pony/SKILL.md +27 -58
- package/src/analyze.ts +1 -1
- package/src/gate.ts +3 -3
- package/src/hook.ts +210 -123
- package/src/init-mem.ts +57 -71
- package/src/init.ts +12 -16
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +2 -123
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +53 -26
- package/src/install.ts +12 -3
- package/src/mcp/primitives.ts +1 -1
- package/src/mcp/tools/index.ts +0 -74
- package/src/mcp/transport.ts +4 -39
- package/src/mcp/worktree.ts +1 -1
- package/src/mem/commands/read.ts +219 -146
- package/src/mem/index.ts +4 -13
- package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
- package/src/seed/primitives.ts +66 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/stats/data.ts +1 -1
- package/src/telemetry.ts +1 -1
- package/src/util.ts +61 -0
- 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/math.ts +0 -13
- package/src/mcp/tools/verdict.ts +0 -161
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
9
|
import { DEFAULT_MEM_DIR, FAPONY_DIR, loadConfig } from "./db/index.js";
|
|
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
|
-
}
|
|
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
|
@@ -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,
|
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,
|
package/src/install/opencode.ts
CHANGED
|
@@ -335,10 +335,10 @@ export const FaponyCommitHint = async ({ directory }) => {
|
|
|
335
335
|
export function editHintPluginSource(root: string): string {
|
|
336
336
|
const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
|
|
337
337
|
return `// fapony edit hint — annotates edits to source files that have importers
|
|
338
|
-
// with the importer count plus
|
|
338
|
+
// with the importer count, plus mem/debt context rows about the file.
|
|
339
339
|
// Annotate only: never blocks, dedupe per (session, file) inside editHintFor.
|
|
340
340
|
// Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
|
|
341
|
-
import { editHintFor, recordHintFire } from ${hookModule};
|
|
341
|
+
import { editHintFor, readContextData, recordHintFire } from ${hookModule};
|
|
342
342
|
|
|
343
343
|
export const FaponyEditHint = async ({ directory }) => {
|
|
344
344
|
return {
|
|
@@ -350,16 +350,25 @@ export const FaponyEditHint = async ({ directory }) => {
|
|
|
350
350
|
// spends the per-session dedupe row or logs a fire that never showed.
|
|
351
351
|
if (typeof output.output !== "string") return;
|
|
352
352
|
const filePath = input.args?.filePath;
|
|
353
|
+
const parts = [];
|
|
353
354
|
const hint = editHintFor({
|
|
354
355
|
filePath,
|
|
355
356
|
cwd: directory,
|
|
356
357
|
session: input.sessionID,
|
|
357
358
|
});
|
|
359
|
+
if (hint) parts.push(hint);
|
|
360
|
+
// Attach mem/debt context (same as read hint — annotate only, cap 5 lines)
|
|
361
|
+
const ctx = readContextData(filePath, directory);
|
|
362
|
+
if (ctx) {
|
|
363
|
+
for (const line of [...ctx.debtLines, ...ctx.memLines]) {
|
|
364
|
+
parts.push(line);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (parts.length > 0) {
|
|
368
|
+
output.output = output.output + "\\n" + parts.join("\\n");
|
|
358
369
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (hint) {
|
|
362
|
-
output.output = output.output + "\\n" + hint;
|
|
370
|
+
// --- hint-fire log (PLAN-edit-importer-hint, opencode parity) ---
|
|
371
|
+
// After output — best-effort, never block the hint.
|
|
363
372
|
try {
|
|
364
373
|
const { spawnSync } = require("node:child_process");
|
|
365
374
|
const git = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf-8" });
|
|
@@ -374,6 +383,12 @@ export const FaponyEditHint = async ({ directory }) => {
|
|
|
374
383
|
} catch { return null; }
|
|
375
384
|
})() : null;
|
|
376
385
|
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "edit", file: rel, count: 1 });
|
|
386
|
+
if (ctx?.debtIds?.length) {
|
|
387
|
+
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "debt", file: rel, count: ctx.debtIds.length, ids: ctx.debtIds });
|
|
388
|
+
}
|
|
389
|
+
if (ctx?.memLines?.length) {
|
|
390
|
+
recordHintFire({ ts: new Date().toISOString(), worktree, surface: "mem", file: rel, count: ctx.memLines.length });
|
|
391
|
+
}
|
|
377
392
|
}
|
|
378
393
|
} catch {
|
|
379
394
|
// best-effort
|
|
@@ -488,8 +503,8 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
|
|
|
488
503
|
}
|
|
489
504
|
|
|
490
505
|
/**
|
|
491
|
-
* OpenCode plugin — session-start context
|
|
492
|
-
*
|
|
506
|
+
* OpenCode plugin — session-start context, the parity of Claude's SessionStart
|
|
507
|
+
* hook.
|
|
493
508
|
*
|
|
494
509
|
* Why `experimental.chat.system.transform` and not the `event` hook on
|
|
495
510
|
* `session.created`: the event hook is observer-only (returns void, no
|
|
@@ -500,19 +515,29 @@ function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
|
|
|
500
515
|
* the same shape as every other OpenCode hint (annotate-after), and arguably
|
|
501
516
|
* better: sessions that never dispatch never pay the spawn.
|
|
502
517
|
*
|
|
518
|
+
* The guard (no mem log = silence), the kickoff spawn and the cap all live in
|
|
519
|
+
* `sessionStartContext` (src/hook.ts), imported at runtime like the read /
|
|
520
|
+
* commit / edit hint plugins — so `git pull` updates this client too. It used
|
|
521
|
+
* to bake `mem kickoff` into the generated body and push stdout, which injected
|
|
522
|
+
* kickoff's "# <path> — 0 entries" header into every session of every repo
|
|
523
|
+
* with no mem log (bug mub2ezhi) and could not be fixed by a pull. One import,
|
|
524
|
+
* one implementation, shared with the Claude/Codex hooks.
|
|
525
|
+
*
|
|
503
526
|
* Once per session (closure Set — the factory runs once per server), silent
|
|
504
|
-
* when
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
527
|
+
* when `sessionStartContext` returns null (no mem log in scope), never throws.
|
|
528
|
+
* `process.execPath` is the bun running opencode itself — no PATH dependency.
|
|
529
|
+
* Verified against OpenCode 1.18.29; `experimental.chat.system.transform` is an
|
|
530
|
+
* experimental hook, so a rename makes this plugin quiet with no error — check
|
|
531
|
+
* on OpenCode upgrades.
|
|
508
532
|
*/
|
|
509
533
|
export function sessionStartPluginSource(root: string): string {
|
|
510
534
|
const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
|
|
511
535
|
const faponyTs = JSON.stringify(join(root, "fapony.ts"));
|
|
512
|
-
return `// fapony session start — injects \`fapony mem kickoff\` as context
|
|
513
|
-
// session, silent when the repo has no mem log.
|
|
514
|
-
//
|
|
515
|
-
|
|
536
|
+
return `// fapony session start — injects the \`fapony mem kickoff\` digest as context
|
|
537
|
+
// once per session via sessionStartContext, silent when the repo has no mem log.
|
|
538
|
+
// Annotate only: never blocks.
|
|
539
|
+
// Generated by \`fapony install\` — logic lives in src/hook.ts (sessionStartContext).
|
|
540
|
+
import { sessionStartContext } from ${hookModule};
|
|
516
541
|
|
|
517
542
|
export const FaponySessionStart = async ({ directory }) => {
|
|
518
543
|
const seen = new Set();
|
|
@@ -522,15 +547,8 @@ export const FaponySessionStart = async ({ directory }) => {
|
|
|
522
547
|
const sessionID = input?.sessionID;
|
|
523
548
|
if (!sessionID || seen.has(sessionID)) return;
|
|
524
549
|
seen.add(sessionID);
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
cwd: directory,
|
|
528
|
-
encoding: "utf-8",
|
|
529
|
-
});
|
|
530
|
-
if (kick.status !== 0) return;
|
|
531
|
-
const out = (kick.stdout ?? "").toString().trim();
|
|
532
|
-
if (!out) return;
|
|
533
|
-
output.system.push(capContext(out));
|
|
550
|
+
const ctx = sessionStartContext(directory, ${faponyTs});
|
|
551
|
+
if (ctx) output.system.push(ctx);
|
|
534
552
|
} catch {
|
|
535
553
|
// context must never break a session start
|
|
536
554
|
}
|
|
@@ -553,7 +571,16 @@ function installSessionStartPlugin(
|
|
|
553
571
|
} catch {
|
|
554
572
|
current = "";
|
|
555
573
|
}
|
|
556
|
-
if (current.includes("
|
|
574
|
+
if (current.includes("FaponySessionStart")) {
|
|
575
|
+
// Ours — and generated, so never overwritten. A file baked by an older
|
|
576
|
+
// fapony differs from what we generate now: say so instead of "no change",
|
|
577
|
+
// which is how the pre-hook plugin (mub2ezhi) would have been kept.
|
|
578
|
+
if (current !== sessionStartPluginSource(INSTALL_ROOT)) {
|
|
579
|
+
console.error(
|
|
580
|
+
` session start: stale fapony plugin — delete ${pluginPath} and re-run install`,
|
|
581
|
+
);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
557
584
|
console.error(` session start: already installed — no change`);
|
|
558
585
|
return;
|
|
559
586
|
}
|