@krtclcdy/tersio 2.23.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +58 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +16 -20
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +49 -37
  7. package/cli/doctor-fix.js +28 -17
  8. package/cli/doctor.js +52 -41
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +180 -117
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +44 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/uninstall.js +135 -44
  21. package/cli/update.js +12 -19
  22. package/dashboard/dist/index.html +16 -16
  23. package/extensions/ai-addons-updater/index.js +31 -24
  24. package/extensions/ai-addons-updater/index.ts +32 -32
  25. package/extensions/caveman-session/index.js +6 -6
  26. package/extensions/caveman-session/index.ts +6 -6
  27. package/extensions/combo-toggle/index.js +42 -40
  28. package/extensions/combo-toggle/index.ts +36 -47
  29. package/extensions/lib/utils.js +36 -9
  30. package/extensions/lib/utils.ts +37 -9
  31. package/extensions/rtk-session/index.js +9 -9
  32. package/extensions/rtk-session/index.ts +9 -14
  33. package/extensions/shared/carbon.js +5 -8
  34. package/extensions/shared/carbon.ts +5 -8
  35. package/extensions/shared/host.js +68 -0
  36. package/extensions/shared/host.ts +104 -0
  37. package/extensions/shared/omp-prompt.js +12 -0
  38. package/extensions/shared/plugin-settings.js +43 -30
  39. package/extensions/shared/plugin-settings.ts +45 -26
  40. package/extensions/shared/pricing.js +26 -15
  41. package/extensions/shared/pricing.ts +28 -16
  42. package/extensions/shared/rtk-gain.js +11 -22
  43. package/extensions/shared/rtk-gain.ts +11 -22
  44. package/extensions/shared/session-state.js +21 -25
  45. package/extensions/shared/session-state.ts +22 -26
  46. package/extensions/shared/types.ts +10 -3
  47. package/extensions/shared/usage-ledger.js +24 -36
  48. package/extensions/shared/usage-ledger.ts +29 -50
  49. package/extensions/shared/usage-store.js +14 -12
  50. package/extensions/shared/usage-store.ts +16 -14
  51. package/extensions/tersio-commands/index.js +2 -1
  52. package/extensions/tersio-commands/index.ts +2 -1
  53. package/package.json +4 -2
  54. package/tersio.js +4 -4
  55. package/extensions/shared/mode-reinforcement.js +0 -26
package/cli/install.js CHANGED
@@ -1,9 +1,9 @@
1
- // cli/install.ts — install/reinstall flow and all setup steps.
1
+ // cli/install.ts — install flow and all setup steps.
2
2
  import { promises as fs } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
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, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
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
7
  import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
8
8
  import { printWelcome } from './banner.js';
9
9
  import { checkForUpdate, runLatestUpdate } from './update.js';
@@ -13,31 +13,17 @@ import { runReset } from './reset.js';
13
13
  import { runUsage } from './usage.js';
14
14
  import { runDashboard } from './dashboard.js';
15
15
  import { wireRtkOmp } from './rtk-wiring.js';
