fapony 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/fapony.ts +78 -0
  4. package/package.json +42 -0
  5. package/skill/git-commit-conventional/SKILL.md +68 -0
  6. package/skill/git-ship/SKILL.md +144 -0
  7. package/skill/move-to-done/SKILL.md +126 -0
  8. package/skill/plan-with-pony/SKILL.md +263 -0
  9. package/skill/review-pony/SKILL.md +254 -0
  10. package/src/analyze.ts +517 -0
  11. package/src/context/index.ts +11 -0
  12. package/src/context/projectHealth.ts +359 -0
  13. package/src/conventions-seed.ts +420 -0
  14. package/src/db/defaults.ts +26 -0
  15. package/src/db/getters.ts +33 -0
  16. package/src/db/index.ts +7 -0
  17. package/src/db/load.ts +57 -0
  18. package/src/db/store.ts +286 -0
  19. package/src/db/types.ts +79 -0
  20. package/src/debt.ts +667 -0
  21. package/src/digest/cli.ts +75 -0
  22. package/src/digest/collect.ts +625 -0
  23. package/src/digest/html.ts +208 -0
  24. package/src/digest/text.ts +191 -0
  25. package/src/gate.ts +153 -0
  26. package/src/gates.ts +194 -0
  27. package/src/hook.ts +436 -0
  28. package/src/init-mem.ts +71 -0
  29. package/src/init.ts +237 -0
  30. package/src/install/claude.ts +361 -0
  31. package/src/install/codex.ts +61 -0
  32. package/src/install/cursor.ts +167 -0
  33. package/src/install/detect.ts +78 -0
  34. package/src/install/opencode.ts +234 -0
  35. package/src/install/skills.ts +106 -0
  36. package/src/install/types.ts +69 -0
  37. package/src/install/utils.ts +29 -0
  38. package/src/install/zcode.ts +120 -0
  39. package/src/install.ts +176 -0
  40. package/src/lint-baseline.ts +260 -0
  41. package/src/map.ts +320 -0
  42. package/src/math.ts +13 -0
  43. package/src/mcp/evidence.ts +332 -0
  44. package/src/mcp/primitives.ts +316 -0
  45. package/src/mcp/tools/check.ts +243 -0
  46. package/src/mcp/tools/collect.ts +157 -0
  47. package/src/mcp/tools/context.ts +66 -0
  48. package/src/mcp/tools/index.ts +309 -0
  49. package/src/mcp/tools/mem.ts +95 -0
  50. package/src/mcp/tools/plans.ts +255 -0
  51. package/src/mcp/tools/report.ts +285 -0
  52. package/src/mcp/tools/stats.ts +96 -0
  53. package/src/mcp/tools/usage.ts +211 -0
  54. package/src/mcp/tools/verdict.ts +148 -0
  55. package/src/mcp/transport.ts +241 -0
  56. package/src/mcp/types.ts +54 -0
  57. package/src/mcp/worktree.ts +27 -0
  58. package/src/memory.ts +264 -0
  59. package/src/parse.ts +71 -0
  60. package/src/plan-seed.ts +599 -0
  61. package/src/price/fetch.ts +146 -0
  62. package/src/price/index.ts +8 -0
  63. package/src/price/resolve.ts +213 -0
  64. package/src/report/cli.ts +92 -0
  65. package/src/report/format.ts +37 -0
  66. package/src/report/index.ts +4 -0
  67. package/src/report/render.ts +206 -0
  68. package/src/review-seed.ts +932 -0
  69. package/src/safety.ts +18 -0
  70. package/src/session/activeSession.ts +153 -0
  71. package/src/session/claude-code.ts +412 -0
  72. package/src/session/codex.ts +347 -0
  73. package/src/session/findModel.ts +376 -0
  74. package/src/session/helpers.ts +640 -0
  75. package/src/session/index.ts +31 -0
  76. package/src/session/opencode.ts +167 -0
  77. package/src/session/registry.ts +45 -0
  78. package/src/session/types.ts +128 -0
  79. package/src/session/zcode.ts +151 -0
  80. package/src/setup.ts +242 -0
  81. package/src/stats/cli.ts +44 -0
  82. package/src/stats/data.ts +1019 -0
  83. package/src/stats/format.ts +584 -0
  84. package/src/stats/index.ts +19 -0
  85. package/src/telemetry.ts +364 -0
  86. package/src/test.ts +2 -0
  87. package/src/update.ts +212 -0
  88. package/src/usage/cache.ts +125 -0
  89. package/src/usage/cli.ts +120 -0
  90. package/src/usage/format.ts +29 -0
  91. package/src/usage/index.ts +4 -0
  92. package/src/usage/render.ts +523 -0
  93. package/src/usage/scan.ts +161 -0
  94. package/src/util.ts +32 -0
  95. package/src/web/html.ts +33 -0
  96. package/templates/PLAN.md +90 -0
  97. package/templates/SPEC.md +30 -0
  98. package/templates/mem/commands/plan.ts +360 -0
  99. package/templates/mem/commands/read.ts +194 -0
  100. package/templates/mem/commands/rotate.ts +59 -0
  101. package/templates/mem/commands/selftest.ts +450 -0
  102. package/templates/mem/commands/write.ts +214 -0
  103. package/templates/mem/mem.ts +68 -0
  104. package/templates/mem/render.ts +63 -0
  105. package/templates/mem/selectors.ts +144 -0
  106. package/templates/mem/store.ts +285 -0
