fapony 0.3.5 → 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.
@@ -2,7 +2,13 @@
2
2
  //
3
3
  // Adds mcp.fapony config to ~/.config/opencode/opencode.json or opencode.jsonc.
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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
- installReadHintPlugin(dryRun, getHome);
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
- installReadHintPlugin(dryRun, getHome);
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
- installReadHintPlugin(dryRun, getHome);
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
- function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
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, "fapony-read-hint.ts");
418
- if (existsSync(pluginPath)) {
419
- let current = "";
450
+ const pluginPath = join(pluginsDir, fileName);
451
+ const write = (): boolean => {
452
+ if (dryRun) return true;
420
453
  try {
421
- current = readFileSync(pluginPath, "utf-8");
422
- } catch {
423
- current = "";
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
- if (current.includes("readHintFor")) {
426
- console.error(` read hint: already installed — no change`);
427
- return;
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
- ` read hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
484
+ ` ${label}: ${pluginPath} exists but isn't fapony's — not overwriting.`,
431
485
  );
432
486
  return;
433
487
  }
434
- if (!dryRun) {
435
- try {
436
- mkdirSync(pluginsDir, { recursive: true });
437
- writeFileSync(pluginPath, readHintPluginSource(INSTALL_ROOT), "utf-8");
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
- console.error(
444
- ` read hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
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
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
450
- const pluginPath = join(pluginsDir, "fapony-commit-hint.ts");
451
- if (existsSync(pluginPath)) {
452
- let current = "";
453
- try {
454
- current = readFileSync(pluginPath, "utf-8");
455
- } catch {
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
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
483
- const pluginPath = join(pluginsDir, "fapony-edit-hint.ts");
484
- if (existsSync(pluginPath)) {
485
- let current = "";
486
- try {
487
- current = readFileSync(pluginPath, "utf-8");
488
- } catch {
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
- const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
575
- const pluginPath = join(pluginsDir, "fapony-session-start.ts");
576
- if (existsSync(pluginPath)) {
577
- let current = "";
578
- try {
579
- current = readFileSync(pluginPath, "utf-8");
580
- } catch {
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
- const pluginPath = join(pluginsDir, "fapony-git-autonomy.ts");
685
- if (existsSync(pluginPath)) {
686
- let current = "";
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
- return;
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, { gitAutonomy });
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, { gitAutonomy });
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":