@krtclcdy/tersio 2.24.0 → 2.25.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 (63) hide show
  1. package/README.md +28 -24
  2. package/cli/banner.js +7 -5
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +2 -7
  5. package/cli/dashboard.js +220 -13
  6. package/cli/doctor-fix.js +99 -29
  7. package/cli/doctor.js +32 -35
  8. package/cli/hosts.js +23 -26
  9. package/cli/install.js +228 -115
  10. package/cli/interactive.js +65 -21
  11. package/cli/manifest.js +8 -4
  12. package/cli/profile.js +49 -16
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +29 -32
  15. package/cli/settings.js +40 -16
  16. package/cli/uninstall.js +79 -29
  17. package/cli/update.js +27 -20
  18. package/cli/usage.js +54 -19
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +75 -20
  21. package/extensions/ai-addons-updater/index.ts +67 -23
  22. package/extensions/caveman-session/index.js +33 -75
  23. package/extensions/caveman-session/index.ts +35 -77
  24. package/extensions/combo-toggle/index.js +40 -62
  25. package/extensions/combo-toggle/index.ts +38 -72
  26. package/extensions/lib/utils.js +64 -15
  27. package/extensions/lib/utils.ts +62 -15
  28. package/extensions/opencode/index.js +104 -0
  29. package/extensions/opencode/server.js +190 -0
  30. package/extensions/opencode/server.ts +200 -0
  31. package/extensions/rtk-session/index.js +38 -57
  32. package/extensions/rtk-session/index.ts +36 -55
  33. package/extensions/shared/carbon.js +4 -10
  34. package/extensions/shared/carbon.ts +5 -10
  35. package/extensions/shared/host.js +95 -56
  36. package/extensions/shared/host.ts +106 -60
  37. package/extensions/shared/plugin-settings.js +6 -18
  38. package/extensions/shared/plugin-settings.ts +6 -18
  39. package/extensions/shared/pricing.js +79 -27
  40. package/extensions/shared/pricing.ts +80 -28
  41. package/extensions/shared/rtk-gain.js +4 -11
  42. package/extensions/shared/rtk-gain.ts +4 -11
  43. package/extensions/shared/session-state.js +28 -39
  44. package/extensions/shared/session-state.ts +29 -38
  45. package/extensions/shared/status.js +38 -0
  46. package/extensions/shared/status.ts +45 -0
  47. package/extensions/shared/types.js +1 -2
  48. package/extensions/shared/types.ts +2 -2
  49. package/extensions/shared/usage-ledger.js +312 -57
  50. package/extensions/shared/usage-ledger.ts +321 -61
  51. package/extensions/shared/usage-store.js +354 -38
  52. package/extensions/shared/usage-store.ts +360 -43
  53. package/extensions/tersio-commands/index.js +5 -14
  54. package/extensions/tersio-commands/index.ts +5 -14
  55. package/package.json +12 -7
  56. package/tersio.js +4 -6
  57. package/cli/agent-hosts.js +0 -42
  58. package/cli/agents.js +0 -266
  59. package/cli/layer.js +0 -24
  60. package/cli/omp-layer.js +0 -51
  61. package/cli/pi-layer.js +0 -37
  62. package/cli/pi-wiring.js +0 -92
  63. package/extensions/shared/omp-prompt.js +0 -12