@@ -0,0 +1,61 @@
1
+ // src/install/codex.ts — Codex install provider
2
+ //
3
+ // Reads/writes ~/.codex/config.toml directly. Codex has no CLI for MCP config.
4
+
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { CODEX_MCP_ENTRY, defaultExit, type InstallDeps } from "./types.js";
9
+
10
+ export function findCodexConfig(getHome: () => string): string | null {
11
+ const p = join(getHome(), ".codex", "config.toml");
12
+ return existsSync(p) ? p : null;
13
+ }
14
+
15
+ function isCodexConfigured(content: string): boolean {
16
+ // Check if [mcp_servers.fapony] section exists with our command
17
+ const sectionRegex = /\[mcp_servers\.fapony\]/;
18
+ if (!sectionRegex.test(content)) return false;
19
+ // Verify it points to fapony
20
+ return content.includes("fapony.ts") && content.includes("mcp");
21
+ }
22
+
23
+ export function cmdInstallCodex(dryRun: boolean, deps: InstallDeps = {}): void {
24
+ const exitFn = deps.exit ?? defaultExit;
25
+ const configPath = findCodexConfig(deps.homedir ?? homedir);
26
+
27
+ if (!configPath) {
28
+ console.error(
29
+ `Codex config not found — open Codex at least once to create ~/.codex/config.toml`,
30
+ );
31
+ exitFn(1);
32
+ return;
33
+ }
34
+
35
+ let content: string;
36
+ try {
37
+ content = readFileSync(configPath, "utf-8");
38
+ } catch (e) {
39
+ console.error(`failed to read ${configPath}: ${(e as Error).message}`);
40
+ exitFn(1);
41
+ return;
42
+ }
43
+
44
+ if (isCodexConfigured(content)) {
45
+ console.error(`✓ mcp_servers.fapony already configured — no change needed`);
46
+ console.error(` (${configPath})`);
47
+ return;
48
+ }
49
+
50
+ if (dryRun) {
51
+ console.error(`── dry-run: would append to ${configPath} ──`);
52
+ console.log(CODEX_MCP_ENTRY);
53
+ return;
54
+ }
55
+
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`);
61
+ }
@@ -0,0 +1,167 @@
1
+ // src/install/cursor.ts — Cursor install provider
2
+ //
3
+ // Writes mcpServers.fapony to ~/.cursor/mcp.json directly (Cursor has no CLI
4
+ // for MCP config) and appends the fapony stop hook to the stop array in
5
+ // ~/.cursor/hooks.json — append, never replace: other tools (e.g.
6
+ // code-review-graph) register hooks in the same file and Cursor runs every
7
+ // entry in the array.
8
+
9
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ CURSOR_MCP_ENTRY,
14
+ defaultExit,
15
+ INSTALL_ROOT,
16
+ type InstallDeps,
17
+ MCP_KEY,
18
+ } from "./types.js";
19
+
20
+ /** ~/.cursor itself — the app creates it on first run, often before the user
21
+ * ever adds an MCP server or hook, so dir-exists is the detect signal. */
22
+ export function findCursorDir(getHome: () => string): string | null {
23
+ const dir = join(getHome(), ".cursor");
24
+ return existsSync(dir) ? dir : null;
25
+ }
26
+
27
+ function isFaponyEntry(entry: unknown): boolean {
28
+ if (!entry || typeof entry !== "object") return false;
29
+ const cfg = entry as Record<string, unknown>;
30
+ return (
31
+ cfg.command === CURSOR_MCP_ENTRY.command &&
32
+ JSON.stringify(cfg.args) === JSON.stringify(CURSOR_MCP_ENTRY.args)
33
+ );
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
+ export function cmdInstallCursor(
49
+ dryRun: boolean,
50
+ deps: InstallDeps = {},
51
+ ): void {
52
+ const exitFn = deps.exit ?? defaultExit;
53
+ const getHome = deps.homedir ?? homedir;
54
+ const cursorDir = findCursorDir(getHome);
55
+
56
+ if (!cursorDir) {
57
+ console.error(
58
+ `Cursor not found — open Cursor at least once to create ~/.cursor`,
59
+ );
60
+ exitFn(1);
61
+ return;
62
+ }
63
+
64
+ // --- 1. MCP server (mcpServers.fapony in ~/.cursor/mcp.json) ---
65
+ const mcpPath = join(cursorDir, "mcp.json");
66
+ let mcp: Record<string, unknown> = {};
67
+ let mcpIsNew = true;
68
+ if (existsSync(mcpPath)) {
69
+ const parsed = readJsonObject(mcpPath);
70
+ if (!parsed) {
71
+ console.error(`failed to parse ${mcpPath} — fix or remove it first`);
72
+ exitFn(1);
73
+ return;
74
+ }
75
+ mcp = parsed;
76
+ mcpIsNew = false;
77
+ }
78
+ const servers = (mcp.mcpServers ?? {}) as Record<string, unknown>;
79
+ const existing = servers[MCP_KEY];
80
+ if (existing !== undefined && !isFaponyEntry(existing)) {
81
+ console.error(
82
+ `an MCP server named "${MCP_KEY}" exists but points elsewhere — not overwriting.`,
83
+ );
84
+ console.error(` inspect ${mcpPath} and remove it first`);
85
+ exitFn(1);
86
+ return;
87
+ }
88
+ if (existing !== undefined) {
89
+ console.error(
90
+ `✓ mcpServers.${MCP_KEY} already configured — no change needed`,
91
+ );
92
+ console.error(` (${mcpPath})`);
93
+ } else {
94
+ const after = {
95
+ ...mcp,
96
+ mcpServers: { ...servers, [MCP_KEY]: CURSOR_MCP_ENTRY },
97
+ };
98
+ if (dryRun) {
99
+ console.error(
100
+ `── dry-run: would ${mcpIsNew ? "create" : "write"} ${mcpPath}${mcpIsNew ? "" : ` (mcpServers.${MCP_KEY})`} ──`,
101
+ );
102
+ } else {
103
+ writeFileSync(mcpPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
104
+ console.error(`✓ added mcpServers.${MCP_KEY} to ${mcpPath}`);
105
+ console.error(` restart Cursor to load the MCP server`);
106
+ }
107
+ }
108
+
109
+ // --- 2. Stop hook (hooks.stop array in ~/.cursor/hooks.json) ---
110
+ installStopHook(dryRun, cursorDir);
111
+ }
112
+
113
+ /**
114
+ * Append `fapony hook-stop` to the stop array in ~/.cursor/hooks.json.
115
+ * Same policy as Claude's installStopHook: never touch a hook someone else
116
+ * registered, never fail the install over it. User hooks run from ~/.cursor/,
117
+ * so the command must be an absolute path.
118
+ */
119
+ function installStopHook(dryRun: boolean, cursorDir: string): void {
120
+ const hooksPath = join(cursorDir, "hooks.json");
121
+ let config: Record<string, unknown> = {};
122
+ if (existsSync(hooksPath)) {
123
+ const parsed = readJsonObject(hooksPath);
124
+ if (!parsed) {
125
+ console.error(
126
+ ` stop hook: ${hooksPath} is unreadable or malformed — skipping`,
127
+ );
128
+ return;
129
+ }
130
+ config = parsed;
131
+ }
132
+ const hookMap = config.hooks;
133
+ if (
134
+ hookMap !== undefined &&
135
+ (typeof hookMap !== "object" || Array.isArray(hookMap))
136
+ ) {
137
+ console.error(
138
+ ` stop hook: ${hooksPath} has an unexpected "hooks" shape — skipping`,
139
+ );
140
+ return;
141
+ }
142
+ const map = (hookMap ?? {}) as Record<string, unknown>;
143
+ const stop = Array.isArray(map.stop) ? (map.stop as unknown[]) : [];
144
+ if (JSON.stringify(stop).includes("hook-stop")) {
145
+ console.error(` stop hook: already configured in hooks.json — no change`);
146
+ return;
147
+ }
148
+
149
+ const command = `bun ${join(INSTALL_ROOT, "fapony.ts")} hook-stop`;
150
+ const after = {
151
+ ...config,
152
+ version: typeof config.version === "number" ? config.version : 1,
153
+ hooks: { ...map, stop: [...stop, { command }] },
154
+ };
155
+
156
+ if (!dryRun) {
157
+ try {
158
+ writeFileSync(hooksPath, `${JSON.stringify(after, null, 2)}\n`, "utf-8");
159
+ } catch (e) {
160
+ console.error(` stop hook: failed to write — ${(e as Error).message}`);
161
+ return;
162
+ }
163
+ }
164
+ console.error(
165
+ ` stop hook: ${dryRun ? "would write" : "wrote"} hooks.stop → ${hooksPath}`,
166
+ );
167
+ }
@@ -0,0 +1,78 @@
1
+ // src/install/detect.ts — detect which MCP clients are installed on this machine.
2
+ //
3
+ // Signal per client:
4
+ // claude = `command -v claude` (CLI on PATH)
5
+ // cursor = ~/.cursor exists (the app creates it on first run)
6
+ // opencode = ~/.config/opencode/{opencode.json,opencode.jsonc} exists
7
+ // zcode = ~/.zcode/cli/config.json or ~/.agents/mcp.json exists
8
+ // codex = ~/.codex/config.toml exists
9
+
10
+ import { homedir } from "node:os";
11
+ import { defaultCheckCmd } from "../setup.js";
12
+ import { findCodexConfig } from "./codex.js";
13
+ import { findCursorDir } from "./cursor.js";
14
+ import { findOpencodeConfig } from "./opencode.js";
15
+ import type { InstallDeps } from "./types.js";
16
+ import { findZcodeConfig } from "./zcode.js";
17
+
18
+ export interface DetectedClient {
19
+ platform: string;
20
+ installed: boolean;
21
+ why: string;
22
+ }
23
+
24
+ /**
25
+ * Detect which MCP clients are available on this machine.
26
+ * Returns one entry per platform, ordered: claude, cursor, opencode, zcode, codex.
27
+ *
28
+ * Uses resolvers from each provider (file/dir-exists check) for
29
+ * cursor/opencode/zcode/codex, and `command -v claude` for claude — all
30
+ * through injected deps for testability.
31
+ */
32
+ export function detectClients(deps: InstallDeps = {}): DetectedClient[] {
33
+ const getHome = deps.homedir ?? homedir;
34
+ const checkCmd = deps.checkCmd ?? defaultCheckCmd;
35
+
36
+ const claude = checkCmd("claude");
37
+
38
+ const cursorDir = findCursorDir(getHome);
39
+ const opencodePath = findOpencodeConfig(getHome);
40
+ const zcodeResult = findZcodeConfig(getHome);
41
+ const codexPath = findCodexConfig(getHome);
42
+
43
+ return [
44
+ {
45
+ platform: "claude",
46
+ installed: claude,
47
+ why: claude ? "claude CLI on PATH" : "claude CLI not found on PATH",
48
+ },
49
+ {
50
+ platform: "cursor",
51
+ installed: cursorDir !== null,
52
+ why: cursorDir
53
+ ? `dir at ${cursorDir}`
54
+ : "no ~/.cursor directory (open Cursor once)",
55
+ },
56
+ {
57
+ platform: "opencode",
58
+ installed: opencodePath !== null,
59
+ why: opencodePath
60
+ ? `config at ${opencodePath}`
61
+ : "no config file (~/.config/opencode/{opencode.json,opencode.jsonc})",
62
+ },
63
+ {
64
+ platform: "zcode",
65
+ installed: zcodeResult !== null,
66
+ why: zcodeResult
67
+ ? `config at ${zcodeResult.path}`
68
+ : "no config file (~/.zcode/cli/config.json or ~/.agents/mcp.json)",
69
+ },
70
+ {
71
+ platform: "codex",
72
+ installed: codexPath !== null,
73
+ why: codexPath
74
+ ? `config at ${codexPath}`
75
+ : "no config file (~/.codex/config.toml)",
76
+ },
77
+ ];
78
+ }
@@ -0,0 +1,234 @@
1
+ // src/install/opencode.ts — OpenCode install provider
2
+ //
3
+ // Adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { claudeSkillsDir, linkSkills, reportSkills } from "./skills.js";
9
+ import {
10
+ defaultExit,
11
+ INSTALL_ROOT,
12
+ type InstallDeps,
13
+ MCP_CONFIG,
14
+ MCP_KEY,
15
+ } from "./types.js";
16
+ import { computeDiff } from "./utils.js";
17
+
18
+ export function findOpencodeConfig(getHome: () => string): string | null {
19
+ const dir = join(getHome(), ".config", "opencode");
20
+ for (const name of ["opencode.json", "opencode.jsonc"]) {
21
+ const p = join(dir, name);
22
+ if (existsSync(p)) return p;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function stripJsonc(input: string): string {
28
+ let out = "";
29
+ let i = 0;
30
+ while (i < input.length) {
31
+ const c = input[i];
32
+ if (c === "/" && input[i + 1] === "/") {
33
+ while (i < input.length && input[i] !== "\n") i++;
34
+ continue;
35
+ }
36
+ if (c === "/" && input[i + 1] === "*") {
37
+ i += 2;
38
+ while (i < input.length && !(input[i] === "*" && input[i + 1] === "/"))
39
+ i++;
40
+ i += 2;
41
+ continue;
42
+ }
43
+ if (c === '"' || c === "'") {
44
+ const quote = c;
45
+ out += c;
46
+ i++;
47
+ while (i < input.length && input[i] !== quote) {
48
+ if (input[i] === "\\") {
49
+ out += input[i];
50
+ i++;
51
+ }
52
+ if (i < input.length) {
53
+ out += input[i];
54
+ i++;
55
+ }
56
+ }
57
+ if (i < input.length) {
58
+ out += input[i];
59
+ i++;
60
+ }
61
+ continue;
62
+ }
63
+ out += c;
64
+ i++;
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function parseJsonc(text: string): Record<string, unknown> {
70
+ return JSON.parse(stripJsonc(text)) as Record<string, unknown>;
71
+ }
72
+
73
+ function isConfigured(mcp: Record<string, unknown> | undefined): boolean {
74
+ if (!mcp) return false;
75
+ const entry = mcp[MCP_KEY];
76
+ if (!entry || typeof entry !== "object") return false;
77
+ const cfg = entry as Record<string, unknown>;
78
+ return (
79
+ cfg.type === MCP_CONFIG.type &&
80
+ JSON.stringify(cfg.command) === JSON.stringify(MCP_CONFIG.command)
81
+ );
82
+ }
83
+
84
+ export function cmdInstallOpencode(
85
+ dryRun: boolean,
86
+ deps: InstallDeps = {},
87
+ ): void {
88
+ const exitFn = deps.exit ?? defaultExit;
89
+ const getHome = deps.homedir ?? homedir;
90
+
91
+ const configPath = findOpencodeConfig(getHome);
92
+ let before: Record<string, unknown>;
93
+ let isNew = false;
94
+
95
+ if (configPath) {
96
+ try {
97
+ before = parseJsonc(readFileSync(configPath, "utf-8"));
98
+ } catch (e) {
99
+ console.error(`failed to parse ${configPath}: ${(e as Error).message}`);
100
+ exitFn(1);
101
+ return;
102
+ }
103
+ } else {
104
+ isNew = true;
105
+ before = {};
106
+ }
107
+
108
+ const mcp = (before.mcp as Record<string, unknown>) ?? {};
109
+ if (isConfigured(mcp)) {
110
+ console.error(`✓ mcp.${MCP_KEY} already configured — no change needed`);
111
+ if (configPath) console.error(` (${configPath})`);
112
+ const skillsDir = claudeSkillsDir(getHome);
113
+ reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
114
+ installReadHintPlugin(dryRun, getHome);
115
+ return;
116
+ }
117
+
118
+ const after = {
119
+ ...before,
120
+ mcp: {
121
+ ...mcp,
122
+ [MCP_KEY]: MCP_CONFIG,
123
+ },
124
+ };
125
+
126
+ if (dryRun) {
127
+ console.error(`── dry-run: would write mcp.${MCP_KEY} ──`);
128
+ if (isNew) {
129
+ console.error(
130
+ ` (new file: ${join(getHome(), ".config", "opencode", "opencode.json")})`,
131
+ );
132
+ } else {
133
+ console.error(` (${configPath})`);
134
+ }
135
+ console.log(computeDiff(before, after));
136
+ installReadHintPlugin(dryRun, getHome);
137
+ return;
138
+ }
139
+
140
+ const targetPath =
141
+ configPath ?? join(getHome(), ".config", "opencode", "opencode.json");
142
+ const dir = targetPath.split("/").slice(0, -1).join("/");
143
+ if (!existsSync(dir)) {
144
+ mkdirSync(dir, { recursive: true });
145
+ }
146
+
147
+ writeFileSync(targetPath, `${JSON.stringify(after, null, 2)}\n`);
148
+ if (isNew) {
149
+ console.error(`✓ created ${targetPath} with mcp.${MCP_KEY}`);
150
+ } else {
151
+ console.error(`✓ added mcp.${MCP_KEY} to ${configPath}`);
152
+ }
153
+ console.error(` restart opencode to load the MCP server`);
154
+ const skillsDir = claudeSkillsDir(getHome);
155
+ reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
156
+ installReadHintPlugin(dryRun, getHome);
157
+ }
158
+
159
+ /**
160
+ * OpenCode plugin — the read hint's in-process shape. `tool.execute.after`
161
+ * receives `input.args` (the read call's own args) and a mutable
162
+ * `output.output` string, so one hook computes and appends the hint with no
163
+ * process spawn per Read. Annotate only: the hook mutates output, never
164
+ * throws, never dedupes ("you already read this" goes false after context
165
+ * compaction — a hook that guesses wrong must never trap the agent).
166
+ *
167
+ * Logic lives in src/hook.ts (readHintFor) — the plugin imports it from the
168
+ * install root (path baked at install time), same one-copy-per-client shape
169
+ * as the skills symlinks: a git pull in INSTALL_ROOT updates every client.
170
+ * Best-effort, same policy as the claude hooks: an existing file that isn't
171
+ * fapony's is never overwritten, and a failure never fails the install.
172
+ */
173
+ export function readHintPluginSource(root: string): string {
174
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
175
+ return `// fapony read hint — annotates full-file reads of large source files with a
176
+ // factual review-seed pointer. Annotate only: never blocks, never dedupes.
177
+ // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
178
+ import { readHintFor } from ${hookModule};
179
+
180
+ export const FaponyReadHint = async ({ directory }) => {
181
+ return {
182
+ "tool.execute.after": async (input, output) => {
183
+ try {
184
+ if (input.tool !== "read") return;
185
+ const hint = readHintFor({
186
+ filePath: input.args?.filePath,
187
+ offset: input.args?.offset,
188
+ limit: input.args?.limit,
189
+ cwd: directory,
190
+ });
191
+ if (hint && typeof output.output === "string") {
192
+ output.output = output.output + "\\n" + hint;
193
+ }
194
+ } catch {
195
+ // a hint must never break a read
196
+ }
197
+ },
198
+ };
199
+ };
200
+ `;
201
+ }
202
+
203
+ function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
204
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
205
+ const pluginPath = join(pluginsDir, "fapony-read-hint.ts");
206
+ if (existsSync(pluginPath)) {
207
+ let current = "";
208
+ try {
209
+ current = readFileSync(pluginPath, "utf-8");
210
+ } catch {
211
+ current = "";
212
+ }
213
+ if (current.includes("readHintFor")) {
214
+ console.error(` read hint: already installed — no change`);
215
+ return;
216
+ }
217
+ console.error(
218
+ ` read hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
219
+ );
220
+ return;
221
+ }
222
+ if (!dryRun) {
223
+ try {
224
+ mkdirSync(pluginsDir, { recursive: true });
225
+ writeFileSync(pluginPath, readHintPluginSource(INSTALL_ROOT), "utf-8");
226
+ } catch (e) {
227
+ console.error(` read hint: failed to write — ${(e as Error).message}`);
228
+ return;
229
+ }
230
+ }
231
+ console.error(
232
+ ` read hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
233
+ );
234
+ }
@@ -0,0 +1,106 @@
1
+ // src/install/skills.ts — skill symlink logic for install
2
+ //
3
+ // A copied skill goes stale the moment fapony updates, and every client would
4
+ // need its own copy to refresh. Symlinking the directory means `fapony update`
5
+ // (a git pull in INSTALL_ROOT) reaches every client at once. The <name>/SKILL.md
6
+ // layout is what Claude Code expects, so the link is directory-to-directory.
7
+ //
8
+ // OpenCode reads ~/.claude/skills too, so linking once covers both.
9
+
10
+ import {
11
+ existsSync,
12
+ lstatSync,
13
+ mkdirSync,
14
+ readdirSync,
15
+ readlinkSync,
16
+ symlinkSync,
17
+ } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { INSTALL_ROOT, type SkillLinkResult } from "./types.js";
20
+
21
+ export function claudeSkillsDir(getHome: () => string): string {
22
+ return join(getHome(), ".claude", "skills");
23
+ }
24
+
25
+ export function agentsSkillsDir(getHome: () => string): string {
26
+ return join(getHome(), ".agents", "skills");
27
+ }
28
+
29
+ /**
30
+ * Link every skill/<name>/ into `skillsDir`.
31
+ *
32
+ * Never overwrites: a destination that already exists and is not already our
33
+ * link is reported as `conflict` and left alone — it may be the user's own
34
+ * skill, or another tool's, and clobbering it is not ours to decide.
35
+ */
36
+ export function linkSkills(
37
+ skillsDir: string,
38
+ dryRun: boolean,
39
+ ): SkillLinkResult[] {
40
+ const srcRoot = join(INSTALL_ROOT, "skill");
41
+ if (!existsSync(srcRoot)) return [];
42
+
43
+ const names = readdirSync(srcRoot, { withFileTypes: true })
44
+ .filter((e) => e.isDirectory())
45
+ .map((e) => e.name)
46
+ .sort();
47
+
48
+ const out: SkillLinkResult[] = [];
49
+ for (const name of names) {
50
+ const src = join(srcRoot, name);
51
+ const dest = join(skillsDir, name);
52
+
53
+ // null = nothing there; "" = exists but not a symlink; else = link target.
54
+ let existing: string | null;
55
+ try {
56
+ existing = lstatSync(dest).isSymbolicLink() ? readlinkSync(dest) : "";
57
+ } catch {
58
+ existing = null;
59
+ }
60
+
61
+ if (existing === src) {
62
+ out.push({ name, action: "already" });
63
+ continue;
64
+ }
65
+ if (existing !== null) {
66
+ out.push({ name, action: "conflict" });
67
+ continue;
68
+ }
69
+ if (!dryRun) {
70
+ mkdirSync(skillsDir, { recursive: true });
71
+ symlinkSync(src, dest);
72
+ }
73
+ out.push({ name, action: "linked" });
74
+ }
75
+ return out;
76
+ }
77
+
78
+ export function reportSkills(
79
+ results: SkillLinkResult[],
80
+ skillsDir: string,
81
+ dryRun: boolean,
82
+ ): void {
83
+ if (results.length === 0) return;
84
+
85
+ const linked = results.filter((r) => r.action === "linked").length;
86
+ const already = results.filter((r) => r.action === "already").length;
87
+ const conflicts = results.filter((r) => r.action === "conflict");
88
+
89
+ if (linked > 0) {
90
+ const verb = dryRun ? "would link" : "linked";
91
+ console.error(
92
+ `✓ ${verb} ${linked} skill${linked === 1 ? "" : "s"} → ${skillsDir}`,
93
+ );
94
+ }
95
+ if (already > 0) {
96
+ console.error(` ${already} already linked — no change`);
97
+ }
98
+ for (const c of conflicts) {
99
+ console.error(
100
+ ` ! ${c.name} already exists and is not a fapony link — not overwriting`,
101
+ );
102
+ console.error(
103
+ ` to replace: rm -r ${join(skillsDir, c.name)} && ln -s ${join(INSTALL_ROOT, "skill", c.name)} ${join(skillsDir, c.name)}`,
104
+ );
105
+ }
106
+ }