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.
- package/LICENSE +21 -0
- package/README.md +473 -0
- package/fapony.ts +78 -0
- package/package.json +42 -0
- package/skill/git-commit-conventional/SKILL.md +68 -0
- package/skill/git-ship/SKILL.md +144 -0
- package/skill/move-to-done/SKILL.md +126 -0
- package/skill/plan-with-pony/SKILL.md +263 -0
- package/skill/review-pony/SKILL.md +254 -0
- package/src/analyze.ts +517 -0
- package/src/context/index.ts +11 -0
- package/src/context/projectHealth.ts +359 -0
- package/src/conventions-seed.ts +420 -0
- package/src/db/defaults.ts +26 -0
- package/src/db/getters.ts +33 -0
- package/src/db/index.ts +7 -0
- package/src/db/load.ts +57 -0
- package/src/db/store.ts +286 -0
- package/src/db/types.ts +79 -0
- package/src/debt.ts +667 -0
- package/src/digest/cli.ts +75 -0
- package/src/digest/collect.ts +625 -0
- package/src/digest/html.ts +208 -0
- package/src/digest/text.ts +191 -0
- package/src/gate.ts +153 -0
- package/src/gates.ts +194 -0
- package/src/hook.ts +436 -0
- package/src/init-mem.ts +71 -0
- package/src/init.ts +237 -0
- package/src/install/claude.ts +361 -0
- package/src/install/codex.ts +61 -0
- package/src/install/cursor.ts +167 -0
- package/src/install/detect.ts +78 -0
- package/src/install/opencode.ts +234 -0
- package/src/install/skills.ts +106 -0
- package/src/install/types.ts +69 -0
- package/src/install/utils.ts +29 -0
- package/src/install/zcode.ts +120 -0
- package/src/install.ts +176 -0
- package/src/lint-baseline.ts +260 -0
- package/src/map.ts +320 -0
- package/src/math.ts +13 -0
- package/src/mcp/evidence.ts +332 -0
- package/src/mcp/primitives.ts +316 -0
- package/src/mcp/tools/check.ts +243 -0
- package/src/mcp/tools/collect.ts +157 -0
- package/src/mcp/tools/context.ts +66 -0
- package/src/mcp/tools/index.ts +309 -0
- package/src/mcp/tools/mem.ts +95 -0
- package/src/mcp/tools/plans.ts +255 -0
- package/src/mcp/tools/report.ts +285 -0
- package/src/mcp/tools/stats.ts +96 -0
- package/src/mcp/tools/usage.ts +211 -0
- package/src/mcp/tools/verdict.ts +148 -0
- package/src/mcp/transport.ts +241 -0
- package/src/mcp/types.ts +54 -0
- package/src/mcp/worktree.ts +27 -0
- package/src/memory.ts +264 -0
- package/src/parse.ts +71 -0
- package/src/plan-seed.ts +599 -0
- package/src/price/fetch.ts +146 -0
- package/src/price/index.ts +8 -0
- package/src/price/resolve.ts +213 -0
- package/src/report/cli.ts +92 -0
- package/src/report/format.ts +37 -0
- package/src/report/index.ts +4 -0
- package/src/report/render.ts +206 -0
- package/src/review-seed.ts +932 -0
- package/src/safety.ts +18 -0
- package/src/session/activeSession.ts +153 -0
- package/src/session/claude-code.ts +412 -0
- package/src/session/codex.ts +347 -0
- package/src/session/findModel.ts +376 -0
- package/src/session/helpers.ts +640 -0
- package/src/session/index.ts +31 -0
- package/src/session/opencode.ts +167 -0
- package/src/session/registry.ts +45 -0
- package/src/session/types.ts +128 -0
- package/src/session/zcode.ts +151 -0
- package/src/setup.ts +242 -0
- package/src/stats/cli.ts +44 -0
- package/src/stats/data.ts +1019 -0
- package/src/stats/format.ts +584 -0
- package/src/stats/index.ts +19 -0
- package/src/telemetry.ts +364 -0
- package/src/test.ts +2 -0
- package/src/update.ts +212 -0
- package/src/usage/cache.ts +125 -0
- package/src/usage/cli.ts +120 -0
- package/src/usage/format.ts +29 -0
- package/src/usage/index.ts +4 -0
- package/src/usage/render.ts +523 -0
- package/src/usage/scan.ts +161 -0
- package/src/util.ts +32 -0
- package/src/web/html.ts +33 -0
- package/templates/PLAN.md +90 -0
- package/templates/SPEC.md +30 -0
- package/templates/mem/commands/plan.ts +360 -0
- package/templates/mem/commands/read.ts +194 -0
- package/templates/mem/commands/rotate.ts +59 -0
- package/templates/mem/commands/selftest.ts +450 -0
- package/templates/mem/commands/write.ts +214 -0
- package/templates/mem/mem.ts +68 -0
- package/templates/mem/render.ts +63 -0
- package/templates/mem/selectors.ts +144 -0
- package/templates/mem/store.ts +285 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/install/types.ts — shared types + constants for install providers
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Repo root (parent of src/) — absolute path to this fapony checkout,
|
|
6
|
+
* so `claude mcp add` works even before `bun link`. Same pattern as src/update.ts.
|
|
7
|
+
* Note: this file lives in src/install/, so ../.. reaches the repo root. */
|
|
8
|
+
export const INSTALL_ROOT = join(import.meta.dir, "..", "..");
|
|
9
|
+
|
|
10
|
+
export const MCP_KEY = "fapony";
|
|
11
|
+
|
|
12
|
+
export const MCP_CONFIG = {
|
|
13
|
+
type: "local",
|
|
14
|
+
command: ["bun", "run", "fapony.ts", "mcp"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** ZCode uses the stdio MCP shape: command is a string, args is an array.
|
|
18
|
+
* See ~/.zcode/cli/plugins/cache/zcode-plugins-official/zcode-guide/...
|
|
19
|
+
* (do not use OpenCode-style `command: [...]` in ZCode's JSON editor). */
|
|
20
|
+
export const ZCODE_MCP_CONFIG = {
|
|
21
|
+
type: "stdio",
|
|
22
|
+
command: "bun",
|
|
23
|
+
args: ["run", join(INSTALL_ROOT, "fapony.ts"), "mcp"],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const CODEX_MCP_ENTRY = `[mcp_servers.fapony]
|
|
27
|
+
command = "bun"
|
|
28
|
+
args = ["run", "${INSTALL_ROOT}/fapony.ts", "mcp"]
|
|
29
|
+
type = "stdio"
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** Cursor's stdio MCP shape (mcpServers.fapony in ~/.cursor/mcp.json):
|
|
33
|
+
* command is a string, args is an array — same as ZCode, not OpenCode's
|
|
34
|
+
* `command: [...]`. */
|
|
35
|
+
export const CURSOR_MCP_ENTRY = {
|
|
36
|
+
command: "bun",
|
|
37
|
+
args: ["run", join(INSTALL_ROOT, "fapony.ts"), "mcp"],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface ClaudeRunResult {
|
|
41
|
+
exitCode: number;
|
|
42
|
+
stdout: string;
|
|
43
|
+
stderr: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface InstallDeps {
|
|
47
|
+
/** Run argv synchronously. Defaults to Bun.spawnSync. Injected in tests. */
|
|
48
|
+
run?: (argv: string[]) => ClaudeRunResult;
|
|
49
|
+
exit?: (code: number) => never;
|
|
50
|
+
/** Override os.homedir() for tests. */
|
|
51
|
+
homedir?: () => string;
|
|
52
|
+
/** Prompt the user (question → answer). Injected in tests; when absent,
|
|
53
|
+
* detect/prompt path prints results and exits instead of asking. */
|
|
54
|
+
ask?: (question: string, defaultVal?: string) => Promise<string>;
|
|
55
|
+
/** Check if a command is on PATH. Defaults to `command -v` with allowlist. */
|
|
56
|
+
checkCmd?: (cmd: string) => boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Default process exit. Shared by all providers — do not duplicate. */
|
|
60
|
+
export function defaultExit(code: number): never {
|
|
61
|
+
return process.exit(code);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SkillLinkAction = "linked" | "already" | "conflict";
|
|
65
|
+
|
|
66
|
+
export interface SkillLinkResult {
|
|
67
|
+
name: string;
|
|
68
|
+
action: SkillLinkAction;
|
|
69
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/install/utils.ts — shared utilities for install providers
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute a line-by-line JSON diff between two objects.
|
|
5
|
+
* Used by OpenCode and ZCode providers for dry-run output.
|
|
6
|
+
*/
|
|
7
|
+
export function computeDiff(
|
|
8
|
+
before: Record<string, unknown>,
|
|
9
|
+
after: Record<string, unknown>,
|
|
10
|
+
): string {
|
|
11
|
+
const beforeStr = JSON.stringify(before, null, 2);
|
|
12
|
+
const afterStr = JSON.stringify(after, null, 2);
|
|
13
|
+
const beforeLines = beforeStr.split("\n");
|
|
14
|
+
const afterLines = afterStr.split("\n");
|
|
15
|
+
|
|
16
|
+
const diff: string[] = [];
|
|
17
|
+
const max = Math.max(beforeLines.length, afterLines.length);
|
|
18
|
+
for (let i = 0; i < max; i++) {
|
|
19
|
+
const b = beforeLines[i];
|
|
20
|
+
const a = afterLines[i];
|
|
21
|
+
if (b !== a) {
|
|
22
|
+
if (b !== undefined) diff.push(`- ${b}`);
|
|
23
|
+
if (a !== undefined) diff.push(`+ ${a}`);
|
|
24
|
+
} else {
|
|
25
|
+
diff.push(` ${b}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return diff.join("\n");
|
|
29
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// src/install/zcode.ts — ZCode install provider
|
|
2
|
+
//
|
|
3
|
+
// Reads/writes ~/.zcode/cli/config.json (fallback ~/.agents/mcp.json) directly.
|
|
4
|
+
// ZCode has no `zcode mcp add` CLI.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { agentsSkillsDir, linkSkills, reportSkills } from "./skills.js";
|
|
10
|
+
import {
|
|
11
|
+
defaultExit,
|
|
12
|
+
type InstallDeps,
|
|
13
|
+
MCP_KEY,
|
|
14
|
+
ZCODE_MCP_CONFIG,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
import { computeDiff } from "./utils.js";
|
|
17
|
+
|
|
18
|
+
/** Resolve the active ZCode config file. Primary: ~/.zcode/cli/config.json.
|
|
19
|
+
* Fallback: ~/.agents/mcp.json (only when primary is absent). */
|
|
20
|
+
export function findZcodeConfig(
|
|
21
|
+
getHome: () => string,
|
|
22
|
+
): { path: string; key: "mcp.servers" | "mcpServers" } | null {
|
|
23
|
+
const primary = join(getHome(), ".zcode", "cli", "config.json");
|
|
24
|
+
if (existsSync(primary)) return { path: primary, key: "mcp.servers" };
|
|
25
|
+
const fallback = join(getHome(), ".agents", "mcp.json");
|
|
26
|
+
if (existsSync(fallback)) return { path: fallback, key: "mcpServers" };
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getZcodeServers(
|
|
31
|
+
cfg: Record<string, unknown>,
|
|
32
|
+
key: "mcp.servers" | "mcpServers",
|
|
33
|
+
): Record<string, unknown> | undefined {
|
|
34
|
+
if (key === "mcpServers") {
|
|
35
|
+
return cfg.mcpServers as Record<string, unknown> | undefined;
|
|
36
|
+
}
|
|
37
|
+
const mcp = cfg.mcp as Record<string, unknown> | undefined;
|
|
38
|
+
return mcp?.servers as Record<string, unknown> | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setZcodeServers(
|
|
42
|
+
cfg: Record<string, unknown>,
|
|
43
|
+
key: "mcp.servers" | "mcpServers",
|
|
44
|
+
servers: Record<string, unknown>,
|
|
45
|
+
): Record<string, unknown> {
|
|
46
|
+
if (key === "mcpServers") {
|
|
47
|
+
return { ...cfg, mcpServers: servers };
|
|
48
|
+
}
|
|
49
|
+
const mcp = (cfg.mcp as Record<string, unknown>) ?? {};
|
|
50
|
+
return { ...cfg, mcp: { ...mcp, servers } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isZcodeConfigured(
|
|
54
|
+
servers: Record<string, unknown> | undefined,
|
|
55
|
+
): boolean {
|
|
56
|
+
if (!servers) return false;
|
|
57
|
+
const entry = servers[MCP_KEY];
|
|
58
|
+
if (!entry || typeof entry !== "object") return false;
|
|
59
|
+
const cfg = entry as Record<string, unknown>;
|
|
60
|
+
if (cfg.type !== "stdio" && cfg.type !== undefined) return false;
|
|
61
|
+
if (cfg.command !== ZCODE_MCP_CONFIG.command) return false;
|
|
62
|
+
if (!Array.isArray(cfg.args)) return false;
|
|
63
|
+
return JSON.stringify(cfg.args) === JSON.stringify(ZCODE_MCP_CONFIG.args);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cmdInstallZcode(dryRun: boolean, deps: InstallDeps = {}): void {
|
|
67
|
+
const exitFn = deps.exit ?? defaultExit;
|
|
68
|
+
const getHome = deps.homedir ?? homedir;
|
|
69
|
+
|
|
70
|
+
const found = findZcodeConfig(getHome);
|
|
71
|
+
if (!found) {
|
|
72
|
+
console.error(
|
|
73
|
+
`ZCode config not found — open ZCode at least once to create ~/.zcode/cli/config.json`,
|
|
74
|
+
);
|
|
75
|
+
exitFn(1);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let before: Record<string, unknown>;
|
|
80
|
+
try {
|
|
81
|
+
before = JSON.parse(readFileSync(found.path, "utf-8")) as Record<
|
|
82
|
+
string,
|
|
83
|
+
unknown
|
|
84
|
+
>;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error(`failed to parse ${found.path}: ${(e as Error).message}`);
|
|
87
|
+
exitFn(1);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const servers = getZcodeServers(before, found.key) ?? {};
|
|
92
|
+
if (isZcodeConfigured(servers)) {
|
|
93
|
+
console.error(`✓ mcp.${MCP_KEY} already configured — no change needed`);
|
|
94
|
+
console.error(` (${found.path})`);
|
|
95
|
+
const skillsDir = agentsSkillsDir(getHome);
|
|
96
|
+
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const after = setZcodeServers(before, found.key, {
|
|
101
|
+
...servers,
|
|
102
|
+
[MCP_KEY]: ZCODE_MCP_CONFIG,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (dryRun) {
|
|
106
|
+
console.error(`── dry-run: would write ${found.key}.${MCP_KEY} ──`);
|
|
107
|
+
console.error(` (${found.path})`);
|
|
108
|
+
console.log(computeDiff(before, after));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
writeFileSync(found.path, `${JSON.stringify(after, null, 2)}\n`);
|
|
113
|
+
console.error(`✓ added mcp.${MCP_KEY} to ${found.path}`);
|
|
114
|
+
if (found.key === "mcpServers") {
|
|
115
|
+
console.error(` (fallback path: ~/.agents/mcp.json)`);
|
|
116
|
+
}
|
|
117
|
+
console.error(` restart ZCode to load the MCP server`);
|
|
118
|
+
const skillsDir = agentsSkillsDir(getHome);
|
|
119
|
+
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
120
|
+
}
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// src/install.ts — `fapony install --platform opencode|claude|cursor|zcode|codex` command.
|
|
2
|
+
// opencode: adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
|
|
3
|
+
// claude: shells out to `claude mcp add` (never parses/writes ~/.claude.json directly).
|
|
4
|
+
// cursor: writes mcpServers.fapony to ~/.cursor/mcp.json directly and merges the
|
|
5
|
+
// fapony stop hook into the stop array in ~/.cursor/hooks.json.
|
|
6
|
+
// zcode: reads/writes ~/.zcode/cli/config.json (fallback ~/.agents/mcp.json) directly,
|
|
7
|
+
// and symlinks skills into ~/.agents/skills.
|
|
8
|
+
// codex: reads/writes ~/.codex/config.toml directly.
|
|
9
|
+
// All platforms are idempotent + support --dry-run.
|
|
10
|
+
// Claude/OpenCode also get skill/<name>/ symlinked into ~/.claude/skills so
|
|
11
|
+
// `fapony update` reaches them without a second copy to keep in sync.
|
|
12
|
+
// ZCode gets skill/<name>/ symlinked into ~/.agents/skills.
|
|
13
|
+
|
|
14
|
+
import { createInterface } from "node:readline";
|
|
15
|
+
import { cmdInstallClaude } from "./install/claude.js";
|
|
16
|
+
import { cmdInstallCodex } from "./install/codex.js";
|
|
17
|
+
import { cmdInstallCursor } from "./install/cursor.js";
|
|
18
|
+
import { detectClients } from "./install/detect.js";
|
|
19
|
+
import { cmdInstallOpencode } from "./install/opencode.js";
|
|
20
|
+
import { defaultExit, type InstallDeps } from "./install/types.js";
|
|
21
|
+
import { cmdInstallZcode } from "./install/zcode.js";
|
|
22
|
+
import { ask } from "./setup.js";
|
|
23
|
+
import { isAffirmative } from "./util.js";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
claudeAddArgs,
|
|
27
|
+
claudeGetArgs,
|
|
28
|
+
claudeGetPointsToFapony,
|
|
29
|
+
cmdInstallClaude,
|
|
30
|
+
} from "./install/claude.js";
|
|
31
|
+
export { cmdInstallCodex } from "./install/codex.js";
|
|
32
|
+
export { cmdInstallCursor } from "./install/cursor.js";
|
|
33
|
+
export { detectClients } from "./install/detect.js";
|
|
34
|
+
export { cmdInstallOpencode } from "./install/opencode.js";
|
|
35
|
+
export {
|
|
36
|
+
agentsSkillsDir,
|
|
37
|
+
claudeSkillsDir,
|
|
38
|
+
linkSkills,
|
|
39
|
+
} from "./install/skills.js";
|
|
40
|
+
export {
|
|
41
|
+
type ClaudeRunResult,
|
|
42
|
+
INSTALL_ROOT,
|
|
43
|
+
type InstallDeps,
|
|
44
|
+
} from "./install/types.js";
|
|
45
|
+
export { cmdInstallZcode } from "./install/zcode.js";
|
|
46
|
+
|
|
47
|
+
export async function cmdInstall(
|
|
48
|
+
args: string[],
|
|
49
|
+
deps: InstallDeps = {},
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const platform = args.find((a) => !a.startsWith("--"));
|
|
52
|
+
const dryRun = args.includes("--dry-run");
|
|
53
|
+
const installAll = args.includes("--all");
|
|
54
|
+
|
|
55
|
+
// --- explicit platform: original behavior, unchanged ---
|
|
56
|
+
if (platform === "claude") {
|
|
57
|
+
cmdInstallClaude(dryRun, deps);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (platform === "cursor") {
|
|
61
|
+
cmdInstallCursor(dryRun, deps);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (platform === "zcode") {
|
|
65
|
+
cmdInstallZcode(dryRun, deps);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (platform === "codex") {
|
|
69
|
+
cmdInstallCodex(dryRun, deps);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (platform === "opencode") {
|
|
73
|
+
cmdInstallOpencode(dryRun, deps);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (platform !== undefined) {
|
|
77
|
+
console.error(
|
|
78
|
+
`usage: fapony install --platform opencode|claude|cursor|zcode|codex [--dry-run]`,
|
|
79
|
+
);
|
|
80
|
+
console.error(
|
|
81
|
+
` supported platforms: opencode, claude, cursor, zcode, codex`,
|
|
82
|
+
);
|
|
83
|
+
(deps.exit ?? defaultExit)(1);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- no platform: detect + prompt ---
|
|
88
|
+
const detected = detectClients(deps);
|
|
89
|
+
const found = detected.filter((d) => d.installed);
|
|
90
|
+
const notFound = detected.filter((d) => !d.installed);
|
|
91
|
+
|
|
92
|
+
const foundNames = found.map((d) => d.platform).join(", ");
|
|
93
|
+
const notFoundNames = notFound.map((d) => d.platform).join(", ");
|
|
94
|
+
console.error(
|
|
95
|
+
` detected: ${foundNames || "(none)"}${notFoundNames ? ` not found: ${notFoundNames}` : ""}`,
|
|
96
|
+
);
|
|
97
|
+
console.error(
|
|
98
|
+
` (not found = no config file yet — or, for claude, the CLI is not on PATH.`,
|
|
99
|
+
);
|
|
100
|
+
console.error(` open the app once, or force with --platform <name>)`);
|
|
101
|
+
|
|
102
|
+
if (found.length === 0) {
|
|
103
|
+
console.error();
|
|
104
|
+
console.error(
|
|
105
|
+
` no MCP client found (looked for claude on PATH; config files for cursor, opencode, zcode, codex).`,
|
|
106
|
+
);
|
|
107
|
+
console.error(
|
|
108
|
+
` open the app once, then re-run — or force with: fapony install --platform <name>`,
|
|
109
|
+
);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --all: install everything detected without prompting (works in CI/non-TTY).
|
|
114
|
+
if (installAll) {
|
|
115
|
+
console.error();
|
|
116
|
+
for (const client of found) {
|
|
117
|
+
installPlatform(client.platform, dryRun, deps);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Non-TTY without --all: print results + hint, don't install anything.
|
|
123
|
+
if (!process.stdin.isTTY) {
|
|
124
|
+
console.error();
|
|
125
|
+
console.error(
|
|
126
|
+
` stdin is not a terminal — re-run with --all, or --platform <name>`,
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build the ask function: injected (tests) or real readline (production).
|
|
132
|
+
// Mirrors cmdSetup's seam — one readline for the whole loop, closed after.
|
|
133
|
+
const rl = deps.ask
|
|
134
|
+
? null
|
|
135
|
+
: createInterface({ input: process.stdin, output: process.stdout });
|
|
136
|
+
const askFn =
|
|
137
|
+
deps.ask ??
|
|
138
|
+
((question: string, defaultVal?: string) => ask(rl!, question, defaultVal));
|
|
139
|
+
|
|
140
|
+
// Prompt each detected client.
|
|
141
|
+
try {
|
|
142
|
+
console.error();
|
|
143
|
+
for (const client of found) {
|
|
144
|
+
const answer = await askFn(`install into ${client.platform}?`, "Y");
|
|
145
|
+
if (isAffirmative(answer)) {
|
|
146
|
+
installPlatform(client.platform, dryRun, deps);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
rl?.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function installPlatform(
|
|
155
|
+
platform: string,
|
|
156
|
+
dryRun: boolean,
|
|
157
|
+
deps: InstallDeps,
|
|
158
|
+
): void {
|
|
159
|
+
switch (platform) {
|
|
160
|
+
case "claude":
|
|
161
|
+
cmdInstallClaude(dryRun, deps);
|
|
162
|
+
break;
|
|
163
|
+
case "cursor":
|
|
164
|
+
cmdInstallCursor(dryRun, deps);
|
|
165
|
+
break;
|
|
166
|
+
case "opencode":
|
|
167
|
+
cmdInstallOpencode(dryRun, deps);
|
|
168
|
+
break;
|
|
169
|
+
case "zcode":
|
|
170
|
+
cmdInstallZcode(dryRun, deps);
|
|
171
|
+
break;
|
|
172
|
+
case "codex":
|
|
173
|
+
cmdInstallCodex(dryRun, deps);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// src/lint-baseline.ts — `fapony lint-baseline`: separate "was red before" from "I made it red".
|
|
2
|
+
//
|
|
3
|
+
// warning กับ error ที่แดงอยู่ก่อนหน้า agent เข้ามา ทำให้ agent ต้อง `fix all` ก่อน
|
|
4
|
+
// แล้วงานจริงถูกกลบใน diff ก้อนเดียว (SPEC-convention-debt §4 — จากปากเจ้าของ:
|
|
5
|
+
// "138 จุดใน ~40 ไฟล์ที่ไม่เกี่ยวกับงาน") ตัวเลขของรีโปที่แดงอยู่แล้ว 212 จุด —
|
|
6
|
+
// agent ที่ไม่ได้ทำอะไรผิดต้องเห็น **0**
|
|
7
|
+
//
|
|
8
|
+
// กลไก — ไม่รู้จัก eslint: รันคำสั่งที่รีโปบอก (--cmd หรือ evidence.json ชื่อ "lint"),
|
|
9
|
+
// parse เป็นชุด `path:rule-id` (**ไม่ใช่เลขบรรทัด** — บรรทัดขยับทุกครั้งที่แก้ไฟล์),
|
|
10
|
+
// เก็บ baseline ไว้ที่ base_sha · ผลลัพธ์ไม่เข้า state.db (2 ตารางห้ามเพิ่ม) —
|
|
11
|
+
// เป็นไฟล์ชั่วคราวใต้ state dir และถูกทิ้งเมื่อ --diff รายงานแล้ว · รายงานอย่างเดียว
|
|
12
|
+
// ไม่ block — agent ตัดสินเองว่าจะแก้ของเก่าด้วยไหม
|
|
13
|
+
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
import {
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { join, relative } from "node:path";
|
|
23
|
+
import { faponyDir } from "./db/load.js";
|
|
24
|
+
import { assertSafe } from "./safety.js";
|
|
25
|
+
|
|
26
|
+
const USAGE =
|
|
27
|
+
'usage: fapony lint-baseline --capture [--cmd "<lint command>"]\n' +
|
|
28
|
+
' fapony lint-baseline --diff [--cmd "<lint command>"]\n' +
|
|
29
|
+
' (without --cmd, uses the evidence.json command named "lint")';
|
|
30
|
+
|
|
31
|
+
interface BaselineFile {
|
|
32
|
+
worktree: string;
|
|
33
|
+
baseSha: string;
|
|
34
|
+
ts: string;
|
|
35
|
+
pairs: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function worktreeOf(cwd: string): string {
|
|
39
|
+
const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
40
|
+
cwd,
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
});
|
|
44
|
+
if (p.exitCode !== 0) {
|
|
45
|
+
console.error("lint-baseline: not a git repository");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
return p.stdout.toString().trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function gitSha(cwd: string): string {
|
|
52
|
+
const p = Bun.spawnSync(["git", "rev-parse", "HEAD"], {
|
|
53
|
+
cwd,
|
|
54
|
+
stdout: "pipe",
|
|
55
|
+
stderr: "pipe",
|
|
56
|
+
});
|
|
57
|
+
return p.exitCode === 0 ? p.stdout.toString().trim().slice(0, 12) : "none";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function baselinePath(worktree: string): string {
|
|
61
|
+
const key = worktree.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
62
|
+
return join(faponyDir(), "lint-baseline", `${key}.json`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readEvidenceLintCmd(worktree: string): string | null {
|
|
66
|
+
const evidence = join(worktree, ".fapony", "evidence.json");
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(readFileSync(evidence, "utf-8")) as {
|
|
69
|
+
commands?: { name?: string; cmd?: string }[];
|
|
70
|
+
};
|
|
71
|
+
const hit = (parsed.commands ?? []).find(
|
|
72
|
+
(c) => c.name === "lint" && typeof c.cmd === "string" && c.cmd.length > 0,
|
|
73
|
+
);
|
|
74
|
+
return hit?.cmd ?? null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runLint(cmd: string, worktree: string): string {
|
|
81
|
+
// --cmd / evidence.json is the repo's own declaration — same trust as
|
|
82
|
+
// `fapony report` running an allowlisted command; deny rules still apply.
|
|
83
|
+
assertSafe(cmd.split(/\s+/));
|
|
84
|
+
try {
|
|
85
|
+
return execSync(cmd, {
|
|
86
|
+
cwd: worktree,
|
|
87
|
+
encoding: "utf-8",
|
|
88
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
89
|
+
timeout: 120_000,
|
|
90
|
+
});
|
|
91
|
+
} catch (e) {
|
|
92
|
+
const err = e as { stdout?: string; stderr?: string };
|
|
93
|
+
// linters exit non-zero when they find anything — stdout IS the result
|
|
94
|
+
const out = `${err.stdout ?? ""}\n${err.stderr ?? ""}`;
|
|
95
|
+
if (out.trim().length === 0) throw e;
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- parsers: eslint JSON first, unix one-liner fallback ---
|
|
101
|
+
|
|
102
|
+
interface Finding {
|
|
103
|
+
file: string;
|
|
104
|
+
rule: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseEslintJson(output: string, worktree: string): Finding[] {
|
|
108
|
+
let parsed: unknown;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(output);
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(parsed)) return [];
|
|
115
|
+
const out: Finding[] = [];
|
|
116
|
+
for (const f of parsed) {
|
|
117
|
+
const o = f as {
|
|
118
|
+
filePath?: string;
|
|
119
|
+
messages?: { ruleId?: string | null }[];
|
|
120
|
+
};
|
|
121
|
+
if (typeof o.filePath !== "string") continue;
|
|
122
|
+
const rel = relative(worktree, o.filePath);
|
|
123
|
+
for (const m of o.messages ?? []) {
|
|
124
|
+
out.push({
|
|
125
|
+
file: rel.startsWith("..") ? o.filePath : rel,
|
|
126
|
+
rule: m.ruleId ?? "syntax-error",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// `/abs/file.ts:12:5: message (rule-id)` or trailing ` rule-id` — eslint -f unix,
|
|
134
|
+
// and close cousins (golangci-lint, shellcheck -f gcc, …)
|
|
135
|
+
const UNIX_LINE_RE = /^(.+?):\d+:\d+:\s+.*?\(?([A-Za-z0-9_@][\w@/.-]*)\)?\s*$/;
|
|
136
|
+
|
|
137
|
+
function parseUnixLines(output: string, worktree: string): Finding[] {
|
|
138
|
+
const out: Finding[] = [];
|
|
139
|
+
for (const line of output.split("\n")) {
|
|
140
|
+
if (!line.trim()) continue;
|
|
141
|
+
const m = UNIX_LINE_RE.exec(line.trim());
|
|
142
|
+
if (!m) continue;
|
|
143
|
+
const raw = m[1];
|
|
144
|
+
const abs = raw.startsWith("/") ? raw : join(worktree, raw);
|
|
145
|
+
const rel = relative(worktree, abs);
|
|
146
|
+
out.push({ file: rel.startsWith("..") ? raw : rel, rule: m[2] });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseFindings(
|
|
152
|
+
output: string,
|
|
153
|
+
worktree: string,
|
|
154
|
+
): {
|
|
155
|
+
findings: Finding[];
|
|
156
|
+
format: string;
|
|
157
|
+
} {
|
|
158
|
+
const asJson = parseEslintJson(output, worktree);
|
|
159
|
+
if (asJson.length > 0) return { findings: asJson, format: "eslint-json" };
|
|
160
|
+
const asLines = parseUnixLines(output, worktree);
|
|
161
|
+
if (asLines.length > 0) return { findings: asLines, format: "unix-lines" };
|
|
162
|
+
return { findings: [], format: output.trim() ? "unknown" : "empty" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function capturePairs(
|
|
166
|
+
cmd: string,
|
|
167
|
+
worktree: string,
|
|
168
|
+
): {
|
|
169
|
+
pairs: Set<string>;
|
|
170
|
+
format: string;
|
|
171
|
+
} {
|
|
172
|
+
const output = runLint(cmd, worktree);
|
|
173
|
+
const { findings, format } = parseFindings(output, worktree);
|
|
174
|
+
if (format === "unknown") {
|
|
175
|
+
console.error(
|
|
176
|
+
"lint-baseline: could not parse the lint output.\n" +
|
|
177
|
+
"Supported: eslint `-f json` (default in most setups), or one-line\n" +
|
|
178
|
+
"`path:line:col: message (rule-id)` output (eslint -f unix).\n" +
|
|
179
|
+
'Try: --cmd "pnpm exec eslint . -f unix"',
|
|
180
|
+
);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
return { pairs: new Set(findings.map((f) => `${f.file}:${f.rule}`)), format };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function lintBaseline(args: string[], cwd: string): void {
|
|
187
|
+
const mode = args.find((a) => a === "--capture" || a === "--diff");
|
|
188
|
+
if (!mode || args.includes("-h") || args.includes("--help")) {
|
|
189
|
+
console.log(USAGE);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const cmdIdx = args.indexOf("--cmd");
|
|
193
|
+
let cmd: string | undefined = cmdIdx >= 0 ? args[cmdIdx + 1] : undefined;
|
|
194
|
+
if (!cmd || cmd.startsWith("--")) cmd = undefined;
|
|
195
|
+
const worktree = worktreeOf(cwd);
|
|
196
|
+
if (!cmd) {
|
|
197
|
+
const fromEvidence = readEvidenceLintCmd(worktree);
|
|
198
|
+
if (fromEvidence) cmd = fromEvidence;
|
|
199
|
+
}
|
|
200
|
+
if (!cmd) {
|
|
201
|
+
console.error(
|
|
202
|
+
'lint-baseline: no lint command — pass --cmd "<lint command>" or add\n' +
|
|
203
|
+
'{ "name": "lint", "cmd": "..." } to .fapony/evidence.json',
|
|
204
|
+
);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
console.log(`lint command: ${cmd} (worktree ${worktree})`);
|
|
208
|
+
|
|
209
|
+
if (mode === "--capture") {
|
|
210
|
+
const { pairs, format } = capturePairs(cmd, worktree);
|
|
211
|
+
const file = baselinePath(worktree);
|
|
212
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
213
|
+
const payload: BaselineFile = {
|
|
214
|
+
worktree,
|
|
215
|
+
baseSha: gitSha(worktree),
|
|
216
|
+
ts: new Date().toISOString(),
|
|
217
|
+
pairs: [...pairs].sort(),
|
|
218
|
+
};
|
|
219
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
220
|
+
console.log(
|
|
221
|
+
`captured ${pairs.size} finding(s) (${format}) at ${payload.baseSha} — baseline stored, not in state.db`,
|
|
222
|
+
);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --diff
|
|
227
|
+
const file = baselinePath(worktree);
|
|
228
|
+
let baseline: BaselineFile;
|
|
229
|
+
try {
|
|
230
|
+
baseline = JSON.parse(readFileSync(file, "utf-8")) as BaselineFile;
|
|
231
|
+
} catch {
|
|
232
|
+
console.error(
|
|
233
|
+
"lint-baseline: no baseline captured — run `fapony lint-baseline --capture` first",
|
|
234
|
+
);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
const { pairs, format } = capturePairs(cmd, worktree);
|
|
238
|
+
const mine = [...pairs].filter((p) => !baseline.pairs.includes(p)).sort();
|
|
239
|
+
const fixed = baseline.pairs.filter((p) => !pairs.has(p)).length;
|
|
240
|
+
console.log(
|
|
241
|
+
`baseline ${baseline.pairs.length} finding(s) at ${baseline.baseSha} · now ${pairs.size} (${format})`,
|
|
242
|
+
);
|
|
243
|
+
if (mine.length === 0) {
|
|
244
|
+
console.log("new findings introduced by this work: 0 — clean");
|
|
245
|
+
} else {
|
|
246
|
+
console.log(`new findings introduced by this work: ${mine.length}`);
|
|
247
|
+
for (const p of mine.slice(0, 40)) console.log(` ${p}`);
|
|
248
|
+
if (mine.length > 40) console.log(` … +${mine.length - 40} more`);
|
|
249
|
+
}
|
|
250
|
+
if (fixed > 0) {
|
|
251
|
+
console.log(`(also fixed ${fixed} pre-existing finding(s) — thank you)`);
|
|
252
|
+
}
|
|
253
|
+
// temporary by design (SPEC §4) — the baseline has served its run
|
|
254
|
+
rmSync(file, { force: true });
|
|
255
|
+
console.log("baseline discarded — capture again for the next unit of work");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function cmdLintBaseline(args: string[]): void {
|
|
259
|
+
lintBaseline(args, process.cwd());
|
|
260
|
+
}
|