@krtclcdy/tersio 2.23.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 (58) hide show
  1. package/README.md +56 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/banner.js +2 -3
  5. package/cli/common.js +16 -20
  6. package/cli/currency.js +11 -19
  7. package/cli/dashboard.js +216 -40
  8. package/cli/doctor-fix.js +40 -26
  9. package/cli/doctor.js +41 -41
  10. package/cli/hosts.js +114 -0
  11. package/cli/install.js +284 -179
  12. package/cli/interactive.js +62 -2
  13. package/cli/layer.js +22 -0
  14. package/cli/manifest.js +33 -0
  15. package/cli/omp-layer.js +51 -0
  16. package/cli/pi-layer.js +37 -0
  17. package/cli/pi-wiring.js +92 -0
  18. package/cli/profile.js +48 -48
  19. package/cli/reset.js +5 -32
  20. package/cli/rtk-wiring.js +7 -17
  21. package/cli/settings.js +3 -8
  22. package/cli/uninstall.js +162 -52
  23. package/cli/update.js +19 -27
  24. package/cli/usage.js +15 -5
  25. package/dashboard/dist/index.html +18 -18
  26. package/extensions/ai-addons-updater/index.js +37 -29
  27. package/extensions/ai-addons-updater/index.ts +39 -38
  28. package/extensions/caveman-session/index.js +19 -34
  29. package/extensions/caveman-session/index.ts +20 -35
  30. package/extensions/combo-toggle/index.js +47 -49
  31. package/extensions/combo-toggle/index.ts +42 -58
  32. package/extensions/lib/utils.js +40 -9
  33. package/extensions/lib/utils.ts +40 -9
  34. package/extensions/rtk-session/index.js +34 -38
  35. package/extensions/rtk-session/index.ts +32 -41
  36. package/extensions/shared/carbon.js +3 -11
  37. package/extensions/shared/carbon.ts +4 -11
  38. package/extensions/shared/host.js +58 -0
  39. package/extensions/shared/host.ts +94 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +33 -30
  42. package/extensions/shared/plugin-settings.ts +35 -26
  43. package/extensions/shared/pricing.js +25 -16
  44. package/extensions/shared/pricing.ts +27 -17
  45. package/extensions/shared/rtk-gain.js +6 -22
  46. package/extensions/shared/rtk-gain.ts +6 -22
  47. package/extensions/shared/session-state.js +44 -36
  48. package/extensions/shared/session-state.ts +48 -35
  49. package/extensions/shared/types.ts +10 -3
  50. package/extensions/shared/usage-ledger.js +160 -51
  51. package/extensions/shared/usage-ledger.ts +170 -67
  52. package/extensions/shared/usage-store.js +178 -24
  53. package/extensions/shared/usage-store.ts +176 -25
  54. package/extensions/tersio-commands/index.js +3 -6
  55. package/extensions/tersio-commands/index.ts +3 -6
  56. package/package.json +6 -5
  57. package/tersio.js +4 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