package/cli/install.js CHANGED
@@ -3,8 +3,8 @@ 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, args, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
7
- import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
6
+ import { BUN_BIN_DIR, COMBO_PRESET_MODES, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, args, allowUnverified, 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';
@@ -12,10 +12,12 @@ import { runDoctor } from './doctor.js';
12
12
  import { runReset } from './reset.js';
13
13
  import { runUsage } from './usage.js';
14
14
  import { runDashboard } from './dashboard.js';
15
- import { wireRtkOmp } from './rtk-wiring.js';
15
+ import { wireRtkOmp, wireRtkOpencode, wireRtkPi } from './rtk-wiring.js';
16
16
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
17
- import { storedProfile, writePluginSettings } from './profile.js';
18
- import { filesUnder, sourcePath } from './manifest.js';
17
+ import { formatCliStatus, storedProfile, storedProfileSync, writePluginSettings } from './profile.js';
18
+ import { runSettings } from './settings.js';
19
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
20
+ import { OPENCODE_SERVER_SHIM, filesUnder, sourcePath } from './manifest.js';
19
21
  import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
20
22
  async function stepPonytail(pluginsDir, options) {
21
23
  if (!options.quiet)
@@ -31,9 +33,9 @@ async function stepPonytail(pluginsDir, options) {
31
33
  }
32
34
  if (options.dryRun) {
33
35
  if (verbose && !options.quiet)
34
- console.log(` [dry-run] would write ${pkgPath}`);
36
+ sayTagged(` [dry-run] would write ${pkgPath}`);
35
37
  if (migrated && verbose && !options.quiet)
36
- console.log(` [dry-run] would drop the separate @dietrichgebert/ponytail dependency (bundled with tersio)`);
38
+ sayTagged(` [dry-run] would drop the separate @dietrichgebert/ponytail dependency (bundled with tersio)`);
37
39
  }
38
40
  else if (migrated) {
39
41
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
@@ -63,7 +65,7 @@ async function stepPonytail(pluginsDir, options) {
63
65
  await execNetwork('Installing bundled Ponytail', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
64
66
  }
65
67
  catch (e) {
66
- console.log(` [fail] Could not install bundled ponytail: ${e.message}`);
68
+ sayTagged(` [fail] Could not install bundled ponytail: ${e.message}`);
67
69
  console.log(' [hint] Manual: cd ~/.omp/plugins && npm install --no-audit --no-fund');
68
70
  }
69
71
  }
@@ -91,14 +93,14 @@ async function stepSelfPlugin(pluginsDir, options) {
91
93
  if (legacy in pkg.dependencies) {
92
94
  delete pkg.dependencies[legacy];
93
95
  if (!options.dryRun && !options.quiet)
94
- console.log(` [migrate] dropped legacy ${legacy} dependency`);
96
+ sayTagged(` [migrate] dropped legacy ${legacy} dependency`);
95
97
  }
96
98
  }
97
99
  if (options.dryRun) {
98
100
  if (verbose && !options.quiet)
99
- console.log(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
101
+ sayTagged(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
100
102
  if (verbose && !options.quiet)
101
- console.log(` [dry-run] would run: npm install --no-audit --no-fund (in ${pluginsDir})`);
103
+ sayTagged(` [dry-run] would run: npm install --no-audit --no-fund (in ${pluginsDir})`);
102
104
  return false;
103
105
  }
104
106
  // pkg.dependencies[PACKAGE_NAME] was assigned above, so only reinstall skips this fast path.
@@ -119,14 +121,14 @@ async function stepSelfPlugin(pluginsDir, options) {
119
121
  await execNetwork('Installing Tersio plugin dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
120
122
  }
121
123
  catch (e) {
122
- console.log(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
123
- console.log(` [hint] Manual: cd ~/.omp/plugins && npm install ${PACKAGE_NAME}@^${PACKAGE_VERSION} --save --no-audit --no-fund`);
124
+ sayTagged(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
125
+ sayTagged(` [hint] Manual: cd ~/.omp/plugins && npm install ${PACKAGE_NAME}@^${PACKAGE_VERSION} --save --no-audit --no-fund`);
124
126
  return false;
125
127
  }
126
128
  }
127
129
  const installedPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
128
130
  if ((await readTextIfExists(installedPkg)) === null) {
129
- console.log(` [warn] ${PACKAGE_NAME} not found in plugins/node_modules after install`);
131
+ sayTagged(` [warn] ${PACKAGE_NAME} not found in plugins/node_modules after install`);
130
132
  return false;
131
133
  }
132
134
  debug('tersio listed in OMP plugins');
@@ -136,7 +138,7 @@ function resolveRtkTriple() {
136
138
  const triple = rtkPlatformSpec()?.triple;
137
139
  if (triple)
138
140
  return triple;
139
- console.log(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
141
+ sayTagged(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
140
142
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
141
143
  return null;
142
144
  }
@@ -145,8 +147,8 @@ function findRtkAsset(release, triple) {
145
147
  const asset = assets.find((a) => a.name === `rtk-${triple}.zip` || a.name === `rtk-${triple}.tar.gz`);
146
148
  if (asset)
147
149
  return asset;
148
- console.log(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
149
- console.log(` [hint] Available: ${assets.map((a) => a.name).filter((n) => n.startsWith('rtk-')).join(', ')}`);
150
+ sayTagged(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
151
+ sayTagged(` [hint] Available: ${assets.map((a) => a.name).filter((n) => n.startsWith('rtk-')).join(', ')}`);
150
152
  return null;
151
153
  }
152
154
  async function downloadRtkChecksums(release) {
@@ -161,26 +163,30 @@ async function downloadRtkChecksums(release) {
161
163
  return null;
162
164
  }
163
165
  }
164
- async function verifyRtkArchive(archivePath, assetName, checksumsText, options = {}) {
165
- if (!checksumsText) {
166
- if (!options.quiet)
167
- console.log(' [warn] No checksums.txt available — skipping verification');
168
- return true;
169
- }
166
+ // Fails closed: a missing checksum is worse than a failed install, since an unverifiable RTK binary runs with the user's privileges. `--allow-unverified` opts out.
167
+ async function verifyRtkArchive(archivePath, assetName, checksumsText) {
168
+ const unverified = (reason) => {
169
+ if (allowUnverified) {
170
+ sayTagged(` [warn] ${reason} — installing anyway (--allow-unverified)`);
171
+ return true;
172
+ }
173
+ sayTagged(` [fail] ${reason}`);
174
+ sayTagged(' [hint] RTK not installed. Retry with --allow-unverified to accept an unverified binary.');
175
+ return false;
176
+ };
177
+ if (!checksumsText)
178
+ return unverified(`No checksums.txt published for ${assetName}`);
170
179
  const expected = parseChecksum(checksumsText, assetName);
180
+ if (!expected)
181
+ return unverified(`checksums.txt has no entry for ${assetName}`);
171
182
  const actual = await sha256File(archivePath);
172
- if (!expected) {
173
- if (!options.quiet)
174
- console.log(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
175
- return true;
176
- }
177
183
  if (actual === expected) {
178
184
  debug(`Checksum verified for ${assetName}`);
179
185
  return true;
180
186
  }
181
- console.log(` [fail] Checksum mismatch for ${assetName}`);
182
- console.log(` [fail] Expected: ${expected}`);
183
- console.log(` [fail] Got: ${actual}`);
187
+ sayTagged(` [fail] Checksum mismatch for ${assetName}`);
188
+ sayTagged(` [fail] Expected: ${expected}`);
189
+ sayTagged(` [fail] Got: ${actual}`);
184
190
  // Caller finally removes the temp dir.
185
191
  return false;
186
192
  }
@@ -214,110 +220,119 @@ async function extractRtkArchive(archivePath, extractDir) {
214
220
  }
215
221
  catch (e) {
216
222
  debug(`tar xzf failed: ${shortError(e)}`);
217
- console.log(` [fail] tar could not extract ${path.basename(archivePath)}`);
223
+ sayTagged(` [fail] tar could not extract ${path.basename(archivePath)}`);
218
224
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
219
225
  return false;
220
226
  }
221
227
  return true;
222
228
  }
223
- console.log(` [fail] Unknown archive format: ${archivePath}`);
229
+ sayTagged(` [fail] Unknown archive format: ${archivePath}`);
224
230
  return false;
225
231
  }
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.
232
+ // The binary is machine-wide; hosts without a hook only rebind. `tersio update` still refreshes it.
228
233
  async function stepRtk(binDir, options, target = 'omp') {
229
234
  const binDest = path.join(binDir, RTK_BINARY_NAME);
230
235
  const found = resolveRtkBinary();
236
+ const hostName = target === 'omp' ? 'OMP' : target === 'opencode' ? 'OpenCode' : 'pi';
231
237
  const bind = async (binary) => {
232
- if (target !== 'omp')
233
- return;
234
238
  if (!(await fileExists(binary))) {
235
- console.log(' [skip] no rtk binary to wire — install rtk, then run: rtk init -g --agent omp');
239
+ console.log(` [skip] no rtk binary to wire — install rtk, then run: rtk init -g ${target === 'opencode' ? '--opencode' : `--agent ${target}`}`);
236
240
  return;
237
241
  }
238
- await wireRtkOmp(binary, options);
242
+ if (target === 'opencode')
243
+ await wireRtkOpencode(binary, options);
244
+ else if (target === 'pi')
245
+ await wireRtkPi(binary, options);
246
+ else
247
+ await wireRtkOmp(binary, options);
239
248
  };
240
249
  if (found && !applyUpdate) {
241
- // The path only under --verbose: a plain run, and a dry run, stay free of
242
- // real user paths.
250
+ // The path only under --verbose: a plain run, and a dry run, stay free of real user paths.
243
251
  if (!options.quiet) {
244
252
  const where = verbose ? ` (${found})` : '';
245
- console.log(` RTK — already installed${where}${target === 'omp' ? ', binding into OMP' : ', nothing to bind on pi'}`);
253
+ console.log(` RTK — already installed${where}, binding into ${hostName}`);
246
254
  }
247
255
  await bind(found);
248
256
  return;
249
257
  }
250
258
  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.
259
+ console.log(` RTK — download binary and wire into ${hostName}`);
260
+ // A failed download must not skip wiring: a pre-existing binary serves the host hook just as well. Dry runs stay offline.
254
261
  if (options.dryRun) {
255
262
  if (verbose && !options.quiet)
256
- console.log(` [dry-run] would download rtk binary and install to ${binDest}`);
263
+ sayTagged(` [dry-run] would download rtk binary and install to ${binDest}`);
257
264
  await bind(found ?? binDest);
258
265
  return;
259
266
  }
260
267
  try {
261
- const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
262
268
  const triple = resolveRtkTriple();
263
269
  if (!triple)
264
270
  return;
265
- const asset = findRtkAsset(release, triple);
266
- if (!asset)
267
- return;
268
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
269
- try {
270
- const archivePath = path.join(tmpDir, asset.name);
271
- const checksumsText = await withInteractiveSpinner('Downloading RTK binary', async (update) => {
272
- const [downloadedChecksums] = await Promise.all([
273
- downloadRtkChecksums(release),
274
- httpsDownload(asset.browser_download_url, archivePath),
275
- ]);
276
- update('Verifying RTK checksum');
277
- return downloadedChecksums;
278
- });
279
- if (!await verifyRtkArchive(archivePath, asset.name, checksumsText, options))
271
+ // One task with streamed sub-steps: release lookup, download, checksum, extract, install. Non-TTY keeps the plain lines each step already prints.
272
+ await withInteractiveTask('Installing RTK', async (update) => {
273
+ const release = await fetchJson(RTK_RELEASE_API);
274
+ const asset = findRtkAsset(release, triple);
275
+ if (!asset) {
276
+ sayTagged(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
280
277
  return;
281
- const extractDir = path.join(tmpDir, 'extracted');
282
- if (!await extractRtkArchive(archivePath, extractDir))
283
- return;
284
- const found = await findFile(extractDir, RTK_BINARY_NAME);
285
- if (!found) {
286
- console.log(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
287
- return;
288
- }
289
- await fs.mkdir(path.dirname(binDest), { recursive: true });
290
- await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
291
- await fs.copyFile(found, binDest);
292
- if (!IS_WINDOWS) {
293
- await fs.chmod(binDest, 0o755);
294
- debug(`chmod 755 ${binDest}`);
295
278
  }
279
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
296
280
  try {
297
- await execP(binDest, ['--version'], { timeout: 30000, shell: false });
281
+ const archivePath = path.join(tmpDir, asset.name);
282
+ update(`Downloading ${asset.name}`);
283
+ const [checksumsText] = await Promise.all([
284
+ downloadRtkChecksums(release),
285
+ httpsDownload(asset.browser_download_url, archivePath),
286
+ ]);
287
+ update('Verifying checksum');
288
+ if (!await verifyRtkArchive(archivePath, asset.name, checksumsText)) {
289
+ sayTagged(' [hint] RTK install skipped for this run');
290
+ return;
291
+ }
292
+ update('Extracting');
293
+ const extractDir = path.join(tmpDir, 'extracted');
294
+ if (!await extractRtkArchive(archivePath, extractDir)) {
295
+ sayTagged(` [fail] Could not extract ${asset.name}`);
296
+ return;
297
+ }
298
+ update('Installing binary');
299
+ const found = await findFile(extractDir, RTK_BINARY_NAME);
300
+ if (!found) {
301
+ sayTagged(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
302
+ return;
303
+ }
304
+ await fs.mkdir(path.dirname(binDest), { recursive: true });
305
+ await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
306
+ await fs.copyFile(found, binDest);
307
+ if (!IS_WINDOWS) {
308
+ await fs.chmod(binDest, 0o755);
309
+ debug(`chmod 755 ${binDest}`);
310
+ }
311
+ try {
312
+ await execP(binDest, ['--version'], { timeout: 30000, shell: false });
313
+ }
314
+ catch {
315
+ sayTagged(` [hint] Verify manually: ${binDest} --version`);
316
+ }
298
317
  }
299
- catch {
300
- console.log(` [hint] Verify manually: ${binDest} --version`);
318
+ finally {
319
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
301
320
  }
302
- }
303
- finally {
304
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
305
- }
321
+ });
306
322
  }
307
323
  catch (e) {
308
- console.log(` [fail] RTK: ${e.message}`);
324
+ sayTagged(` [fail] RTK: ${e.message}`);
309
325
  console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
310
326
  }
311
327
  // Wire rtk's tool_call extension: the binary alone never meters a session.
312
328
  await bind(binDest);
313
329
  }
314
- // Copy sources into the target extension dir. First entry is required; the
315
- // rest are optional companions.
330
+ // Copy sources into the target extension dir. First entry is required; the rest are optional companions.
316
331
  async function copySources(extDir, files, skipLabel, options) {
317
332
  const src = await readTextIfExists(files[0][0]);
318
333
  if (!src) {
319
334
  if (!options.quiet && options.dryRun)
320
- console.log(` [skip] ${skipLabel} not found in repo`);
335
+ sayTagged(` [skip] ${skipLabel} not found in repo`);
321
336
  return false;
322
337
  }
323
338
  await writeIfChanged(path.join(extDir, files[0][1]), src, options);
@@ -341,8 +356,7 @@ async function stepRtkSession(extDir, options) {
341
356
  console.log(' RTK session — install session mode');
342
357
  await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
343
358
  }
344
- // One fetch serves the install; dry runs stay offline and preview the bundled
345
- // rule's destination.
359
+ // One fetch serves the install; dry runs stay offline and preview the bundled rule's destination.
346
360
  async function fetchCavemanRule(options) {
347
361
  const bundled = await readTextIfExists(sourcePath('caveman-session/rule.md'));
348
362
  if (options.dryRun)
@@ -355,7 +369,7 @@ async function fetchCavemanRule(options) {
355
369
  debug(`Using bundled Caveman rule: ${e.message}`);
356
370
  return bundled;
357
371
  }
358
- console.log(` [warn] Could not fetch caveman rule: ${e.message}`);
372
+ sayTagged(` [warn] Could not fetch caveman rule: ${e.message}`);
359
373
  return null;
360
374
  }
361
375
  }
@@ -371,7 +385,7 @@ async function stepCaveman(extDir, rule, options) {
371
385
  }
372
386
  else if (rule === null) {
373
387
  console.log(' [skip] Caveman rule.md unavailable');
374
- console.log(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
388
+ sayTagged(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
375
389
  }
376
390
  else {
377
391
  await writeIfChanged(ruleDest, rule, options);
@@ -391,11 +405,72 @@ async function stepCombo(extDir, options) {
391
405
  console.log(' Combo — install preset switch');
392
406
  await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
393
407
  }
408
+ // The tree ships no node_modules, so the full ruleset rides along as files.
409
+ const PONYTAIL_BUNDLE_FILES = [
410
+ 'hooks/ponytail-instructions.js',
411
+ 'hooks/ponytail-config.js',
412
+ 'skills/ponytail/SKILL.md',
413
+ ];
414
+ async function stepPonytailBundle(treeDir, options) {
415
+ const root = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)));
416
+ if (!root) {
417
+ if (!options.quiet)
418
+ console.log(' [skip] Ponytail package not found next to the CLI — OpenCode keeps the built-in text');
419
+ return;
420
+ }
421
+ if (!options.quiet)
422
+ console.log(' Ponytail — bundle the full ruleset for OpenCode');
423
+ for (const rel of PONYTAIL_BUNDLE_FILES) {
424
+ const src = path.join(root, ...rel.split('/'));
425
+ let text;
426
+ try {
427
+ text = await fs.readFile(src, 'utf8');
428
+ }
429
+ catch {
430
+ sayTagged(` [fail] Ponytail bundle file missing: ${rel}`);
431
+ return;
432
+ }
433
+ await writeIfChanged(path.join(treeDir, 'ponytail-bundle', ...rel.split('/')), text, options);
434
+ }
435
+ }
436
+ // OpenCode loads imported .ts plugins from the config plugins dir, so a plain tree copy + one config entry is enough.
437
+ async function stepOpencode(options) {
438
+ const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
439
+ if (!options.quiet)
440
+ console.log(` OpenCode — write the plugin tree (${dir})`);
441
+ await stepSharedSessionState(dir, options);
442
+ await copySources(dir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
443
+ await copySources(dir, filesUnder('caveman-session'), 'caveman-session/index.ts', options);
444
+ await stepCombo(dir, options);
445
+ await copySources(dir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
446
+ await stepUpdater(dir, options);
447
+ await copySources(dir, filesUnder('opencode'), 'opencode/server.ts', options);
448
+ await stepPonytailBundle(dir, options);
449
+ // The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
450
+ await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, options);
451
+ if (!options.dryRun)
452
+ await fs.rm(path.join(dir, 'opencode', 'index.ts'), { force: true }).catch(() => { });
453
+ const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
454
+ let config = {};
455
+ try {
456
+ config = JSON.parse((await readTextIfExists(configPath)) ?? '{}');
457
+ }
458
+ catch {
459
+ return;
460
+ }
461
+ const plugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
462
+ const withoutTersio = plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio'));
463
+ if (!withoutTersio.includes(dir))
464
+ withoutTersio.push(dir);
465
+ config.plugins = withoutTersio;
466
+ if (!options.dryRun)
467
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
468
+ await writeIfChanged(configPath, `${JSON.stringify(config, null, 2)}\n`, options);
469
+ }
394
470
  async function resolveProfile(opts = {}) {
395
471
  // Seed from the stored defaults so flag-less runs keep them.
396
472
  const profile = await storedProfile();
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.
473
+ // One numbered prompt for all three modes, for a real terminal user with no default flags. Never for --apply-update, and never for a script.
399
474
  if (tty() && !profileFlagsGiven && !applyUpdate) {
400
475
  const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
401
476
  { value: 'off', label: 'off' },
@@ -446,10 +521,11 @@ async function runCommandMenu() {
446
521
  console.log(`\n [update] staying on ${PACKAGE_VERSION} — run ` + '`tersio update`' + ` anytime`);
447
522
  }
448
523
  updatePromptDone = true;
449
- const choice = await askInteractiveChoice('Tersio — what next?', [
524
+ const choice = await askInteractiveChoice(`Tersio — what next?\n${formatCliStatus(storedProfileSync())}`, [
450
525
  { value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
451
526
  { value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
452
527
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
528
+ { value: 'settings', label: 'Settings', hint: 'default combo mode, currency, diagnosis schedule' },
453
529
  { value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
454
530
  { value: 'dashboard', label: 'Dashboard', hint: 'open the report in your browser' },
455
531
  { value: 'reset', label: 'Reset statistics', hint: 'clear statistics; transcripts and RTK history stay' },
@@ -464,8 +540,7 @@ async function runCommandMenu() {
464
540
  await runInstall();
465
541
  break;
466
542
  case 'update': {
467
- // One bound flow: the version check already ran above, so report it
468
- // and offer the refresh in the same breath — no second "update" quiz.
543
+ // One bound flow: the version check already ran above, so report it and offer the refresh in the same breath — no second "update" quiz.
469
544
  if (typeof newer === 'string')
470
545
  console.log(` tersio ${newer} available (installed ${PACKAGE_VERSION})`);
471
546
  else
@@ -503,6 +578,10 @@ async function runCommandMenu() {
503
578
  closeRL();
504
579
  break;
505
580
  }
581
+ case 'settings':
582
+ await runSettings();
583
+ closeRL();
584
+ break;
506
585
  case 'usage':
507
586
  await runUsage();
508
587
  closeRL();
@@ -521,16 +600,24 @@ async function runCommandMenu() {
521
600
  break;
522
601
  }
523
602
  }
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.
603
+ // Which host this run targets. --host pins it; a TTY user picks; scripts, pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
526
604
  let targetHost = 'omp';
605
+ // Hosts the menu advertises but cannot install yet. Listed disabled so the roadmap is visible without offering a target that does nothing.
606
+ const SOON_HOSTS = [
607
+ { value: 'claude', label: 'Claude Code', hint: 'Coming soon' },
608
+ { value: 'codex', label: 'Codex', hint: 'Coming soon' },
609
+ ];
527
610
  async function chooseHost(title, only) {
528
611
  const hosts = detectHosts().filter((host) => !only || only.includes(host.id));
529
- const choice = await askInteractiveChoice(title, hosts.map((host) => ({
612
+ const options = hosts.map((host) => ({
530
613
  value: host.id,
531
614
  label: hostLabel(host),
532
615
  hint: hostHint(host),
533
- })), targetHost);
616
+ }));
617
+ // A scoped menu (uninstall --host) lists only real hosts.
618
+ if (!only)
619
+ options.push(...SOON_HOSTS.map((h) => ({ ...h, disabled: true })));
620
+ const choice = await askInteractiveChoice(title, options, targetHost);
534
621
  if (choice.status !== 'selected') {
535
622
  closeRL();
536
623
  process.exit(130);
@@ -540,14 +627,13 @@ async function chooseHost(title, only) {
540
627
  function hostEntry(id) {
541
628
  return detectHosts().find((host) => host.id === id);
542
629
  }
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) {
630
+ // pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes the same tree as OMP's and registers nothing.
631
+ async function stepPiLayer(options) {
546
632
  const agentDir = piAgentDir();
547
633
  const extDir = path.join(agentDir, 'extensions');
548
634
  const declared = piTersioSource(agentDir);
549
635
  if (declared)
550
- console.log(` [note] pi also has a tersio package (${declared}) — remove one, or both copies load: pi remove npm:${PACKAGE_NAME}`);
636
+ sayTagged(` [note] pi also has a tersio package (${declared}) — remove one, or both copies load: pi remove npm:${PACKAGE_NAME}`);
551
637
  if (!options.quiet)
552
638
  console.log(` Pi — write the extension tree (${extDir})`);
553
639
  const cavemanRule = await fetchCavemanRule(options);
@@ -570,7 +656,7 @@ async function stepPonytailForPi(agentDir, options) {
570
656
  return;
571
657
  }
572
658
  catch (e) {
573
- console.log(` [fail] pi install ponytail: ${shortError(e)}`);
659
+ sayTagged(` [fail] pi install ponytail: ${shortError(e)}`);
574
660
  }
575
661
  }
576
662
  const skills = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)), 'skills');
@@ -582,7 +668,7 @@ async function stepPonytailForPi(agentDir, options) {
582
668
  const dest = path.join(agentDir, 'skills');
583
669
  if (options.dryRun) {
584
670
  if (verbose && !options.quiet)
585
- console.log(` [dry-run] would copy Ponytail skills to ${dest}`);
671
+ sayTagged(` [dry-run] would copy Ponytail skills to ${dest}`);
586
672
  return;
587
673
  }
588
674
  for (const entry of await fs.readdir(skills, { withFileTypes: true })) {
@@ -592,12 +678,11 @@ async function stepPonytailForPi(agentDir, options) {
592
678
  await fs.rm(to, { recursive: true, force: true });
593
679
  await fs.cp(path.join(skills, entry.name), to, { recursive: true });
594
680
  if (!options.quiet)
595
- console.log(` [write] skill ${entry.name} → ${to}`);
681
+ sayTagged(` [write] skill ${entry.name} → ${to}`);
596
682
  }
597
683
  }
598
684
  async function runInstall() {
599
- // Without this guard a picked command re-enters here with command === null
600
- // and loops straight back into the picker.
685
+ // Without this guard a picked command re-enters here with command === null and loops straight back into the picker.
601
686
  if (command === null && tty() && !yes && !updatePromptDone) {
602
687
  await runCommandMenu();
603
688
  return;
@@ -616,7 +701,6 @@ async function runInstall() {
616
701
  else if (tty() && !yes && !dryRun && !applyUpdate && command !== 'uninstall') {
617
702
  targetHost = await chooseHost('Install Tersio into which agent?');
618
703
  }
619
- const host = hostEntry(targetHost);
620
704
  // apply-update stays silent: the parent printed the plan.
621
705
  const quiet = applyUpdate;
622
706
  if (!quiet)
@@ -630,8 +714,7 @@ async function runInstall() {
630
714
  if (tty() && !applyUpdate && command !== 'uninstall') {
631
715
  const newer = await checkForUpdate();
632
716
  if (typeof newer === 'string') {
633
- // Update pending: offer it here instead of burying it above the install
634
- // prompts. Yes runs the full update and stops.
717
+ // Update pending: offer it here instead of burying it above the install prompts. Yes runs the full update and stops.
635
718
  if (command === null && !dryRun && !yes && !updatePromptDone) {
636
719
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
637
720
  if (answer.status === 'confirmed' && answer.value) {
@@ -656,6 +739,25 @@ async function runInstall() {
656
739
  const userExtDir = path.join(userDir, 'extensions');
657
740
  // apply-update refreshes every add-on like a reinstall.
658
741
  const installOptions = { dryRun, verbose, yes, reinstall: applyUpdate, quiet };
742
+ // Point of no return: disk writes start here; pipes stay unattended.
743
+ if (!yes && !dryRun && !applyUpdate && tty()) {
744
+ const target = hostEntry(targetHost);
745
+ const where = targetHost === 'pi'
746
+ ? path.join(piAgentDir(), 'extensions')
747
+ : targetHost === 'opencode'
748
+ ? path.join(HOME, '.config', 'opencode', 'plugins', 'tersio')
749
+ : path.join(OMP_AGENT_DIR, 'extensions');
750
+ console.log(`\nWill install into ${target.label}:`);
751
+ console.log(` ${where}`);
752
+ console.log(' caveman · rtk · ponytail session modes');
753
+ console.log(` session defaults → ${tersioSettingsFile()}`);
754
+ const go = await askInteractiveConfirm(`Install Tersio into ${target.label}?`);
755
+ if (go.status !== 'confirmed' || !go.value) {
756
+ closeRL();
757
+ console.log('\nAborted. Nothing was changed.');
758
+ return;
759
+ }
760
+ }
659
761
  try {
660
762
  const v = (await execP(OMP_BIN, ['--version'])).stdout.trim();
661
763
  if (verbose && !quiet)
@@ -672,11 +774,22 @@ async function runInstall() {
672
774
  }
673
775
  catch (e) {
674
776
  failures.push(label);
675
- console.log(` [fail] ${label}: ${shortError(e)}`);
777
+ sayTagged(` [fail] ${label}: ${shortError(e)}`);
676
778
  }
677
779
  };
780
+ if (targetHost === 'opencode') {
781
+ await capture('opencode tree', () => stepOpencode(installOptions));
782
+ await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'opencode'));
783
+ await capture('settings', () => writePluginSettings(profile, installOptions));
784
+ if (failures.length > 0)
785
+ console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
786
+ else
787
+ console.log('\nDone — restart OpenCode, then /combo balanced.');
788
+ closeRL();
789
+ return;
790
+ }
678
791
  if (targetHost === 'pi') {
679
- await capture('pi tree', () => stepPiLayer(profile, installOptions));
792
+ await capture('pi tree', () => stepPiLayer(installOptions));
680
793
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'pi'));
681
794
  await capture('settings', () => writePluginSettings(profile, installOptions));
682
795
  if (failures.length > 0)
@@ -708,4 +821,4 @@ async function runInstall() {
708
821
  }
709
822
  closeRL();
710
823
  }
711
- export { runInstall };
824
+ export { runInstall, verifyRtkArchive };