@krtclcdy/tersio 2.19.0 → 2.20.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 (34) hide show
  1. package/cli/common.js +13 -1
  2. package/cli/doctor.js +7 -7
  3. package/cli/install.js +28 -28
  4. package/extensions/ai-addons-updater/index.ts +377 -0
  5. package/extensions/caveman-session/index.ts +161 -0
  6. package/extensions/combo-toggle/index.ts +217 -0
  7. package/extensions/lib/utils.ts +162 -0
  8. package/extensions/rtk-session/index.ts +164 -0
  9. package/extensions/shared/carbon.ts +123 -0
  10. package/extensions/shared/mode-reinforcement.ts +29 -0
  11. package/extensions/shared/plugin-settings.ts +57 -0
  12. package/extensions/shared/pricing.ts +168 -0
  13. package/extensions/shared/rtk-gain.ts +105 -0
  14. package/extensions/shared/session-state.ts +201 -0
  15. package/extensions/shared/types.ts +85 -0
  16. package/extensions/shared/usage-ledger.ts +406 -0
  17. package/extensions/shared/usage-store.ts +325 -0
  18. package/extensions/tersio-commands/index.ts +110 -0
  19. package/package.json +19 -24
  20. package/extensions/ai-addons-updater/index.js +0 -331
  21. package/extensions/caveman-session/index.js +0 -152
  22. package/extensions/combo-toggle/index.js +0 -165
  23. package/extensions/lib/utils.js +0 -154
  24. package/extensions/rtk-session/index.js +0 -148
  25. package/extensions/shared/carbon.js +0 -88
  26. package/extensions/shared/mode-reinforcement.js +0 -26
  27. package/extensions/shared/plugin-settings.js +0 -51
  28. package/extensions/shared/pricing.js +0 -163
  29. package/extensions/shared/rtk-gain.js +0 -79
  30. package/extensions/shared/session-state.js +0 -177
  31. package/extensions/shared/types.js +0 -3
  32. package/extensions/shared/usage-ledger.js +0 -354
  33. package/extensions/shared/usage-store.js +0 -294
  34. package/extensions/tersio-commands/index.js +0 -102
package/cli/common.js CHANGED
@@ -147,6 +147,13 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
147
147
  const line = ` - ${normalizedPath}`;
148
148
  const raw = await readTextIfExists(configPath);
149
149
  let lines = (raw || '').split('\n');