package/cli/install.js CHANGED
@@ -1,10 +1,10 @@
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';
7
- import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.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
+ 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';
@@ -13,31 +13,19 @@ 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 { runSettings } from './settings.js';
19
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
20
+ import { filesUnder, sourcePath } from './manifest.js';
21
+ import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
33
22
  async function stepPonytail(pluginsDir, options) {
34
23
  if (!options.quiet)
35
24
  console.log(' Ponytail — ensure bundled plugin');
36
25
  await fs.mkdir(pluginsDir, { recursive: true });
37
26
  const pkgPath = path.join(pluginsDir, 'package.json');
38
27
  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.
28
+ // Drop the legacy Ponytail plugin row; it is a tersio dependency now.
41
29
  let migrated = false;
42
30
  if ('@dietrichgebert/ponytail' in pkg.dependencies) {
43
31
  delete pkg.dependencies['@dietrichgebert/ponytail'];
@@ -45,9 +33,9 @@ async function stepPonytail(pluginsDir, options) {
45
33
  }
46
34
  if (options.dryRun) {
47
35
  if (verbose && !options.quiet)
48
- console.log(` [dry-run] would write ${pkgPath}`);
36
+ sayTagged(` [dry-run] would write ${pkgPath}`);
49
37
  if (migrated && verbose && !options.quiet)
50
- 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)`);
51
39
  }
52
40
  else if (migrated) {
53
41
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
@@ -56,9 +44,7 @@ async function stepPonytail(pluginsDir, options) {
56
44
  }
57
45
  const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
58
46
  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.
47
+ // Skip the network unless a refresh was asked; migration always reinstalls.
62
48
  let ponytailExtExists = !options.reinstall && !options.dryRun && !migrated ? await probeExt() : false;
63
49
  if (ponytailExtExists) {
64
50
  debug('Bundled Ponytail pi-extension already installed; skipping network refresh');
@@ -70,8 +56,7 @@ async function stepPonytail(pluginsDir, options) {
70
56
  ponytailExtExists = true;
71
57
  }
72
58
  else {
73
- // Self-plugin npm install already materialized the bundled copy.
74
- // Reinstall here only when the probe still misses.
59
+ // Reinstall only when the self-plugin step did not materialize it.
75
60
  try {
76
61
  await execNetwork('Installing bundled Ponytail', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
77
62
  }
@@ -80,7 +65,7 @@ async function stepPonytail(pluginsDir, options) {
80
65
  await execNetwork('Installing bundled Ponytail', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
81
66
  }
82
67
  catch (e) {
83
- console.log(` [fail] Could not install bundled ponytail: ${e.message}`);
68
+ sayTagged(` [fail] Could not install bundled ponytail: ${e.message}`);
84
69
  console.log(' [hint] Manual: cd ~/.omp/plugins && npm install --no-audit --no-fund');
85
70
  }
86
71
  }
@@ -97,9 +82,7 @@ async function stepPonytail(pluginsDir, options) {
97
82
  // hideStatus=true keeps the upstream ponytail bar hidden; combo owns the bar.
98
83
  await ensurePonytailConfigValue('hideStatus', true, options);
99
84
  }
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.
85
+ // Registers the package so OMP lists it under Settings → Plugins.
103
86
  async function stepSelfPlugin(pluginsDir, options) {
104
87
  if (!options.quiet)
105
88
  console.log(' Tersio — register plugin');
@@ -110,14 +93,14 @@ async function stepSelfPlugin(pluginsDir, options) {
110
93
  if (legacy in pkg.dependencies) {
111
94
  delete pkg.dependencies[legacy];
112
95
  if (!options.dryRun && !options.quiet)
113
- console.log(` [migrate] dropped legacy ${legacy} dependency`);
96
+ sayTagged(` [migrate] dropped legacy ${legacy} dependency`);
114
97
  }
115
98
  }
116
99
  if (options.dryRun) {
117
100
  if (verbose && !options.quiet)
118
- console.log(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
101
+ sayTagged(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
119
102
  if (verbose && !options.quiet)
120
- 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})`);
121
104
  return false;
122
105
  }
123
106
  // pkg.dependencies[PACKAGE_NAME] was assigned above, so only reinstall skips this fast path.
