@krtclcdy/tersio 2.22.1 → 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 (59) hide show
  1. package/README.md +62 -46
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +55 -55
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +76 -49
  7. package/cli/doctor-fix.js +61 -44
  8. package/cli/doctor.js +66 -44
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +187 -143
  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 +45 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/settings.js +1 -1
  21. package/cli/uninstall.js +135 -44
  22. package/cli/update.js +24 -30
  23. package/cli/usage.js +6 -1
  24. package/dashboard/dist/index.html +16 -16
  25. package/extensions/ai-addons-updater/index.js +36 -25
  26. package/extensions/ai-addons-updater/index.ts +36 -24
  27. package/extensions/caveman-session/index.js +23 -18
  28. package/extensions/caveman-session/index.ts +23 -18
  29. package/extensions/caveman-session/rule.md +90 -0
  30. package/extensions/combo-toggle/index.js +63 -41
  31. package/extensions/combo-toggle/index.ts +61 -48
  32. package/extensions/lib/utils.js +56 -13
  33. package/extensions/lib/utils.ts +55 -13
  34. package/extensions/rtk-session/index.js +20 -19
  35. package/extensions/rtk-session/index.ts +18 -24
  36. package/extensions/shared/carbon.js +5 -8
  37. package/extensions/shared/carbon.ts +5 -8
  38. package/extensions/shared/host.js +68 -0
  39. package/extensions/shared/host.ts +104 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +63 -16
  42. package/extensions/shared/plugin-settings.ts +67 -17
  43. package/extensions/shared/pricing.js +26 -15
  44. package/extensions/shared/pricing.ts +28 -16
  45. package/extensions/shared/rtk-gain.js +13 -24
  46. package/extensions/shared/rtk-gain.ts +14 -25
  47. package/extensions/shared/session-state.js +26 -28
  48. package/extensions/shared/session-state.ts +26 -29
  49. package/extensions/shared/types.ts +15 -3
  50. package/extensions/shared/usage-ledger.js +136 -38
  51. package/extensions/shared/usage-ledger.ts +149 -52
  52. package/extensions/shared/usage-store.js +14 -12
  53. package/extensions/shared/usage-store.ts +16 -14
  54. package/extensions/tersio-commands/index.js +2 -1
  55. package/extensions/tersio-commands/index.ts +2 -1
  56. package/package.json +15 -7
  57. package/tersio.js +6 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
  59. package/extensions/shared/mode-reinforcement.ts +0 -29
package/cli/install.js CHANGED
@@ -1,44 +1,29 @@
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, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, 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';
10
10
  import { runUninstall } from './uninstall.js';
11
11
  import { runDoctor } from './doctor.js';
12
12
  import { runReset } from './reset.js';
13
- import { runSettings } from './settings.js';
14
13
  import { runUsage } from './usage.js';
15
14
  import { runDashboard } from './dashboard.js';
16
15
  import { wireRtkOmp } from './rtk-wiring.js';
17
- 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';
18
17
  import { storedProfile, writePluginSettings } from './profile.js';