16
- import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
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
- // Paths to extension source files (relative to this script)
19
- const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
20
- const SHARED_SESSION_STATE = path.join(EXT_DIR, 'shared', 'session-state.ts');
21
- const CAVEMAN_INDEX = path.join(EXT_DIR, 'caveman-session', 'index.ts');
22
- const CAVEMAN_RULE = path.join(EXT_DIR, 'caveman-session', 'rule.md');
23
- const RTK_SESSION_INDEX = path.join(EXT_DIR, 'rtk-session', 'index.ts');
24
- const UPDATER_INDEX = path.join(EXT_DIR, 'ai-addons-updater', 'index.ts');
25
- const COMBO_TOGGLE_INDEX = path.join(EXT_DIR, 'combo-toggle', 'index.ts');
26
- const TERSIO_COMMANDS_INDEX = path.join(EXT_DIR, 'tersio-commands', 'index.ts');
27
- const SHARED_TYPES = path.join(EXT_DIR, 'shared', 'types.ts');
28
- const LIB_UTILS = path.join(EXT_DIR, 'lib', 'utils.ts');
29
- const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.ts');
30
- const SHARED_USAGE_LEDGER = path.join(EXT_DIR, 'shared', 'usage-ledger.ts');
31
- const SHARED_PRICING = path.join(EXT_DIR, 'shared', 'pricing.ts');
32
- const SHARED_CARBON = path.join(EXT_DIR, 'shared', 'carbon.ts');
18
+ import { filesUnder, sourcePath } from './manifest.js';
19
+ import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
33
20
  async function stepPonytail(pluginsDir, options) {
34
21
  if (!options.quiet)
35
22
  console.log(' Ponytail — ensure bundled plugin');
36
23
  await fs.mkdir(pluginsDir, { recursive: true });
37
24
  const pkgPath = path.join(pluginsDir, 'package.json');
38
25
  const pkg = await readPluginsPackage(pkgPath);
39
- // Migration: ponytail was a separate plugin row. It is now a tersio
40
- // dependency, so drop the legacy row. Runs after stepSelfPlugin.
26
+ // Drop the legacy Ponytail plugin row; it is a tersio dependency now.
41
27
  let migrated = false;
42
28
  if ('@dietrichgebert/ponytail' in pkg.dependencies) {
43
29
  delete pkg.dependencies['@dietrichgebert/ponytail'];
@@ -56,9 +42,7 @@ async function stepPonytail(pluginsDir, options) {
56
42
  }
57
43
  const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
58
44
  const probeExt = async () => (await readTextIfExists(ponytailExtPath)) !== null;
59
- // Fast path: bundled copy already present and no refresh asked — skip
60
- // network. Migration always reinstalls: the probe may hit the stale legacy
61
- // copy, and only npm install prunes it into the tersio-owned one.
45
+ // Skip the network unless a refresh was asked; migration always reinstalls.
62
46
  let ponytailExtExists = !options.reinstall && !options.dryRun && !migrated ? await probeExt() : false;
63
47
  if (ponytailExtExists) {
64
48
  debug('Bundled Ponytail pi-extension already installed; skipping network refresh');
@@ -70,8 +54,7 @@ async function stepPonytail(pluginsDir, options) {
70
54
  ponytailExtExists = true;
71
55
  }
72
56
  else {
73
- // Self-plugin npm install already materialized the bundled copy.
74
- // Reinstall here only when the probe still misses.
57
+ // Reinstall only when the self-plugin step did not materialize it.
75
58
  try {
76
59
  await execNetwork('Installing bundled Ponytail', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
77
60
  }
@@ -97,9 +80,7 @@ async function stepPonytail(pluginsDir, options) {
97
80
  // hideStatus=true keeps the upstream ponytail bar hidden; combo owns the bar.
98
81
  await ensurePonytailConfigValue('hideStatus', true, options);
99
82
  }
100
- // Registers this package in ~/.omp/plugins so OMP lists it on the
101
- // Settings → Plugins page (OMP enumerates plugins/package.json dependencies).
102
- // Returns true when the package is verified in plugins/node_modules.
83
+ // Registers the package so OMP lists it under Settings → Plugins.
103
84
  async function stepSelfPlugin(pluginsDir, options) {
104
85
  if (!options.quiet)
105
86
  console.log(' Tersio — register plugin');
@@ -242,17 +223,38 @@ async function extractRtkArchive(archivePath, extractDir) {
242
223
  console.log(` [fail] Unknown archive format: ${archivePath}`);
243
224
  return false;
244
225
  }
245
- async function stepRtk(binDir, options) {
246
- if (!options.quiet)
247
- console.log(' RTK — download binary and wire into OMP');
226
+ // The binary is machine-wide and only OMP needs the wiring, so an install that
227
+ // finds one present only rebinds. `tersio update` still refreshes it.
228
+ async function stepRtk(binDir, options, target = 'omp') {
248
229
  const binDest = path.join(binDir, RTK_BINARY_NAME);
249
- // Download failure must not skip wiring: a pre-existing rtk binary is
250
- // exactly as good for the OMP hook, so wire whatever ends up at binDest.
251
- // Dry runs stay offline: no registry probe, just the plan line above.
230
+ const found = resolveRtkBinary();
231
+ const bind = async (binary) => {
232
+ if (target !== 'omp')
233
+ return;
234
+ if (!(await fileExists(binary))) {
235
+ console.log(' [skip] no rtk binary to wire — install rtk, then run: rtk init -g --agent omp');
236
+ return;
237
+ }
238
+ await wireRtkOmp(binary, options);
239
+ };
240
+ if (found && !applyUpdate) {
241
+ // The path only under --verbose: a plain run, and a dry run, stay free of
242
+ // real user paths.
243
+ if (!options.quiet) {
244
+ const where = verbose ? ` (${found})` : '';
245
+ console.log(` RTK — already installed${where}${target === 'omp' ? ', binding into OMP' : ', nothing to bind on pi'}`);
246
+ }
247
+ await bind(found);
248
+ return;
249
+ }
250
+ if (!options.quiet)
251
+ console.log(` RTK — download binary and ${target === 'omp' ? 'wire into OMP' : 'put it on PATH'}`);
252
+ // A failed download must not skip wiring: a pre-existing binary serves the
253
+ // OMP hook just as well. Dry runs stay offline.
252
254
  if (options.dryRun) {
253
255
  if (verbose && !options.quiet)
254
256
  console.log(` [dry-run] would download rtk binary and install to ${binDest}`);
255
- await wireRtkOmp(binDest, options);
257
+ await bind(found ?? binDest);
256
258
  return;
257
259
  }
258
260
  try {
@@ -306,20 +308,11 @@ async function stepRtk(binDir, options) {
306
308
  console.log(` [fail] RTK: ${e.message}`);
307
309
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
308
310
  }
309
- if (options.dryRun) {
310
- await wireRtkOmp(binDest, options);
311
- return;
312
- }
313
- if (!(await fileExists(binDest))) {
314
- console.log(' [skip] no rtk binary to wire — install rtk, then run: rtk init -g --agent omp');
315
- return;
316
- }
317
- // Binary alone never meters OMP sessions — wire rtk's tool_call
318
- // extension so bash commands rewrite to rtk and land in history.db.
319
- await wireRtkOmp(binDest, options);
311
+ // Wire rtk's tool_call extension: the binary alone never meters a session.
312
+ await bind(binDest);
320
313
  }
321
- // Copy repo source files into the target extension dir. First entry is
322
- // required (skip label on missing); rest are optional companions.
314
+ // Copy sources into the target extension dir. First entry is required; the
315
+ // rest are optional companions.
323
316
  async function copySources(extDir, files, skipLabel, options) {
324
317
  const src = await readTextIfExists(files[0][0]);
325
318
  if (!src) {
@@ -340,25 +333,18 @@ async function copySources(extDir, files, skipLabel, options) {
340
333
  async function stepSharedSessionState(extDir, options) {
341
334
  if (!options.quiet)
342
335
  console.log(' Shared files — sync session bridge');
343
- await copySources(extDir, [
344
- [SHARED_SESSION_STATE, path.join('shared', 'session-state.ts')],
345
- [SHARED_TYPES, path.join('shared', 'types.ts')],
346
- [LIB_UTILS, path.join('lib', 'utils.ts')],
347
- [SHARED_PLUGIN_SETTINGS, path.join('shared', 'plugin-settings.ts')],
348
- [SHARED_USAGE_LEDGER, path.join('shared', 'usage-ledger.ts')],
349
- [SHARED_PRICING, path.join('shared', 'pricing.ts')],
350
- [SHARED_CARBON, path.join('shared', 'carbon.ts')],
351
- ], 'shared/session-state.js', options);
336
+ const shared = [...filesUnder('shared'), ...filesUnder('lib')];
337
+ await copySources(extDir, shared, 'shared/session-state.js', options);
352
338
  }
353
339
  async function stepRtkSession(extDir, options) {
354
340
  if (!options.quiet)
355
341
  console.log(' RTK session — install session mode');
356
- await copySources(extDir, [[RTK_SESSION_INDEX, path.join('rtk-session', 'index.ts')]], 'rtk-session/index.ts', options);
342
+ await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
357
343
  }
358
- // One rule fetch serves the install; dry runs stay offline
359
- // and fall back to the bundled rule to preview its destination.
344
+ // One fetch serves the install; dry runs stay offline and preview the bundled
345
+ // rule's destination.
360
346
  async function fetchCavemanRule(options) {
361
- const bundled = await readTextIfExists(CAVEMAN_RULE);
347
+ const bundled = await readTextIfExists(sourcePath('caveman-session/rule.md'));
362
348
  if (options.dryRun)
363
349
  return bundled;
364
350
  try {
@@ -390,30 +376,27 @@ async function stepCaveman(extDir, rule, options) {
390
376
  else {
391
377
  await writeIfChanged(ruleDest, rule, options);
392
378
  }
393
- await copySources(extDir, [[CAVEMAN_INDEX, path.join('caveman-session', 'index.ts')]], 'caveman-session/index.ts', options);
379
+ await copySources(extDir, filesUnder('caveman-session').filter(([, to]) => to.endsWith('index.ts')), 'caveman-session/index.ts', options);
394
380
  }
395
381
  async function stepTersioCommands(extDir, options) {
396
382
  if (!options.quiet)
397
383
  console.log(' Tersio commands — install /tersio root command');
398
- await copySources(extDir, [[TERSIO_COMMANDS_INDEX, path.join('tersio-commands', 'index.ts')]], 'tersio-commands/index.ts', options);
384
+ await copySources(extDir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
399
385
  }
400
386
  async function stepUpdater(extDir, options) {
401
- await copySources(extDir, [[UPDATER_INDEX, path.join('ai-addons-updater', 'index.ts')]], 'ai-addons-updater/index.ts', options);
387
+ await copySources(extDir, filesUnder('ai-addons-updater'), 'ai-addons-updater/index.ts', options);
402
388
  }
403
389
  async function stepCombo(extDir, options) {
404
390
  if (!options.quiet)
405
391
  console.log(' Combo — install preset switch');
406
- await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.ts')]], 'combo-toggle/index.ts', options);
392
+ await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
407
393
  }
408
- async function resolveProfile(forceReinstall = false, opts = {}) {
409
- // Seed from the lock file so flag-less update/reinstall runs keep the
410
- // user's configured defaults instead of resetting them to off.
394
+ async function resolveProfile(opts = {}) {
395
+ // Seed from the stored defaults so flag-less runs keep them.
411
396
  const profile = await storedProfile();
412
- // Single interactive prompt: the Combo preset implies all three modes.
413
- // Numbered menu — no typing preset names.
414
- // Only for a real user at a terminal, only when no default flags were
415
- // given, and never for --apply-update runs.
416
- if (tty() && !profileFlagsGiven && !applyUpdate && (install || forceReinstall || reinstall)) {
397
+ // One numbered prompt for all three modes, for a real terminal user with no
398
+ // default flags. Never for --apply-update, and never for a script.
399
+ if (tty() && !profileFlagsGiven && !applyUpdate) {
417
400
  const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
418
401
  { value: 'off', label: 'off' },
419
402
  { value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
@@ -445,9 +428,7 @@ async function resolveProfile(forceReinstall = false, opts = {}) {
445
428
  return profile;
446
429
  }
447
430
  let updatePromptDone = false;
448
- // Bare `tersio` at a terminal is a command picker, not an install run:
449
- // update offer first (when pending), then a Clack menu over every command.
450
- // Scripts, pipes, --yes, and --dry-run keep the old straight-to-install path.
431
+ // Bare `tersio` at a terminal picks a command; scripts and flags install.
451
432
  async function runCommandMenu() {
452
433
  printWelcome();
453
434
  const newer = await checkForUpdate();
@@ -468,7 +449,6 @@ async function runCommandMenu() {
468
449
  const choice = await askInteractiveChoice('Tersio — what next?', [
469
450
  { value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
470
451
  { value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
471
- { value: 'reinstall', label: 'Reinstall', hint: 'clean and reinstall the add-ons, Ponytail package kept' },
472
452
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
473
453
  { value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
474
454
  { value: 'dashboard', label: 'Dashboard', hint: 'open the report in your browser' },
@@ -504,10 +484,6 @@ async function runCommandMenu() {
504
484
  closeRL();
505
485
  break;
506
486
  }
507
- case 'reinstall':
508
- await runInstall({ reinstall: true });
509
- closeRL();
510
- break;
511
487
  case 'doctor': {
512
488
  const summary = await runDoctor();
513
489
  if (!dryRun && summary.missing + summary.warn > 0) {
@@ -545,40 +521,117 @@ async function runCommandMenu() {
545
521
  break;
546
522
  }
547
523
  }
548
- async function runInstall(overrides = {}) {
549
- const isReinstall = overrides.reinstall ?? reinstall;
550
- // Menu-driven installs must fall through: bare `tersio` re-enters here
551
- // with command === null after the picker, and without this guard the
552
- // choice loops straight back into runCommandMenu() forever.
524
+ // Which host this run targets. --host pins it; a TTY user picks; scripts,
525
+ // pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
526
+ let targetHost = 'omp';
527
+ async function chooseHost(title, only) {
528
+ const hosts = detectHosts().filter((host) => !only || only.includes(host.id));
529
+ const choice = await askInteractiveChoice(title, hosts.map((host) => ({
530
+ value: host.id,
531
+ label: hostLabel(host),
532
+ hint: hostHint(host),
533
+ })), targetHost);
534
+ if (choice.status !== 'selected') {
535
+ closeRL();
536
+ process.exit(130);
537
+ }
538
+ return choice.value;
539
+ }
540
+ function hostEntry(id) {
541
+ return detectHosts().find((host) => host.id === id);
542
+ }
543
+ // pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes
544
+ // the same tree as OMP's and registers nothing.
545
+ async function stepPiLayer(profile, options) {
546
+ const agentDir = piAgentDir();
547
+ const extDir = path.join(agentDir, 'extensions');
548
+ const declared = piTersioSource(agentDir);
549
+ if (declared)
550
+ console.log(` [note] pi also has a tersio package (${declared}) — remove one, or both copies load: pi remove npm:${PACKAGE_NAME}`);
551
+ if (!options.quiet)
552
+ console.log(` Pi — write the extension tree (${extDir})`);
553
+ const cavemanRule = await fetchCavemanRule(options);
554
+ await stepSharedSessionState(extDir, options);
555
+ // OMP loads rtk-session from its plugin manifest, so only the pi tree copies it.
556
+ await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
557
+ await stepCaveman(extDir, cavemanRule, options);
558
+ await stepCombo(extDir, options);
559
+ await stepTersioCommands(extDir, options);
560
+ await stepUpdater(extDir, options);
561
+ await stepPonytailForPi(agentDir, options);
562
+ }
563
+ // Ponytail for pi: install the package, or copy the bundled skills offline.
564
+ async function stepPonytailForPi(agentDir, options) {
565
+ if (!options.quiet)
566
+ console.log(' Ponytail — ensure the pi package');
567
+ if (!options.dryRun) {
568
+ try {
569
+ await execNetwork('Installing Ponytail for pi', 'pi', ['install', 'npm:@dietrichgebert/ponytail'], { timeout: 300000 });
570
+ return;
571
+ }
572
+ catch (e) {
573
+ console.log(` [fail] pi install ponytail: ${shortError(e)}`);
574
+ }
575
+ }
576
+ const skills = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)), 'skills');
577
+ if (!skills) {
578
+ console.log(' [skip] Ponytail package not found next to the CLI — skills not copied');
579
+ console.log(' [hint] The /ponytail command will not work; run: pi install npm:@dietrichgebert/ponytail');
580
+ return;
581
+ }
582
+ const dest = path.join(agentDir, 'skills');
583
+ if (options.dryRun) {
584
+ if (verbose && !options.quiet)
585
+ console.log(` [dry-run] would copy Ponytail skills to ${dest}`);
586
+ return;
587
+ }
588
+ for (const entry of await fs.readdir(skills, { withFileTypes: true })) {
589
+ if (!entry.isDirectory())
590
+ continue;
591
+ const to = path.join(dest, entry.name);
592
+ await fs.rm(to, { recursive: true, force: true });
593
+ await fs.cp(path.join(skills, entry.name), to, { recursive: true });
594
+ if (!options.quiet)
595
+ console.log(` [write] skill ${entry.name} → ${to}`);
596
+ }
597
+ }
598
+ async function runInstall() {
599
+ // Without this guard a picked command re-enters here with command === null
600
+ // and loops straight back into the picker.
553
601
  if (command === null && tty() && !yes && !updatePromptDone) {
554
602
  await runCommandMenu();
555
603
  return;
556
604
  }
557
- // apply-update is the delegated payload of `tersio update`: stay silent —
558
- // the parent already printed the plan and owns the closing summary. Banner
559
- // repeats here otherwise (the reported double-print), burying real output.
605
+ // Ask the host first: it decides the tree, the cleanup, and the defaults file.
606
+ let pinned;
607
+ try {
608
+ pinned = parseHostArg(args);
609
+ }
610
+ catch (e) {
611
+ console.error(`[fail] ${e.message}`);
612
+ process.exit(1);
613
+ }
614
+ if (pinned)
615
+ targetHost = pinned;
616
+ else if (tty() && !yes && !dryRun && !applyUpdate && command !== 'uninstall') {
617
+ targetHost = await chooseHost('Install Tersio into which agent?');
618
+ }
619
+ const host = hostEntry(targetHost);
620
+ // apply-update stays silent: the parent printed the plan.
560
621
  const quiet = applyUpdate;
561
622
  if (!quiet)
562
623
  printWelcome();
563
- if (isReinstall) {
564
- // removeRtk stays false: reinstall is about to replace the binary, and
565
- // deleting it first would leave nothing to wire if the fresh download
566
- // fails (rate limit, offline). rtk.ts is removed here and re-wired below.
567
- await runUninstall({ yes: true, removePonytail: false, removeRtk: false });
568
- }
569
624
  if (dryRun && !quiet)
570
625
  console.log('Preview — no changes will be written.\n');
571
626
  if (!quiet) {
572
627
  console.log(`Installing Tersio v${PACKAGE_VERSION}`);
573
628
  }
574
- // Remind humans a newer release exists; silent for scripts (no TTY) and for
575
- // the apply-update payload, which is itself an update run.
629
+ // Nudge about a newer release; silent without a TTY.
576
630
  if (tty() && !applyUpdate && command !== 'uninstall') {
577
631
  const newer = await checkForUpdate();
578
632
  if (typeof newer === 'string') {
579
- // Bare `tersio` with an update pending: offer it now (Y/n) instead of
580
- // burying the banner above the install prompts. Yes runs the full
581
- // update and stops here — the fresh binary owns what follows.
633
+ // Update pending: offer it here instead of burying it above the install
634
+ // prompts. Yes runs the full update and stops.
582
635
  if (command === null && !dryRun && !yes && !updatePromptDone) {
583
636
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
584
637
  if (answer.status === 'confirmed' && answer.value) {
@@ -598,22 +651,20 @@ async function runInstall(overrides = {}) {
598
651
  }
599
652
  }
600
653
  // Resolve session defaults: flags > interactive prompt > defaults.
601
- const profile = await resolveProfile(isReinstall, { quiet });
654
+ const profile = await resolveProfile({ quiet });
602
655
  const userDir = OMP_AGENT_DIR;
603
656
  const userExtDir = path.join(userDir, 'extensions');
604
- // apply-update is `tersio update`'s payload run: treat it like reinstall so
605
- // the add-ons refresh too — Ponytail package via npm, self plugin, RTK
606
- // binary (always re-downloaded), and the Caveman rule (always re-fetched).
607
- const options = { dryRun, verbose, yes, reinstall: isReinstall || applyUpdate, quiet };
657
+ // apply-update refreshes every add-on like a reinstall.
658
+ const installOptions = { dryRun, verbose, yes, reinstall: applyUpdate, quiet };
608
659
  try {
609
660
  const v = (await execP(OMP_BIN, ['--version'])).stdout.trim();
610
661
  if (verbose && !quiet)
611
662
  console.log(` omp ${v}`);
612
663
  }
613
664
  catch {
614
- console.log(' [fail] omp not found — ensure it\'s installed');
665
+ if (targetHost === 'omp')
666
+ console.log(' [fail] omp not found — ensure it\'s installed');
615
667
  }
616
- const cavemanRule = await fetchCavemanRule(options);
617
668
  const failures = [];
618
669
  const capture = async (label, work) => {
619
670
  try {
@@ -624,18 +675,30 @@ async function runInstall(overrides = {}) {
624
675
  console.log(` [fail] ${label}: ${shortError(e)}`);
625
676
  }
626
677
  };
627
- await capture('shared', () => stepSharedSessionState(userExtDir, options));
678
+ if (targetHost === 'pi') {
679
+ await capture('pi tree', () => stepPiLayer(profile, installOptions));
680
+ await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'pi'));
681
+ await capture('settings', () => writePluginSettings(profile, installOptions));
682
+ if (failures.length > 0)
683
+ console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
684
+ else
685
+ console.log('\nDone — restart pi, then /combo medium.');
686
+ closeRL();
687
+ return;
688
+ }
689
+ const cavemanRule = await fetchCavemanRule(installOptions);
690
+ await capture('shared', () => stepSharedSessionState(userExtDir, installOptions));
628
691
  let selfPlugin = false;
629
- await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, options); });
630
- await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, options));
631
- await capture('rtk', () => stepRtk(BUN_BIN_DIR, options));
632
- await capture('rtk session', () => stepRtkSession(userExtDir, options));
633
- await capture('caveman', () => stepCaveman(path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions'), cavemanRule, options));
634
- await capture('combo', () => stepCombo(userExtDir, options));
635
- await capture('commands', () => stepTersioCommands(userExtDir, options));
636
- await capture('updater', () => stepUpdater(userExtDir, options));
692
+ await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, installOptions); });
693
+ await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, installOptions));
694
+ await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions));
695
+ await capture('rtk session', () => stepRtkSession(userExtDir, installOptions));
696
+ await capture('caveman', () => stepCaveman(path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions'), cavemanRule, installOptions));
697
+ await capture('combo', () => stepCombo(userExtDir, installOptions));
698
+ await capture('commands', () => stepTersioCommands(userExtDir, installOptions));
699
+ await capture('updater', () => stepUpdater(userExtDir, installOptions));
637
700
  if (selfPlugin)
638
- await capture('settings', () => writePluginSettings(profile, options));
701
+ await capture('settings', () => writePluginSettings(profile, installOptions));
639
702
  if (quiet) {
640
703
  if (failures.length > 0)
641
704
  console.log(` add-ons: ${failures.length} failed (${failures.join(', ')}) — see [fail] lines above`);
@@ -67,6 +67,32 @@ async function askInteractiveConfirm(message, initialValue = true) {
67
67
  }
68
68
  return { status: 'confirmed', value: answer };
69
69
  }
70
+ // Confirm a destructive run: a Clack dialog at a terminal, a plain prompt in a
71
+ // pipe or script. False means the user declined or aborted.
72
+ async function confirmDestructive(message) {
73
+ if (tty()) {
74
+ closeRL();
75
+ const answer = await clackConfirm({ message, initialValue: false });
76
+ if (typeof answer !== 'boolean') {
77
+ clackCancel('Aborted.');
78
+ closeRL();
79
+ return false;
80
+ }
81
+ if (!answer) {
82
+ console.log('Aborted.');
83
+ closeRL();
84
+ return false;
85
+ }
86
+ return true;
87
+ }
88
+ const typed = await ask('\nProceed? [y/N]: ');
89
+ if (!typed.toLowerCase().startsWith('y')) {
90
+ console.log('Aborted.');
91
+ closeRL();
92
+ return false;
93
+ }
94
+ return true;
95
+ }
70
96
  // Run collecting work under one TTY-only Clack task. Callers print after the
71
97
  // task completes, keeping normal output out of the spinner animation.
72
98
  async function runInteractivePhase(title, collect) {
@@ -80,4 +106,4 @@ async function runInteractivePhase(title, collect) {
80
106
  async function execNetwork(label, cmd, args, opts = {}) {
81
107
  return withInteractiveSpinner(label, () => execP(cmd, args, opts));
82
108
  }
83
- export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, askInteractiveConfirm, runInteractivePhase, };
109
+ export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, askInteractiveConfirm, confirmDestructive, runInteractivePhase, };
package/cli/layer.js ADDED
@@ -0,0 +1,24 @@
1
+ // cli/layer.ts — the shared shape of an extension tree.
2
+ import path from 'node:path';
3
+ // Turns a `[dir, label]` table into resolved paths under `extDir`.
4
+ export function layerEntries(extDir, table) {
5
+ return table.map(([dir, label]) => ({ label, path: path.join(extDir, dir) }));
6
+ }
7
+ // Directory names only, for callers that key off the names alone.
8
+ export function layerDirs(table) {
9
+ return table.map(([dir]) => dir);
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.
14
+ export function rewritePortImports(content) {
15
+ return content.replace(/from '\.\.\/\.\.\/(shared|lib)\//g, "from '../$1/");
16
+ }
17
+ // What of the layer is on disk.
18
+ export function reportLayer(extDir, tables, exists) {
19
+ const all = tables.flatMap((table) => layerEntries(extDir, table));
20
+ return {
21
+ present: all.filter((entry) => exists(entry.path)),
22
+ missing: all.filter((entry) => !exists(entry.path)),
23
+ };
24
+ }
@@ -0,0 +1,36 @@
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.
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ export const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
8
+ /** Every file in the tree, as paths relative to the extensions directory. */
9
+ export const TREE_FILES = [
10
+ 'shared/host.ts',
11
+ 'shared/session-state.ts',
12
+ 'shared/types.ts',
13
+ 'shared/plugin-settings.ts',
14
+ 'shared/usage-ledger.ts',
15
+ 'shared/pricing.ts',
16
+ 'shared/carbon.ts',
17
+ 'lib/utils.ts',
18
+ 'caveman-session/index.ts',
19
+ 'caveman-session/rule.md',
20
+ 'rtk-session/index.ts',
21
+ 'combo-toggle/index.ts',
22
+ 'tersio-commands/index.ts',
23
+ 'ai-addons-updater/index.ts',
24
+ ];
25
+ export function sourcePath(relative) {
26
+ return path.join(EXT_DIR, ...relative.split('/'));
27
+ }
28
+ /** The entries under one directory, as source paths and tree-relative targets. */
29
+ export function filesUnder(dir) {
30
+ const found = [];
31
+ for (const file of TREE_FILES) {
32
+ if (file.startsWith(`${dir}/`))
33
+ found.push([sourcePath(file), file]);
34
+ }
35
+ return found;
36
+ }
@@ -0,0 +1,51 @@
1
+ // The Oh My Pi layer as data: the extension directories, the RTK wiring and the Ponytail package, named in one place...
2
+ import path from 'node:path';
3
+ import { layerEntries, reportLayer } from './layer.js';
4
+ const INSTALLED_EXTENSIONS = [
5
+ ['caveman-session', 'caveman session mode'],
6
+ ['rtk-session', 'rtk session mode'],
7
+ ['ai-addons-updater', 'add-on updater'],
8
+ ['combo-toggle', 'combo preset switch'],
9
+ ['tersio-commands', '/tersio command'],
10
+ ['shared', 'session bridge, usage and pricing store'],
11
+ // Read by the add-on updater; the updater itself is removed above.
12
+ ['lib', 'shared installer and updater helpers'],
13
+ ];
14
+ const RETIRED_EXTENSIONS = [
15
+ // Legacy always-on combo helper; imports shared/session-state.js, so it breaks with a module-not-found warning once the...
16
+ ['aaa-combo-boot', 'retired combo boot helper'],
17
+ ];
18
+ // `rtkBinaryName` is passed in because it is platform-dependent and already resolved by the caller.
19
+ export function ompLayer(home, rtkBinaryName) {
20
+ const agentDir = path.join(home, '.omp', 'agent');
21
+ const extDir = path.join(agentDir, 'extensions');
22
+ const pluginsDir = path.join(home, '.omp', 'plugins');
23
+ return {
24
+ extDir,
25
+ configFile: path.join(agentDir, 'config.yml'),
26
+ pluginsDir,
27
+ ponytailPackage: path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail'),
28
+ rtkBinary: path.join(home, '.bun', 'bin', rtkBinaryName),
29
+ rtkExtension: path.join(extDir, 'rtk.ts'),
30
+ installed: layerEntries(extDir, INSTALLED_EXTENSIONS),
31
+ retired: layerEntries(extDir, RETIRED_EXTENSIONS),
32
+ };
33
+ }
34
+ // Every extension directory the layer owns, installed and retired together.
35
+ export function ompExtensionTargets(layer) {
36
+ return [...layer.installed, ...layer.retired].map((entry) => entry.path);
37
+ }
38
+ export function tersioPluginDir(home) {
39
+ return path.join(home, '.omp', 'plugins', 'node_modules', '@krtclcdy', 'tersio');
40
+ }
41
+ export function reportOmpLayer(home, exists) {
42
+ const extDir = path.join(home, '.omp', 'agent', 'extensions');
43
+ const pluginPath = tersioPluginDir(home);
44
+ const { present, missing } = reportLayer(extDir, [INSTALLED_EXTENSIONS], exists);
45
+ return {
46
+ pluginPath: exists(pluginPath) ? pluginPath : null,
47
+ extensions: present,
48
+ missing,
49
+ installed: exists(pluginPath),
50
+ };
51
+ }