@krtclcdy/tersio 2.24.1 → 2.25.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 (62) hide show
  1. package/README.md +27 -21
  2. package/cli/banner.js +7 -4
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +1 -2
  5. package/cli/dashboard.js +57 -14
  6. package/cli/doctor-fix.js +88 -21
  7. package/cli/doctor.js +32 -24
  8. package/cli/hosts.js +19 -18
  9. package/cli/install.js +123 -52
  10. package/cli/interactive.js +29 -19
  11. package/cli/manifest.js +7 -0
  12. package/cli/profile.js +42 -11
  13. package/cli/rtk-wiring.js +28 -26
  14. package/cli/settings.js +37 -8
  15. package/cli/uninstall.js +40 -7
  16. package/cli/update.js +22 -14
  17. package/cli/usage.js +39 -14
  18. package/dashboard/dist/index.html +16 -16
  19. package/extensions/ai-addons-updater/index.js +69 -15
  20. package/extensions/ai-addons-updater/index.ts +60 -17
  21. package/extensions/caveman-session/index.js +28 -55
  22. package/extensions/caveman-session/index.ts +28 -55
  23. package/extensions/combo-toggle/index.js +40 -58
  24. package/extensions/combo-toggle/index.ts +38 -67
  25. package/extensions/lib/utils.js +60 -15
  26. package/extensions/lib/utils.ts +59 -15
  27. package/extensions/opencode/index.js +104 -0
  28. package/extensions/opencode/server.js +190 -0
  29. package/extensions/opencode/server.ts +200 -0
  30. package/extensions/rtk-session/index.js +20 -35
  31. package/extensions/rtk-session/index.ts +19 -34
  32. package/extensions/shared/carbon.js +1 -2
  33. package/extensions/shared/carbon.ts +1 -2
  34. package/extensions/shared/host.js +95 -46
  35. package/extensions/shared/host.ts +106 -50
  36. package/extensions/shared/plugin-settings.js +4 -6
  37. package/extensions/shared/plugin-settings.ts +4 -6
  38. package/extensions/shared/pricing.js +79 -25
  39. package/extensions/shared/pricing.ts +80 -26
  40. package/extensions/shared/rtk-gain.js +3 -5
  41. package/extensions/shared/rtk-gain.ts +3 -5
  42. package/extensions/shared/session-state.js +10 -33
  43. package/extensions/shared/session-state.ts +12 -38
  44. package/extensions/shared/status.js +38 -0
  45. package/extensions/shared/status.ts +45 -0
  46. package/extensions/shared/types.js +1 -2
  47. package/extensions/shared/types.ts +2 -2
  48. package/extensions/shared/usage-ledger.js +190 -56
  49. package/extensions/shared/usage-ledger.ts +194 -58
  50. package/extensions/shared/usage-store.js +228 -64
  51. package/extensions/shared/usage-store.ts +235 -67
  52. package/extensions/tersio-commands/index.js +4 -9
  53. package/extensions/tersio-commands/index.ts +4 -9
  54. package/package.json +8 -2
  55. package/tersio.js +3 -3
  56. package/cli/agent-hosts.js +0 -42
  57. package/cli/agents.js +0 -266
  58. package/cli/layer.js +0 -22
  59. package/cli/omp-layer.js +0 -51
  60. package/cli/pi-layer.js +0 -37
  61. package/cli/pi-wiring.js +0 -92
  62. package/extensions/shared/omp-prompt.js +0 -12
package/cli/doctor.js CHANGED
@@ -1,34 +1,31 @@
1
1
  // cli/doctor.ts — installation health checks.
