fapony 0.3.6 → 0.4.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/README.md +10 -21
- package/package.json +2 -1
- package/src/install/opencode.ts +136 -177
- package/src/install.ts +15 -5
- package/src/update.ts +60 -3
package/README.md
CHANGED
|
@@ -38,26 +38,9 @@ quietly counted as free.
|
|
|
38
38
|
|
|
39
39
|
That is day one. Past that, fapony keeps what coding agents actually did — the frozen
|
|
40
40
|
ledger of graded runs (rounds, pass/fail, cost per grade, readable via CLI, no new grades)
|
|
41
|
-
plus the live mem log — through 3 MCP tools any agent can call.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
vibe. On top of history it checks claims against git facts: handoff conformance and
|
|
45
|
-
allowlisted evidence — with everything the agent claimed but couldn't prove marked as such.
|
|
46
|
-
|
|
47
|
-
**What that question looks like answered, from one project's own (frozen — reads history,
|
|
48
|
-
no new grades) ledger — the top of the `n≥5` frontier (`fapony stats --mode verdict --regime code`):**
|
|
49
|
-
|
|
50
|
-
| model | tokens/pass | quality | n |
|
|
51
|
-
|---|---|---|---|
|
|
52
|
-
| `claude-opus-5` | 22.5M | 3.8 | 10 |
|
|
53
|
-
| `claude-sonnet-5` | 5.6M | 3.5 | 11 |
|
|
54
|
-
| `muse-spark-1.3-contributor-free` | 4.3M | 4.0 | 5 |
|
|
55
|
-
|
|
56
|
-
Same quality band, an 8× token spread — the kind of answer a session log can't give (it has tokens,
|
|
57
|
-
no grades) and a benchmark can't give either (it has grades, not your codebase). One caveat that's
|
|
58
|
-
on you to hold: work isn't randomly assigned to models, so a gap this size is a strong prior, not a
|
|
59
|
-
controlled trial — you likely route easy tasks to the cheap model already. `n≥5` is fapony's own
|
|
60
|
-
floor before a model counts toward the frontier at all; below that it's a data point, not a pick.
|
|
41
|
+
plus the live mem log — through 3 MCP tools any agent can call. It checks claims against
|
|
42
|
+
git facts: handoff conformance and allowlisted evidence — with everything the agent claimed
|
|
43
|
+
but couldn't prove marked as such.
|
|
61
44
|
|
|
62
45
|
**The reason to keep it running is the third layer: knowledge accumulation — and the thing it
|
|
63
46
|
accumulates is pain.** An agent has no memory of pain across sessions: it writes the 37th
|
|
@@ -72,7 +55,7 @@ already decided on, that is the loop: notice the repeated cost, name the shared
|
|
|
72
55
|
migration finish. Finding dead code and duplication is *not* part of it — knip and friends already
|
|
73
56
|
do that better, and a convention with a `checker` is deliberately left to the checker.
|
|
74
57
|
|
|
75
|
-
**The measurement layer underneath it:** Any single client already logs its own session — timing, tokens, tool calls. What none of them see is *across* runs, clients and task shapes:
|
|
58
|
+
**The measurement layer underneath it:** Any single client already logs its own session — timing, tokens, tool calls. What none of them see is *across* runs, clients and task shapes: what each model costs you, across clients, on one yardstick, in this project (`fapony usage-web`, above) — tokens and cost, nothing self-graded. The frozen ledger still has old verdict rows (`fapony stats`), but the tool that wrote them (`verdict_submit`) is gone from the MCP surface: no new rows, for anyone, ever again, so a count of old rows (`n`) means nothing without the grading that stopped. New accumulation goes to the mem log instead: decisions, bugs and notes with `files[]`, written by the agents doing the work.
|
|
76
59
|
|
|
77
60
|
Three tiers, deliberately: **measurement ships today** and needs no per-project setup — raw facts nobody can call unfair. **Verification is the sharper edge** but stays beta until its evidence layer is hardened; fapony doesn't control your agent's flow, so it never promises "verified" as a headline. **Knowledge accumulation is the compounding one** — it's worthless on run 1 and gets more useful every run after, which is exactly why it's the layer competitors can't clone by copying a feature list.
|
|
78
61
|
|
|
@@ -381,6 +364,12 @@ at once. ZCode and Codex get the same skills linked into `~/.agents/skills`. A d
|
|
|
381
364
|
that already exists and isn't a fapony link is reported and left alone — replace it by hand
|
|
382
365
|
if you want fapony's version.
|
|
383
366
|
|
|
367
|
+
OpenCode is the one client whose hooks are generated files rather than `fapony hook-*`
|
|
368
|
+
commands, so `fapony update` also re-runs the installer in a fresh process to refresh them —
|
|
369
|
+
with `--plugins-only`, so a refresh touches fapony's plugin files and never your
|
|
370
|
+
`opencode.json`. A fapony-owned plugin is rewritten in place; a file that isn't fapony's is
|
|
371
|
+
reported and left alone, same as the skill links.
|
|
372
|
+
|
|
384
373
|
`plan-with-pony` is vendor-neutral — the SKILL.md *is* the prompt, so pipe it to any agent:
|
|
385
374
|
|
|
386
375
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fapony",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Token usage across Claude Code, OpenCode, Codex & ZCode on one yardstick — plus a project mem log and convention-debt tracker agents query via 3 MCP tools. No server, your data stays local",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "delamind (https://github.com/kire21b)",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"check": "bun run lint && bun run typecheck && bun test --timeout 20000 --parallel",
|
|
36
36
|
"prepublishOnly": "bash scripts/smoke-publish.sh",
|
|
37
37
|
"release": "git checkout main && git pull --ff-only && npm version patch -m 'release v%s' && git push origin main --follow-tags",
|
|
38
|
+
"release:minor": "git checkout main && git pull --ff-only && npm version minor -m 'release v%s' && git push origin main --follow-tags",
|
|
38
39
|
"overview": "bun fapony.ts report-web /tmp/fapony-overview.html && open /tmp/fapony-overview.html"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
package/src/install/opencode.ts
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
6
12
|
import { homedir } from "node:os";
|
|
7
13
|
import { join } from "node:path";
|
|
8
14
|
import { claudeSkillsDir, linkSkills, reportSkills } from "./skills.js";
|
|
@@ -84,6 +90,11 @@ function isConfigured(mcp: Record<string, unknown> | undefined): boolean {
|
|
|
84
90
|
export interface OpencodeInstallOpts {
|
|
85
91
|
/** Opt-in only: write the git-autonomy rewrite plugin. Never default. */
|
|
86
92
|
gitAutonomy?: boolean;
|
|
93
|
+
/** Refresh only the generated plugin bodies — skip the opencode.json write
|
|
94
|
+
* and the skills symlink entirely. This is `fapony update`'s post-pull
|
|
95
|
+
* refresh: it must touch only fapony-owned plugin files, never the user's
|
|
96
|
+
* config (rule 6c — overwriting what exists = ask first, or refuse). */
|
|
97
|
+
pluginsOnly?: boolean;
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
export function cmdInstallOpencode(
|
|
@@ -94,6 +105,21 @@ export function cmdInstallOpencode(
|
|
|
94
105
|
const exitFn = deps.exit ?? defaultExit;
|
|
95
106
|
const getHome = deps.homedir ?? homedir;
|
|
96
107
|
|
|
108
|
+
const installPlugins = (): void => {
|
|
109
|
+
installReadHintPlugin(dryRun, getHome);
|
|
110
|
+
installCommitHintPlugin(dryRun, getHome);
|
|
111
|
+
installEditHintPlugin(dryRun, getHome);
|
|
112
|
+
installSessionStartPlugin(dryRun, getHome);
|
|
113
|
+
if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Refresh path: plugin bodies only — returns before findOpencodeConfig, so
|
|
117
|
+
// neither opencode.json nor the skills symlink is read or written.
|
|
118
|
+
if (opts.pluginsOnly) {
|
|
119
|
+
installPlugins();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
97
123
|
const configPath = findOpencodeConfig(getHome);
|
|
98
124
|
let before: Record<string, unknown>;
|
|
99
125
|
let isNew = false;
|
|
@@ -117,11 +143,7 @@ export function cmdInstallOpencode(
|
|
|
117
143
|
if (configPath) console.error(` (${configPath})`);
|
|
118
144
|
const skillsDir = claudeSkillsDir(getHome);
|
|
119
145
|
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
120
|
-
|
|
121
|
-
installCommitHintPlugin(dryRun, getHome);
|
|
122
|
-
installEditHintPlugin(dryRun, getHome);
|
|
123
|
-
installSessionStartPlugin(dryRun, getHome);
|
|
124
|
-
if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
|
|
146
|
+
installPlugins();
|
|
125
147
|
return;
|
|
126
148
|
}
|
|
127
149
|
|
|
@@ -143,11 +165,7 @@ export function cmdInstallOpencode(
|
|
|
143
165
|
console.error(` (${configPath})`);
|
|
144
166
|
}
|
|
145
167
|
console.log(computeDiff(before, after));
|
|
146
|
-
|
|
147
|
-
installCommitHintPlugin(dryRun, getHome);
|
|
148
|
-
installEditHintPlugin(dryRun, getHome);
|
|
149
|
-
installSessionStartPlugin(dryRun, getHome);
|
|
150
|
-
if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
|
|
168
|
+
installPlugins();
|
|
151
169
|
return;
|
|
152
170
|
}
|
|
153
171
|
|
|
@@ -167,11 +185,7 @@ export function cmdInstallOpencode(
|
|
|
167
185
|
console.error(` restart opencode to load the MCP server`);
|
|
168
186
|
const skillsDir = claudeSkillsDir(getHome);
|
|
169
187
|
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
170
|
-
|
|
171
|
-
installCommitHintPlugin(dryRun, getHome);
|
|
172
|
-
installEditHintPlugin(dryRun, getHome);
|
|
173
|
-
installSessionStartPlugin(dryRun, getHome);
|
|
174
|
-
if (opts.gitAutonomy) installGitAutonomyPlugin(dryRun, getHome);
|
|
188
|
+
installPlugins();
|
|
175
189
|
}
|
|
176
190
|
|
|
177
191
|
/**
|
|
@@ -412,102 +426,102 @@ export const FaponyEditHint = async ({ directory }) => {
|
|
|
412
426
|
`;
|
|
413
427
|
}
|
|
414
428
|
|
|
415
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Write one generated OpenCode plugin file, refreshing our own older copy.
|
|
431
|
+
*
|
|
432
|
+
* Ownership is the exported plugin name (`FaponyReadHint`, …) — the one token
|
|
433
|
+
* every generated body carries. A file without it is someone's own plugin and
|
|
434
|
+
* is never touched, same rule as the skill symlinks. A file *with* it but
|
|
435
|
+
* different content is ours and stale: the plugin body is baked at install
|
|
436
|
+
* time, so a `git pull` updates the imported logic (src/hook.ts) but never this
|
|
437
|
+
* file. Refusing to overwrite it is how an old hook stays reachable forever —
|
|
438
|
+
* the Thai-only bug marker survived exactly that way — so ours gets rewritten
|
|
439
|
+
* in place, and only a foreign file is reported and left alone.
|
|
440
|
+
*/
|
|
441
|
+
function installPluginFile(
|
|
442
|
+
dryRun: boolean,
|
|
443
|
+
getHome: () => string,
|
|
444
|
+
fileName: string,
|
|
445
|
+
label: string,
|
|
446
|
+
ownedName: string,
|
|
447
|
+
desired: string,
|
|
448
|
+
): void {
|
|
416
449
|
const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
|
|
417
|
-
const pluginPath = join(pluginsDir,
|
|
418
|
-
|
|
419
|
-
|
|
450
|
+
const pluginPath = join(pluginsDir, fileName);
|
|
451
|
+
const write = (): boolean => {
|
|
452
|
+
if (dryRun) return true;
|
|
420
453
|
try {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
454
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
455
|
+
writeFileSync(pluginPath, desired, "utf-8");
|
|
456
|
+
return true;
|
|
457
|
+
} catch (e) {
|
|
458
|
+
console.error(` ${label}: failed to write — ${(e as Error).message}`);
|
|
459
|
+
return false;
|
|
424
460
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
if (!existsSync(pluginPath)) {
|
|
464
|
+
if (write()) {
|
|
465
|
+
console.error(
|
|
466
|
+
` ${label}: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
467
|
+
);
|
|
428
468
|
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let current = "";
|
|
473
|
+
try {
|
|
474
|
+
current = readFileSync(pluginPath, "utf-8");
|
|
475
|
+
} catch {
|
|
476
|
+
current = "";
|
|
477
|
+
}
|
|
478
|
+
if (current === desired) {
|
|
479
|
+
console.error(` ${label}: already installed — no change`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (!current.includes(ownedName)) {
|
|
429
483
|
console.error(
|
|
430
|
-
`
|
|
484
|
+
` ${label}: ${pluginPath} exists but isn't fapony's — not overwriting.`,
|
|
431
485
|
);
|
|
432
486
|
return;
|
|
433
487
|
}
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
} catch (e) {
|
|
439
|
-
console.error(` read hint: failed to write — ${(e as Error).message}`);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
488
|
+
if (write()) {
|
|
489
|
+
console.error(
|
|
490
|
+
` ${label}: ${dryRun ? "would update" : "updated"} ${pluginPath}`,
|
|
491
|
+
);
|
|
442
492
|
}
|
|
443
|
-
|
|
444
|
-
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
|
|
496
|
+
installPluginFile(
|
|
497
|
+
dryRun,
|
|
498
|
+
getHome,
|
|
499
|
+
"fapony-read-hint.ts",
|
|
500
|
+
"read hint",
|
|
501
|
+
"FaponyReadHint",
|
|
502
|
+
readHintPluginSource(INSTALL_ROOT),
|
|
445
503
|
);
|
|
446
504
|
}
|
|
447
505
|
|
|
448
506
|
function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
current = "";
|
|
457
|
-
}
|
|
458
|
-
if (current.includes("commitHintFor")) {
|
|
459
|
-
console.error(` commit hint: already installed — no change`);
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
console.error(
|
|
463
|
-
` commit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
|
|
464
|
-
);
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
if (!dryRun) {
|
|
468
|
-
try {
|
|
469
|
-
mkdirSync(pluginsDir, { recursive: true });
|
|
470
|
-
writeFileSync(pluginPath, commitHintPluginSource(INSTALL_ROOT), "utf-8");
|
|
471
|
-
} catch (e) {
|
|
472
|
-
console.error(` commit hint: failed to write — ${(e as Error).message}`);
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
console.error(
|
|
477
|
-
` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
507
|
+
installPluginFile(
|
|
508
|
+
dryRun,
|
|
509
|
+
getHome,
|
|
510
|
+
"fapony-commit-hint.ts",
|
|
511
|
+
"commit hint",
|
|
512
|
+
"FaponyCommitHint",
|
|
513
|
+
commitHintPluginSource(INSTALL_ROOT),
|
|
478
514
|
);
|
|
479
515
|
}
|
|
480
516
|
|
|
481
517
|
function installEditHintPlugin(dryRun: boolean, getHome: () => string): void {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
current = "";
|
|
490
|
-
}
|
|
491
|
-
if (current.includes("editHintFor")) {
|
|
492
|
-
console.error(` edit hint: already installed — no change`);
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
console.error(
|
|
496
|
-
` edit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
|
|
497
|
-
);
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
if (!dryRun) {
|
|
501
|
-
try {
|
|
502
|
-
mkdirSync(pluginsDir, { recursive: true });
|
|
503
|
-
writeFileSync(pluginPath, editHintPluginSource(INSTALL_ROOT), "utf-8");
|
|
504
|
-
} catch (e) {
|
|
505
|
-
console.error(` edit hint: failed to write — ${(e as Error).message}`);
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
console.error(
|
|
510
|
-
` edit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
518
|
+
installPluginFile(
|
|
519
|
+
dryRun,
|
|
520
|
+
getHome,
|
|
521
|
+
"fapony-edit-hint.ts",
|
|
522
|
+
"edit hint",
|
|
523
|
+
"FaponyEditHint",
|
|
524
|
+
editHintPluginSource(INSTALL_ROOT),
|
|
511
525
|
);
|
|
512
526
|
}
|
|
513
527
|
|
|
@@ -571,50 +585,13 @@ function installSessionStartPlugin(
|
|
|
571
585
|
dryRun: boolean,
|
|
572
586
|
getHome: () => string,
|
|
573
587
|
): void {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
current = "";
|
|
582
|
-
}
|
|
583
|
-
if (current.includes("FaponySessionStart")) {
|
|
584
|
-
// Ours — and generated, so never overwritten. A file baked by an older
|
|
585
|
-
// fapony differs from what we generate now: say so instead of "no change",
|
|
586
|
-
// which is how the pre-hook plugin (mub2ezhi) would have been kept.
|
|
587
|
-
if (current !== sessionStartPluginSource(INSTALL_ROOT)) {
|
|
588
|
-
console.error(
|
|
589
|
-
` session start: stale fapony plugin — delete ${pluginPath} and re-run install`,
|
|
590
|
-
);
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
console.error(` session start: already installed — no change`);
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
console.error(
|
|
597
|
-
` session start: ${pluginPath} exists but isn't fapony's — not overwriting.`,
|
|
598
|
-
);
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
if (!dryRun) {
|
|
602
|
-
try {
|
|
603
|
-
mkdirSync(pluginsDir, { recursive: true });
|
|
604
|
-
writeFileSync(
|
|
605
|
-
pluginPath,
|
|
606
|
-
sessionStartPluginSource(INSTALL_ROOT),
|
|
607
|
-
"utf-8",
|
|
608
|
-
);
|
|
609
|
-
} catch (e) {
|
|
610
|
-
console.error(
|
|
611
|
-
` session start: failed to write — ${(e as Error).message}`,
|
|
612
|
-
);
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
console.error(
|
|
617
|
-
` session start: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
588
|
+
installPluginFile(
|
|
589
|
+
dryRun,
|
|
590
|
+
getHome,
|
|
591
|
+
"fapony-session-start.ts",
|
|
592
|
+
"session start",
|
|
593
|
+
"FaponySessionStart",
|
|
594
|
+
sessionStartPluginSource(INSTALL_ROOT),
|
|
618
595
|
);
|
|
619
596
|
}
|
|
620
597
|
|
|
@@ -680,45 +657,27 @@ function installGitAutonomyPlugin(
|
|
|
680
657
|
dryRun: boolean,
|
|
681
658
|
getHome: () => string,
|
|
682
659
|
): void {
|
|
660
|
+
installPluginFile(
|
|
661
|
+
dryRun,
|
|
662
|
+
getHome,
|
|
663
|
+
"fapony-git-autonomy.ts",
|
|
664
|
+
"git-autonomy",
|
|
665
|
+
"FaponyGitAutonomy",
|
|
666
|
+
gitAutonomyPluginSource(INSTALL_ROOT),
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** The fapony-generated plugin files present in an OpenCode install. Empty =
|
|
671
|
+
* OpenCode has none, so `fapony update` must not install into a client the user
|
|
672
|
+
* never opted into. Callers also use it to tell whether the opt-in git-autonomy
|
|
673
|
+
* plugin is installed, so an update can refresh it without ever creating it. */
|
|
674
|
+
export function opencodePluginFiles(getHome: () => string = homedir): string[] {
|
|
683
675
|
const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
try {
|
|
688
|
-
current = readFileSync(pluginPath, "utf-8");
|
|
689
|
-
} catch {
|
|
690
|
-
current = "";
|
|
691
|
-
}
|
|
692
|
-
if (current.includes("FaponyGitAutonomy")) {
|
|
693
|
-
// Ours — and generated, so never overwritten. Same stale-check shape
|
|
694
|
-
// as the session-start plugin: an older generation must say so instead
|
|
695
|
-
// of "no change", or a fixed rewrite never reaches the client.
|
|
696
|
-
if (current !== gitAutonomyPluginSource(INSTALL_ROOT)) {
|
|
697
|
-
console.error(
|
|
698
|
-
` git-autonomy: stale fapony plugin — delete ${pluginPath} and re-run install --git-autonomy`,
|
|
699
|
-
);
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
console.error(` git-autonomy: already installed — no change`);
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
console.error(
|
|
706
|
-
` git-autonomy: ${pluginPath} exists but isn't fapony's — not overwriting.`,
|
|
676
|
+
try {
|
|
677
|
+
return readdirSync(pluginsDir).filter(
|
|
678
|
+
(f) => f.startsWith("fapony-") && f.endsWith(".ts"),
|
|
707
679
|
);
|
|
708
|
-
|
|
680
|
+
} catch {
|
|
681
|
+
return [];
|
|
709
682
|
}
|
|
710
|
-
if (!dryRun) {
|
|
711
|
-
try {
|
|
712
|
-
mkdirSync(pluginsDir, { recursive: true });
|
|
713
|
-
writeFileSync(pluginPath, gitAutonomyPluginSource(INSTALL_ROOT), "utf-8");
|
|
714
|
-
} catch (e) {
|
|
715
|
-
console.error(
|
|
716
|
-
` git-autonomy: failed to write — ${(e as Error).message}`,
|
|
717
|
-
);
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
console.error(
|
|
722
|
-
` git-autonomy: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
723
|
-
);
|
|
724
683
|
}
|
package/src/install.ts
CHANGED
|
@@ -39,7 +39,9 @@ export {
|
|
|
39
39
|
editHintPluginSource,
|
|
40
40
|
gitAutonomyPluginSource,
|
|
41
41
|
type OpencodeInstallOpts,
|
|
42
|
+
opencodePluginFiles,
|
|
42
43
|
readHintPluginSource,
|
|
44
|
+
sessionStartPluginSource,
|
|
43
45
|
} from "./install/opencode.js";
|
|
44
46
|
export {
|
|
45
47
|
agentsSkillsDir,
|
|
@@ -64,6 +66,8 @@ export async function cmdInstall(
|
|
|
64
66
|
// not a utility — never installed unless this flag rides along, including
|
|
65
67
|
// via --all. Opencode-only; other platforms ignore it.
|
|
66
68
|
const gitAutonomy = args.includes("--git-autonomy");
|
|
69
|
+
// Refresh-only seam for `fapony update`: plugin bodies, never opencode.json.
|
|
70
|
+
const pluginsOnly = args.includes("--plugins-only");
|
|
67
71
|
|
|
68
72
|
// --- explicit platform: original behavior, unchanged ---
|
|
69
73
|
if (platform === "antigravity") {
|
|
@@ -87,12 +91,12 @@ export async function cmdInstall(
|
|
|
87
91
|
return;
|
|
88
92
|
}
|
|
89
93
|
if (platform === "opencode") {
|
|
90
|
-
cmdInstallOpencode(dryRun, deps, { gitAutonomy });
|
|
94
|
+
cmdInstallOpencode(dryRun, deps, { gitAutonomy, pluginsOnly });
|
|
91
95
|
return;
|
|
92
96
|
}
|
|
93
97
|
if (platform !== undefined) {
|
|
94
98
|
console.error(
|
|
95
|
-
`usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy]`,
|
|
99
|
+
`usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run] [--git-autonomy] [--plugins-only]`,
|
|
96
100
|
);
|
|
97
101
|
console.error(
|
|
98
102
|
` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
|
|
@@ -131,7 +135,10 @@ export async function cmdInstall(
|
|
|
131
135
|
if (installAll) {
|
|
132
136
|
console.error();
|
|
133
137
|
for (const client of found) {
|
|
134
|
-
installPlatform(client.platform, dryRun, deps, {
|
|
138
|
+
installPlatform(client.platform, dryRun, deps, {
|
|
139
|
+
gitAutonomy,
|
|
140
|
+
pluginsOnly,
|
|
141
|
+
});
|
|
135
142
|
}
|
|
136
143
|
return;
|
|
137
144
|
}
|
|
@@ -160,7 +167,10 @@ export async function cmdInstall(
|
|
|
160
167
|
for (const client of found) {
|
|
161
168
|
const answer = await askFn(`install into ${client.platform}?`, "Y");
|
|
162
169
|
if (isAffirmative(answer)) {
|
|
163
|
-
installPlatform(client.platform, dryRun, deps, {
|
|
170
|
+
installPlatform(client.platform, dryRun, deps, {
|
|
171
|
+
gitAutonomy,
|
|
172
|
+
pluginsOnly,
|
|
173
|
+
});
|
|
164
174
|
}
|
|
165
175
|
}
|
|
166
176
|
} finally {
|
|
@@ -172,7 +182,7 @@ function installPlatform(
|
|
|
172
182
|
platform: string,
|
|
173
183
|
dryRun: boolean,
|
|
174
184
|
deps: InstallDeps,
|
|
175
|
-
opts: { gitAutonomy?: boolean } = {},
|
|
185
|
+
opts: { gitAutonomy?: boolean; pluginsOnly?: boolean } = {},
|
|
176
186
|
): void {
|
|
177
187
|
switch (platform) {
|
|
178
188
|
case "antigravity":
|
package/src/update.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// ROOT must be the repo root: import.meta.dir is src/, one level below it.
|
|
3
3
|
// Shows old → new version, recent commits, and warns if uncommitted changes.
|
|
4
4
|
|
|
5
|
-
import { execSync } from "node:child_process";
|
|
5
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { createInterface } from "node:readline";
|
|
10
|
+
import { opencodePluginFiles } from "./install/opencode.js";
|
|
9
11
|
import { isAffirmative } from "./util.js";
|
|
10
12
|
|
|
11
13
|
/** Repo root (parent of src/) — where package.json and bun.lock live.
|
|
@@ -27,12 +29,59 @@ function defaultInstall(): void {
|
|
|
27
29
|
execSync("bun install", { cwd: ROOT, stdio: "pipe", timeout: 300_000 });
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** argv for the spawned plugin refresh — exported so tests can pin the flags:
|
|
33
|
+
* a wrong flag fails silently (the child would just do a full install and
|
|
34
|
+
* rewrite opencode.json, the exact bug `--plugins-only` exists to prevent). */
|
|
35
|
+
export function refreshArgv(files: string[]): string[] {
|
|
36
|
+
const argv = [
|
|
37
|
+
join(ROOT, "fapony.ts"),
|
|
38
|
+
"install",
|
|
39
|
+
"--platform",
|
|
40
|
+
"opencode",
|
|
41
|
+
// Plugins only: a refresh touches fapony-owned plugin files, never the
|
|
42
|
+
// user's opencode.json (rule 6c).
|
|
43
|
+
"--plugins-only",
|
|
44
|
+
];
|
|
45
|
+
// Opt-in plugin: refresh it when the user installed it, never create it.
|
|
46
|
+
if (files.includes("fapony-git-autonomy.ts")) argv.push("--git-autonomy");
|
|
47
|
+
return argv;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Refresh OpenCode's generated plugin bodies after the pull.
|
|
52
|
+
*
|
|
53
|
+
* OpenCode is the only client whose hooks are baked files — every other client
|
|
54
|
+
* writes a `fapony hook-*` command resolved at run time, so a pull alone keeps
|
|
55
|
+
* them current. Spawning a *fresh* process is the whole point: this one already
|
|
56
|
+
* loaded the pre-pull templates, so calling the installer in-process would
|
|
57
|
+
* rewrite the old body — the exact bug this exists to fix. `process.execPath`
|
|
58
|
+
* is the bun running fapony, so no PATH dependency. Runs `--plugins-only`, so
|
|
59
|
+
* the refresh never reads or writes opencode.json or the skills symlink.
|
|
60
|
+
* Best-effort: a refresh must never fail an update.
|
|
61
|
+
*/
|
|
62
|
+
function defaultRefreshPlugins(): void {
|
|
63
|
+
const getHome = (): string => homedir();
|
|
64
|
+
const files = opencodePluginFiles(getHome);
|
|
65
|
+
if (files.length === 0) return;
|
|
66
|
+
console.log("\n Refreshing OpenCode plugins...");
|
|
67
|
+
const r = spawnSync(process.execPath, refreshArgv(files), {
|
|
68
|
+
stdio: "pipe",
|
|
69
|
+
timeout: 30_000,
|
|
70
|
+
});
|
|
71
|
+
if (r.error || r.status !== 0) {
|
|
72
|
+
console.log(
|
|
73
|
+
" ⚠ plugin refresh failed — run manually: fapony install --platform opencode --plugins-only",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
30
78
|
/** Minimal seam for cmdUpdate — git runner (map args→result, throws on failure),
|
|
31
|
-
* prompt, exit,
|
|
32
|
-
* (production) path and the test path. */
|
|
79
|
+
* prompt, exit, bun-install, and the post-pull OpenCode plugin refresh. Every
|
|
80
|
+
* field is used by both the default (production) path and the test path. */
|
|
33
81
|
export interface UpdateDeps {
|
|
34
82
|
git?: (args: string) => string;
|
|
35
83
|
install?: () => void;
|
|
84
|
+
refresh?: () => void;
|
|
36
85
|
prompt?: (question: string, defaultVal?: string) => Promise<string>;
|
|
37
86
|
exit?: (code: number) => never;
|
|
38
87
|
}
|
|
@@ -88,6 +137,7 @@ function defaultPrompt(question: string, defaultVal?: string): Promise<string> {
|
|
|
88
137
|
export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
|
|
89
138
|
const git = deps.git ?? defaultGit;
|
|
90
139
|
const installFn = deps.install ?? defaultInstall;
|
|
140
|
+
const refreshFn = deps.refresh ?? defaultRefreshPlugins;
|
|
91
141
|
const promptFn = deps.prompt ?? defaultPrompt;
|
|
92
142
|
const exitFn = deps.exit ?? ((code: number): never => process.exit(code));
|
|
93
143
|
const gitQuiet = (args: string): string | null => {
|
|
@@ -173,6 +223,10 @@ export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
|
|
|
173
223
|
// --- show what changed ---
|
|
174
224
|
if (isUpToDate(oldSha, newSha)) {
|
|
175
225
|
console.log(`\n ✓ Already up to date (${oldVersion} @ ${oldSha}).`);
|
|
226
|
+
// The repo being current says nothing about the generated plugin bodies —
|
|
227
|
+
// a user who pulled by hand, or installed before the template changed, is
|
|
228
|
+
// exactly who needs this. Refresh is a no-op when nothing is stale.
|
|
229
|
+
refreshFn();
|
|
176
230
|
return;
|
|
177
231
|
}
|
|
178
232
|
|
|
@@ -201,6 +255,9 @@ export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
|
|
|
201
255
|
}
|
|
202
256
|
}
|
|
203
257
|
|
|
258
|
+
// --- refresh generated plugin bodies (after deps — the fresh process needs them) ---
|
|
259
|
+
refreshFn();
|
|
260
|
+
|
|
204
261
|
console.log(`
|
|
205
262
|
┌──────────────────────────────────────────┐
|
|
206
263
|
│ Update complete! │
|