@@ -138,14 +121,14 @@ async function stepSelfPlugin(pluginsDir, options) {
138
121
  await execNetwork('Installing Tersio plugin dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
139
122
  }
140
123
  catch (e) {
141
- console.log(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
142
- 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`);
143
126
  return false;
144
127
  }
145
128
  }
146
129
  const installedPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
147
130
  if ((await readTextIfExists(installedPkg)) === null) {
148
- 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`);
149
132
  return false;
150
133
  }
151
134
  debug('tersio listed in OMP plugins');
@@ -155,7 +138,7 @@ function resolveRtkTriple() {
155
138
  const triple = rtkPlatformSpec()?.triple;
156
139
  if (triple)
157
140
  return triple;
158
- console.log(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
141
+ sayTagged(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
159
142
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
160
143
  return null;
161
144
  }
@@ -164,8 +147,8 @@ function findRtkAsset(release, triple) {
164
147
  const asset = assets.find((a) => a.name === `rtk-${triple}.zip` || a.name === `rtk-${triple}.tar.gz`);
165
148
  if (asset)
166
149
  return asset;
167
- console.log(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
168
- 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(', ')}`);
169
152
  return null;
170
153
  }
171
154
  async function downloadRtkChecksums(release) {
@@ -190,16 +173,16 @@ async function verifyRtkArchive(archivePath, assetName, checksumsText, options =
190
173
  const actual = await sha256File(archivePath);
191
174
  if (!expected) {
192
175
  if (!options.quiet)
193
- console.log(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
176
+ sayTagged(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
194
177
  return true;
195
178
  }
196
179
  if (actual === expected) {
197
180
  debug(`Checksum verified for ${assetName}`);
198
181
  return true;
199
182
  }
200
- console.log(` [fail] Checksum mismatch for ${assetName}`);
201
- console.log(` [fail] Expected: ${expected}`);
202
- console.log(` [fail] Got: ${actual}`);
183
+ sayTagged(` [fail] Checksum mismatch for ${assetName}`);
184
+ sayTagged(` [fail] Expected: ${expected}`);
185
+ sayTagged(` [fail] Got: ${actual}`);
203
186
  // Caller finally removes the temp dir.
204
187
  return false;
205
188
  }
@@ -233,98 +216,120 @@ async function extractRtkArchive(archivePath, extractDir) {
233
216
  }
234
217
  catch (e) {
235
218
  debug(`tar xzf failed: ${shortError(e)}`);
236
- console.log(` [fail] tar could not extract ${path.basename(archivePath)}`);
219
+ sayTagged(` [fail] tar could not extract ${path.basename(archivePath)}`);
237
220
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
238
221
  return false;
239
222
  }
240
223
  return true;
241
224
  }
242
- console.log(` [fail] Unknown archive format: ${archivePath}`);
225
+ sayTagged(` [fail] Unknown archive format: ${archivePath}`);
243
226
  return false;
244
227
  }
245
- async function stepRtk(binDir, options) {
246
- if (!options.quiet)
247
- console.log(' RTK — download binary and wire into OMP');
228
+ // The binary is machine-wide and only OMP needs the wiring, so an install that
229
+ // finds one present only rebinds. `tersio update` still refreshes it.
230
+ async function stepRtk(binDir, options, target = 'omp') {
248
231
  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.
232
+ const found = resolveRtkBinary();
233
+ const bind = async (binary) => {
234
+ if (target !== 'omp')
235
+ return;
236
+ if (!(await fileExists(binary))) {
237
+ console.log(' [skip] no rtk binary to wire — install rtk, then run: rtk init -g --agent omp');
238
+ return;
239
+ }
240
+ await wireRtkOmp(binary, options);
241
+ };
242
+ if (found && !applyUpdate) {
243
+ // The path only under --verbose: a plain run, and a dry run, stay free of
244
+ // real user paths.
245
+ if (!options.quiet) {
246
+ const where = verbose ? ` (${found})` : '';
247
+ console.log(` RTK — already installed${where}${target === 'omp' ? ', binding into OMP' : ', nothing to bind on pi'}`);
248
+ }
249
+ await bind(found);
250
+ return;
251
+ }
252
+ if (!options.quiet)
253
+ console.log(` RTK — download binary and ${target === 'omp' ? 'wire into OMP' : 'put it on PATH'}`);
254
+ // A failed download must not skip wiring: a pre-existing binary serves the
255
+ // OMP hook just as well. Dry runs stay offline.
252
256
  if (options.dryRun) {
253
257
  if (verbose && !options.quiet)
254
- console.log(` [dry-run] would download rtk binary and install to ${binDest}`);
255
- await wireRtkOmp(binDest, options);
258
+ sayTagged(` [dry-run] would download rtk binary and install to ${binDest}`);
259
+ await bind(found ?? binDest);
256
260
  return;
257
261
  }
258
262
  try {
259
- const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
260
263
  const triple = resolveRtkTriple();
261
264
  if (!triple)
262
265
  return;
263
- const asset = findRtkAsset(release, triple);
264
- if (!asset)
265
- return;
266
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
267
- try {
268
- const archivePath = path.join(tmpDir, asset.name);
269
- const checksumsText = await withInteractiveSpinner('Downloading RTK binary', async (update) => {
270
- const [downloadedChecksums] = await Promise.all([
271
- downloadRtkChecksums(release),
272
- httpsDownload(asset.browser_download_url, archivePath),
273
- ]);
274
- update('Verifying RTK checksum');
275
- return downloadedChecksums;
276
- });
277
- if (!await verifyRtkArchive(archivePath, asset.name, checksumsText, options))
278
- return;
279
- const extractDir = path.join(tmpDir, 'extracted');
280
- if (!await extractRtkArchive(archivePath, extractDir))
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}`);
281
273
  return;
282
- const found = await findFile(extractDir, RTK_BINARY_NAME);
283
- if (!found) {
284
- console.log(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
285
- return;
286
- }
287
- await fs.mkdir(path.dirname(binDest), { recursive: true });
288
- await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
289
- await fs.copyFile(found, binDest);
290
- if (!IS_WINDOWS) {
291
- await fs.chmod(binDest, 0o755);
292
- debug(`chmod 755 ${binDest}`);
293
274
  }
275
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
294
276
  try {
295
- 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
+ }
296
313
  }
297
- catch {
298
- console.log(` [hint] Verify manually: ${binDest} --version`);
314
+ finally {
315
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
299
316
  }
300
- }
301
- finally {
302
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
303
- }
317
+ });
304
318
  }