2
2
  import { existsSync, promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
5
- import { detectHosts, ompPackageDir } from './hosts.js';
4
+ import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
5
+ import { detectHosts, hostExtensionsDir, ompPackageDir } from './hosts.js';
6
6
  import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
7
7
  import { usageDbPath } from '../extensions/shared/usage-store.js';
8
8
  import { pricesCachePath } from '../extensions/shared/pricing.js';
9
9
  import { readTextIfExists, resolveRtkBinary } from '../extensions/lib/utils.js';
10
+ import { TREE_FILES } from './manifest.js';
10
11
  async function runDoctor(recheck = false) {
11
12
  console.log('\n=== Tersio Doctor ===');
12
- // OMP loads the plugin from its plugins dir; pi loads the same package as a
13
- // pi package, which its own settings.json declares and its npm dir holds.
13
+ // OMP loads the plugin from its plugins dir; pi loads the same package as a pi package, which its own settings.json declares and its npm dir holds.
14
14
  const pluginsDir = OMP_PLUGINS_DIR;
15
15
  const rtkBin = resolveRtkBinary();
16
16
  // Ponytail
17
17
  const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
18
- const ponytailExt = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
19
18
  const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
20
19
  const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts');
21
20
  const cavemanRule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
22
21
  const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts');
23
22
  const updaterIndex = path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts');
24
- // Independent probes start concurrently; sections report in fixed order as
25
- // their data settles. Every probe resolves instead of rejecting.
23
+ // Independent probes start concurrently; sections report in fixed order as their data settles. Every probe resolves instead of rejecting.
26
24
  const hosts = detectHosts();
27
25
  const probes = {
28
26
  ompPkgText: readTextIfExists(path.join(ompPackageDir(), 'package.json')),
29
27
  configText: readTextIfExists(path.join(OMP_AGENT_DIR, 'config.yml')),
30
28
  ponytailPkgText: readTextIfExists(ponytailPkg),
31
- ponytailExtText: readTextIfExists(ponytailExt),
32
29
  rtkBinText: rtkBin ? readTextIfExists(rtkBin) : Promise.resolve(null),
33
30
  cavemanIndexText: readTextIfExists(cavemanIndex),
34
31
  cavemanRuleText: readTextIfExists(cavemanRule),
@@ -43,7 +40,7 @@ async function runDoctor(recheck = false) {
43
40
  const err = e;
44
41
  return err.stdout?.trim() || err.stderr?.trim() || null;
45
42
  }) : Promise.resolve(null);
46
- const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText, ponytailExtText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
43
+ const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
47
44
  Promise.all([
48
45
  probes.ompPkgText,
49
46
  probes.configText,
@@ -53,7 +50,6 @@ async function runDoctor(recheck = false) {
53
50
  probes.rtkIndexText,
54
51
  probes.updaterIndexText,
55
52
  probes.ponytailPkgText,
56
- probes.ponytailExtText,
57
53
  ]),
58
54
  Promise.all([
59
55
  probes.cavemanRuleText,
@@ -65,8 +61,7 @@ async function runDoctor(recheck = false) {
65
61
  probes.pricesMtime,
66
62
  ]),
67
63
  ]));
68
- // Categorized output with a tally; success rows stay quiet (no path echoes)
69
- // while failures print the expected path or fix so they stay actionable.
64
+ // Categorized output with a tally; success rows stay quiet (no path echoes) while failures print the expected path or fix so they stay actionable.
70
65
  const tally = { ok: 0, missing: 0, warn: 0 };
71
66
  function absDate(ms) {
72
67
  const d = new Date(ms);
@@ -88,37 +83,45 @@ async function runDoctor(recheck = false) {
88
83
  tally.warn++;
89
84
  console.log(` ⚠️ ${label}: warn ${detail}`);
90
85
  }
91
- // A host you do not use is not a fault, so it stays out of the tally and only
92
- // carries the one line that installs it.
86
+ // A host you do not use is not a fault, so it stays out of the tally and only carries the one line that installs it.
93
87
  function unused(label, detail) {
94
88
  console.log(` — ${label}: not installed ${detail}`);
95
89
  }
96
90
  section('Hosts');
97
91
  for (const host of hosts) {
92
+ // pi can hold a working tree while reading as not installed; use the tree as ground truth.
93
+ const piTree = (host.id === 'pi' && !host.installed && existsSync(hostExtensionsDir('pi')))
94
+ ? hostExtensionsDir('pi')
95
+ : null;
96
+ if (piTree !== null) {
97
+ check(host.label, true, `extensions ${PACKAGE_NAME} ${PACKAGE_VERSION}`);
98
+ continue;
99
+ }
98
100
  if (!host.installed) {
99
- // A pi declaration without the tree or the package is a broken install,
100
- // so it stays a counted row rather than reading as "not installed".
101
+ // A pi declaration without the tree or the package is a broken install, so it stays a counted row rather than reading as "not installed".
101
102
  if (host.declared)
102
103
  check(host.label, false, `declared (${host.declared}) but not installed`);
103
104
  else
104
105
  unused(host.label, `· ${host.installCmd}`);
105
106
  continue;
106
107
  }
107
- const where = host.via === 'tree' ? `extensions in ${host.dir}` : `package ${PACKAGE_NAME}`;
108
- check(host.label, true, `${where}${host.version ? ` ${host.version}` : ''}`);
108
+ // One line per host, omp style: kind and version, no path echo.
109
+ const kind = host.via === 'tree' ? 'extensions' : 'package';
110
+ check(host.label, true, `${kind} ${PACKAGE_NAME}${host.version ? ` ${host.version}` : ''}`);
109
111
  }
110
112
  section('Extensions & plugins');
111
- const ompEntry = hosts.find((host) => host.id === 'omp');
112
- if (ompEntry?.via === 'package') {
113
+ if (hosts.find((host) => host.id === 'omp')?.via === 'package') {
113
114
  const explicitEntries = (configText ?? '').split('\n')
114
115
  .map((line) => line.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, ''))
115
116
  .filter((line) => line.startsWith('/') || line.startsWith('.'));
116
117
  const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
117
118
  check('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : '');
118
- check('Caveman extension', existsSync(path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts')), existsSync(path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts')) ? '' : path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
119
- check('RTK extension', existsSync(path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts')), existsSync(path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts')) ? '' : path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
120
- check('Updater extension', existsSync(path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts')), existsSync(path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts')) ? '' : path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts'));
121
- check('Ponytail extension', existsSync(path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js')), existsSync(path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js')) ? '' : path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'));
119
+ // A half-written tree imports a module that is absent; OMP then drops the extension.
120
+ const extDir = path.join(tersioPluginDir, 'extensions');
121
+ const missing = TREE_FILES.filter((file) => !existsSync(path.join(extDir, ...file.split('/'))));
122
+ check('Extension tree', missing.length === 0, missing.length === 0
123
+ ? `${TREE_FILES.length} files ok`
124
+ : `${missing.length} of ${TREE_FILES.length} missing: ${missing.join(', ')}`);
122
125
  }
123
126
  section('Usage & records');
124
127
  console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
@@ -129,6 +132,11 @@ async function runDoctor(recheck = false) {
129
132
  check('Caveman rule', cavemanRuleText !== null, ruleAge);
130
133
  const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
131
134
  check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
135
+ // rtk_run needs a host exec API, which doctor cannot assume.
136
+ if (rtkBin === null)
137
+ unused('RTK exec', '· install Tersio to place rtk in ~/.bun/bin');
138
+ else if (!hosts.some((host) => host.installed && (host.id === 'omp' || host.id === 'opencode')))
139
+ warnLine('RTK exec', 'no installed host exposes an exec API — rtk_run is unavailable; call rtk from bash instead');
132
140
  if (rtkBinText !== null && !rtkVersion)
133
141
  warnLine('RTK version', 'unavailable — binary may not be executable');
134
142
  const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
package/cli/hosts.js CHANGED
@@ -1,14 +1,15 @@
1
- // cli/hosts.ts — the agent hosts Tersio installs into, and what is on disk.
2
- // One detector, shared by the doctor and both menus, so they cannot disagree.
1
+ // cli/hosts.ts — the agent hosts Tersio installs into, and what is on disk. One detector, shared by the doctor and both menus, so they cannot disagree.
3
2
  import { existsSync, readFileSync } from 'node:fs';
4
3
  import path from 'node:path';
5
4
  import { HOME, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION } from './common.js';
6
5
  import { TREE_FILES } from './manifest.js';
7
6
  import { piAgentDir } from '../extensions/lib/utils.js';
8
7
  export function hostExtensionsDir(id, agentDir = piAgentDir()) {
9
- return id === 'omp'
10
- ? path.join(HOME, '.omp', 'agent', 'extensions')
11
- : path.join(agentDir, 'extensions');
8
+ if (id === 'omp')
9
+ return path.join(HOME, '.omp', 'agent', 'extensions');
10
+ if (id === 'opencode')
11
+ return path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
12
+ return path.join(agentDir, 'extensions');
12
13
  }
13
14
  export function missingTreeFiles(id, agentDir = piAgentDir()) {
14
15
  const dir = hostExtensionsDir(id, agentDir);
@@ -32,8 +33,7 @@ function packageVersion(dir) {
32
33
  return null;
33
34
  }
34
35
  }
35
- // The tersio package pi declared in its own settings.json, or null. pi
36
- // identifies a source by name, so an npm spec, git URL, or path all count.
36
+ // The tersio package pi declared in its own settings.json, or null. pi identifies a source by name, so an npm spec, git URL, or path all count.
37
37
  export function piTersioSource(agentDir = piAgentDir()) {
38
38
  let settings;
39
39
  try {
@@ -61,24 +61,26 @@ export function piTersioSource(agentDir = piAgentDir()) {
61
61
  const HOSTS = [
62
62
  { id: 'omp', label: 'Oh My Pi', bin: 'omp', installCmd: `omp plugin install ${PACKAGE_NAME}`, removeCmd: `omp plugin remove ${PACKAGE_NAME}` },
63
63
  { id: 'pi', label: 'Pi', bin: 'pi', installCmd: `pi install npm:${PACKAGE_NAME}`, removeCmd: `pi remove npm:${PACKAGE_NAME}` },
64
+ { id: 'opencode', label: 'OpenCode', bin: 'opencode', installCmd: `tersio install --host opencode`, removeCmd: `tersio uninstall --host opencode` },
64
65
  ];
65
66
  function detect(agentDir = piAgentDir()) {
66
67
  return HOSTS.map((host) => {
67
- const pkgDir = host.id === 'omp' ? ompPackageDir() : piPackageDir(agentDir);
68
+ const pkgDir = host.id === 'omp' ? ompPackageDir() : host.id === 'pi' ? piPackageDir(agentDir) : null;
68
69
  const tree = hostExtensionsDir(host.id, agentDir);
69
- // A tree wins: it is what the installer writes, and it carries no
70
- // package.json, so its version is ours.
70
+ // A tree wins: it is what the installer writes, and it carries no package.json, so its version is ours.
71
71
  const via = missingTreeFiles(host.id, agentDir).length === 0
72
72
  ? 'tree'
73
- : existsSync(pkgDir) ? 'package' : null;
73
+ : pkgDir && existsSync(pkgDir) ? 'package' : null;
74
74
  return {
75
75
  ...host,
76
76
  installed: via !== null,
77
77
  via,
78
78
  declared: host.id === 'omp'
79
- ? (existsSync(pkgDir) ? `plugin ${PACKAGE_NAME}` : null)
80
- : piTersioSource(agentDir),
81
- version: via === 'tree' ? PACKAGE_VERSION : via === 'package' ? packageVersion(pkgDir) : null,
79
+ ? (pkgDir && existsSync(pkgDir) ? `plugin ${PACKAGE_NAME}` : null)
80
+ : host.id === 'pi'
81
+ ? piTersioSource(agentDir)
82
+ : existsSync(tree) ? 'plugin tree' : null,
83
+ version: via === 'tree' ? PACKAGE_VERSION : via === 'package' && pkgDir ? packageVersion(pkgDir) : null,
82
84
  dir: via === 'tree' ? tree : via === 'package' ? pkgDir : null,
83
85
  };
84
86
  });
@@ -99,16 +101,15 @@ export function hostHint(host) {
99
101
  return host.via === 'tree' ? 'extension tree' : 'plugin package';
100
102
  return host.installCmd;
101
103
  }
102
- // `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown
103
- // value so the caller can exit(1) with the message.
104
+ // `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown value so the caller can exit(1) with the message.
104
105
  export function parseHostArg(argv) {
105
106
  const i = argv.indexOf('--host');
106
107
  const raw = (i === -1 ? undefined : argv[i + 1]) ?? argv.find((a) => a.startsWith('--host='))?.slice('--host='.length);
107
108
  if (raw === undefined)
108
109
  return undefined;
109
110
  const value = raw.trim().toLowerCase();
110
- if (value !== 'omp' && value !== 'pi')
111
- throw new Error(`Invalid --host: ${raw}. Valid: omp, pi`);
111
+ if (value !== 'omp' && value !== 'pi' && value !== 'opencode')
112
+ throw new Error(`Invalid --host: ${raw}. Valid: omp, pi, opencode`);
112
113
  return value;
113
114
  }
114
115
  export { HOME, detect as detectHosts };
package/cli/install.js CHANGED
@@ -3,7 +3,7 @@ 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';
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
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';
@@ -12,12 +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';
17
+ import { formatCliStatus, storedProfile, storedProfileSync, writePluginSettings } from './profile.js';
18
18
  import { runSettings } from './settings.js';
19
19
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
20
- import { filesUnder, sourcePath } from './manifest.js';
20
+ import { OPENCODE_SERVER_SHIM, filesUnder, sourcePath } from './manifest.js';
21
21
  import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
22
22
  async function stepPonytail(pluginsDir, options) {
23
23
  if (!options.quiet)
@@ -163,19 +163,23 @@ async function downloadRtkChecksums(release) {
163
163
  return null;
164
164
  }
165
165
  }
166
- async function verifyRtkArchive(archivePath, assetName, checksumsText, options = {}) {
167
- if (!checksumsText) {
168
- if (!options.quiet)
169
- console.log(' [warn] No checksums.txt available — skipping verification');
170
- return true;
171
- }
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}`);
172
179
  const expected = parseChecksum(checksumsText, assetName);
180
+ if (!expected)
181
+ return unverified(`checksums.txt has no entry for ${assetName}`);
173
182
  const actual = await sha256File(archivePath);
174
- if (!expected) {
175
- if (!options.quiet)
176
- sayTagged(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
177
- return true;
178
- }
179
183
  if (actual === expected) {
180
184
  debug(`Checksum verified for ${assetName}`);
181
185
  return true;
@@ -225,34 +229,35 @@ async function extractRtkArchive(archivePath, extractDir) {
225
229
  sayTagged(` [fail] Unknown archive format: ${archivePath}`);
226
230
  return false;
227
231
  }
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.
232
+ // The binary is machine-wide; hosts without a hook only rebind. `tersio update` still refreshes it.
230
233
  async function stepRtk(binDir, options, target = 'omp') {
231
234
  const binDest = path.join(binDir, RTK_BINARY_NAME);
232
235
  const found = resolveRtkBinary();
236
+ const hostName = target === 'omp' ? 'OMP' : target === 'opencode' ? 'OpenCode' : 'pi';
233
237
  const bind = async (binary) => {
234
- if (target !== 'omp')
235
- return;
236
238
  if (!(await fileExists(binary))) {
237
- 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}`}`);
238
240
  return;
239
241
  }
240
- 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);
241
248
  };
242
249
  if (found && !applyUpdate) {
243
- // The path only under --verbose: a plain run, and a dry run, stay free of
244
- // real user paths.
250
+ // The path only under --verbose: a plain run, and a dry run, stay free of real user paths.
245
251
  if (!options.quiet) {
246
252
  const where = verbose ? ` (${found})` : '';
247
- 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}`);
248
254
  }
249
255
  await bind(found);
250
256
  return;
251
257
  }
252
258
  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.
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.
256
261
  if (options.dryRun) {
257
262
  if (verbose && !options.quiet)
258
263
  sayTagged(` [dry-run] would download rtk binary and install to ${binDest}`);
@@ -263,8 +268,7 @@ async function stepRtk(binDir, options, target = 'omp') {
263
268
  const triple = resolveRtkTriple();
264
269
  if (!triple)
265
270
  return;
266
- // One task with streamed sub-steps: release lookup, download, checksum,
267
- // extract, install. Non-TTY keeps the plain lines each step already prints.
271
+ // One task with streamed sub-steps: release lookup, download, checksum, extract, install. Non-TTY keeps the plain lines each step already prints.
268
272
  await withInteractiveTask('Installing RTK', async (update) => {
269
273
  const release = await fetchJson(RTK_RELEASE_API);
270
274
  const asset = findRtkAsset(release, triple);
@@ -281,7 +285,7 @@ async function stepRtk(binDir, options, target = 'omp') {
281
285
  httpsDownload(asset.browser_download_url, archivePath),
282
286
  ]);
283
287
  update('Verifying checksum');
284
- if (!await verifyRtkArchive(archivePath, asset.name, checksumsText, options)) {
288
+ if (!await verifyRtkArchive(archivePath, asset.name, checksumsText)) {
285
289
  sayTagged(' [hint] RTK install skipped for this run');
286
290
  return;
287
291
  }
@@ -323,8 +327,7 @@ async function stepRtk(binDir, options, target = 'omp') {
323
327
  // Wire rtk's tool_call extension: the binary alone never meters a session.
324
328
  await bind(binDest);
325
329
  }
326
- // Copy sources into the target extension dir. First entry is required; the
327
- // rest are optional companions.
330
+ // Copy sources into the target extension dir. First entry is required; the rest are optional companions.
328
331
  async function copySources(extDir, files, skipLabel, options) {
329
332
  const src = await readTextIfExists(files[0][0]);
330
333
  if (!src) {
@@ -353,8 +356,7 @@ async function stepRtkSession(extDir, options) {
353
356
  console.log(' RTK session — install session mode');
354
357
  await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
355
358
  }
356
- // One fetch serves the install; dry runs stay offline and preview the bundled
357
- // rule's destination.
359
+ // One fetch serves the install; dry runs stay offline and preview the bundled rule's destination.
358
360
  async function fetchCavemanRule(options) {
359
361
  const bundled = await readTextIfExists(sourcePath('caveman-session/rule.md'));
360
362
  if (options.dryRun)
@@ -403,11 +405,72 @@ async function stepCombo(extDir, options) {
403
405
  console.log(' Combo — install preset switch');
404
406
  await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
405
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
+ }
406
470
  async function resolveProfile(opts = {}) {
407
471
  // Seed from the stored defaults so flag-less runs keep them.
408
472
  const profile = await storedProfile();
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.
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.
411
474
  if (tty() && !profileFlagsGiven && !applyUpdate) {
412
475
  const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
413
476
  { value: 'off', label: 'off' },
@@ -458,7 +521,7 @@ async function runCommandMenu() {
458
521
  console.log(`\n [update] staying on ${PACKAGE_VERSION} — run ` + '`tersio update`' + ` anytime`);
459
522
  }
460
523
  updatePromptDone = true;
461
- const choice = await askInteractiveChoice('Tersio — what next?', [
524
+ const choice = await askInteractiveChoice(`Tersio — what next?\n${formatCliStatus(storedProfileSync())}`, [
462
525
  { value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
463
526
  { value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
464
527
  { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
@@ -477,8 +540,7 @@ async function runCommandMenu() {
477
540
  await runInstall();
478
541
  break;
479
542
  case 'update': {
480
- // One bound flow: the version check already ran above, so report it
481
- // 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.
482
544
  if (typeof newer === 'string')
483
545
  console.log(` tersio ${newer} available (installed ${PACKAGE_VERSION})`);
484
546
  else
@@ -538,14 +600,11 @@ async function runCommandMenu() {
538
600
  break;
539
601
  }
540
602
  }
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.
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.
543
604
  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.
605
+ // Hosts the menu advertises but cannot install yet. Listed disabled so the roadmap is visible without offering a target that does nothing.
546
606
  const SOON_HOSTS = [
547
607
  { value: 'claude', label: 'Claude Code', hint: 'Coming soon' },
548
- { value: 'opencode', label: 'OpenCode', hint: 'Coming soon' },
549
608
  { value: 'codex', label: 'Codex', hint: 'Coming soon' },
550
609
  ];
551
610
  async function chooseHost(title, only) {
@@ -568,8 +627,7 @@ async function chooseHost(title, only) {
568
627
  function hostEntry(id) {
569
628
  return detectHosts().find((host) => host.id === id);
570
629
  }
571
- // pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes
572
- // the same tree as OMP's and registers nothing.
630
+ // pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes the same tree as OMP's and registers nothing.
573
631
  async function stepPiLayer(options) {
574
632
  const agentDir = piAgentDir();
575
633
  const extDir = path.join(agentDir, 'extensions');
@@ -624,8 +682,7 @@ async function stepPonytailForPi(agentDir, options) {
624
682
  }
625
683
  }
626
684
  async function runInstall() {
627
- // Without this guard a picked command re-enters here with command === null
628
- // 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.
629
686
  if (command === null && tty() && !yes && !updatePromptDone) {
630
687
  await runCommandMenu();
631
688
  return;
@@ -657,8 +714,7 @@ async function runInstall() {
657
714
  if (tty() && !applyUpdate && command !== 'uninstall') {
658
715
  const newer = await checkForUpdate();
659
716
  if (typeof newer === 'string') {
660
- // Update pending: offer it here instead of burying it above the install
661
- // 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.
662
718
  if (command === null && !dryRun && !yes && !updatePromptDone) {
663
719
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
664
720
  if (answer.status === 'confirmed' && answer.value) {
@@ -686,7 +742,11 @@ async function runInstall() {
686
742
  // Point of no return: disk writes start here; pipes stay unattended.
687
743
  if (!yes && !dryRun && !applyUpdate && tty()) {
688
744
  const target = hostEntry(targetHost);
689
- const where = targetHost === 'pi' ? path.join(piAgentDir(), 'extensions') : path.join(OMP_AGENT_DIR, 'extensions');
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');
690
750
  console.log(`\nWill install into ${target.label}:`);
691
751
  console.log(` ${where}`);
692
752
  console.log(' caveman · rtk · ponytail session modes');
@@ -717,6 +777,17 @@ async function runInstall() {
717
777
  sayTagged(` [fail] ${label}: ${shortError(e)}`);
718
778
  }
719
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
+ }
720
791
  if (targetHost === 'pi') {
721
792
  await capture('pi tree', () => stepPiLayer(installOptions));
722
793
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'pi'));
@@ -750,4 +821,4 @@ async function runInstall() {
750
821
  }
751
822
  closeRL();
752
823
  }
753
- export { runInstall };
824
+ export { runInstall, verifyRtkArchive };
@@ -26,13 +26,11 @@ function tty() {
26
26
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
27
27
  }
28
28
  let spinnerDepth = 0;
29
- // Run async work under a TTY-only timer spinner. The spinner is cleared before
30
- // the caller prints its normal result line, preserving locked output shapes.
29
+ // Run async work under a TTY-only timer spinner. The spinner is cleared before the caller prints its normal result line, preserving locked output shapes.
31
30
  async function withInteractiveSpinner(message, work) {
32
31
  if (!tty() || spinnerDepth > 0)
33
32
  return work(() => { });
34
- // Clack manages stdin itself. Close the legacy question interface before its
35
- // first use; non-interactive callers never reach this branch.
33
+ // Clack manages stdin itself. Close the legacy question interface before its first use; non-interactive callers never reach this branch.
36
34
  closeRL();
37
35
  const active = clackSpinner({ indicator: 'timer' });
38
36
  active.start(message);
@@ -49,26 +47,33 @@ async function askInteractiveChoice(message, options, initialValue) {
49
47
  if (!tty())
50
48
  return { status: 'unavailable' };
51
49
  closeRL();
52
- const choice = await clackSelect({ message, options, initialValue });
53
- if (typeof choice !== 'string') {
54
- clackCancel('Aborted.');
55
- return { status: 'cancelled' };
50
+ try {
51
+ const choice = await clackSelect({ message, options, initialValue });
52
+ if (typeof choice !== 'string') {
53
+ clackCancel('Aborted.');
54
+ return { status: 'cancelled' };
55
+ }
56
+ return { status: 'selected', value: choice };
57
+ }
58
+ finally {
56
59
  }
57
- return { status: 'selected', value: choice };
58
60
  }
59
61
  async function askInteractiveConfirm(message, initialValue = true) {
60
62
  if (!tty())
61
63
  return { status: 'unavailable' };
62
64
  closeRL();
63
- const answer = await clackConfirm({ message, initialValue });
64
- if (typeof answer !== 'boolean') {
65
- clackCancel('Aborted.');
66
- return { status: 'cancelled' };
65
+ try {
66
+ const answer = await clackConfirm({ message, initialValue });
67
+ if (typeof answer !== 'boolean') {
68
+ clackCancel('Aborted.');
69
+ return { status: 'cancelled' };
70
+ }
71
+ return { status: 'confirmed', value: answer };
72
+ }
73
+ finally {
67
74
  }
68
- return { status: 'confirmed', value: answer };
69
75
  }
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.
76
+ // Confirm a destructive run: a Clack dialog at a terminal, a plain prompt in a pipe or script. False means the user declined or aborted.
72
77
  async function confirmDestructive(message) {
73
78
  if (tty()) {
74
79
  closeRL();
@@ -93,14 +98,17 @@ async function confirmDestructive(message) {
93
98
  }
94
99
  return true;
95
100
  }
96
- // Run collecting work under one TTY-only Clack task. Callers print after the
97
- // task completes, keeping normal output out of the spinner animation.
101
+ // Run collecting work under one TTY-only Clack task. Callers print after the task completes, keeping normal output out of the spinner animation.
98
102
  async function runInteractivePhase(title, collect) {
99
103
  if (!tty())
100
104
  return collect();
101
105
  closeRL();
102
106
  let result;
103
- await clackTasks([{ title, task: async () => { result = await collect(); } }]);
107
+ try {
108
+ await clackTasks([{ title, task: async () => { result = await collect(); } }]);
109
+ }
110
+ finally {
111
+ }
104
112
  return result;
105
113
  }
106
114
  const TAG_KIND = {
@@ -131,6 +139,8 @@ async function withInteractiveTask(title, work) {
131
139
  spin.error(shortSpinnerError(e));
132
140
  throw e;
133
141
  }
142
+ finally {
143
+ }
134
144
  return result;
135
145
  }
136
146
  function shortSpinnerError(e) {