@krtclcdy/tersio 2.24.0 → 2.24.1

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 (49) hide show
  1. package/README.md +1 -3
  2. package/cli/banner.js +2 -3
  3. package/cli/currency.js +3 -7
  4. package/cli/dashboard.js +167 -3
  5. package/cli/doctor-fix.js +15 -12
  6. package/cli/doctor.js +1 -12
  7. package/cli/hosts.js +7 -11
  8. package/cli/install.js +113 -71
  9. package/cli/interactive.js +36 -2
  10. package/cli/layer.js +1 -3
  11. package/cli/manifest.js +1 -4
  12. package/cli/profile.js +9 -7
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +1 -6
  15. package/cli/settings.js +3 -8
  16. package/cli/uninstall.js +43 -24
  17. package/cli/update.js +7 -8
  18. package/cli/usage.js +15 -5
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +6 -5
  21. package/extensions/ai-addons-updater/index.ts +7 -6
  22. package/extensions/caveman-session/index.js +14 -29
  23. package/extensions/caveman-session/index.ts +15 -30
  24. package/extensions/combo-toggle/index.js +10 -14
  25. package/extensions/combo-toggle/index.ts +10 -15
  26. package/extensions/lib/utils.js +4 -0
  27. package/extensions/lib/utils.ts +3 -0
  28. package/extensions/rtk-session/index.js +27 -31
  29. package/extensions/rtk-session/index.ts +25 -29
  30. package/extensions/shared/carbon.js +3 -8
  31. package/extensions/shared/carbon.ts +4 -8
  32. package/extensions/shared/host.js +1 -11
  33. package/extensions/shared/host.ts +1 -11
  34. package/extensions/shared/plugin-settings.js +4 -14
  35. package/extensions/shared/plugin-settings.ts +4 -14
  36. package/extensions/shared/pricing.js +4 -6
  37. package/extensions/shared/pricing.ts +4 -6
  38. package/extensions/shared/rtk-gain.js +2 -7
  39. package/extensions/shared/rtk-gain.ts +2 -7
  40. package/extensions/shared/session-state.js +30 -18
  41. package/extensions/shared/session-state.ts +33 -16
  42. package/extensions/shared/usage-ledger.js +138 -17
  43. package/extensions/shared/usage-ledger.ts +143 -19
  44. package/extensions/shared/usage-store.js +175 -23
  45. package/extensions/shared/usage-store.ts +171 -22
  46. package/extensions/tersio-commands/index.js +1 -5
  47. package/extensions/tersio-commands/index.ts +1 -5
  48. package/package.json +6 -7
  49. package/tersio.js +1 -3
package/cli/install.js CHANGED
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { BUN_BIN_DIR, COMBO_PRESET_MODES, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, args, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
7
- import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
7
+ import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, withInteractiveTask, sayTagged } from './interactive.js';
8
8
  import { printWelcome } from './banner.js';
9
9
  import { checkForUpdate, runLatestUpdate } from './update.js';
10
10
  import { runUninstall } from './uninstall.js';
@@ -15,6 +15,8 @@ import { runDashboard } from './dashboard.js';
15
15
  import { wireRtkOmp } from './rtk-wiring.js';
16
16
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
17
17
  import { storedProfile, writePluginSettings } from './profile.js';
18
+ import { runSettings } from './settings.js';
19
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
18
20
  import { filesUnder, sourcePath } from './manifest.js';
19
21
  import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