19
- // Paths to extension source files (relative to this script)
20
- const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
21
- const SHARED_SESSION_STATE = path.join(EXT_DIR, 'shared', 'session-state.ts');
22
- const CAVEMAN_INDEX = path.join(EXT_DIR, 'caveman-session', 'index.ts');
23
- const RTK_SESSION_INDEX = path.join(EXT_DIR, 'rtk-session', 'index.ts');
24
- const UPDATER_INDEX = path.join(EXT_DIR, 'ai-addons-updater', 'index.ts');
25
- const COMBO_TOGGLE_INDEX = path.join(EXT_DIR, 'combo-toggle', 'index.ts');
26
- const TERSIO_COMMANDS_INDEX = path.join(EXT_DIR, 'tersio-commands', 'index.ts');
27
- const MODE_REINFORCEMENT_INDEX = path.join(EXT_DIR, 'shared', 'mode-reinforcement.ts');
28
- const SHARED_TYPES = path.join(EXT_DIR, 'shared', 'types.ts');
29
- const LIB_UTILS = path.join(EXT_DIR, 'lib', 'utils.ts');
30
- const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.ts');
31
- const SHARED_USAGE_LEDGER = path.join(EXT_DIR, 'shared', 'usage-ledger.ts');
32
- const SHARED_PRICING = path.join(EXT_DIR, 'shared', 'pricing.ts');
33
- const SHARED_CARBON = path.join(EXT_DIR, 'shared', 'carbon.ts');
34
- async function stepPonytail(pluginsDir, userDir, options) {
18
+ import { filesUnder, sourcePath } from './manifest.js';
19
+ import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
20
+ async function stepPonytail(pluginsDir, options) {
35
21
  if (!options.quiet)
36
22
  console.log(' Ponytail — ensure bundled plugin');
37
23
  await fs.mkdir(pluginsDir, { recursive: true });
38
24
  const pkgPath = path.join(pluginsDir, 'package.json');
39
25
  const pkg = await readPluginsPackage(pkgPath);
40
- // Migration: ponytail was a separate plugin row. It is now a tersio
41
- // dependency, so drop the legacy row. Runs after stepSelfPlugin.
26
+ // Drop the legacy Ponytail plugin row; it is a tersio dependency now.
42
27
  let migrated = false;
43
28
  if ('@dietrichgebert/ponytail' in pkg.dependencies) {
44
29
  delete pkg.dependencies['@dietrichgebert/ponytail'];
@@ -57,9 +42,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
57
42
  }
58
43
  const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
59
44
  const probeExt = async () => (await readTextIfExists(ponytailExtPath)) !== null;
60
- // Fast path: bundled copy already present and no refresh asked — skip
61
- // network. Migration always reinstalls: the probe may hit the stale legacy
62
- // copy, and only npm install prunes it into the tersio-owned one.
45
+ // Skip the network unless a refresh was asked; migration always reinstalls.
63
46
  let ponytailExtExists = !options.reinstall && !options.dryRun && !migrated ? await probeExt() : false;
64
47
  if (ponytailExtExists) {
65
48
  debug('Bundled Ponytail pi-extension already installed; skipping network refresh');
@@ -71,8 +54,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
71
54
  ponytailExtExists = true;
72
55
  }
73
56
  else {
74
- // Self-plugin npm install already materialized the bundled copy.
75
- // Reinstall here only when the probe still misses.
57
+ // Reinstall only when the self-plugin step did not materialize it.
76
58
  try {
77
59
  await execNetwork('Installing bundled Ponytail', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
78
60
  }
@@ -92,19 +74,13 @@ async function stepPonytail(pluginsDir, userDir, options) {
92
74
  console.log(' [hint] The /ponytail command won\'t work, but ponytail skills will still load');
93
75
  }
94
76
  else if (!options.dryRun && !options.quiet) {
95
- // Wire extension into config.yml so /ponytail command loads
96
- debug('Bundled Ponytail pi-extension found');
77
+ debug('Bundled Ponytail pi-extension found; OMP loads it from the plugin manifest');
97
78
  }
98
- // Still set Ponytail config defaults even without the extension command
99
- const configPath = path.join(userDir, 'config.yml');
100
- await ensureExtensionInConfig(configPath, ponytailExtPath, 'ponytail', options);
101
79
  await ensurePonytailConfigValue('defaultMode', 'off', options);
102
80
  // hideStatus=true keeps the upstream ponytail bar hidden; combo owns the bar.
103
81
  await ensurePonytailConfigValue('hideStatus', true, options);
104
82
  }
105
- // Registers this package in ~/.omp/plugins so OMP lists it on the
106
- // Settings → Plugins page (OMP enumerates plugins/package.json dependencies).
107
- // Returns true when the package is verified in plugins/node_modules.
83
+ // Registers the package so OMP lists it under Settings → Plugins.
108
84
  async function stepSelfPlugin(pluginsDir, options) {
109
85
  if (!options.quiet)
110
86
  console.log(' Tersio — register plugin');
@@ -247,17 +223,38 @@ async function extractRtkArchive(archivePath, extractDir) {
247
223
  console.log(` [fail] Unknown archive format: ${archivePath}`);
248
224
  return false;
249
225
  }
250
- async function stepRtk(binDir, options) {
251
- if (!options.quiet)
252
- 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') {
253
229
  const binDest = path.join(binDir, RTK_BINARY_NAME);
254
- // Download failure must not skip wiring: a pre-existing rtk binary is
255
- // exactly as good for the OMP hook, so wire whatever ends up at binDest.
256
- // 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.
257
254
  if (options.dryRun) {
258
255
  if (verbose && !options.quiet)
259
256
  console.log(` [dry-run] would download rtk binary and install to ${binDest}`);
260
- await wireRtkOmp(binDest, options);
257
+ await bind(found ?? binDest);
261
258
  return;
262
259
  }
263
260
  try {
@@ -311,20 +308,11 @@ async function stepRtk(binDir, options) {
311
308
  console.log(` [fail] RTK: ${e.message}`);
312
309
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
313
310
  }
314
- if (options.dryRun) {
315
- await wireRtkOmp(binDest, options);
316
- return;
317
- }
318
- if (!(await fileExists(binDest))) {
319
- console.log(' [skip] no rtk binary to wire — install rtk, then run: rtk init -g --agent omp');
320
- return;
321
- }
322
- // Binary alone never meters OMP sessions — wire rtk's tool_call
323
- // extension so bash commands rewrite to rtk and land in history.db.
324
- await wireRtkOmp(binDest, options);
311
+ // Wire rtk's tool_call extension: the binary alone never meters a session.
312
+ await bind(binDest);
325
313
  }
326
- // Copy repo source files into the target extension dir. First entry is
327
- // 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.
328
316
  async function copySources(extDir, files, skipLabel, options) {
329
317
  const src = await readTextIfExists(files[0][0]);
330
318
  if (!src) {
@@ -345,38 +333,28 @@ async function copySources(extDir, files, skipLabel, options) {
345
333
  async function stepSharedSessionState(extDir, options) {
346
334
  if (!options.quiet)
347
335
  console.log(' Shared files — sync session bridge');
348
- await copySources(extDir, [
349
- [SHARED_SESSION_STATE, path.join('shared', 'session-state.ts')],
350
- [SHARED_TYPES, path.join('shared', 'types.ts')],
351
- [LIB_UTILS, path.join('lib', 'utils.ts')],
352
- [SHARED_PLUGIN_SETTINGS, path.join('shared', 'plugin-settings.ts')],
353
- [SHARED_USAGE_LEDGER, path.join('shared', 'usage-ledger.ts')],
354
- [SHARED_PRICING, path.join('shared', 'pricing.ts')],
355
- [SHARED_CARBON, path.join('shared', 'carbon.ts')],
356
- ], 'shared/session-state.js', options);
357
- }
358
- async function stepModeReinforcement(extDir, ponytailExtPath, options) {
359
- if (!options.quiet)
360
- console.log(' Session helpers — sync shared files');
361
- const dest = path.join(extDir, 'shared', 'mode-reinforcement.ts');
362
- if (!await copySources(extDir, [[MODE_REINFORCEMENT_INDEX, path.join('shared', 'mode-reinforcement.ts')]], 'shared/mode-reinforcement.ts', options))
363
- return;
364
- await ensureExtensionAfterConfigEntry(path.join(path.dirname(extDir), 'config.yml'), dest, ponytailExtPath, 'mode reinforcement', options);
336
+ const shared = [...filesUnder('shared'), ...filesUnder('lib')];
337
+ await copySources(extDir, shared, 'shared/session-state.js', options);
365
338
  }
366
339
  async function stepRtkSession(extDir, options) {
367
340
  if (!options.quiet)
368
341
  console.log(' RTK session — install session mode');
369
- 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);
370
343
  }
371
- // One rule fetch serves the install; dry runs stay offline
372
- // 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.
373
346
  async function fetchCavemanRule(options) {
347
+ const bundled = await readTextIfExists(sourcePath('caveman-session/rule.md'));
374
348
  if (options.dryRun)
375
- return (await readTextIfExists(path.join(path.dirname(CAVEMAN_INDEX), 'rule.md'))) || '';
349
+ return bundled;
376
350
  try {
377
351
  return await withInteractiveSpinner('Fetching Caveman rule', () => httpsGet(CAVEMAN_REMOTE_RULE));
378
352
  }
379
353
  catch (e) {
354
+ if (bundled !== null) {
355
+ debug(`Using bundled Caveman rule: ${e.message}`);
356
+ return bundled;
357
+ }
380
358
  console.log(` [warn] Could not fetch caveman rule: ${e.message}`);
381
359
  return null;
382
360
  }
@@ -398,35 +376,27 @@ async function stepCaveman(extDir, rule, options) {
398
376
  else {
399
377
  await writeIfChanged(ruleDest, rule, options);
400
378
  }
401
- 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);
402
380
  }
403
381
  async function stepTersioCommands(extDir, options) {
404
382
  if (!options.quiet)
405
383
  console.log(' Tersio commands — install /tersio root command');
406
- 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);
407
385
  }
408
386
  async function stepUpdater(extDir, options) {
409
- 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);
410
388
  }
411
389
  async function stepCombo(extDir, options) {
412
390
  if (!options.quiet)
413
391
  console.log(' Combo — install preset switch');
414
- const dest = path.join(extDir, 'combo-toggle', 'index.ts');
415
- if (!await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.ts')]], 'combo-toggle/index.ts', options))
416
- return;
417
- // Auto-register combo in config.yml
418
- const configPath = path.join(path.dirname(extDir), 'config.yml');
419
- await ensureExtensionInConfig(configPath, dest, 'combo', options);
392
+ await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
420
393
  }
421
- async function resolveProfile(forceReinstall = false, opts = {}) {
422
- // Seed from the lock file so flag-less update/reinstall runs keep the
423
- // 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.
424
396
  const profile = await storedProfile();
425
- // Single interactive prompt: the Combo preset implies all three modes.
426
- // Numbered menu — no typing preset names.
427
- // Only for a real user at a terminal, only when no default flags were
428
- // given, and never for --apply-update runs.
429
- 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) {
430
400
  const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
431
401
  { value: 'off', label: 'off' },
432
402
  { value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
@@ -458,9 +428,7 @@ async function resolveProfile(forceReinstall = false, opts = {}) {
458
428
  return profile;
459
429
  }
460
430
  let updatePromptDone = false;
461
- // Bare `tersio` at a terminal is a command picker, not an install run:
462
- // update offer first (when pending), then a Clack menu over every command.
463
- // 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.
464
432
  async function runCommandMenu() {
465
433
  printWelcome();
466
434
  const newer = await checkForUpdate();
@@ -481,12 +449,10 @@ async function runCommandMenu() {
481
449
  const choice = await askInteractiveChoice('Tersio — what next?', [
482
450
  { value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
483
451
  { value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
484
- { value: 'reinstall', label: 'Reinstall', hint: 'clean and reinstall the add-ons, Ponytail package kept' },
485
452
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
486
453
  { value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
487
454
  { value: 'dashboard', label: 'Dashboard', hint: 'open the report in your browser' },
488
455
  { value: 'reset', label: 'Reset statistics', hint: 'clear statistics; transcripts and RTK history stay' },
489
- { value: 'settings', label: 'Settings', hint: 'defaults: combo, caveman, rtk, ponytail, currency' },
490
456
  { value: 'uninstall', label: 'Uninstall', hint: 'remove tersio' },
491
457
  ], 'install');
492
458
  if (choice.status !== 'selected') {
@@ -518,10 +484,6 @@ async function runCommandMenu() {
518
484
  closeRL();
519
485
  break;
520
486
  }
521
- case 'reinstall':
522
- await runInstall({ reinstall: true });
523
- closeRL();
524
- break;
525
487
  case 'doctor': {
526
488
  const summary = await runDoctor();
527
489
  if (!dryRun && summary.missing + summary.warn > 0) {
@@ -553,49 +515,123 @@ async function runCommandMenu() {
553
515
  await runReset();
554
516
  closeRL();
555
517
  break;
556
- case 'settings':
557
- await runSettings();
558
- break;
559
518
  case 'uninstall':
560
519
  await runUninstall();
561
520
  closeRL();
562
521
  break;
563
522
  }
564
523
  }
565
- async function runInstall(overrides = {}) {
566
- const isReinstall = overrides.reinstall ?? reinstall;
567
- // Menu-driven installs must fall through: bare `tersio` re-enters here
568
- // with command === null after the picker, and without this guard the
569
- // 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.
570
601
  if (command === null && tty() && !yes && !updatePromptDone) {
571
602
  await runCommandMenu();
572
603
  return;
573
604
  }
574
- // apply-update is the delegated payload of `tersio update`: stay silent —
575
- // the parent already printed the plan and owns the closing summary. Banner
576
- // 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.
577
621
  const quiet = applyUpdate;
578
622
  if (!quiet)
579
623
  printWelcome();
580
- if (isReinstall) {
581
- // removeRtk stays false: reinstall is about to replace the binary, and
582
- // deleting it first would leave nothing to wire if the fresh download
583
- // fails (rate limit, offline). rtk.ts is removed here and re-wired below.
584
- await runUninstall({ yes: true, removePonytail: false, removeRtk: false });
585
- }
586
624
  if (dryRun && !quiet)
587
625
  console.log('Preview — no changes will be written.\n');
588
626
  if (!quiet) {
589
627
  console.log(`Installing Tersio v${PACKAGE_VERSION}`);
590
628
  }
591
- // Remind humans a newer release exists; silent for scripts (no TTY) and for
592
- // the apply-update payload, which is itself an update run.
629
+ // Nudge about a newer release; silent without a TTY.
593
630
  if (tty() && !applyUpdate && command !== 'uninstall') {
594
631
  const newer = await checkForUpdate();
595
632
  if (typeof newer === 'string') {
596
- // Bare `tersio` with an update pending: offer it now (Y/n) instead of
597
- // burying the banner above the install prompts. Yes runs the full
598
- // 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.
599
635
  if (command === null && !dryRun && !yes && !updatePromptDone) {
600
636
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
601
637
  if (answer.status === 'confirmed' && answer.value) {
@@ -615,22 +651,20 @@ async function runInstall(overrides = {}) {
615
651
  }
616
652
  }
617
653
  // Resolve session defaults: flags > interactive prompt > defaults.
618
- const profile = await resolveProfile(isReinstall, { quiet });
654
+ const profile = await resolveProfile({ quiet });
619
655
  const userDir = OMP_AGENT_DIR;
620
656
  const userExtDir = path.join(userDir, 'extensions');
621
- // apply-update is `tersio update`'s payload run: treat it like reinstall so
622
- // the add-ons refresh too — Ponytail package via npm, self plugin, RTK
623
- // binary (always re-downloaded), and the Caveman rule (always re-fetched).
624
- 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 };
625
659
  try {
626
660
  const v = (await execP(OMP_BIN, ['--version'])).stdout.trim();
627
661
  if (verbose && !quiet)
628
662
  console.log(` omp ${v}`);
629
663
  }
630
664
  catch {
631
- 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');
632
667
  }
633
- const cavemanRule = await fetchCavemanRule(options);
634
668
  const failures = [];
635
669
  const capture = async (label, work) => {
636
670
  try {
@@ -641,20 +675,30 @@ async function runInstall(overrides = {}) {
641
675
  console.log(` [fail] ${label}: ${shortError(e)}`);
642
676
  }
643
677
  };
644
- 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));
645
691
  let selfPlugin = false;
646
- await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, options); });
647
- await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, userDir, options));
648
- const ponytailExtPath = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
649
- await capture('rtk', () => stepRtk(BUN_BIN_DIR, options));
650
- await capture('rtk session', () => stepRtkSession(userExtDir, options));
651
- await capture('caveman', () => stepCaveman(userExtDir, cavemanRule, options));
652
- await capture('combo', () => stepCombo(userExtDir, options));
653
- await capture('commands', () => stepTersioCommands(userExtDir, options));
654
- await capture('reinforcement', () => stepModeReinforcement(userExtDir, ponytailExtPath, options));
655
- 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));
656
700
  if (selfPlugin)
657
- await capture('settings', () => writePluginSettings(profile, options));
701
+ await capture('settings', () => writePluginSettings(profile, installOptions));
658
702
  if (quiet) {
659
703
  if (failures.length > 0)
660
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
+ }