@krtclcdy/tersio 2.4.1 → 2.6.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/tersio.js CHANGED
@@ -2,84 +2,14 @@
2
2
  // tersio.ts — Install Tersio (caveman/rtk/ponytail) add-ons on any OMP device.
3
3
  // Usage: node tersio.js [install|update|reinstall|doctor|uninstall|version|help] [options]
4
4
  // Requires: node/npm and omp CLI
5
- import { execFile } from 'node:child_process';
6
- import { promises as fs } from 'node:fs';
7
- import os from 'node:os';
8
- import path from 'node:path';
9
- import readline from 'node:readline';
10
- import { fileURLToPath } from 'node:url';
11
- import { promisify } from 'node:util';
12
- import { createRequire } from 'node:module';
13
- import { cancel as clackCancel, confirm as clackConfirm, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
14
- import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, sha256File, parseChecksum, readTextIfExists, rtkPlatformSpec, } from './extensions/lib/utils.js';
15
- const IS_WINDOWS = process.platform === 'win32';
16
- const RTK_BINARY_NAME = IS_WINDOWS ? 'rtk.exe' : 'rtk';
17
- const HOME = process.env.HOME || process.env.USERPROFILE || '';
18
- const OMP_AGENT_DIR = path.join(HOME, '.omp', 'agent');
19
- const OMP_PLUGINS_DIR = path.join(HOME, '.omp', 'plugins');
20
- const BUN_BIN_DIR = path.join(HOME, '.bun', 'bin');
21
- const OMP_BIN = IS_WINDOWS ? 'omp.cmd' : 'omp';
22
- const PACKAGE_NAME = '@krtclcdy/tersio';
23
- const PACKAGE_BIN = 'tersio';
24
- const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('./package.json');
25
- // Session-start mode defaults for fresh installs.
26
- const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
27
- const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'review']);
28
- const RTK_DEFAULTS = new Set(['on', 'off']);
29
- // ponytail: Combo preset implies all three modes. Mirrors COMBO_LEVELS in
30
- // extensions/shared/session-state.ts (rtk as boolean here). Single source.
31
- const COMBO_PRESET_MODES = {
32
- off: { caveman: 'off', rtk: false, ponytail: 'off' },
33
- medium: { caveman: 'lite', rtk: true, ponytail: 'lite' },
34
- balanced: { caveman: 'full', rtk: true, ponytail: 'full' },
35
- max: { caveman: 'ultra', rtk: true, ponytail: 'ultra' },
36
- };
37
- const COMBO_DEFAULTS = new Set(Object.keys(COMBO_PRESET_MODES));
38
- function parseEnum(value, valid, flag) {
39
- if (value === undefined)
40
- return undefined;
41
- const v = value.trim().toLowerCase();
42
- if (!valid.has(v)) {
43
- console.error(`[fail] Invalid ${flag}: ${value}. Valid: ${[...valid].join(', ')}`);
44
- process.exit(1);
45
- }
46
- return v;
47
- }
48
- function flagValue(name) {
49
- const i = args.indexOf(name);
50
- if (i !== -1)
51
- return args[i + 1];
52
- const prefix = `${name}=`;
53
- return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
54
- }
55
- // --- CLI flags ---
56
- const COMMANDS = new Set(['install', 'update', 'reinstall', 'doctor', 'uninstall', 'version', 'help']);
57
- const args = process.argv.slice(2);
58
- const commandArg = args.find((arg, index) => !arg.startsWith('-') && args[index - 1] !== '--scope');
59
- const command = commandArg?.toLowerCase() || null;
60
- const unknownCommand = command !== null && !COMMANDS.has(command);
61
- const install = command === 'install';
62
- const update = command === 'update';
63
- const reinstall = command === 'reinstall';
64
- const showVersion = command === 'version' || args.includes('--version') || args.includes('-v');
65
- const showHelp = command === 'help' || args.includes('--help') || args.includes('-h');
66
- const applyUpdate = args.includes('--apply-update');
67
- const dryRun = args.includes('--dry-run');
68
- const yes = args.includes('--yes') || args.includes('-y') || install || update || reinstall || applyUpdate;
69
- const verbose = args.includes('--verbose');
70
- const doctor = command === 'doctor' || args.includes('--doctor');
71
- const uninstall = command === 'uninstall' || args.includes('--uninstall');
72
- const removePonytail = args.includes('--remove-ponytail');
73
- const keepPonytail = args.includes('--keep-ponytail');
74
- const removeRtk = args.includes('--remove-rtk');
75
- const scopeFlag = flagValue('--scope')?.toLowerCase() ?? null;
76
- // --- Profile selection (session-start mode defaults) ---
77
- const comboDefaultFlag = parseEnum(flagValue('--combo-default'), COMBO_DEFAULTS, '--combo-default');
78
- const cavemanDefaultFlag = parseEnum(flagValue('--caveman-default'), CAVEMAN_DEFAULTS, '--caveman-default');
79
- const ponytailDefaultFlag = parseEnum(flagValue('--ponytail-default'), PONYTAIL_DEFAULTS, '--ponytail-default');
80
- const rtkDefaultFlag = parseEnum(flagValue('--rtk-default'), RTK_DEFAULTS, '--rtk-default');
81
- const profileFlagsGiven = [comboDefaultFlag, cavemanDefaultFlag, rtkDefaultFlag, ponytailDefaultFlag]
82
- .some((flag) => flag !== undefined);
5
+ import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, dashboard, dashboardExport, dashboardOpen, dashboardPort, doctor, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
6
+ import { closeRL } from './cli/interactive.js';
7
+ import { runInstall } from './cli/install.js';
8
+ import { runDoctor } from './cli/doctor.js';
9
+ import { runLatestUpdate } from './cli/update.js';
10
+ import { runUninstall } from './cli/uninstall.js';
11
+ import { runUsage } from './cli/usage.js';
12
+ import { runDashboard } from './cli/dashboard.js';
83
13
  function printHelp() {
84
14
  console.log(`Usage: ${PACKAGE_BIN} [command] [options]
85
15
 
@@ -88,6 +18,8 @@ Commands:
88
18
  update Refresh the CLI and add-ons (RTK binary, Caveman rule, Ponytail)
89
19
  reinstall Clean and reinstall the user-scope add-ons
90
20
  doctor Check the current installation
21
+ usage Ledger-backed usage + savings report
22
+ dashboard Serve the gain dashboard (localhost only)
91
23
  uninstall Remove the managed extensions
92
24
  version Print the package version
93
25
  help Show this help
@@ -103,1204 +35,12 @@ Options:
103
35
  --yes, -y
104
36
  --dry-run
105
37
  --verbose
38
+ --port <n> (dashboard: pin port, default ephemeral)
39
+ --open (dashboard: open browser)
40
+ --export <file> (dashboard: write HTML file instead of serving)
106
41
  --version, -v
107
42
  --help, -h`);
108
43
  }