150
+ // Drop legacy compiled twins (same dir/name, .js): previous installs
151
+ // registered them, and OMP must never load both copies. Match on the
152
+ // trailing path so absolute and relative entries are both caught.
153
+ if (normalizedPath.endsWith('.ts')) {
154
+ const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
155
+ lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
156
+ }
150
157
  if (lines.some((l) => l.includes(normalizedPath))) {
151
158
  debug(`${label} already in config.yml`);
152
159
  return false;
@@ -176,7 +183,12 @@ async function ensureExtensionAfterConfigEntry(configPath, extensionPath, afterP
176
183
  const normalizedAfterPath = afterPath.replace(/\\/g, '/');
177
184
  const line = ` - ${normalizedPath}`;
178
185
  const raw = await readTextIfExists(configPath);
179
- const lines = (raw || '').split('\n');
186
+ let lines = (raw || '').split('\n');
187
+ // Same legacy-twin drop as ensureExtensionInConfig above.
188
+ if (normalizedPath.endsWith('.ts')) {
189
+ const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
190
+ lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
191
+ }
180
192
  const existingIndex = lines.findIndex((entry) => entry.includes(normalizedPath));
181
193
  const afterIndex = lines.findIndex((entry) => entry.includes(normalizedAfterPath));
182
194
  if (existingIndex !== -1 && afterIndex !== -1 && existingIndex === afterIndex + 1)
package/cli/doctor.js CHANGED
@@ -19,13 +19,13 @@ async function runDoctor() {
19
19
  // Ponytail
20
20
  const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
21
21
  const ponytailExt = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
22
- const cavemanIndex = path.join(extDir, 'caveman-session', 'index.js');
22
+ const cavemanIndex = path.join(extDir, 'caveman-session', 'index.ts');
23
23
  const cavemanRule = path.join(extDir, 'caveman-session', 'rule.md');
24
- const rtkIndex = path.join(extDir, 'rtk-session', 'index.js');
25
- const updaterIndex = path.join(extDir, 'ai-addons-updater', 'index.js');
26
- const comboIndex = path.join(extDir, 'combo-toggle', 'index.js');
27
- const tersioIndex = path.join(extDir, 'tersio-commands', 'index.js');
28
- const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.js');
24
+ const rtkIndex = path.join(extDir, 'rtk-session', 'index.ts');
25
+ const updaterIndex = path.join(extDir, 'ai-addons-updater', 'index.ts');
26
+ const comboIndex = path.join(extDir, 'combo-toggle', 'index.ts');
27
+ const tersioIndex = path.join(extDir, 'tersio-commands', 'index.ts');
28
+ const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.ts');
29
29
  const selfPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
30
30
  // Independent probes start concurrently; sections report in fixed order as
31
31
  // their data settles. Every probe resolves instead of rejecting.
@@ -33,7 +33,7 @@ async function runDoctor() {
33
33
  ompVersion: execP(OMP_BIN, ['--version']).then((r) => r.stdout.trim(), () => null),
34
34
  agentEntries: fs.readdir(agentDir).catch(() => null),
35
35
  extEntries: fs.readdir(extDir).catch(() => null),
36
- sharedStateText: readTextIfExists(path.join(extDir, 'shared', 'session-state.js')),
36
+ sharedStateText: readTextIfExists(path.join(extDir, 'shared', 'session-state.ts')),
37
37
  configText: readTextIfExists(configPath),
38
38
  ponytailPkgText: readTextIfExists(ponytailPkg),
39
39
  ponytailExtText: readTextIfExists(ponytailExt),
package/cli/install.js CHANGED
@@ -18,19 +18,19 @@ import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, ht
18
18
  import { storedProfile, writePluginSettings } from './profile.js';
19
19
  // Paths to extension source files (relative to this script)
20
20
  const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
21
- const SHARED_SESSION_STATE = path.join(EXT_DIR, 'shared', 'session-state.js');
22
- const CAVEMAN_INDEX = path.join(EXT_DIR, 'caveman-session', 'index.js');
23
- const RTK_SESSION_INDEX = path.join(EXT_DIR, 'rtk-session', 'index.js');
24
- const UPDATER_INDEX = path.join(EXT_DIR, 'ai-addons-updater', 'index.js');
25
- const COMBO_TOGGLE_INDEX = path.join(EXT_DIR, 'combo-toggle', 'index.js');
26
- const TERSIO_COMMANDS_INDEX = path.join(EXT_DIR, 'tersio-commands', 'index.js');
27
- const MODE_REINFORCEMENT_INDEX = path.join(EXT_DIR, 'shared', 'mode-reinforcement.js');
28
- const SHARED_TYPES = path.join(EXT_DIR, 'shared', 'types.js');
29
- const LIB_UTILS = path.join(EXT_DIR, 'lib', 'utils.js');
30
- const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.js');
31
- const SHARED_USAGE_LEDGER = path.join(EXT_DIR, 'shared', 'usage-ledger.js');
32
- const SHARED_PRICING = path.join(EXT_DIR, 'shared', 'pricing.js');
33
- const SHARED_CARBON = path.join(EXT_DIR, 'shared', 'carbon.js');
21
+ const SHARED_SESSION_STATE = path.join(EXT_DIR, 'shared', 'session-state.ts');
22
+ const CAVEMAN_INDEX = path.join(EXT_DIR, 'caveman-session', 'index.ts');
23
+ const RTK_SESSION_INDEX = path.join(EXT_DIR, 'rtk-session', 'index.ts');
24
+ const UPDATER_INDEX = path.join(EXT_DIR, 'ai-addons-updater', 'index.ts');
25
+ const COMBO_TOGGLE_INDEX = path.join(EXT_DIR, 'combo-toggle', 'index.ts');
26
+ const TERSIO_COMMANDS_INDEX = path.join(EXT_DIR, 'tersio-commands', 'index.ts');
27
+ const MODE_REINFORCEMENT_INDEX = path.join(EXT_DIR, 'shared', 'mode-reinforcement.ts');
28
+ const SHARED_TYPES = path.join(EXT_DIR, 'shared', 'types.ts');
29
+ const LIB_UTILS = path.join(EXT_DIR, 'lib', 'utils.ts');
30
+ const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.ts');
31
+ const SHARED_USAGE_LEDGER = path.join(EXT_DIR, 'shared', 'usage-ledger.ts');
32
+ const SHARED_PRICING = path.join(EXT_DIR, 'shared', 'pricing.ts');
33
+ const SHARED_CARBON = path.join(EXT_DIR, 'shared', 'carbon.ts');
34
34
  const PONYTAIL_GITHUB_SPEC = 'github:DietrichGebert/ponytail';
35
35
  const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
36
36
  async function stepPonytail(pluginsDir, userDir, options) {
@@ -375,27 +375,27 @@ async function stepSharedSessionState(extDir, options) {
375
375
  if (!options.quiet)
376
376
  console.log(' Shared files — sync session bridge');
377
377
  await copySources(extDir, [
378
- [SHARED_SESSION_STATE, path.join('shared', 'session-state.js')],
379
- [SHARED_TYPES, path.join('shared', 'types.js')],
380
- [LIB_UTILS, path.join('lib', 'utils.js')],
381
- [SHARED_PLUGIN_SETTINGS, path.join('shared', 'plugin-settings.js')],
382
- [SHARED_USAGE_LEDGER, path.join('shared', 'usage-ledger.js')],
383
- [SHARED_PRICING, path.join('shared', 'pricing.js')],
384
- [SHARED_CARBON, path.join('shared', 'carbon.js')],
378
+ [SHARED_SESSION_STATE, path.join('shared', 'session-state.ts')],
379
+ [SHARED_TYPES, path.join('shared', 'types.ts')],
380
+ [LIB_UTILS, path.join('lib', 'utils.ts')],
381
+ [SHARED_PLUGIN_SETTINGS, path.join('shared', 'plugin-settings.ts')],
382
+ [SHARED_USAGE_LEDGER, path.join('shared', 'usage-ledger.ts')],
383
+ [SHARED_PRICING, path.join('shared', 'pricing.ts')],
384
+ [SHARED_CARBON, path.join('shared', 'carbon.ts')],
385
385
  ], 'shared/session-state.js', options);
386
386
  }
387
387
  async function stepModeReinforcement(extDir, ponytailExtPath, options) {
388
388
  if (!options.quiet)
389
389
  console.log(' Session helpers — sync shared files');
390
- const dest = path.join(extDir, 'shared', 'mode-reinforcement.js');
391
- if (!await copySources(extDir, [[MODE_REINFORCEMENT_INDEX, path.join('shared', 'mode-reinforcement.js')]], 'shared/mode-reinforcement.js', options))
390
+ const dest = path.join(extDir, 'shared', 'mode-reinforcement.ts');
391
+ if (!await copySources(extDir, [[MODE_REINFORCEMENT_INDEX, path.join('shared', 'mode-reinforcement.ts')]], 'shared/mode-reinforcement.ts', options))
392
392
  return;
393
393
  await ensureExtensionAfterConfigEntry(path.join(path.dirname(extDir), 'config.yml'), dest, ponytailExtPath, 'mode reinforcement', options);
394
394
  }
395
395
  async function stepRtkSession(extDir, options) {
396
396
  if (!options.quiet)
397
397
  console.log(' RTK session — install session mode');
398
- await copySources(extDir, [[RTK_SESSION_INDEX, path.join('rtk-session', 'index.js')]], 'rtk-session/index.js', options);
398
+ await copySources(extDir, [[RTK_SESSION_INDEX, path.join('rtk-session', 'index.ts')]], 'rtk-session/index.ts', options);
399
399
  }
400
400
  // One rule fetch serves the install; dry runs stay offline
401
401
  // and fall back to the bundled rule to preview its destination.
@@ -427,21 +427,21 @@ async function stepCaveman(extDir, rule, options) {
427
427
  else {
428
428
  await writeIfChanged(ruleDest, rule, options);
429
429
  }
430
- await copySources(extDir, [[CAVEMAN_INDEX, path.join('caveman-session', 'index.js')]], 'caveman-session/index.js', options);
430
+ await copySources(extDir, [[CAVEMAN_INDEX, path.join('caveman-session', 'index.ts')]], 'caveman-session/index.ts', options);
431
431
  }
432
432
  async function stepTersioCommands(extDir, options) {
433
433
  if (!options.quiet)
434
434
  console.log(' Tersio commands — install /tersio root command');
435
- await copySources(extDir, [[TERSIO_COMMANDS_INDEX, path.join('tersio-commands', 'index.js')]], 'tersio-commands/index.js', options);
435
+ await copySources(extDir, [[TERSIO_COMMANDS_INDEX, path.join('tersio-commands', 'index.ts')]], 'tersio-commands/index.ts', options);
436
436
  }
437
437
  async function stepUpdater(extDir, options) {
438
- await copySources(extDir, [[UPDATER_INDEX, path.join('ai-addons-updater', 'index.js')]], 'ai-addons-updater/index.js', options);
438
+ await copySources(extDir, [[UPDATER_INDEX, path.join('ai-addons-updater', 'index.ts')]], 'ai-addons-updater/index.ts', options);
439
439
  }
440
440
  async function stepCombo(extDir, options) {
441
441
  if (!options.quiet)
442
442
  console.log(' Combo — install preset switch');
443
- const dest = path.join(extDir, 'combo-toggle', 'index.js');
444
- if (!await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.js')]], 'combo-toggle/index.js', options))
443
+ const dest = path.join(extDir, 'combo-toggle', 'index.ts');
444
+ if (!await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.ts')]], 'combo-toggle/index.ts', options))
445
445
  return;
446
446
  // Auto-register combo in config.yml
447
447
  const configPath = path.join(path.dirname(extDir), 'config.yml');
@@ -0,0 +1,377 @@
1
+ // OMP extension: /ai-addons manual updater for Ponytail, RTK, Caveman.
2
+ // Built-in Node modules only. Default off; registers a single slash command.
3
+ // ponytail: `skipped: none` — semantics match one-liner: fetch + compare + run install.
4
+ // rtk: `skipped: signature verification` — checksums.txt ships only SHA256 of release assets; add sigchain when upstream publishes a signing key.
5
+ // caveman: `skipped: none` — exactly the ask: write rule.md, report old/new hash.
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import {
14
+ CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE,
15
+ RTK_RELEASE_API,
16
+ RtkRelease,
17
+ fetchJson,
18
+ findFile,
19
+ httpsGet,
20
+ httpsDownload,
21
+ sha256Hex,
22
+ parseChecksum,
23
+ normalizeRtkVersion,
24
+ readTextIfExists,
25
+ rtkPlatformSpec,
26
+ } from '../lib/utils.ts';
27
+
28
+ const IS_WINDOWS = process.platform === 'win32';
29
+ const HOME = os.homedir();
30
+
31
+ const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
32
+ const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
33
+ const PONYTAIL_LOCAL = path.join(HOME, '.omp', 'plugins', 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
34
+ const RTK_BINARY = path.join(HOME, '.bun', 'bin', IS_WINDOWS ? 'rtk.exe' : 'rtk');
35
+ const CAVEMAN_LOCAL = path.join(HOME, '.omp', 'agent', 'extensions', 'caveman-session', 'rule.md');
36
+
37
+ const RELOAD_MSG = 'Reminder: restart OMP (or reload extensions) for updates to take effect.';
38
+
39
+ // --- Types ---
40
+
41
+ interface AddonUpdaterCtx {
42
+ cwd?: string;
43
+ ui?: {
44
+ notify?: (message: string, level?: string) => void;
45
+ };
46
+ }
47
+
48
+ interface AddonUpdaterPi {
49
+ setLabel?: (label: string) => void;
50
+ registerCommand?: (name: string, config: { description: string; handler: (args: string, ctx: AddonUpdaterCtx) => Promise<string> }) => void;
51
+ exec?: (cmd: string, args: string[], opts?: { cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number }>;
52
+ cwd?: string;
53
+ }
54
+
55
+ type NotifyLevel = 'info' | 'warning';
56
+
57
+ function notify(ctx: AddonUpdaterCtx | undefined, msg: string, level: NotifyLevel): void {
58
+ ctx?.ui?.notify?.(String(msg), level);
59
+ }
60
+
61
+ function report(ctx: AddonUpdaterCtx | undefined, msg: string, level: NotifyLevel): string {
62
+ notify(ctx, msg, level);
63
+ return msg;
64
+ }
65
+
66
+ interface AddonStatus {
67
+ text: string;
68
+ level: NotifyLevel;
69
+ }
70
+
71
+ function shortHash(text: string): string {
72
+ return sha256Hex(text).slice(0, 16);
73
+ }
74
+
75
+ function checkFailed(name: string, e: unknown): AddonStatus {
76
+ return { text: `${name} check failed: ${(e as Error).message}`, level: 'warning' };
77
+ }
78
+
79
+ // Single error-handling source for the three probes; messages unchanged.
80
+ function runCheck(name: string, probe: () => Promise<string>): Promise<AddonStatus> {
81
+ return probe().then((text) => ({ text, level: 'info' as const }), (e) => checkFailed(name, e));
82
+ }
83
+
84
+ // Check: no mutation. Probes run concurrently via checkAddons below.
85
+ function checkPonytail(): Promise<AddonStatus> {
86
+ return runCheck('Ponytail', async () => {
87
+ const remoteJson = await fetchJson<{ version?: string }>(PONYTAIL_REMOTE);
88
+ const localRaw = await readTextIfExists(PONYTAIL_LOCAL);
89
+ const localVer = localRaw ? (JSON.parse(localRaw) as { version?: string }).version ?? null : null;
90
+ const remoteVer = remoteJson.version;
91
+ const status = !localVer ? 'not installed'
92
+ : localVer === remoteVer ? 'up to date'
93
+ : 'update available';
94
+ return `Ponytail ${status}: local=${localVer || '—'} latest=${remoteVer}`;
95
+ });
96
+ }
97
+
98
+ async function fetchRelease(): Promise<RtkRelease> {
99
+ return fetchJson<RtkRelease>(RTK_RELEASE_API);
100
+ }
101
+
102
+ function checkRtk(): Promise<AddonStatus> {
103
+ return runCheck('RTK', async () => {
104
+ const release = await fetchRelease();
105
+ const latestTag = release.tag_name || null;
106
+ let localVer: string | null = null;
107
+ try {
108
+ const out = execFileSync(RTK_BINARY, ['--version'], { encoding: 'utf8', windowsHide: true, shell: false, timeout: 10000 }) || '';
109
+ if (out) localVer = out.trim().split(/\r?\n/)[0];
110
+ } catch { localVer = null; }
111
+ const status = localVer === null ? 'not installed'
112
+ : normalizeRtkVersion(localVer) === normalizeRtkVersion(latestTag ?? undefined) ? 'up to date'
113
+ : 'update available';
114
+ return `RTK ${status}: local=${localVer || '—'} latest=${latestTag || '—'}`;
115
+ });
116
+ }
117
+
118
+ // Caveman (rule.md)
119
+ function checkCaveman(): Promise<AddonStatus> {
120
+ return runCheck('Caveman', async () => {
121
+ const remote = await httpsGet(CAVEMAN_REMOTE);
122
+ const remoteHash = shortHash(remote);
123
+ const local = await readTextIfExists(CAVEMAN_LOCAL);
124
+ const localHash = local ? shortHash(local) : null;
125
+ const status = !local ? 'rule.md missing'
126
+ : localHash === remoteHash ? 'rule.md up to date'
127
+ : 'rule.md update available';
128
+ return `Caveman ${status}: local=${localHash || '—'} remote=${remoteHash}`;
129
+ });
130
+ }
131
+
132
+ export async function checkAddonsSummary(ctx: AddonUpdaterCtx): Promise<string> {
133
+ return checkAddons(ctx);
134
+ }
135
+
136
+ export async function runAddonUpdate(pi: { exec?: AddonUpdaterPi['exec'] }, ctx: AddonUpdaterCtx, target: string, dryRun = false): Promise<string> {
137
+ const results: string[] = [];
138
+ const updaters: Record<string, () => Promise<string>> = {
139
+ ponytail: () => updatePonytail(pi, ctx, dryRun),
140
+ rtk: () => updateRtk(ctx, dryRun),
141
+ caveman: () => updateCaveman(ctx, dryRun),
142
+ };
143
+ if (target === 'all') {
144
+ notify(ctx, `ai-addons update all${dryRun ? ' dry-run' : ''}: starting ponytail → rtk → caveman sequentially…`, 'info');
145
+ for (const name of ['ponytail', 'rtk', 'caveman']) results.push(await updaters[name]());
146
+ if (!dryRun) results.push(RELOAD_MSG);
147
+ notify(ctx, `ai-addons update all ${dryRun ? 'dry-run ' : ''}complete.${dryRun ? '' : ` ${RELOAD_MSG}`}`, 'info');
148
+ } else if (Object.hasOwn(updaters, target)) {
149
+ results.push(await updaters[target]());
150
+ } else {
151
+ const m = 'Usage: /ai-addons update <ponytail|rtk|caveman|all> [--dry-run]';
152
+ return report(ctx, m, 'warning');
153
+ }
154
+ return results.join('\n\n');
155
+ }
156
+
157
+ async function checkAddons(ctx: AddonUpdaterCtx): Promise<string> {
158
+ const [ponytail, rtk, caveman] = await Promise.all([checkPonytail(), checkRtk(), checkCaveman()]);
159
+ const lines: string[] = [];
160
+ for (const status of [ponytail, rtk, caveman]) {
161
+ lines.push(status.text);
162
+ notify(ctx, status.text, status.level);
163
+ }
164
+ return lines.join('\n');
165
+ }
166
+
167
+ async function updatePonytail(pi: AddonUpdaterPi, ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
168
+ const pluginsDir = path.join(HOME, '.omp', 'plugins');
169
+ if (dryRun) {
170
+ const m = `Ponytail dry-run: would run \`npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund\` in ${pluginsDir}.`;
171
+ return report(ctx, m, 'info');
172
+ }
173
+ notify(ctx, 'Ponytail: ensuring plugin directory exists…', 'info');
174
+ try {
175
+ await fs.mkdir(pluginsDir, { recursive: true });
176
+ } catch (e) {
177
+ const m = `Ponytail update failed: failed to create ${pluginsDir}: ${(e as Error).message}`;
178
+ return report(ctx, m, 'warning');
179
+ }
180
+ if (!pi.exec) {
181
+ const m = 'Ponytail update failed: extension host does not provide exec';
182
+ return report(ctx, m, 'warning');
183
+ }
184
+ notify(ctx, 'Ponytail: running npm install…', 'info');
185
+ let out = '';
186
+ try {
187
+ const r = await pi.exec('npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir });
188
+ out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
189
+ if (r.code !== 0) throw new Error(r.stderr || `npm exited ${r.code}`);
190
+ } catch (e) {
191
+ const m = `Ponytail update failed: ${(e as Error).message}`;
192
+ return report(ctx, m, 'warning');
193
+ }
194
+ const m = `Ponytail update finished.${out ? `\n${out}` : ''}\n${RELOAD_MSG}`;
195
+ notify(ctx, 'Ponytail update finished. ' + RELOAD_MSG, 'info');
196
+ return m;
197
+ }
198
+
199
+ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
200
+ let release: RtkRelease;
201
+ try {
202
+ release = await fetchRelease();
203
+ } catch (e) {
204
+ const m = `RTK: cannot fetch release info: ${(e as Error).message}`;
205
+ return report(ctx, m, 'warning');
206
+ }
207
+ const tag = release.tag_name || 'unknown';
208
+ const assets = Array.isArray(release.assets) ? release.assets : [];
209
+
210
+ // Cross-platform asset selection (mirrors installer stepRtk)
211
+ const PLATFORM = process.platform;
212
+ const ARCH = process.arch;
213
+ const spec = rtkPlatformSpec(PLATFORM, ARCH);
214
+ if (!spec) {
215
+ const m = `RTK: unsupported platform ${PLATFORM}/${ARCH}`;
216
+ return report(ctx, m, 'warning');
217
+ }
218
+ const { triple: assetTriple, ext: assetExt, binary: binaryName } = spec;
219
+
220
+ const asset = assets.find((a) => a.name === `rtk-${assetTriple}${assetExt}`);
221
+ const checksAsset = assets.find((a) => a.name === 'checksums.txt');
222
+ if (!asset || !checksAsset) {
223
+ const m = `RTK: required assets not found in release ${tag} (need rtk-${assetTriple}${assetExt} and checksums.txt)`;
224
+ return report(ctx, m, 'warning');
225
+ }
226
+
227
+ if (dryRun) {
228
+ const m = `RTK dry-run: would download ${asset.name} (${tag}), verify checksums.txt, and replace ${RTK_BINARY}.`;
229
+ return report(ctx, m, 'info');
230
+ }
231
+
232
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'rtk-update-'));
233
+ const archivePath = path.join(tmp, asset.name);
234
+ const checksPath = path.join(tmp, 'checksums.txt');
235
+
236
+ try {
237
+ notify(ctx, `RTK: downloading ${asset.name} (${tag})…`, 'info');
238
+ notify(ctx, 'RTK: downloading checksums.txt…', 'info');
239
+ await Promise.all([
240
+ httpsDownload(asset.browser_download_url, archivePath),
241
+ httpsDownload(checksAsset.browser_download_url, checksPath),
242
+ ]);
243
+ // Verify SHA256 against checksums.txt
244
+ const checks = await fs.readFile(checksPath, 'utf8');
245
+ const expected = parseChecksum(checks, asset.name);
246
+ if (!expected) {
247
+ const m = `RTK: checksums.txt has no entry for ${asset.name}`;
248
+ return report(ctx, m, 'warning');
249
+ }
250
+ const archiveBuf = await fs.readFile(archivePath);
251
+ const actual = createHash('sha256').update(archiveBuf).digest('hex').toLowerCase();
252
+ if (actual !== expected) {
253
+ const m = `RTK: checksum mismatch! expected=${expected.slice(0, 12)}… actual=${actual.slice(0, 12)}…`;
254
+ return report(ctx, m, 'warning');
255
+ }
256
+ notify(ctx, 'RTK: checksum verified.', 'info');
257
+
258
+ // Extract by archive format
259
+ const extractDir = path.join(tmp, 'extracted');
260
+ await fs.mkdir(extractDir, { recursive: true });
261
+
262
+ if (asset.name.endsWith('.zip')) {
263
+ if (IS_WINDOWS) {
264
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
265
+ `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${extractDir}' -Force`],
266
+ { encoding: 'utf8', windowsHide: true, shell: false });
267
+ } else {
268
+ execFileSync('unzip', [archivePath, '-d', extractDir], { encoding: 'utf8', shell: false });
269
+ }
270
+ } else if (asset.name.endsWith('.tar.gz') || asset.name.endsWith('.tgz')) {
271
+ try {
272
+ execFileSync('tar', ['xzf', archivePath, '-C', extractDir], { encoding: 'utf8', shell: false });
273
+ } catch (e) {
274
+ throw new Error(`tar could not extract ${asset.name}: ${(e as Error).message}`);
275
+ }
276
+ } else {
277
+ throw new Error(`Unknown archive format: ${asset.name}`);
278
+ }
279
+ const rtkExtracted = await findFile(extractDir, binaryName);
280
+ if (!rtkExtracted) {
281
+ const m = `RTK: ${binaryName} not found in extracted archive`;
282
+ return report(ctx, m, 'warning');
283
+ }
284
+ await fs.mkdir(path.dirname(RTK_BINARY), { recursive: true });
285
+ const backupPath = `${RTK_BINARY}.bak`;
286
+ const backedUp = await fs.copyFile(RTK_BINARY, backupPath).then(() => true, () => false);
287
+
288
+ await fs.copyFile(rtkExtracted, RTK_BINARY);
289
+
290
+ // Set executable bit on Unix
291
+ if (!IS_WINDOWS) {
292
+ await fs.chmod(RTK_BINARY, 0o755);
293
+ }
294
+
295
+ let versionOut = '';
296
+ try {
297
+ versionOut = execFileSync(RTK_BINARY, ['--version'], { encoding: 'utf8', windowsHide: true, shell: false, timeout: 10000 }).trim();
298
+ } catch (e) {
299
+ if (backedUp) await fs.copyFile(backupPath, RTK_BINARY);
300
+ throw new Error(`new ${binaryName} failed --version${backedUp ? '; restored backup' : ''}: ${(e as Error).message}`);
301
+ }
302
+ if (normalizeRtkVersion(versionOut) !== normalizeRtkVersion(tag)) {
303
+ if (backedUp) await fs.copyFile(backupPath, RTK_BINARY);
304
+ throw new Error(`new ${binaryName} reports ${versionOut}, expected ${tag}${backedUp ? '; restored backup' : ''}`);
305
+ }
306
+ const m = `RTK updated to ${tag} → ${RTK_BINARY}\nbackup=${backedUp ? backupPath : '—'}\n${RELOAD_MSG}`;
307
+ notify(ctx, 'RTK update finished. ' + RELOAD_MSG, 'info');
308
+ return m;
309
+ } catch (e) {
310
+ const m = `RTK update failed: ${(e as Error).message}`;
311
+ return report(ctx, m, 'warning');
312
+ } finally {
313
+ fs.rm(tmp, { recursive: true, force: true }).catch(() => { });
314
+ }
315
+ }
316
+
317
+ async function updateCaveman(ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
318
+ let remote: string;
319
+ try { remote = await httpsGet(CAVEMAN_REMOTE); }
320
+ catch (e) { return report(ctx, `Caveman update failed: ${(e as Error).message}`, 'warning'); }
321
+
322
+ const remoteHash = shortHash(remote);
323
+ const oldLocal = await readTextIfExists(CAVEMAN_LOCAL);
324
+ const oldHash = oldLocal ? shortHash(oldLocal) : null;
325
+
326
+ if (dryRun) {
327
+ const m = `Caveman dry-run: would write ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}.`;
328
+ return report(ctx, m, 'info');
329
+ }
330
+
331
+ try {
332
+ await fs.mkdir(path.dirname(CAVEMAN_LOCAL), { recursive: true });
333
+ const backupPath = `${CAVEMAN_LOCAL}.bak`;
334
+ if (oldLocal !== null) await fs.writeFile(backupPath, oldLocal, 'utf8');
335
+ await fs.writeFile(CAVEMAN_LOCAL, remote, 'utf8');
336
+ const written = await fs.readFile(CAVEMAN_LOCAL, 'utf8');
337
+ const writtenHash = shortHash(written);
338
+ if (writtenHash !== remoteHash) {
339
+ if (oldLocal !== null) await fs.writeFile(CAVEMAN_LOCAL, oldLocal, 'utf8');
340
+ throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
341
+ }
342
+ const m = `Caveman rule.md updated → ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`;
343
+ notify(ctx, 'Caveman rule.md updated. ' + RELOAD_MSG, 'info');
344
+ return m;
345
+ } catch (e) {
346
+ const m = `Caveman update failed: ${(e as Error).message}`;
347
+ return report(ctx, m, 'warning');
348
+ }
349
+ }
350
+
351
+ export default function aiAddonsUpdaterExtension(pi: AddonUpdaterPi): void {
352
+ pi.setLabel?.('AI add-ons updater');
353
+
354
+ pi.registerCommand?.('ai-addons', {
355
+ description: 'Check or update AI add-ons (ponytail/rtk/caveman/all). Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]',
356
+ handler: async (args, ctx) => {
357
+ const arg = String(args || '').trim().toLowerCase();
358
+ const parts = arg.split(/\s+/).filter(Boolean);
359
+ const dryRun = parts.includes('--dry-run') || parts.includes('dry-run');
360
+ const cleanParts = parts.filter((p) => p !== '--dry-run' && p !== 'dry-run');
361
+ const sub = cleanParts[0];
362
+
363
+ if (sub === 'check' || sub === 'status') {
364
+ const summary = await checkAddons(ctx);
365
+ notify(ctx, 'ai-addons check complete.', 'info');
366
+ return summary;
367
+ }
368
+ if (sub === 'update' && cleanParts[1]) {
369
+ return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
370
+ }
371
+ const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
372
+ return report(ctx, m, 'warning');
373
+ },
374
+ });
375
+ }
376
+
377
+ export { parseChecksum };