305
319
  catch (e) {
306
- console.log(` [fail] RTK: ${e.message}`);
320
+ sayTagged(` [fail] RTK: ${e.message}`);
307
321
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
308
322
  }
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);
323
+ // Wire rtk's tool_call extension: the binary alone never meters a session.
324
+ await bind(binDest);
320
325
  }
321
- // Copy repo source files into the target extension dir. First entry is
322
- // required (skip label on missing); rest are optional companions.
326
+ // Copy sources into the target extension dir. First entry is required; the
327
+ // rest are optional companions.
323
328
  async function copySources(extDir, files, skipLabel, options) {
324
329
  const src = await readTextIfExists(files[0][0]);
325
330
  if (!src) {
326
331
  if (!options.quiet && options.dryRun)
327
- console.log(` [skip] ${skipLabel} not found in repo`);
332
+ sayTagged(` [skip] ${skipLabel} not found in repo`);
328
333
  return false;
329
334
  }
330
335
  await writeIfChanged(path.join(extDir, files[0][1]), src, options);
@@ -340,25 +345,18 @@ async function copySources(extDir, files, skipLabel, options) {
340
345
  async function stepSharedSessionState(extDir, options) {
341
346
  if (!options.quiet)
342
347
  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);
348
+ const shared = [...filesUnder('shared'), ...filesUnder('lib')];
349
+ await copySources(extDir, shared, 'shared/session-state.js', options);
352
350
  }
353
351
  async function stepRtkSession(extDir, options) {
354
352
  if (!options.quiet)
355
353
  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);
354
+ await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
357
355
  }
358
- // One rule fetch serves the install; dry runs stay offline
359
- // and fall back to the bundled rule to preview its destination.
356
+ // One fetch serves the install; dry runs stay offline and preview the bundled
357
+ // rule's destination.
360
358
  async function fetchCavemanRule(options) {
361
- const bundled = await readTextIfExists(CAVEMAN_RULE);
359
+ const bundled = await readTextIfExists(sourcePath('caveman-session/rule.md'));
362
360
  if (options.dryRun)
363
361
  return bundled;
364
362
  try {
@@ -369,7 +367,7 @@ async function fetchCavemanRule(options) {
369
367
  debug(`Using bundled Caveman rule: ${e.message}`);
370
368
  return bundled;
371
369
  }
372
- console.log(` [warn] Could not fetch caveman rule: ${e.message}`);
370
+ sayTagged(` [warn] Could not fetch caveman rule: ${e.message}`);
373
371
  return null;
374
372
  }
375
373
  }