20
22
  async function stepPonytail(pluginsDir, options) {
@@ -31,9 +33,9 @@ async function stepPonytail(pluginsDir, options) {
31
33
  }
32
34
  if (options.dryRun) {
33
35
  if (verbose && !options.quiet)
34
- console.log(` [dry-run] would write ${pkgPath}`);
36
+ sayTagged(` [dry-run] would write ${pkgPath}`);
35
37
  if (migrated && verbose && !options.quiet)
36
- console.log(` [dry-run] would drop the separate @dietrichgebert/ponytail dependency (bundled with tersio)`);
38
+ sayTagged(` [dry-run] would drop the separate @dietrichgebert/ponytail dependency (bundled with tersio)`);
37
39
  }
38
40
  else if (migrated) {
39
41
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
@@ -63,7 +65,7 @@ async function stepPonytail(pluginsDir, options) {
63
65
  await execNetwork('Installing bundled Ponytail', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
64
66
  }
65
67
  catch (e) {
66
- console.log(` [fail] Could not install bundled ponytail: ${e.message}`);
68
+ sayTagged(` [fail] Could not install bundled ponytail: ${e.message}`);
67
69
  console.log(' [hint] Manual: cd ~/.omp/plugins && npm install --no-audit --no-fund');
68
70
  }
69
71
  }
@@ -91,14 +93,14 @@ async function stepSelfPlugin(pluginsDir, options) {
91
93
  if (legacy in pkg.dependencies) {
92
94
  delete pkg.dependencies[legacy];
93
95
  if (!options.dryRun && !options.quiet)
94
- console.log(` [migrate] dropped legacy ${legacy} dependency`);
96
+ sayTagged(` [migrate] dropped legacy ${legacy} dependency`);
95
97
  }
96
98
  }
97
99
  if (options.dryRun) {
98
100
  if (verbose && !options.quiet)
99
- console.log(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
101
+ sayTagged(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
100
102
  if (verbose && !options.quiet)
101
- console.log(` [dry-run] would run: npm install --no-audit --no-fund (in ${pluginsDir})`);
103
+ sayTagged(` [dry-run] would run: npm install --no-audit --no-fund (in ${pluginsDir})`);
102
104
  return false;
103
105
  }
104
106
  // pkg.dependencies[PACKAGE_NAME] was assigned above, so only reinstall skips this fast path.
@@ -119,14 +121,14 @@ async function stepSelfPlugin(pluginsDir, options) {
119
121
  await execNetwork('Installing Tersio plugin dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
120
122
  }
121
123
  catch (e) {
122
- console.log(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
123
- console.log(` [hint] Manual: cd ~/.omp/plugins && npm install ${PACKAGE_NAME}@^${PACKAGE_VERSION} --save --no-audit --no-fund`);
124
+ sayTagged(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
125
+ sayTagged(` [hint] Manual: cd ~/.omp/plugins && npm install ${PACKAGE_NAME}@^${PACKAGE_VERSION} --save --no-audit --no-fund`);
124
126
  return false;
125
127
  }
126
128
  }
127
129
  const installedPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
128
130
  if ((await readTextIfExists(installedPkg)) === null) {
129
- console.log(` [warn] ${PACKAGE_NAME} not found in plugins/node_modules after install`);
131
+ sayTagged(` [warn] ${PACKAGE_NAME} not found in plugins/node_modules after install`);
130
132
  return false;
131
133
  }
132
134
  debug('tersio listed in OMP plugins');
@@ -136,7 +138,7 @@ function resolveRtkTriple() {
136
138
  const triple = rtkPlatformSpec()?.triple;
137
139
  if (triple)
138
140
  return triple;
139
- console.log(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
141
+ sayTagged(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
140
142
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
141
143
  return null;
142
144
  }
@@ -145,8 +147,8 @@ function findRtkAsset(release, triple) {
145
147
  const asset = assets.find((a) => a.name === `rtk-${triple}.zip` || a.name === `rtk-${triple}.tar.gz`);
146
148
  if (asset)
147
149
  return asset;
148
- console.log(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
149
- console.log(` [hint] Available: ${assets.map((a) => a.name).filter((n) => n.startsWith('rtk-')).join(', ')}`);
150
+ sayTagged(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
151
+ sayTagged(` [hint] Available: ${assets.map((a) => a.name).filter((n) => n.startsWith('rtk-')).join(', ')}`);
150
152
  return null;
151
153
  }
152
154
  async function downloadRtkChecksums(release) {
@@ -171,16 +173,16 @@ async function verifyRtkArchive(archivePath, assetName, checksumsText, options =
171
173
  const actual = await sha256File(archivePath);
172
174
  if (!expected) {
173
175
  if (!options.quiet)
174
- console.log(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
176
+ sayTagged(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
175
177
  return true;
176
178
  }
177
179
  if (actual === expected) {
178
180
  debug(`Checksum verified for ${assetName}`);
179
181
  return true;
180
182
  }
181
- console.log(` [fail] Checksum mismatch for ${assetName}`);
182
- console.log(` [fail] Expected: ${expected}`);
183
- console.log(` [fail] Got: ${actual}`);
183
+ sayTagged(` [fail] Checksum mismatch for ${assetName}`);
184
+ sayTagged(` [fail] Expected: ${expected}`);
185
+ sayTagged(` [fail] Got: ${actual}`);
184
186
  // Caller finally removes the temp dir.
185
187
  return false;
186
188
  }
@@ -214,13 +216,13 @@ async function extractRtkArchive(archivePath, extractDir) {
214
216
  }
215
217
  catch (e) {
216
218
  debug(`tar xzf failed: ${shortError(e)}`);
217
- console.log(` [fail] tar could not extract ${path.basename(archivePath)}`);
219
+ sayTagged(` [fail] tar could not extract ${path.basename(archivePath)}`);
218
220
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
219
221
  return false;
220
222
  }
221
223
  return true;
222
224
  }
223
- console.log(` [fail] Unknown archive format: ${archivePath}`);
225
+ sayTagged(` [fail] Unknown archive format: ${archivePath}`);
224
226
  return false;
225
227
  }
226
228
  // The binary is machine-wide and only OMP needs the wiring, so an install that
@@ -253,59 +255,69 @@ async function stepRtk(binDir, options, target = 'omp') {
253
255
  // OMP hook just as well. Dry runs stay offline.
254
256
  if (options.dryRun) {
255
257
  if (verbose && !options.quiet)
256
- console.log(` [dry-run] would download rtk binary and install to ${binDest}`);
258
+ sayTagged(` [dry-run] would download rtk binary and install to ${binDest}`);
257
259
  await bind(found ?? binDest);
258
260
  return;
259
261
  }
260
262
  try {
261
- const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
262
263
  const triple = resolveRtkTriple();
263
264
  if (!triple)
264
265
  return;
265
- const asset = findRtkAsset(release, triple);
266
- if (!asset)
267
- return;
268
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
269
- try {
270
- const archivePath = path.join(tmpDir, asset.name);
271
- const checksumsText = await withInteractiveSpinner('Downloading RTK binary', async (update) => {
272
- const [downloadedChecksums] = await Promise.all([
273
- downloadRtkChecksums(release),
274
- httpsDownload(asset.browser_download_url, archivePath),
275
- ]);
276
- update('Verifying RTK checksum');
277
- return downloadedChecksums;
278
- });
279
- if (!await verifyRtkArchive(archivePath, asset.name, checksumsText, options))
280
- return;
281
- const extractDir = path.join(tmpDir, 'extracted');
282
- if (!await extractRtkArchive(archivePath, extractDir))
283
- return;
284
- const found = await findFile(extractDir, RTK_BINARY_NAME);
285
- if (!found) {
286
- console.log(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
266
+ // One task with streamed sub-steps: release lookup, download, checksum,
267
+ // extract, install. Non-TTY keeps the plain lines each step already prints.
268
+ await withInteractiveTask('Installing RTK', async (update) => {
269
+ const release = await fetchJson(RTK_RELEASE_API);
270
+ const asset = findRtkAsset(release, triple);
271
+ if (!asset) {
272
+ sayTagged(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
287
273
  return;
288
274
  }
289
- await fs.mkdir(path.dirname(binDest), { recursive: true });
290
- await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
291
- await fs.copyFile(found, binDest);
292
- if (!IS_WINDOWS) {
293
- await fs.chmod(binDest, 0o755);
294
- debug(`chmod 755 ${binDest}`);
295
- }
275
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
296
276
  try {
297
- await execP(binDest, ['--version'], { timeout: 30000, shell: false });
277
+ const archivePath = path.join(tmpDir, asset.name);
278
+ update(`Downloading ${asset.name}`);
279
+ const [checksumsText] = await Promise.all([
280
+ downloadRtkChecksums(release),
281
+ httpsDownload(asset.browser_download_url, archivePath),
282
+ ]);
283
+ update('Verifying checksum');
284
+ if (!await verifyRtkArchive(archivePath, asset.name, checksumsText, options)) {
285
+ sayTagged(' [hint] RTK install skipped for this run');
286
+ return;
287
+ }
288
+ update('Extracting');
289
+ const extractDir = path.join(tmpDir, 'extracted');
290
+ if (!await extractRtkArchive(archivePath, extractDir)) {
291
+ sayTagged(` [fail] Could not extract ${asset.name}`);
292
+ return;
293
+ }
294
+ update('Installing binary');
295
+ const found = await findFile(extractDir, RTK_BINARY_NAME);
296
+ if (!found) {
297
+ sayTagged(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
298
+ return;
299
+ }
300
+ await fs.mkdir(path.dirname(binDest), { recursive: true });
301
+ await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
302
+ await fs.copyFile(found, binDest);
303
+ if (!IS_WINDOWS) {
304
+ await fs.chmod(binDest, 0o755);
305
+ debug(`chmod 755 ${binDest}`);
306
+ }
307
+ try {
308
+ await execP(binDest, ['--version'], { timeout: 30000, shell: false });
309
+ }
310
+ catch {
311
+ sayTagged(` [hint] Verify manually: ${binDest} --version`);
312
+ }
298
313
  }
299
- catch {
300
- console.log(` [hint] Verify manually: ${binDest} --version`);
314
+ finally {
315
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
301
316
  }
302
- }
303
- finally {
304
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
305
- }
317
+ });
306
318
  }
307
319
  catch (e) {
308
- console.log(` [fail] RTK: ${e.message}`);
320
+ sayTagged(` [fail] RTK: ${e.message}`);
309
321
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
310
322
  }
311
323
  // Wire rtk's tool_call extension: the binary alone never meters a session.
@@ -317,7 +329,7 @@ async function copySources(extDir, files, skipLabel, options) {
317
329
  const src = await readTextIfExists(files[0][0]);
318
330
  if (!src) {
319
331
  if (!options.quiet && options.dryRun)
320
- console.log(` [skip] ${skipLabel} not found in repo`);
332
+ sayTagged(` [skip] ${skipLabel} not found in repo`);
321
333
  return false;
322
334
  }
323
335
  await writeIfChanged(path.join(extDir, files[0][1]), src, options);
@@ -355,7 +367,7 @@ async function fetchCavemanRule(options) {
355
367
  debug(`Using bundled Caveman rule: ${e.message}`);
356
368
  return bundled;
357
369
  }
358
- console.log(` [warn] Could not fetch caveman rule: ${e.message}`);
370
+ sayTagged(` [warn] Could not fetch caveman rule: ${e.message}`);
359
371
  return null;
360
372
  }
361
373
  }
@@ -371,7 +383,7 @@ async function stepCaveman(extDir, rule, options) {
371
383
  }
372
384
  else if (rule === null) {
373
385
  console.log(' [skip] Caveman rule.md unavailable');
374
- console.log(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
386
+ sayTagged(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
375
387
  }
376
388
  else {
377
389
  await writeIfChanged(ruleDest, rule, options);
@@ -450,6 +462,7 @@ async function runCommandMenu() {
450
462
  { value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
451
463
  { value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
452
464
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
465
+ { value: 'settings', label: 'Settings', hint: 'default combo mode, currency, diagnosis schedule' },
453
466
  { value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
454
467
  { value: 'dashboard', label: 'Dashboard', hint: 'open the report in your browser' },
455
468
  { value: 'reset', label: 'Reset statistics', hint: 'clear statistics; transcripts and RTK history stay' },
@@ -503,6 +516,10 @@ async function runCommandMenu() {
503
516
  closeRL();
504
517
  break;
505
518
  }
519
+ case 'settings':
520
+ await runSettings();
521
+ closeRL();
522
+ break;
506
523
  case 'usage':
507
524
  await runUsage();
508
525
  closeRL();
@@ -524,13 +541,24 @@ async function runCommandMenu() {
524
541
  // Which host this run targets. --host pins it; a TTY user picks; scripts,
525
542
  // pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
526
543
  let targetHost = 'omp';
544
+ // Hosts the menu advertises but cannot install yet. Listed disabled so the
545
+ // roadmap is visible without offering a target that does nothing.
546
+ const SOON_HOSTS = [
547
+ { value: 'claude', label: 'Claude Code', hint: 'Coming soon' },
548
+ { value: 'opencode', label: 'OpenCode', hint: 'Coming soon' },
549
+ { value: 'codex', label: 'Codex', hint: 'Coming soon' },
550
+ ];
527
551
  async function chooseHost(title, only) {
528
552
  const hosts = detectHosts().filter((host) => !only || only.includes(host.id));
529
- const choice = await askInteractiveChoice(title, hosts.map((host) => ({
553
+ const options = hosts.map((host) => ({
530
554
  value: host.id,
531
555
  label: hostLabel(host),
532
556
  hint: hostHint(host),
533
- })), targetHost);
557
+ }));
558
+ // A scoped menu (uninstall --host) lists only real hosts.
559
+ if (!only)
560
+ options.push(...SOON_HOSTS.map((h) => ({ ...h, disabled: true })));
561
+ const choice = await askInteractiveChoice(title, options, targetHost);
534
562
  if (choice.status !== 'selected') {
535
563
  closeRL();
536
564
  process.exit(130);
@@ -542,12 +570,12 @@ function hostEntry(id) {
542
570
  }
543
571
  // pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes
544
572
  // the same tree as OMP's and registers nothing.
545
- async function stepPiLayer(profile, options) {
573
+ async function stepPiLayer(options) {
546
574
  const agentDir = piAgentDir();
547
575
  const extDir = path.join(agentDir, 'extensions');
548
576
  const declared = piTersioSource(agentDir);
549
577
  if (declared)
550
- console.log(` [note] pi also has a tersio package (${declared}) — remove one, or both copies load: pi remove npm:${PACKAGE_NAME}`);
578
+ sayTagged(` [note] pi also has a tersio package (${declared}) — remove one, or both copies load: pi remove npm:${PACKAGE_NAME}`);
551
579
  if (!options.quiet)
552
580
  console.log(` Pi — write the extension tree (${extDir})`);
553
581
  const cavemanRule = await fetchCavemanRule(options);
@@ -570,7 +598,7 @@ async function stepPonytailForPi(agentDir, options) {
570
598
  return;
571
599
  }
572
600
  catch (e) {
573
- console.log(` [fail] pi install ponytail: ${shortError(e)}`);
601
+ sayTagged(` [fail] pi install ponytail: ${shortError(e)}`);
574
602
  }
575
603
  }
576
604
  const skills = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)), 'skills');
@@ -582,7 +610,7 @@ async function stepPonytailForPi(agentDir, options) {
582
610
  const dest = path.join(agentDir, 'skills');
583
611
  if (options.dryRun) {
584
612
  if (verbose && !options.quiet)
585
- console.log(` [dry-run] would copy Ponytail skills to ${dest}`);
613
+ sayTagged(` [dry-run] would copy Ponytail skills to ${dest}`);
586
614
  return;
587
615
  }
588
616
  for (const entry of await fs.readdir(skills, { withFileTypes: true })) {
@@ -592,7 +620,7 @@ async function stepPonytailForPi(agentDir, options) {
592
620
  await fs.rm(to, { recursive: true, force: true });
593
621
  await fs.cp(path.join(skills, entry.name), to, { recursive: true });
594
622
  if (!options.quiet)
595
- console.log(` [write] skill ${entry.name} → ${to}`);
623
+ sayTagged(` [write] skill ${entry.name} → ${to}`);
596
624
  }
597
625
  }
598
626
  async function runInstall() {
@@ -616,7 +644,6 @@ async function runInstall() {
616
644
  else if (tty() && !yes && !dryRun && !applyUpdate && command !== 'uninstall') {
617
645
  targetHost = await chooseHost('Install Tersio into which agent?');
618
646
  }
619
- const host = hostEntry(targetHost);
620
647
  // apply-update stays silent: the parent printed the plan.
621
648
  const quiet = applyUpdate;
622
649
  if (!quiet)
@@ -656,6 +683,21 @@ async function runInstall() {
656
683
  const userExtDir = path.join(userDir, 'extensions');
657
684
  // apply-update refreshes every add-on like a reinstall.
658
685
  const installOptions = { dryRun, verbose, yes, reinstall: applyUpdate, quiet };
686
+ // Point of no return: disk writes start here; pipes stay unattended.
687
+ if (!yes && !dryRun && !applyUpdate && tty()) {
688
+ const target = hostEntry(targetHost);
689
+ const where = targetHost === 'pi' ? path.join(piAgentDir(), 'extensions') : path.join(OMP_AGENT_DIR, 'extensions');
690
+ console.log(`\nWill install into ${target.label}:`);
691
+ console.log(` ${where}`);
692
+ console.log(' caveman · rtk · ponytail session modes');
693
+ console.log(` session defaults → ${tersioSettingsFile()}`);
694
+ const go = await askInteractiveConfirm(`Install Tersio into ${target.label}?`);
695
+ if (go.status !== 'confirmed' || !go.value) {
696
+ closeRL();
697
+ console.log('\nAborted. Nothing was changed.');
698
+ return;
699
+ }
700
+ }
659
701
  try {
660
702
  const v = (await execP(OMP_BIN, ['--version'])).stdout.trim();
661
703
  if (verbose && !quiet)
@@ -672,11 +714,11 @@ async function runInstall() {
672
714
  }
673
715
  catch (e) {
674
716
  failures.push(label);
675
- console.log(` [fail] ${label}: ${shortError(e)}`);
717
+ sayTagged(` [fail] ${label}: ${shortError(e)}`);
676
718
  }
677
719
  };
678
720
  if (targetHost === 'pi') {
679
- await capture('pi tree', () => stepPiLayer(profile, installOptions));
721
+ await capture('pi tree', () => stepPiLayer(installOptions));
680
722
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'pi'));
681
723
  await capture('settings', () => writePluginSettings(profile, installOptions));
682
724
  if (failures.length > 0)
@@ -1,6 +1,6 @@
1
1
  // cli/interactive.ts — TTY layer: readline, Clack spinners, selects, task phases.
2
2
  import readline from 'node:readline';
3
- import { cancel as clackCancel, confirm as clackConfirm, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
3
+ import { cancel as clackCancel, confirm as clackConfirm, log as clackLog, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
4
4
  import { execP } from './common.js';
5
5
  const RL = readline.createInterface({ input: process.stdin, output: process.stdout });
6
6
  let rlOpen = true;
@@ -103,7 +103,41 @@ async function runInteractivePhase(title, collect) {
103
103
  await clackTasks([{ title, task: async () => { result = await collect(); } }]);
104
104
  return result;
105
105
  }
106
+ const TAG_KIND = {
107
+ fail: 'error', warn: 'warn', hint: 'info', ok: 'success', note: 'message',
108
+ write: 'step', rm: 'step', migrate: 'step', skip: 'info', 'dry-run': 'info',
109
+ };
110
+ function sayTagged(line) {
111
+ const m = /^\s*\[([\w-]+)\]\s?([\s\S]*)$/.exec(line);
112
+ const kind = (m && TAG_KIND[m[1]]) || 'info';
113
+ if (tty() && m)
114
+ clackLog[kind](m[2]);
115
+ else
116
+ console.log(line);
117
+ }
118
+ // One live-updating task line instead of a spinner per step.
119
+ async function withInteractiveTask(title, work) {
120
+ if (!tty())
121
+ return work(() => { });
122
+ closeRL();
123
+ let result;
124
+ const spin = clackSpinner();
125
+ spin.start(title);
126
+ try {
127
+ result = await work((message) => spin.message(message));
128
+ spin.stop('done');
129
+ }
130
+ catch (e) {
131
+ spin.error(shortSpinnerError(e));
132
+ throw e;
133
+ }
134
+ return result;
135
+ }
136
+ function shortSpinnerError(e) {
137
+ const msg = e instanceof Error ? e.message : String(e);
138
+ return (msg.split('\n')[0] || 'failed').slice(0, 120);
139
+ }
106
140
  async function execNetwork(label, cmd, args, opts = {}) {
107
141
  return withInteractiveSpinner(label, () => execP(cmd, args, opts));
108
142
  }
109
- export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, askInteractiveConfirm, confirmDestructive, runInteractivePhase, };
143
+ export { ask, closeRL, tty, withInteractiveSpinner, withInteractiveTask, execNetwork, sayTagged, askInteractiveChoice, askInteractiveConfirm, confirmDestructive, runInteractivePhase, };
package/cli/layer.js CHANGED
@@ -8,9 +8,7 @@ export function layerEntries(extDir, table) {
8
8
  export function layerDirs(table) {
9
9
  return table.map(([dir]) => dir);
10
10
  }
11
- // A port source sits two levels below extensions/; installed, it sits one level
12
- // below <agent-dir>/extensions/. Rewriting on the way in keeps one spelling in
13
- // the repo and the same other spelling in every installed tree.
11
+ // A port source sits two levels below extensions/, one level once installed.
14
12
  export function rewritePortImports(content) {
15
13
  return content.replace(/from '\.\.\/\.\.\/(shared|lib)\//g, "from '../$1/");
16
14
  }
package/cli/manifest.js CHANGED
@@ -1,7 +1,4 @@
1
- // cli/manifest.ts — the one list of what an install writes into a host's
2
- // extension tree. The installer, `doctor --fix extensions`, the doctor's
3
- // per-host tree row, and the loadability test all read it, so none of them
4
- // can drift from what actually ships.
1
+ // Single list of install writes, shared by installer, doctor, and tests.
5
2
  import path from 'node:path';
6
3
  import { fileURLToPath } from 'node:url';
7
4
  export const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
package/cli/profile.js CHANGED
@@ -1,7 +1,5 @@
1
- // cli/profile.ts — session-start defaults profile (combo/caveman/rtk/ponytail)
2
- // plus the display-currency default for usage/dashboard reports.
3
- // Stored in ~/.tersio/settings.json, which every host reads. Extracted from
4
- // cli/install.ts so both install and settings share it.
1
+ // cli/profile.ts — session-start defaults and the display-currency default,
2
+ // stored in ~/.tersio/settings.json. Shared by install and settings.
5
3
  import { existsSync, promises as fs } from 'node:fs';
6
4
  import path from 'node:path';
7
5
  import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, PACKAGE_NAME, PONYTAIL_DEFAULTS, verbose, } from './common.js';
@@ -9,6 +7,7 @@ import os from 'node:os';
9
7
  import { DEFAULT_CURRENCY, isCurrencyCode } from './currency.js';
10
8
  import { readTextIfExists } from '../extensions/lib/utils.js';
11
9
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
10
+ export const BACKUP_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
12
11
  function defaultProfile() {
13
12
  return {
14
13
  comboDefault: 'off',
@@ -16,11 +15,10 @@ function defaultProfile() {
16
15
  rtkDefault: false,
17
16
  ponytailDefault: 'off',
18
17
  currency: DEFAULT_CURRENCY,
18
+ backupSchedule: 'monthly',
19
19
  };
20
20
  }
21
- // Stored profile: ~/.tersio/settings.json. An install that predates that file
22
- // kept the same values as OMP plugin settings, so seed from there once and
23
- // never lose a user's choice.
21
+ // Seed once from OMP plugin settings so a pre-~/.tersio install keeps its values.
24
22
  async function storedProfile() {
25
23
  const base = defaultProfile();
26
24
  const stored = (await readTextIfExists(tersioSettingsFile())) !== null
@@ -39,6 +37,9 @@ async function storedProfile() {
39
37
  if (typeof stored.currency === 'string' && isCurrencyCode(stored.currency.trim().toUpperCase())) {
40
38
  base.currency = stored.currency.trim().toUpperCase();
41
39
  }
40
+ if (typeof stored.backupSchedule === 'string' && BACKUP_SCHEDULES.has(stored.backupSchedule)) {
41
+ base.backupSchedule = stored.backupSchedule;
42
+ }
42
43
  return base;
43
44
  }
44
45
  function legacyOmpLockPath() {
@@ -75,6 +76,7 @@ async function writePluginSettings(profile, options) {
75
76
  rtkDefault: profile.rtkDefault,
76
77
  ponytailDefault: profile.ponytailDefault,
77
78
  currency: profile.currency,
79
+ backupSchedule: profile.backupSchedule,
78
80
  };
79
81
  if (options.dryRun) {
80
82
  if (verbose && !options.quiet)
package/cli/reset.js CHANGED
@@ -1,10 +1,6 @@
1
- // cli/reset.ts — clear tersio-owned statistics: the usage ledger plus a reset
2
- // watermark that filters session-derived and RTK-metered rows out of every
3
- // derived view. Session transcripts (~/.omp/agent/sessions) and the RTK
4
- // database are host/tool-owned and are never touched; the watermark only
5
- // changes what tersio shows.
1
+ // Clears tersio-owned stats only; transcripts and RTK data stay.
6
2
  import { dryRun, yes } from './common.js';
7
- import { confirmDestructive } from './interactive.js';
3
+ import { confirmDestructive, sayTagged } from './interactive.js';
8
4
  import { clearUsageLedger, importSessionTokens, ledgerPath, markReset, readUsage, sessionsDir } from '../extensions/shared/usage-ledger.js';
9
5
  import { clearUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
10
6
  import { readRtkGain, rtkDbPath } from '../extensions/shared/rtk-gain.js';
@@ -29,7 +25,7 @@ async function runReset() {
29
25
  const cleared = clearUsageLedger();
30
26
  const dbCleared = clearUsageDb();
31
27
  const ts = markReset();
32
- console.log(`[ok] reset — removed ${cleared} usage rows${dbCleared ? ' + usage.db' : ''}; statistics view starts at ${new Date(ts).toISOString()}`);
28
+ sayTagged(`[ok] reset — removed ${cleared} usage rows${dbCleared ? ' + usage.db' : ''}; statistics view starts at ${new Date(ts).toISOString()}`);
33
29
  return true;
34
30
  }
35
31
  export { runReset };
package/cli/rtk-wiring.js CHANGED
@@ -1,9 +1,4 @@
1
- // cli/rtk-wiring.ts — wire the installed rtk binary into OMP.
2
- // `rtk init -g --agent omp` writes rtk's tool_call extension to
3
- // ~/.omp/agent/extensions/rtk.ts but does not register it, and OMP loads only
4
- // listed extensions, so we add the path to config.yml. Every rewritten bash
5
- // call then lands in rtk's history.db. Fail-open: a failed wire only downgrades
6
- // to manual `rtk` prefixing. No cli/common.ts import (argv side effects).
1
+ // OMP loads only listed extensions, so register rtk's path; fail-open.
7
2
  import { promises as fs } from 'node:fs';
8
3
  import os from 'node:os';
9
4
  import path from 'node:path';
package/cli/settings.js CHANGED
@@ -1,8 +1,4 @@
1
- // cli/settings.ts — view/edit session-start defaults (combo/caveman/rtk/ponytail)
2
- // plus the display-currency default for usage/dashboard reports.
3
- // Non-interactive: `tersio settings --combo-default balanced` (plus per-mode
4
- // override flags and --currency). Interactive: clack selects seeded from the
5
- // stored profile.
1
+ // cli/settings.ts — view/edit session-start defaults and the display currency.
6
2
  import path from 'node:path';
7
3
  import { CAVEMAN_DEFAULTS, COMBO_DEFAULTS, COMBO_PRESET_MODES, OMP_PLUGINS_DIR, PACKAGE_NAME, PONYTAIL_DEFAULTS, cavemanDefaultFlag, comboDefaultFlag, currency, currencyGiven, diagScheduleFlag, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, settingArg, } from './common.js';
8
4
  import { CURRENCY_CODES } from './currency.js';
@@ -44,6 +40,7 @@ async function askProfile(current) {
44
40
  rtkDefault: preset.rtk,
45
41
  ponytailDefault: preset.ponytail,
46
42
  currency: current.currency,
43
+ backupSchedule: current.backupSchedule,
47
44
  };
48
45
  const caveman = await askInteractiveChoice('Caveman default', [...CAVEMAN_DEFAULTS].map((v) => ({
49
46
  value: v, label: v,
@@ -162,9 +159,7 @@ async function runSingleSetting(name, current, nextDiag) {
162
159
  }
163
160
  function printSettingsTable(current) {
164
161
  console.log('\n=== Tersio Settings ===');
165
- // Currency lives here no longer: the dashboard owns displaying and
166
- // persisting it (its picker POSTs to /currency); settings can still set
167
- // the stored default via --currency or the prompt below.
162
+ // The dashboard owns displaying currency; settings only sets the default.
168
163
  const rows = [
169
164
  ['combo', current.comboDefault, [...COMBO_DEFAULTS].join(' | ')],
170
165
  ['caveman', current.cavemanDefault, [...CAVEMAN_DEFAULTS].join(' | ')],