109
- function debug(...a) {
110
- if (verbose)
111
- console.log(' [debug]', ...a);
112
- }
113
- const RL = readline.createInterface({ input: process.stdin, output: process.stdout });
114
- let rlOpen = true;
115
- function ask(q) {
116
- return new Promise((resolve) => {
117
- if (!rlOpen) {
118
- resolve('');
119
- return;
120
- }
121
- try {
122
- RL.question(q, resolve);
123
- }
124
- catch {
125
- resolve('');
126
- }
127
- });
128
- }
129
- function closeRL() { if (rlOpen) {
130
- RL.close();
131
- rlOpen = false;
132
- } }
133
- // Paths to extension source files (relative to this script)
134
- const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
135
- const EXT_DIR = path.join(SCRIPT_DIR, 'extensions');
136
- const SHARED_SESSION_STATE = path.join(EXT_DIR, 'shared', 'session-state.js');
137
- const CAVEMAN_INDEX = path.join(EXT_DIR, 'caveman-session', 'index.js');
138
- const RTK_SESSION_INDEX = path.join(EXT_DIR, 'rtk-session', 'index.js');
139
- const UPDATER_INDEX = path.join(EXT_DIR, 'ai-addons-updater', 'index.js');
140
- const COMBO_TOGGLE_INDEX = path.join(EXT_DIR, 'combo-toggle', 'index.js');
141
- const MODE_REINFORCEMENT_INDEX = path.join(EXT_DIR, 'shared', 'mode-reinforcement.js');
142
- const SHARED_TYPES = path.join(EXT_DIR, 'shared', 'types.js');
143
- const LIB_UTILS = path.join(EXT_DIR, 'lib', 'utils.js');
144
- const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.js');
145
- // --- Helpers ---
146
- const execFileP = promisify(execFile);
147
- async function execP(cmd, args, opts = {}) {
148
- return execFileP(cmd, args, {
149
- timeout: opts.timeout || 120000,
150
- encoding: 'utf8',
151
- ...opts,
152
- });
153
- }
154
- async function execNetwork(label, cmd, args, opts = {}) {
155
- return withInteractiveSpinner(label, () => execP(cmd, args, opts));
156
- }
157
- async function writeIfChanged(dest, content, options = {}) {
158
- const existing = await readTextIfExists(dest);
159
- if (existing === content) {
160
- debug(`${dest} already up to date`);
161
- return false;
162
- }
163
- if (options.dryRun) {
164
- console.log(` [dry-run] would write ${dest}`);
165
- return true;
166
- }
167
- await fs.mkdir(path.dirname(dest), { recursive: true });
168
- if (existing !== null) {
169
- await fs.copyFile(dest, `${dest}.bak`);
170
- debug(`${path.basename(dest)} → ${path.basename(dest)}.bak`);
171
- }
172
- await fs.writeFile(dest, content, 'utf8');
173
- console.log(` [write] ${dest}`);
174
- return true;
175
- }
176
- // omp ships 'extensions: null'; appending list items under a null scalar breaks
177
- // YAML parsing. Normalize null/~/[]/empty to a mapping key first.
178
- const EXTENSIONS_KEY_RE = /^\s*extensions\s*:/i;
179
- const EXTENSIONS_NULL_RE = /^\s*extensions\s*:\s*(?:\[\s*\]|null|~)?\s*$/i;
180
- function normalizeExtensionsKey(lines) {
181
- const idx = lines.findIndex((l) => EXTENSIONS_NULL_RE.test(l));
182
- if (idx === -1)
183
- return false;
184
- lines[idx] = 'extensions:';
185
- return true;
186
- }
187
- async function writeConfigLines(configPath, lines, logMsg) {
188
- await fs.mkdir(path.dirname(configPath), { recursive: true });
189
- await fs.copyFile(configPath, `${configPath}.bak`).catch(() => { });
190
- await fs.writeFile(configPath, lines.join('\n'), 'utf8');
191
- console.log(logMsg);
192
- }
193
- async function ensureExtensionInConfig(configPath, extensionPath, label, options = {}) {
194
- const normalizedPath = extensionPath.replace(/\\/g, '/');
195
- const line = ` - ${normalizedPath}`;
196
- const raw = await readTextIfExists(configPath);
197
- let lines = (raw || '').split('\n');
198
- if (lines.some((l) => l.includes(normalizedPath))) {
199
- debug(`${label} already in config.yml`);
200
- return false;
201
- }
202
- const extLineIdx = lines.findIndex((l) => EXTENSIONS_KEY_RE.test(l));
203
- if (options.dryRun) {
204
- console.log(` [dry-run] would add ${label} to config.yml: ${normalizedPath}`);
205
- return true;
206
- }
207
- if (normalizeExtensionsKey(lines)) {
208
- lines.splice(extLineIdx + 1, 0, line, '');
209
- }
210
- else if (extLineIdx === -1) {
211
- lines.push('extensions:');
212
- lines.push(line);
213
- lines.push('');
214
- }
215
- else {
216
- lines.splice(extLineIdx + 1, 0, line);
217
- }
218
- await writeConfigLines(configPath, lines, ` [write] Added ${label} to config.yml`);
219
- return true;
220
- }
221
- async function ensureExtensionAfterConfigEntry(configPath, extensionPath, afterPath, label, options = {}) {
222
- const normalizedPath = extensionPath.replace(/\\/g, '/');
223
- const normalizedAfterPath = afterPath.replace(/\\/g, '/');
224
- const line = ` - ${normalizedPath}`;
225
- const raw = await readTextIfExists(configPath);
226
- const lines = (raw || '').split('\n');
227
- const existingIndex = lines.findIndex((entry) => entry.includes(normalizedPath));
228
- const afterIndex = lines.findIndex((entry) => entry.includes(normalizedAfterPath));
229
- if (existingIndex !== -1 && afterIndex !== -1 && existingIndex === afterIndex + 1)
230
- return false;
231
- if (options.dryRun) {
232
- console.log(` [dry-run] would place ${label} after Ponytail in config.yml: ${normalizedPath}`);
233
- return true;
234
- }
235
- if (existingIndex !== -1)
236
- lines.splice(existingIndex, 1);
237
- const refreshedAfterIndex = lines.findIndex((entry) => entry.includes(normalizedAfterPath));
238
- if (refreshedAfterIndex !== -1) {
239
- lines.splice(refreshedAfterIndex + 1, 0, line);
240
- }
241
- else {
242
- const extensionsIndex = lines.findIndex((entry) => EXTENSIONS_KEY_RE.test(entry));
243
- if (extensionsIndex === -1) {
244
- lines.push('extensions:', line, '');
245
- }
246
- else {
247
- normalizeExtensionsKey(lines);
248
- lines.splice(extensionsIndex + 1, 0, line);
249
- }
250
- }
251
- await writeConfigLines(configPath, lines, ` [write] Placed ${label} after Ponytail in config.yml`);
252
- return true;
253
- }
254
- function readPonytailConfig() {
255
- const dir = process.env.XDG_CONFIG_HOME
256
- ? path.join(process.env.XDG_CONFIG_HOME, 'ponytail')
257
- : path.join(HOME, '.config', 'ponytail');
258
- return { dir, path: path.join(dir, 'config.json') };
259
- }
260
- function parseJsonObject(text) {
261
- if (!text)
262
- return null;
263
- try {
264
- const parsed = JSON.parse(text.replace(/^\uFEFF/, ''));
265
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
266
- return null;
267
- return parsed;
268
- }
269
- catch {
270
- return null;
271
- }
272
- }
273
- function parsePonytailConfig(existing) {
274
- return parseJsonObject(existing) ?? {};
275
- }
276
- async function patchPonytailConfig(apply, dryRunNote, writeNote, options = {}) {
277
- const config = readPonytailConfig();
278
- if (options.dryRun) {
279
- console.log(` [dry-run] ${dryRunNote} in ${config.path}`);
280
- return;
281
- }
282
- const cfg = parsePonytailConfig(await readTextIfExists(config.path));
283
- if (!apply(cfg)) {
284
- debug('Ponytail config already up to date');
285
- return;
286
- }
287
- await fs.mkdir(config.dir, { recursive: true });
288
- await fs.writeFile(config.path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
289
- console.log(` [write] ${writeNote} in ${config.path}`);
290
- }
291
- async function ensurePonytailConfigValue(key, value, options = {}) {
292
- await patchPonytailConfig((cfg) => {
293
- if (cfg[key] === value)
294
- return false;
295
- cfg[key] = value;
296
- return true;
297
- }, `would set Ponytail ${key}=${value}`, `Set Ponytail ${key}=${value}`, options);
298
- }
299
- // Read plugins/package.json tolerantly; corrupt or missing files start fresh
300
- // with installer-managed defaults.
301
- async function readPluginsPackage(pkgPath) {
302
- const pkg = parseJsonObject(await readTextIfExists(pkgPath)) ?? {};
303
- return { ...pkg, name: pkg.name || 'omp-plugins', private: true, dependencies: pkg.dependencies || {} };
304
- }
305
- const PONYTAIL_GITHUB_SPEC = 'github:DietrichGebert/ponytail';
306
- const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
307
- // --- Steps ---
308
- async function stepPonytail(pluginsDir, userDir, options) {
309
- console.log('\n[1/7] Installing Ponytail plugin...');
310
- await fs.mkdir(pluginsDir, { recursive: true });
311
- const pkgPath = path.join(pluginsDir, 'package.json');
312
- const pkg = await readPluginsPackage(pkgPath);
313
- pkg.dependencies['@dietrichgebert/ponytail'] = PONYTAIL_GITHUB_SPEC;
314
- if (options.dryRun) {
315
- console.log(` [dry-run] would write ${pkgPath}`);
316
- }
317
- else {
318
- await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
319
- console.log(' [write] package.json');
320
- }
321
- const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
322
- const probeExt = async () => (await readTextIfExists(ponytailExtPath)) !== null;
323
- // Fast path: extension already present and no refresh asked — skip network.
324
- let ponytailExtExists = !options.reinstall && !options.dryRun ? await probeExt() : false;
325
- if (ponytailExtExists) {
326
- debug('Ponytail pi-extension already installed; skipping network refresh');
327
- }
328
- else if (options.dryRun) {
329
- // Dry runs preview the wiring below without touching the network.
330
- console.log(` [dry-run] would run: omp plugin install ${PONYTAIL_GITHUB_SPEC}`);
331
- if (options.reinstall) {
332
- console.log(` [dry-run] would run: npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund`);
333
- }
334
- ponytailExtExists = true;
335
- }
336
- else {
337
- // Try omp plugin install first
338
- try {
339
- await execNetwork('Installing Ponytail plugin', OMP_BIN, ['plugin', 'install', PONYTAIL_GITHUB_SPEC], { cwd: pluginsDir });
340
- console.log(' [ok] omp plugin install ran');
341
- }
342
- catch (e) {
343
- console.log(` [warn] omp plugin install failed: ${e.message}`);
344
- }
345
- if (options.reinstall) {
346
- try {
347
- await execNetwork('Refreshing Ponytail package', 'npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 120000 });
348
- console.log(' [ok] Ponytail refreshed');
349
- }
350
- catch (e) {
351
- console.log(` [fail] Could not refresh ponytail: ${e.message}`);
352
- console.log(` [hint] Manual: cd ~/.omp/plugins && npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund`);
353
- }
354
- }
355
- // Verify the pi-extension/index.js actually exists
356
- ponytailExtExists = await probeExt();
357
- // Fallback: try bun install or npm install
358
- if (!ponytailExtExists) {
359
- console.log(' [info] pi-extension/index.js not found after omp plugin install — trying npm/bun install...');
360
- try {
361
- await execNetwork('Installing Ponytail dependencies', 'npm', ['install'], { cwd: pluginsDir, timeout: 120000 });
362
- console.log(' [ok] npm install completed');
363
- }
364
- catch {
365
- try {
366
- await execNetwork('Installing Ponytail dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 120000 });
367
- console.log(' [ok] bun install completed');
368
- }
369
- catch (e2) {
370
- console.log(` [fail] Could not install ponytail: ${e2.message}`);
371
- console.log(' [hint] Manual: cd ~/.omp/plugins && npm install');
372
- }
373
- }
374
- ponytailExtExists = await probeExt();
375
- }
376
- // Last-resort fallback: git clone the repo into node_modules
377
- if (!ponytailExtExists) {
378
- console.log(' [info] npm/bun did not produce pi-extension — trying git clone...');
379
- try {
380
- const dest = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail');
381
- await fs.mkdir(path.dirname(dest), { recursive: true });
382
- await execNetwork('Cloning Ponytail repository', 'git', ['clone', '--depth', '1', 'https://github.com/DietrichGebert/ponytail.git', dest], { timeout: 180000 });
383
- console.log(' [ok] git clone completed');
384
- ponytailExtExists = await probeExt();
385
- }
386
- catch (e3) {
387
- console.log(` [fail] git clone failed: ${e3.message}`);
388
- console.log(' [hint] Install git or check network: https://github.com/DietrichGebert/ponytail');
389
- }
390
- }
391
- }
392
- if (!ponytailExtExists) {
393
- console.log(' [skip] Ponytail pi-extension/index.js still not found — skill-only mode');
394
- console.log(' [hint] The /ponytail command won\'t work, but ponytail skills will still load');
395
- }
396
- else if (!options.dryRun) {
397
- // Wire extension into config.yml so /ponytail command loads
398
- console.log(' [ok] Ponytail pi-extension found');
399
- }
400
- // Still set Ponytail config defaults even without the extension command
401
- const configPath = path.join(userDir, 'config.yml');
402
- await ensureExtensionInConfig(configPath, ponytailExtPath, 'ponytail', options);
403
- await ensurePonytailConfigValue('defaultMode', 'off', options);
404
- // hideStatus=true keeps the upstream ponytail bar hidden; combo owns the bar.
405
- await ensurePonytailConfigValue('hideStatus', true, options);
406
- }
407
- // Registers this package in ~/.omp/plugins so OMP lists it on the
408
- // Settings → Plugins page (OMP enumerates plugins/package.json dependencies).
409
- // Returns true when the package is verified in plugins/node_modules.
410
- async function stepSelfPlugin(pluginsDir, options) {
411
- console.log('\n[2/7] Registering tersio as OMP plugin...');
412
- const pkgPath = path.join(pluginsDir, 'package.json');
413
- const pkg = await readPluginsPackage(pkgPath);
414
- pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
415
- for (const legacy of ['oh-my-pi-token-saver', 'tersio-omp']) {
416
- if (legacy in pkg.dependencies) {
417
- delete pkg.dependencies[legacy];
418
- if (!options.dryRun)
419
- console.log(` [migrate] dropped legacy ${legacy} dependency`);
420
- }
421
- }
422
- if (options.dryRun) {
423
- console.log(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
424
- console.log(` [dry-run] would run: npm install --no-audit --no-fund (in ${pluginsDir})`);
425
- return false;
426
- }
427
- // pkg.dependencies[PACKAGE_NAME] was assigned above, so only reinstall skips this fast path.
428
- if (!options.reinstall) {
429
- const installedPkgRaw = await readTextIfExists(path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json'));
430
- if (parseJsonObject(installedPkgRaw)?.version === PACKAGE_VERSION) {
431
- debug(`${PACKAGE_NAME} already installed at v${PACKAGE_VERSION}; skipping npm install`);
432
- console.log(' [ok] Listed in OMP Settings → Plugins as tersio');
433
- return true;
434
- }
435
- }
436
- await fs.mkdir(pluginsDir, { recursive: true });
437
- await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
438
- try {
439
- await execNetwork('Installing Tersio plugin dependencies', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
440
- }
441
- catch {
442
- try {
443
- await execNetwork('Installing Tersio plugin dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
444
- }
445
- catch (e) {
446
- console.log(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
447
- console.log(` [hint] Manual: cd ~/.omp/plugins && npm install ${PACKAGE_NAME}@^${PACKAGE_VERSION} --save --no-audit --no-fund`);
448
- return false;
449
- }
450
- }
451
- const installedPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
452
- if ((await readTextIfExists(installedPkg)) === null) {
453
- console.log(` [warn] ${PACKAGE_NAME} not found in plugins/node_modules after install`);
454
- return false;
455
- }
456
- console.log(' [ok] Listed in OMP Settings → Plugins as tersio');
457
- return true;
458
- }
459
- function resolveRtkTriple() {
460
- const triple = rtkPlatformSpec()?.triple;
461
- if (triple)
462
- return triple;
463
- console.log(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
464
- console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
465
- return null;
466
- }
467
- function findRtkAsset(release, triple) {
468
- const assets = release.assets || [];
469
- const asset = assets.find((a) => a.name === `rtk-${triple}.zip` || a.name === `rtk-${triple}.tar.gz`);
470
- if (asset)
471
- return asset;
472
- console.log(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
473
- console.log(` [hint] Available: ${assets.map((a) => a.name).filter((n) => n.startsWith('rtk-')).join(', ')}`);
474
- return null;
475
- }
476
- async function downloadRtkChecksums(release) {
477
- const checksumsAsset = (release.assets || []).find((a) => a.name === 'checksums.txt');
478
- if (!checksumsAsset)
479
- return null;
480
- try {
481
- return await httpsGet(checksumsAsset.browser_download_url);
482
- }
483
- catch (e) {
484
- debug(`Could not download checksums.txt: ${e.message}`);
485
- return null;
486
- }
487
- }
488
- async function verifyRtkArchive(archivePath, assetName, checksumsText) {
489
- if (!checksumsText) {
490
- console.log(' [warn] No checksums.txt available — skipping verification');
491
- return true;
492
- }
493
- const expected = parseChecksum(checksumsText, assetName);
494
- const actual = await sha256File(archivePath);
495
- if (!expected) {
496
- console.log(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
497
- return true;
498
- }
499
- if (actual === expected) {
500
- console.log(` [ok] Checksum verified for ${assetName}`);
501
- return true;
502
- }
503
- console.log(` [fail] Checksum mismatch for ${assetName}`);
504
- console.log(` [fail] Expected: ${expected}`);
505
- console.log(` [fail] Got: ${actual}`);
506
- // Caller finally removes the temp dir.
507
- return false;
508
- }
509
- function shortError(e) {
510
- return ((e.stderr) || e.message || '').trim().slice(0, 200);
511
- }
512
- async function extractRtkArchive(archivePath, extractDir) {
513
- await fs.mkdir(extractDir, { recursive: true });
514
- if (archivePath.endsWith('.zip')) {
515
- if (IS_WINDOWS) {
516
- await execP('powershell', ['Expand-Archive', '-Path', archivePath, '-DestinationPath', extractDir, '-Force'], { timeout: 60000 });
517
- }
518
- else {
519
- await execP('unzip', [archivePath, '-d', extractDir], { timeout: 60000 });
520
- }
521
- return true;
522
- }
523
- if (archivePath.endsWith('.tar.gz') || archivePath.endsWith('.tgz')) {
524
- try {
525
- await execP('tar', ['xzf', archivePath, '-C', extractDir], { timeout: 60000 });
526
- debug('tar xzf ok');
527
- }
528
- catch (e) {
529
- debug(`tar xzf failed: ${shortError(e)}`);
530
- console.log(` [fail] tar could not extract ${path.basename(archivePath)}`);
531
- console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
532
- return false;
533
- }
534
- return true;
535
- }
536
- console.log(` [fail] Unknown archive format: ${archivePath}`);
537
- return false;
538
- }
539
- async function stepRtk(binDir, options) {
540
- console.log('\n[3/7] Installing RTK binary...');
541
- try {
542
- const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
543
- const triple = resolveRtkTriple();
544
- if (!triple)
545
- return;
546
- const asset = findRtkAsset(release, triple);
547
- if (!asset)
548
- return;
549
- const binDest = path.join(binDir, RTK_BINARY_NAME);
550
- if (options.dryRun) {
551
- console.log(` [dry-run] would download ${asset.name} from release ${release.tag_name}`);
552
- console.log(' [dry-run] would verify checksum against checksums.txt');
553
- console.log(` [dry-run] would extract and install to ${binDest}`);
554
- return;
555
- }
556
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
557
- try {
558
- const archivePath = path.join(tmpDir, asset.name);
559
- const checksumsText = await withInteractiveSpinner('Downloading RTK binary', async (update) => {
560
- const [downloadedChecksums] = await Promise.all([
561
- downloadRtkChecksums(release),
562
- httpsDownload(asset.browser_download_url, archivePath),
563
- ]);
564
- update('Verifying RTK checksum');
565
- return downloadedChecksums;
566
- });
567
- if (!await verifyRtkArchive(archivePath, asset.name, checksumsText))
568
- return;
569
- const extractDir = path.join(tmpDir, 'extracted');
570
- if (!await extractRtkArchive(archivePath, extractDir))
571
- return;
572
- const found = await findFile(extractDir, RTK_BINARY_NAME);
573
- if (!found) {
574
- console.log(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
575
- return;
576
- }
577
- await fs.mkdir(path.dirname(binDest), { recursive: true });
578
- await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
579
- await fs.copyFile(found, binDest);
580
- console.log(` [write] ${binDest}`);
581
- if (!IS_WINDOWS) {
582
- await fs.chmod(binDest, 0o755);
583
- debug(`chmod 755 ${binDest}`);
584
- }
585
- try {
586
- const v = (await execP(binDest, ['--version'], { timeout: 10000, shell: false })).stdout.trim();
587
- console.log(` [ok] ${binDest} → ${v}`);
588
- }
589
- catch {
590
- console.log(` [hint] Verify manually: ${binDest} --version`);
591
- }
592
- }
593
- finally {
594
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
595
- }
596
- }
597
- catch (e) {
598
- console.log(` [fail] RTK: ${e.message}`);
599
- console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
600
- }
601
- }
602
- // Copy repo source files into the target extension dir. First entry is
603
- // required (skip label on missing); rest are optional companions.
604
- async function copySources(extDir, files, skipLabel, options) {
605
- const src = await readTextIfExists(files[0][0]);
606
- if (!src) {
607
- console.log(` [skip] ${skipLabel} not found in repo`);
608
- return false;
609
- }
610
- await writeIfChanged(path.join(extDir, files[0][1]), src, options);
611
- const rest = files.slice(1);
612
- const extras = await Promise.all(rest.map(([from]) => readTextIfExists(from)));
613
- for (let i = 0; i < rest.length; i++) {
614
- const extra = extras[i];
615
- if (extra)
616
- await writeIfChanged(path.join(extDir, rest[i][1]), extra, options);
617
- }
618
- return true;
619
- }
620
- async function stepSharedSessionState(extDir, options) {
621
- await copySources(extDir, [
622
- [SHARED_SESSION_STATE, path.join('shared', 'session-state.js')],
623
- [SHARED_TYPES, path.join('shared', 'types.js')],
624
- [LIB_UTILS, path.join('lib', 'utils.js')],
625
- [SHARED_PLUGIN_SETTINGS, path.join('shared', 'plugin-settings.js')],
626
- ], 'shared/session-state.js', options);
627
- }
628
- async function stepModeReinforcement(extDir, ponytailExtPath, options) {
629
- console.log('\n[7/7] Installing mode reinforcement extension...');
630
- const dest = path.join(extDir, 'shared', 'mode-reinforcement.js');
631
- if (!await copySources(extDir, [[MODE_REINFORCEMENT_INDEX, path.join('shared', 'mode-reinforcement.js')]], 'shared/mode-reinforcement.js', options))
632
- return;
633
- await ensureExtensionAfterConfigEntry(path.join(path.dirname(extDir), 'config.yml'), dest, ponytailExtPath, 'mode reinforcement', options);
634
- }
635
- async function stepRtkSession(extDir, options) {
636
- console.log('\n[4/7] Installing RTK session extension...');
637
- await copySources(extDir, [[RTK_SESSION_INDEX, path.join('rtk-session', 'index.js')]], 'rtk-session/index.js', options);
638
- }
639
- // One rule fetch serves user- and project-level installs; dry runs stay offline
640
- // and fall back to the bundled rule to preview its destination.
641
- async function fetchCavemanRule(options) {
642
- if (options.dryRun)
643
- return (await readTextIfExists(path.join(path.dirname(CAVEMAN_INDEX), 'rule.md'))) || '';
644
- try {
645
- return await withInteractiveSpinner('Fetching Caveman rule', () => httpsGet(CAVEMAN_REMOTE_RULE));
646
- }
647
- catch (e) {
648
- console.log(` [warn] Could not fetch caveman rule: ${e.message}`);
649
- return null;
650
- }
651
- }
652
- async function stepCaveman(extDir, rule, options) {
653
- console.log('\n[5/7] Installing Caveman session extension...');
654
- const cavemanDir = path.join(extDir, 'caveman-session');
655
- if (!options.dryRun)
656
- await fs.mkdir(cavemanDir, { recursive: true });
657
- const ruleDest = path.join(cavemanDir, 'rule.md');
658
- if (rule === null && (await readTextIfExists(ruleDest)) !== null) {
659
- console.log(' [info] Keeping existing rule.md');
660
- }
661
- else if (rule === null) {
662
- console.log(' [skip] Caveman rule.md unavailable');
663
- console.log(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
664
- }
665
- else {
666
- await writeIfChanged(ruleDest, rule, options);
667
- }
668
- await copySources(extDir, [[CAVEMAN_INDEX, path.join('caveman-session', 'index.js')]], 'caveman-session/index.js', options);
669
- }
670
- async function stepUpdater(extDir, options) {
671
- await copySources(extDir, [[UPDATER_INDEX, path.join('ai-addons-updater', 'index.js')]], 'ai-addons-updater/index.js', options);
672
- }
673
- async function stepCombo(extDir, options) {
674
- console.log('\n[6/7] Installing Combo toggle extension...');
675
- const dest = path.join(extDir, 'combo-toggle', 'index.js');
676
- if (!await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.js')]], 'combo-toggle/index.js', options))
677
- return;
678
- // Auto-register combo in config.yml
679
- const configPath = path.join(path.dirname(extDir), 'config.yml');
680
- await ensureExtensionInConfig(configPath, dest, 'combo', options);
681
- }
682
- // --- Doctor ---
683
- async function runDoctor() {
684
- console.log('\n=== Tersio Doctor ===');
685
- // Directories
686
- const agentDir = OMP_AGENT_DIR;
687
- const extDir = path.join(agentDir, 'extensions');
688
- const configPath = path.join(agentDir, 'config.yml');
689
- const pluginsDir = OMP_PLUGINS_DIR;
690
- const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
691
- // Ponytail
692
- const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
693
- const ponytailExt = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
694
- const cavemanIndex = path.join(extDir, 'caveman-session', 'index.js');
695
- const cavemanRule = path.join(extDir, 'caveman-session', 'rule.md');
696
- const rtkIndex = path.join(extDir, 'rtk-session', 'index.js');
697
- const updaterIndex = path.join(extDir, 'ai-addons-updater', 'index.js');
698
- const comboIndex = path.join(extDir, 'combo-toggle', 'index.js');
699
- const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.js');
700
- const selfPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
701
- // Independent probes start concurrently; sections report in fixed order as
702
- // their data settles. Every probe resolves instead of rejecting.
703
- const probes = {
704
- ompVersion: execP(OMP_BIN, ['--version']).then((r) => r.stdout.trim(), () => null),
705
- agentEntries: fs.readdir(agentDir).catch(() => null),
706
- extEntries: fs.readdir(extDir).catch(() => null),
707
- sharedStateText: readTextIfExists(path.join(extDir, 'shared', 'session-state.js')),
708
- configText: readTextIfExists(configPath),
709
- ponytailPkgText: readTextIfExists(ponytailPkg),
710
- ponytailExtText: readTextIfExists(ponytailExt),
711
- pluginsPkgRaw: readTextIfExists(path.join(pluginsDir, 'package.json')),
712
- selfPkgText: readTextIfExists(selfPkg),
713
- rtkBinText: readTextIfExists(rtkBin),
714
- cavemanIndexText: readTextIfExists(cavemanIndex),
715
- cavemanRuleText: readTextIfExists(cavemanRule),
716
- rtkIndexText: readTextIfExists(rtkIndex),
717
- updaterIndexText: readTextIfExists(updaterIndex),
718
- comboIndexText: readTextIfExists(comboIndex),
719
- modeReinforcementText: readTextIfExists(modeReinforcement),
720
- ruleMtime: fs.stat(cavemanRule).catch(() => null),
721
- rtkMtime: fs.stat(rtkBin).catch(() => null),
722
- ponytailMtime: fs.stat(ponytailPkg).catch(() => null),
723
- updateVersion: checkForUpdate(),
724
- };
725
- const rtkVersionProbe = probes.rtkBinText.then((text) => text === null ? null : execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => r.stdout.trim() || r.stderr.trim() || null, (e) => {
726
- const err = e;
727
- return err.stdout?.trim() || err.stderr?.trim() || null;
728
- }));
729
- const [ompVersion, agentEntries, extEntries, sharedStateText, configText, updateVersion] = await runInteractivePhase('Checking environment and installation', () => Promise.all([
730
- probes.ompVersion,
731
- probes.agentEntries,
732
- probes.extEntries,
733
- probes.sharedStateText,
734
- probes.configText,
735
- probes.updateVersion,
736
- ]));
737
- const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
738
- probes.cavemanIndexText,
739
- probes.rtkIndexText,
740
- probes.updaterIndexText,
741
- probes.comboIndexText,
742
- probes.modeReinforcementText,
743
- probes.ponytailPkgText,
744
- probes.ponytailExtText,
745
- probes.pluginsPkgRaw,
746
- probes.selfPkgText,
747
- ]));
748
- const [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime] = await runInteractivePhase('Checking add-ons', () => Promise.all([
749
- probes.cavemanRuleText,
750
- probes.ruleMtime,
751
- probes.rtkBinText,
752
- probes.rtkMtime,
753
- rtkVersionProbe,
754
- probes.ponytailMtime,
755
- ]));
756
- // Categorized output with a tally; section headers group related probes.
757
- const tally = { ok: 0, missing: 0, warn: 0 };
758
- function check(label, ok, detail = '') {
759
- if (ok) {
760
- tally.ok++;
761
- console.log(` ${label}: ok${detail ? ` ${detail}` : ''}`);
762
- }
763
- else {
764
- tally.missing++;
765
- console.log(` ${label}: MISSING${detail ? ` ${detail}` : ''}`);
766
- }
767
- }
768
- function warnLine(label, detail) {
769
- tally.warn++;
770
- console.log(` ${label}: warn ${detail}`);
771
- }
772
- section('Environment');
773
- check('Node', true, process.version);
774
- check('OMP CLI', ompVersion !== null, ompVersion ?? '');
775
- if (updateVersion)
776
- warnLine('Tersio CLI', `${updateVersion} available — run tersio update`);
777
- else
778
- check('Tersio CLI', true, PACKAGE_VERSION);
779
- console.log(` Home: ${HOME}`);
780
- section('Installation');
781
- check('OMP agent dir', agentEntries !== null, agentDir);
782
- check('OMP extensions dir', extEntries !== null, extDir);
783
- check('OMP config.yml', configText !== null, configPath);
784
- check('Shared session bridge', sharedStateText !== null);
785
- section('Extensions');
786
- check('Caveman extension', cavemanIndexText !== null);
787
- check('RTK extension', rtkIndexText !== null);
788
- check('Updater extension', updaterIndexText !== null);
789
- check('Combo extension', comboIndexText !== null);
790
- check('Mode reinforcement extension', modeReinforcementText !== null);
791
- section('Plugins');
792
- check('Ponytail package', ponytailPkgText !== null);
793
- check('Ponytail extension', ponytailExtText !== null);
794
- if (configText) {
795
- check('Ponytail in config.yml', configText.includes('ponytail') && configText.includes('pi-extension'));
796
- check('Combo in config.yml', configText.includes('combo-toggle'));
797
- }
798
- check('Self plugin package', selfPkgText !== null, parseJsonObject(selfPkgText)?.version ?? '');
799
- const selfDep = PACKAGE_NAME in (parseJsonObject(pluginsPkgRaw)?.dependencies || {});
800
- check('Self plugin in plugins/package.json', selfDep);
801
- section('Add-ons');
802
- const ruleAge = ruleMtime ? `updated ${relTime(Date.now() - ruleMtime.mtimeMs)}` : '';
803
- check('Caveman rule', cavemanRuleText !== null, ruleAge);
804
- const rtkAge = rtkMtime ? `updated ${relTime(Date.now() - rtkMtime.mtimeMs)}` : '';
805
- if (rtkBinText === null)
806
- check('RTK binary', false, rtkBin);
807
- else {
808
- const bits = [rtkVersion, rtkAge].filter(Boolean).join(', ');
809
- check('RTK binary', true, bits);
810
- if (!rtkVersion)
811
- warnLine('RTK version', 'unavailable — binary may not be executable');
812
- }
813
- const ponytailAge = ponytailMtime ? `updated ${relTime(Date.now() - ponytailMtime.mtimeMs)}` : '';
814
- check('Ponytail', ponytailPkgText !== null, [parseJsonObject(ponytailPkgText)?.version ?? '', ponytailAge].filter(Boolean).join(', '));
815
- const total = tally.ok + tally.missing + tally.warn;
816
- console.log(`\n Summary: ${total} checks — ${tally.ok} ok, ${tally.warn} warn, ${tally.missing} missing`);
817
- }
818
- function section(name) {
819
- console.log(`\n${name}`);
820
- }
821
- // Read-modify-write a JSON file. mutate returns true when it changed
822
- // something; no change (or unreadable file) means no output at all.
823
- async function updateJsonFile(filePath, mutate, dryRunNote, writeNote, dryRun) {
824
- const raw = await readTextIfExists(filePath);
825
- if (!raw)
826
- return;
827
- let data;
828
- try {
829
- data = JSON.parse(raw);
830
- }
831
- catch {
832
- debug(`Could not parse ${filePath}`);
833
- return;
834
- }
835
- if (!mutate(data))
836
- return;
837
- if (dryRun) {
838
- console.log(` [dry-run] ${dryRunNote}`);
839
- return;
840
- }
841
- await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
842
- console.log(` [write] ${writeNote}`);
843
- }
844
- function dropKey(section, key) {
845
- if (!section || typeof section !== 'object')
846
- return false;
847
- const record = section;
848
- if (!(key in record))
849
- return false;
850
- delete record[key];
851
- return true;
852
- }
853
- async function removeUninstallTarget(target, shouldDryRun, recursive = true) {
854
- try {
855
- if (shouldDryRun) {
856
- console.log(` [dry-run] would remove ${target}`);
857
- return;
858
- }
859
- if (recursive)
860
- await fs.rm(target, { recursive: true, force: true });
861
- else
862
- await fs.unlink(target);
863
- console.log(` [rm] ${target}`);
864
- }
865
- catch {
866
- debug(`Could not remove ${target}`);
867
- }
868
- }
869
- async function runUninstall(options = {}) {
870
- const shouldDryRun = options.dryRun ?? dryRun;
871
- const confirmed = (options.yes ?? yes) || shouldDryRun;
872
- // Ponytail ships with Tersio's presets, so a full uninstall removes it too;
873
- // --keep-ponytail opts out and reinstall always preserves it.
874
- const shouldRemovePonytail = options.removePonytail ?? (removePonytail || !keepPonytail);
875
- const shouldRemoveRtk = options.removeRtk ?? removeRtk;
876
- console.log('\n=== Tersio Uninstall ===\n');
877
- const extDir = path.join(OMP_AGENT_DIR, 'extensions');
878
- const configPath = path.join(OMP_AGENT_DIR, 'config.yml');
879
- const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
880
- const pluginsDir = OMP_PLUGINS_DIR;
881
- const ponytailPkgDir = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail');
882
- const targets = [
883
- 'caveman-session',
884
- 'rtk-session',
885
- 'ai-addons-updater',
886
- 'combo-toggle',
887
- 'shared',
888
- // Only consumed by ai-addons-updater (removed above); otherwise orphaned.
889
- 'lib',
890
- // Legacy always-on combo helper; imports shared/session-state.js, so it
891
- // breaks with a module-not-found warning once the shared dir is removed.
892
- 'aaa-combo-boot',
893
- ].map((dir) => path.join(extDir, dir));
894
- console.log('Will remove:');
895
- for (const t of targets) {
896
- console.log(` ${t}`);
897
- }
898
- if (shouldRemovePonytail) {
899
- console.log(` ${ponytailPkgDir} (ponytail plugin package)`);
900
- }
901
- if (shouldRemoveRtk) {
902
- console.log(` ${rtkBin}`);
903
- }
904
- if (!confirmed) {
905
- if (tty()) {
906
- closeRL();
907
- const confirmedChoice = await clackConfirm({ message: 'Remove the listed Tersio files?', initialValue: false });
908
- if (typeof confirmedChoice !== 'boolean') {
909
- clackCancel('Aborted.');
910
- closeRL();
911
- return false;
912
- }
913
- if (!confirmedChoice) {
914
- console.log('Aborted.');
915
- closeRL();
916
- return false;
917
- }
918
- }
919
- else {
920
- const answer = await ask('\nProceed? [y/N]: ');
921
- if (!answer.toLowerCase().startsWith('y')) {
922
- console.log('Aborted.');
923
- closeRL();
924
- return false;
925
- }
926
- }
927
- }
928
- // Remove extension directories (independent paths, so concurrently).
929
- await Promise.all(targets.map((t) => removeUninstallTarget(t, shouldDryRun)));
930
- // Remove Combo and mode-reinforcement registrations; Ponytail only when requested.
931
- const configRaw = await readTextIfExists(configPath);
932
- if (configRaw) {
933
- let lines = configRaw.split('\n');
934
- const before = lines.length;
935
- lines = lines.filter((l) => {
936
- if (l.includes('combo-toggle') || l.includes('mode-reinforcement'))
937
- return false;
938
- if (shouldRemovePonytail && l.includes('ponytail') && l.includes('pi-extension'))
939
- return false;
940
- return true;
941
- });
942
- if (lines.length !== before) {
943
- if (shouldDryRun)
944
- console.log(` [dry-run] would remove ${before - lines.length} config.yml entries`);
945
- else
946
- await writeConfigLines(configPath, lines, ` [write] Updated config.yml (removed ${before - lines.length} entries)`);
947
- }
948
- }
949
- // Remove the Ponytail plugin package the installer added (dep, files,
950
- // lock entry); the config.yml entry was filtered above.
951
- if (shouldRemovePonytail) {
952
- const pluginsPkgPath = path.join(pluginsDir, 'package.json');
953
- await updateJsonFile(pluginsPkgPath, (data) => dropKey(data.dependencies, '@dietrichgebert/ponytail'), `would remove @dietrichgebert/ponytail from ${pluginsPkgPath}`, 'Removed @dietrichgebert/ponytail from plugins/package.json', shouldDryRun);
954
- try {
955
- if (shouldDryRun)
956
- console.log(` [dry-run] would remove ${ponytailPkgDir}`);
957
- else {
958
- await fs.rm(ponytailPkgDir, { recursive: true, force: true });
959
- console.log(` [rm] ${ponytailPkgDir}`);
960
- await fs.rm(path.dirname(ponytailPkgDir));
961
- console.log(` [rm] ${path.dirname(ponytailPkgDir)} (empty scope)`);
962
- }
963
- }
964
- catch {
965
- debug('Could not remove ponytail package dir (scope may hold other packages)');
966
- }
967
- const lockPath = path.join(pluginsDir, 'omp-plugins.lock.json');
968
- await updateJsonFile(lockPath, (data) => dropKey(data.plugins, '@dietrichgebert/ponytail'), `would remove @dietrichgebert/ponytail from ${lockPath}`, `Removed @dietrichgebert/ponytail from ${lockPath}`, shouldDryRun);
969
- }
970
- // Remove our plugin registration from ~/.omp/plugins
971
- const pluginsPkgPath = path.join(pluginsDir, 'package.json');
972
- await updateJsonFile(pluginsPkgPath, (data) => dropKey(data.dependencies, PACKAGE_NAME), `would remove ${PACKAGE_NAME} from ${pluginsPkgPath}`, `Removed ${PACKAGE_NAME} from plugins/package.json`, shouldDryRun);
973
- const selfPluginDir = path.join(pluginsDir, 'node_modules', PACKAGE_NAME);
974
- if ((await readTextIfExists(path.join(selfPluginDir, 'package.json'))) !== null) {
975
- await removeUninstallTarget(selfPluginDir, shouldDryRun);
976
- }
977
- // Remove RTK binary if requested
978
- if (shouldRemoveRtk)
979
- await removeUninstallTarget(rtkBin, shouldDryRun, false);
980
- console.log('\nDone. Restart OMP for changes to take effect.');
981
- return true;
982
- }
983
- const SCOPE_MAP = { user: '1', project: '2', both: '3' };
984
- // --- Update check (cached; used by the bare-invocation banner and doctor) ---
985
- const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
986
- function newerThan(a, b) {
987
- const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
988
- const pb = b.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
989
- for (let i = 0; i < 3; i++) {
990
- if ((pa[i] || 0) !== (pb[i] || 0))
991
- return (pa[i] || 0) > (pb[i] || 0);
992
- }
993
- return false;
994
- }
995
- async function latestPublishedVersion() {
996
- try {
997
- const args = IS_WINDOWS
998
- ? ['/d', '/s', '/c', 'npm', 'view', PACKAGE_NAME, 'version', '--prefer-online']
999
- : ['view', PACKAGE_NAME, 'version', '--prefer-online'];
1000
- const r = await execNetwork('Checking npm registry for tersio updates', IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm', args, {
1001
- timeout: 4000,
1002
- maxBuffer: 1024 * 1024,
1003
- windowsHide: true,
1004
- shell: false,
1005
- });
1006
- return r.stdout.trim() || null;
1007
- }
1008
- catch {
1009
- return null;
1010
- }
1011
- }
1012
- // Returns a newer published version, or null. Reads the registry at most once
1013
- // per TTL (cached under OMP_PLUGINS_DIR) and stays silent on failure.
1014
- async function checkForUpdate() {
1015
- const cachePath = path.join(OMP_PLUGINS_DIR, 'tersio-update-check.json');
1016
- const cached = parseJsonObject(await readTextIfExists(cachePath));
1017
- const cacheFresh = typeof cached?.lastCheck === 'number' && Date.now() - cached.lastCheck < UPDATE_CHECK_TTL_MS;
1018
- if (cacheFresh && cached?.latest)
1019
- return newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : null;
1020
- const latest = await latestPublishedVersion();
1021
- if (latest) {
1022
- await fs.mkdir(OMP_PLUGINS_DIR, { recursive: true }).catch(() => { });
1023
- await fs.writeFile(cachePath, JSON.stringify({ latest, lastCheck: Date.now() }) + '\n', 'utf8').catch(() => { });
1024
- return newerThan(latest, PACKAGE_VERSION) ? latest : null;
1025
- }
1026
- // Registry unreachable: fall back to a stale cache rather than staying silent.
1027
- return cached?.latest && newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : null;
1028
- }
1029
- function relTime(ageMs) {
1030
- const mins = Math.floor(ageMs / 60000);
1031
- if (mins < 1)
1032
- return 'just now';
1033
- if (mins < 60)
1034
- return `${mins} min${mins === 1 ? '' : 's'} ago`;
1035
- const hours = Math.floor(mins / 60);
1036
- if (hours < 24)
1037
- return `${hours} hour${hours === 1 ? '' : 's'} ago`;
1038
- const days = Math.floor(hours / 24);
1039
- if (days < 14)
1040
- return `${days} day${days === 1 ? '' : 's'} ago`;
1041
- const weeks = Math.floor(days / 7);
1042
- return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
1043
- }
1044
- async function runLatestUpdate() {
1045
- const updateScope = scopeFlag || 'user';
1046
- if (!Object.hasOwn(SCOPE_MAP, updateScope)) {
1047
- console.error(`[fail] Invalid --scope: ${updateScope}. Use: user, project, both`);
1048
- process.exitCode = 1;
1049
- return;
1050
- }
1051
- const forwardedArgs = ['--yes', '--scope', updateScope];
1052
- if (dryRun)
1053
- forwardedArgs.push('--dry-run');
1054
- if (verbose)
1055
- forwardedArgs.push('--verbose');
1056
- const npmArgs = [
1057
- 'exec',
1058
- '--yes',
1059
- '--prefer-online',
1060
- `--package=${PACKAGE_NAME}@latest`,
1061
- '--',
1062
- PACKAGE_BIN,
1063
- '--apply-update',
1064
- ...forwardedArgs,
1065
- ];
1066
- const npmCommand = IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm';
1067
- const npmCommandArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', ...npmArgs] : npmArgs;
1068
- console.log('=== Updating Tersio ===');
1069
- console.log(` Running the latest ${PACKAGE_NAME} installer...\n`);
1070
- // The npx delegation below only refreshes the OMP-side files; the globally
1071
- // installed CLI keeps its old version until npm -g runs. Refresh both.
1072
- if (dryRun) {
1073
- console.log(` [dry-run] would run: npm install -g ${PACKAGE_NAME}@latest --no-audit --no-fund`);
1074
- }
1075
- else {
1076
- try {
1077
- const globalArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', 'install', '-g', `${PACKAGE_NAME}@latest`, '--no-audit', '--no-fund'] : ['install', '-g', `${PACKAGE_NAME}@latest`, '--no-audit', '--no-fund'];
1078
- const globalResult = await execNetwork('Updating global Tersio CLI', npmCommand, globalArgs, {
1079
- timeout: 300000,
1080
- maxBuffer: 10 * 1024 * 1024,
1081
- windowsHide: true,
1082
- shell: false,
1083
- });
1084
- if (globalResult.stdout)
1085
- process.stdout.write(globalResult.stdout);
1086
- if (globalResult.stderr)
1087
- process.stderr.write(globalResult.stderr);
1088
- console.log(` [ok] CLI updated to ${PACKAGE_NAME}@latest`);
1089
- }
1090
- catch (e) {
1091
- console.log(` [warn] Global CLI update skipped: ${e.message}`);
1092
- console.log(` [hint] Manual: npm install -g ${PACKAGE_NAME}@latest`);
1093
- }
1094
- }
1095
- try {
1096
- const result = await execNetwork('Running latest Tersio installer', npmCommand, npmCommandArgs, {
1097
- timeout: 300000,
1098
- maxBuffer: 10 * 1024 * 1024,
1099
- windowsHide: true,
1100
- shell: false,
1101
- });
1102
- if (result.stdout)
1103
- process.stdout.write(result.stdout);
1104
- if (result.stderr)
1105
- process.stderr.write(result.stderr);
1106
- console.log('\n=== Update complete ===');
1107
- }
1108
- catch (e) {
1109
- const err = e;
1110
- if (err.stdout)
1111
- process.stdout.write(err.stdout);
1112
- if (err.stderr)
1113
- process.stderr.write(err.stderr);
1114
- process.exitCode = 1;
1115
- }
1116
- }
1117
- // --- Install profile (session-start mode defaults) ---
1118
- function defaultProfile() {
1119
- return {
1120
- comboDefault: 'off',
1121
- cavemanDefault: 'off',
1122
- rtkDefault: false,
1123
- ponytailDefault: 'off',
1124
- };
1125
- }
1126
- function tty() {
1127
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1128
- }
1129
- let spinnerDepth = 0;
1130
- // Run async work under a TTY-only timer spinner. The spinner is cleared before
1131
- // the caller prints its normal result line, preserving locked output shapes.
1132
- async function withInteractiveSpinner(message, work) {
1133
- if (!tty() || spinnerDepth > 0)
1134
- return work(() => { });
1135
- // Clack manages stdin itself. Close the legacy question interface before its
1136
- // first use; non-interactive callers never reach this branch.
1137
- closeRL();
1138
- const active = clackSpinner({ indicator: 'timer' });
1139
- active.start(message);
1140
- spinnerDepth += 1;
1141
- try {
1142
- return await work((next) => { active.message(next); });
1143
- }
1144
- finally {
1145
- active.clear();
1146
- spinnerDepth -= 1;
1147
- }
1148
- }
1149
- async function askInteractiveChoice(message, options, initialValue) {
1150
- if (!tty())
1151
- return { status: 'unavailable' };
1152
- closeRL();
1153
- const choice = await clackSelect({ message, options, initialValue });
1154
- if (typeof choice !== 'string') {
1155
- clackCancel('Aborted.');
1156
- return { status: 'cancelled' };
1157
- }
1158
- return { status: 'selected', value: choice };
1159
- }
1160
- // Run collecting work under one TTY-only Clack task. Callers print after the
1161
- // task completes, keeping normal output out of the spinner animation.
1162
- async function runInteractivePhase(title, collect) {
1163
- if (!tty())
1164
- return collect();
1165
- closeRL();
1166
- let result;
1167
- await clackTasks([{ title, task: async () => { result = await collect(); } }]);
1168
- return result;
1169
- }
1170
- // Determine install scope: reinstall > flag > non-interactive default > prompt.
1171
- async function resolveScope() {
1172
- if (reinstall) {
1173
- console.log(' Scope: user (reinstall)');
1174
- return '1';
1175
- }
1176
- if (scopeFlag) {
1177
- const scope = SCOPE_MAP[scopeFlag];
1178
- if (!scope) {
1179
- console.log(` [fail] Invalid --scope: ${scopeFlag}. Use: user, project, both`);
1180
- closeRL();
1181
- process.exit(1);
1182
- }
1183
- console.log(` Scope: ${scopeFlag}`);
1184
- return scope;
1185
- }
1186
- if (install || yes) {
1187
- console.log(` Scope: user (${install ? 'install default' : '--scope omitted, defaulting to user with --yes'})`);
1188
- return '1';
1189
- }
1190
- if (tty()) {
1191
- const choice = await askInteractiveChoice('Install scope', [
1192
- { value: '1', label: 'User-level', hint: 'all OMP sessions' },
1193
- { value: '2', label: 'Project-level', hint: 'this repo only' },
1194
- { value: '3', label: 'Both' },
1195
- ], '1');
1196
- if (choice.status === 'selected')
1197
- return choice.value;
1198
- closeRL();
1199
- process.exit(130);
1200
- }
1201
- console.log('\nInstall scope:');
1202
- console.log(' 1) User-level (all OMP sessions)');
1203
- console.log(' 2) Project-level (this repo only)');
1204
- console.log(' 3) Both');
1205
- while (true) {
1206
- const answer = (await ask('\nChoose [1-3] (default 1): ')).trim() || '1';
1207
- if (answer === '1' || answer === '2' || answer === '3')
1208
- return answer;
1209
- console.log(` [fail] Invalid scope: ${answer}. Choose 1, 2, or 3.`);
1210
- }
1211
- }
1212
- async function resolveProfile() {
1213
- const profile = defaultProfile();
1214
- // Single interactive prompt: the Combo preset implies all three modes.
1215
- // Numbered menu, same pattern as the scope prompt — no typing preset names.
1216
- // Only for a real user at a terminal, only when no default flags were
1217
- // given, and never for --apply-update runs.
1218
- if (tty() && !profileFlagsGiven && !applyUpdate && (install || reinstall)) {
1219
- const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
1220
- { value: 'off', label: 'off' },
1221
- { value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
1222
- { value: 'balanced', label: 'balanced', hint: 'caveman=full, rtk=on, ponytail=full' },
1223
- { value: 'max', label: 'max', hint: 'caveman=ultra, rtk=on, ponytail=ultra' },
1224
- ], 'off');
1225
- if (choice.status === 'selected')
1226
- profile.comboDefault = choice.value;
1227
- else {
1228
- closeRL();
1229
- process.exit(130);
1230
- }
1231
- }
1232
- // Explicit flags always win over the Combo preset.
1233
- if (comboDefaultFlag !== undefined)
1234
- profile.comboDefault = comboDefaultFlag;
1235
- const preset = COMBO_PRESET_MODES[profile.comboDefault] ?? COMBO_PRESET_MODES.off;
1236
- profile.cavemanDefault = preset.caveman;
1237
- profile.rtkDefault = preset.rtk;
1238
- profile.ponytailDefault = preset.ponytail;
1239
- if (cavemanDefaultFlag !== undefined)
1240
- profile.cavemanDefault = cavemanDefaultFlag;
1241
- if (rtkDefaultFlag !== undefined)
1242
- profile.rtkDefault = rtkDefaultFlag === 'on';
1243
- if (ponytailDefaultFlag !== undefined)
1244
- profile.ponytailDefault = ponytailDefaultFlag;
1245
- console.log(` Profile: combo default=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault})`);
1246
- return profile;
1247
- }
1248
- // ponytail: post-install guard. List entries under `extensions: null` break
1249
- // OMP launch, so fail loudly here instead of at the next OMP start.
1250
- async function validateConfigExtensions(agentDir, options) {
1251
- if (options.dryRun)
1252
- return;
1253
- const raw = await readTextIfExists(path.join(agentDir, 'config.yml'));
1254
- if (!raw)
1255
- return;
1256
- const lines = raw.split('\n');
1257
- const keyIdx = lines.findIndex((l) => EXTENSIONS_KEY_RE.test(l));
1258
- if (keyIdx === -1)
1259
- return;
1260
- const scalarNull = /^\s*extensions\s*:\s*(null|~|\[\s*\])\s*$/i.test(lines[keyIdx]);
1261
- const hasEntries = lines.slice(keyIdx + 1).some((l) => /^\s*-\s+\S/.test(l));
1262
- if (scalarNull && hasEntries) {
1263
- console.log(' [fail] config.yml lists extensions under `extensions: null` — OMP will fail to launch.');
1264
- console.log(' [hint] Replace the `extensions: null` line with `extensions:`, then reinstall.');
1265
- process.exitCode = 1;
1266
- }
1267
- else {
1268
- console.log(' [ok] config.yml extensions key valid');
1269
- }
1270
- }
1271
- // Persist the profile as omp plugin settings so `omp plugin config get`
1272
- // reflects the choice and the extensions pick it up on session start.
1273
- async function writePluginSettings(profile, options) {
1274
- const pluginsDir = OMP_PLUGINS_DIR;
1275
- const lockPath = path.join(pluginsDir, 'omp-plugins.lock.json');
1276
- let config = {};
1277
- const existing = await readTextIfExists(lockPath);
1278
- if (existing) {
1279
- try {
1280
- config = JSON.parse(existing);
1281
- }
1282
- catch {
1283
- config = {};
1284
- }
1285
- }
1286
- config.plugins = config.plugins || {};
1287
- config.settings = config.settings || {};
1288
- config.settings[PACKAGE_NAME] = {
1289
- ...(config.settings[PACKAGE_NAME] || {}),
1290
- comboDefault: profile.comboDefault,
1291
- cavemanDefault: profile.cavemanDefault,
1292
- rtkDefault: profile.rtkDefault,
1293
- ponytailDefault: profile.ponytailDefault,
1294
- };
1295
- if (options.dryRun) {
1296
- console.log(` [dry-run] would write plugin settings (${PACKAGE_NAME}) to ${lockPath}`);
1297
- return;
1298
- }
1299
- await fs.mkdir(pluginsDir, { recursive: true });
1300
- await fs.writeFile(lockPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
1301
- console.log(` [write] Plugin settings in ${lockPath}`);
1302
- }
1303
- // --- Main ---
1304
44
  async function main() {
1305
45
  if (showVersion) {
1306
46
  console.log(PACKAGE_VERSION);
@@ -1329,79 +69,21 @@ async function main() {
1329
69
  closeRL();
1330
70
  return;
1331
71
  }
1332
- if (uninstall) {
1333
- await runUninstall();
72
+ if (usage) {
73
+ await runUsage();
1334
74
  closeRL();
1335
75
  return;
1336
76
  }
1337
- if (reinstall) {
1338
- await runUninstall({ yes: true, removePonytail: false, removeRtk: true });
1339
- }
1340
- if (dryRun)
1341
- console.log('[dry-run] No changes will be written.\n');
1342
- console.log(`=== Tersio v${PACKAGE_VERSION} ===`);
1343
- console.log(` Platform: ${process.platform}`);
1344
- console.log(` Arch: ${process.arch}`);
1345
- console.log(` Home: ${HOME}`);
1346
- // Remind humans a newer release exists; silent for scripts (no TTY) and for
1347
- // the apply-update payload, which is itself an update run.
1348
- if (tty() && !applyUpdate && command !== 'uninstall') {
1349
- const newer = await checkForUpdate();
1350
- if (newer)
1351
- console.log(`\n [update] tersio ${newer} available (installed ${PACKAGE_VERSION}) — run \`tersio update\``);
1352
- }
1353
- const scope = await resolveScope();
1354
- // Resolve session defaults: flags > interactive prompt > defaults.
1355
- const profile = await resolveProfile();
1356
- const userDir = OMP_AGENT_DIR;
1357
- const userExtDir = path.join(userDir, 'extensions');
1358
- const projectExtDir = path.join(process.cwd(), '.omp', 'extensions');
1359
- // apply-update is `tersio update`'s payload run: treat it like reinstall so
1360
- // the add-ons refresh too — Ponytail package via npm, self plugin, RTK
1361
- // binary (always re-downloaded), and the Caveman rule (always re-fetched).
1362
- const options = { dryRun, verbose, yes, scope, reinstall: reinstall || applyUpdate };
1363
- // Check prerequisites
1364
- console.log('\nPrerequisites:');
1365
- try {
1366
- const v = (await execP(OMP_BIN, ['--version'])).stdout.trim();
1367
- console.log(` [ok] omp ${v}`);
1368
- }
1369
- catch {
1370
- console.log(' [fail] omp not found — ensure it\'s installed');
1371
- }
1372
- const cavemanRule = await fetchCavemanRule(options);
1373
- if (scope === '1' || scope === '3') {
1374
- console.log('\n--- User-level install ---');
1375
- await stepSharedSessionState(userExtDir, options);
1376
- await stepPonytail(OMP_PLUGINS_DIR, userDir, options);
1377
- const selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, options);
1378
- const ponytailExtPath = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
1379
- await stepRtk(BUN_BIN_DIR, options);
1380
- await stepRtkSession(userExtDir, options);
1381
- await stepCaveman(userExtDir, cavemanRule, options);
1382
- await stepCombo(userExtDir, options);
1383
- await stepModeReinforcement(userExtDir, ponytailExtPath, options);
1384
- await stepUpdater(userExtDir, options);
1385
- if (selfPlugin)
1386
- await writePluginSettings(profile, options);
1387
- await validateConfigExtensions(userDir, options);
77
+ if (dashboard) {
78
+ await runDashboard({ port: dashboardPort, open: dashboardOpen, exportFile: dashboardExport });
79
+ closeRL();
80
+ return;
1388
81
  }
1389
- if (scope === '2' || scope === '3') {
1390
- console.log('\n--- Project-level install ---');
1391
- await stepSharedSessionState(projectExtDir, options);
1392
- await stepRtkSession(projectExtDir, options);
1393
- await stepCaveman(projectExtDir, cavemanRule, options);
1394
- await stepUpdater(projectExtDir, options);
1395
- console.log(' [note] Ponytail, RTK binary, and Combo toggle require user-level (global) install');
82
+ if (uninstall) {
83
+ await runUninstall();
84
+ closeRL();
85
+ return;
1396
86
  }
1397
- console.log('\n=== Installation complete ===');
1398
- console.log('\nNext steps:');
1399
- console.log(' 1. Restart OMP');
1400
- console.log(' 2. /caveman full');
1401
- console.log(' 3. /rtk on');
1402
- console.log(' 4. /ponytail full');
1403
- console.log(' 5. /ai-addons check');
1404
- console.log(' 6. /combo medium (toggle all 3 at once — off by default)');
1405
- closeRL();
87
+ await runInstall();
1406
88
  }
1407
89
  main().catch((e) => { closeRL(); console.error(e); process.exitCode = 1; });