@@ -385,35 +383,32 @@ async function stepCaveman(extDir, rule, options) {
385
383
  }
386
384
  else if (rule === null) {
387
385
  console.log(' [skip] Caveman rule.md unavailable');
388
- console.log(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
386
+ sayTagged(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
389
387
  }
390
388
  else {
391
389
  await writeIfChanged(ruleDest, rule, options);
392
390
  }
393
- await copySources(extDir, [[CAVEMAN_INDEX, path.join('caveman-session', 'index.ts')]], 'caveman-session/index.ts', options);
391
+ await copySources(extDir, filesUnder('caveman-session').filter(([, to]) => to.endsWith('index.ts')), 'caveman-session/index.ts', options);
394
392
  }
395
393
  async function stepTersioCommands(extDir, options) {
396
394
  if (!options.quiet)
397
395
  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);
396
+ await copySources(extDir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
399
397
  }
400
398
  async function stepUpdater(extDir, options) {
401
- await copySources(extDir, [[UPDATER_INDEX, path.join('ai-addons-updater', 'index.ts')]], 'ai-addons-updater/index.ts', options);
399
+ await copySources(extDir, filesUnder('ai-addons-updater'), 'ai-addons-updater/index.ts', options);
402
400
  }
403
401
  async function stepCombo(extDir, options) {
404
402
  if (!options.quiet)
405
403
  console.log(' Combo — install preset switch');
406
- await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.ts')]], 'combo-toggle/index.ts', options);
404
+ await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
407
405
  }
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.
406
+ async function resolveProfile(opts = {}) {
407
+ // Seed from the stored defaults so flag-less runs keep them.
411
408
  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)) {
409
+ // One numbered prompt for all three modes, for a real terminal user with no
410
+ // default flags. Never for --apply-update, and never for a script.
411
+ if (tty() && !profileFlagsGiven && !applyUpdate) {
417
412
  const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
418
413
  { value: 'off', label: 'off' },
419
414
  { value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
@@ -445,9 +440,7 @@ async function resolveProfile(forceReinstall = false, opts = {}) {
445
440
  return profile;
446
441
  }
447
442
  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.
443
+ // Bare `tersio` at a terminal picks a command; scripts and flags install.
451
444
  async function runCommandMenu() {
452
445
  printWelcome();
453
446
  const newer = await checkForUpdate();
@@ -468,8 +461,8 @@ async function runCommandMenu() {
468
461
  const choice = await askInteractiveChoice('Tersio — what next?', [
469
462
  { value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
470
463
  { 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
464
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
465
+ { value: 'settings', label: 'Settings', hint: 'default combo mode, currency, diagnosis schedule' },
473
466
  { value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
474
467
  { value: 'dashboard', label: 'Dashboard', hint: 'open the report in your browser' },
475
468
  { value: 'reset', label: 'Reset statistics', hint: 'clear statistics; transcripts and RTK history stay' },
@@ -504,10 +497,6 @@ async function runCommandMenu() {
504
497
  closeRL();
505
498
  break;
506
499
  }
507
- case 'reinstall':
508
- await runInstall({ reinstall: true });
509
- closeRL();
510
- break;
511
500
  case 'doctor': {
512
501
  const summary = await runDoctor();
513
502
  if (!dryRun && summary.missing + summary.warn > 0) {
@@ -527,6 +516,10 @@ async function runCommandMenu() {
527
516
  closeRL();
528
517
  break;
529
518
  }
519
+ case 'settings':
520
+ await runSettings();
521
+ closeRL();
522
+ break;
530
523
  case 'usage':
531
524
  await runUsage();
532
525
  closeRL();
@@ -545,40 +538,127 @@ async function runCommandMenu() {
545
538
  break;
546
539
  }
547
540
  }
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.
541
+ // Which host this run targets. --host pins it; a TTY user picks; scripts,
542
+ // pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
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
+ ];
551
+ async function chooseHost(title, only) {
552
+ const hosts = detectHosts().filter((host) => !only || only.includes(host.id));
553
+ const options = hosts.map((host) => ({
554
+ value: host.id,
555
+ label: hostLabel(host),
556
+ hint: hostHint(host),
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);
562
+ if (choice.status !== 'selected') {
563
+ closeRL();
564
+ process.exit(130);
565
+ }
566
+ return choice.value;
567
+ }
568
+ function hostEntry(id) {
569
+ return detectHosts().find((host) => host.id === id);
570
+ }
571
+ // pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes
572
+ // the same tree as OMP's and registers nothing.
573
+ async function stepPiLayer(options) {
574
+ const agentDir = piAgentDir();
575
+ const extDir = path.join(agentDir, 'extensions');
576
+ const declared = piTersioSource(agentDir);
577
+ if (declared)
578
+ sayTagged(` [note] pi also has a tersio package (${declared}) — remove one, or both copies load: pi remove npm:${PACKAGE_NAME}`);
579
+ if (!options.quiet)
580
+ console.log(` Pi — write the extension tree (${extDir})`);
581
+ const cavemanRule = await fetchCavemanRule(options);
582
+ await stepSharedSessionState(extDir, options);
583
+ // OMP loads rtk-session from its plugin manifest, so only the pi tree copies it.
584
+ await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
585
+ await stepCaveman(extDir, cavemanRule, options);
586
+ await stepCombo(extDir, options);
587
+ await stepTersioCommands(extDir, options);
588
+ await stepUpdater(extDir, options);
589
+ await stepPonytailForPi(agentDir, options);
590
+ }
591
+ // Ponytail for pi: install the package, or copy the bundled skills offline.
592
+ async function stepPonytailForPi(agentDir, options) {
593
+ if (!options.quiet)
594
+ console.log(' Ponytail — ensure the pi package');
595
+ if (!options.dryRun) {
596
+ try {
597
+ await execNetwork('Installing Ponytail for pi', 'pi', ['install', 'npm:@dietrichgebert/ponytail'], { timeout: 300000 });
598
+ return;
599
+ }
600
+ catch (e) {
601
+ sayTagged(` [fail] pi install ponytail: ${shortError(e)}`);
602
+ }
603
+ }
604
+ const skills = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)), 'skills');
605
+ if (!skills) {
606
+ console.log(' [skip] Ponytail package not found next to the CLI — skills not copied');
607
+ console.log(' [hint] The /ponytail command will not work; run: pi install npm:@dietrichgebert/ponytail');
608
+ return;
609
+ }
610
+ const dest = path.join(agentDir, 'skills');
611
+ if (options.dryRun) {
612
+ if (verbose && !options.quiet)
613
+ sayTagged(` [dry-run] would copy Ponytail skills to ${dest}`);
614
+ return;
615
+ }
616
+ for (const entry of await fs.readdir(skills, { withFileTypes: true })) {
617
+ if (!entry.isDirectory())
618
+ continue;
619
+ const to = path.join(dest, entry.name);
620
+ await fs.rm(to, { recursive: true, force: true });
621
+ await fs.cp(path.join(skills, entry.name), to, { recursive: true });
622
+ if (!options.quiet)
623
+ sayTagged(` [write] skill ${entry.name} → ${to}`);
624
+ }
625
+ }
626
+ async function runInstall() {
627
+ // Without this guard a picked command re-enters here with command === null
628
+ // and loops straight back into the picker.
553
629
  if (command === null && tty() && !yes && !updatePromptDone) {
554
630
  await runCommandMenu();
555
631
  return;
556
632
  }
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.
633
+ // Ask the host first: it decides the tree, the cleanup, and the defaults file.
634
+ let pinned;
635
+ try {
636
+ pinned = parseHostArg(args);
637
+ }
638
+ catch (e) {
639
+ console.error(`[fail] ${e.message}`);
640
+ process.exit(1);
641
+ }
642
+ if (pinned)
643
+ targetHost = pinned;
644
+ else if (tty() && !yes && !dryRun && !applyUpdate && command !== 'uninstall') {
645
+ targetHost = await chooseHost('Install Tersio into which agent?');
646
+ }
647
+ // apply-update stays silent: the parent printed the plan.
560
648
  const quiet = applyUpdate;
561
649
  if (!quiet)
562
650
  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
651
  if (dryRun && !quiet)
570
652
  console.log('Preview — no changes will be written.\n');
571
653
  if (!quiet) {
572
654
  console.log(`Installing Tersio v${PACKAGE_VERSION}`);
573
655
  }
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.
656
+ // Nudge about a newer release; silent without a TTY.
576
657
  if (tty() && !applyUpdate && command !== 'uninstall') {
577
658
  const newer = await checkForUpdate();
578
659
  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.
660
+ // Update pending: offer it here instead of burying it above the install
661
+ // prompts. Yes runs the full update and stops.
582
662
  if (command === null && !dryRun && !yes && !updatePromptDone) {
583
663
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
584
664
  if (answer.status === 'confirmed' && answer.value) {
@@ -598,22 +678,35 @@ async function runInstall(overrides = {}) {
598
678
  }
599
679
  }
600
680
  // Resolve session defaults: flags > interactive prompt > defaults.
601
- const profile = await resolveProfile(isReinstall, { quiet });
681
+ const profile = await resolveProfile({ quiet });
602
682
  const userDir = OMP_AGENT_DIR;
603
683
  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 };
684
+ // apply-update refreshes every add-on like a reinstall.
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
+ }
608
701
  try {
609
702
  const v = (await execP(OMP_BIN, ['--version'])).stdout.trim();
610
703
  if (verbose && !quiet)
611
704
  console.log(` omp ${v}`);
612
705
  }
613
706
  catch {
614
- console.log(' [fail] omp not found — ensure it\'s installed');
707
+ if (targetHost === 'omp')
708
+ console.log(' [fail] omp not found — ensure it\'s installed');
615
709
  }
616
- const cavemanRule = await fetchCavemanRule(options);
617
710
  const failures = [];
618
711
  const capture = async (label, work) => {
619
712
  try {
@@ -621,21 +714,33 @@ async function runInstall(overrides = {}) {
621
714
  }
622
715
  catch (e) {
623
716
  failures.push(label);
624
- console.log(` [fail] ${label}: ${shortError(e)}`);
717
+ sayTagged(` [fail] ${label}: ${shortError(e)}`);
625
718
  }
626
719
  };
627
- await capture('shared', () => stepSharedSessionState(userExtDir, options));
720
+ if (targetHost === 'pi') {
721
+ await capture('pi tree', () => stepPiLayer(installOptions));
722
+ await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'pi'));
723
+ await capture('settings', () => writePluginSettings(profile, installOptions));
724
+ if (failures.length > 0)
725
+ console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
726
+ else
727
+ console.log('\nDone — restart pi, then /combo medium.');
728
+ closeRL();
729
+ return;
730
+ }
731
+ const cavemanRule = await fetchCavemanRule(installOptions);
732
+ await capture('shared', () => stepSharedSessionState(userExtDir, installOptions));
628
733
  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));
734
+ await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, installOptions); });
735
+ await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, installOptions));
736
+ await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions));
737
+ await capture('rtk session', () => stepRtkSession(userExtDir, installOptions));
738
+ await capture('caveman', () => stepCaveman(path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions'), cavemanRule, installOptions));
739
+ await capture('combo', () => stepCombo(userExtDir, installOptions));
740
+ await capture('commands', () => stepTersioCommands(userExtDir, installOptions));
741
+ await capture('updater', () => stepUpdater(userExtDir, installOptions));
637
742
  if (selfPlugin)
638
- await capture('settings', () => writePluginSettings(profile, options));
743
+ await capture('settings', () => writePluginSettings(profile, installOptions));
639
744
  if (quiet) {
640
745
  if (failures.length > 0)
641
746
  console.log(` add-ons: ${failures.length} failed (${failures.join(', ')}) — see [fail] lines above`);