@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/doctor-fix.js CHANGED
@@ -3,48 +3,98 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage, writeIfChanged, } from './common.js';
6
- import { execNetwork } from './interactive.js';
7
- import { wireRtkOmp, ensureRtkInConfig } from './rtk-wiring.js';
6
+ import { execNetwork, sayTagged } from './interactive.js';
7
+ import { wireRtkOmp, wireRtkOpencode, wireRtkPi, ensureRtkInConfig } from './rtk-wiring.js';
8
8
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
9
9
  import { runLatestUpdate } from './update.js';
10
- import { TREE_FILES, sourcePath } from './manifest.js';
10
+ import { OPENCODE_SERVER_SHIM, TREE_FILES, sourcePath } from './manifest.js';
11
11
  import { detectHosts, hostExtensionsDir } from './hosts.js';
12
12
  const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
13
- // A written tree is the install for both hosts now, so --fix restores it for
14
- // whichever hosts have it, alongside the OMP package when that is present.
13
+ // A written tree is the install for both hosts now, so --fix restores it for whichever hosts have it, alongside the OMP package when that is present.
15
14
  async function fixExtensionTrees() {
15
+ // The opencode entrypoint is foreign to pi/omp loaders: never copy it there, and remove strays.
16
+ const forHost = (id) => TREE_FILES.filter((file) => id === 'opencode' || !file.startsWith('opencode/'));
17
+ const scrubOpencodeDir = async (dest) => {
18
+ const dir = path.join(dest, 'opencode');
19
+ if (!existsSync(dir))
20
+ return;
21
+ if (dryRun) {
22
+ sayTagged(` [dry-run] would remove foreign ${dir}`);
23
+ return;
24
+ }
25
+ await fs.rm(dir, { recursive: true, force: true });
26
+ sayTagged(` [rm] foreign ${dir}`);
27
+ };
16
28
  for (const host of detectHosts()) {
17
29
  const dest = hostExtensionsDir(host.id);
18
- // Repair a host whose tree is partly there: a half-written tree is exactly
19
- // the case this runs for, and it reads as "not installed" to the detector.
20
- const present = TREE_FILES.filter((file) => existsSync(path.join(dest, file))).length;
21
- if (present === 0)
30
+ // Repair a host whose tree is partly there: a half-written tree is exactly the case this runs for, and it reads as "not installed" to the detector.
31
+ const present = forHost(host.id).filter((file) => existsSync(path.join(dest, file))).length;
32
+ if (present === 0) {
33
+ if (host.id !== 'opencode')
34
+ await scrubOpencodeDir(dest);
22
35
  continue;
36
+ }
23
37
  await fs.mkdir(dest, { recursive: true });
24
- for (const file of TREE_FILES) {
38
+ for (const file of forHost(host.id)) {
25
39
  const from = path.join(EXT_DIR, ...file.split('/'));
26
40
  const text = await readTextIfExists(from);
27
41
  if (text === null)
28
- console.log(` [warn] bundled source missing: ${from}`);
42
+ sayTagged(` [warn] bundled source missing: ${from}`);
29
43
  else
30
44
  await writeIfChanged(path.join(dest, file), text, { dryRun, verbose });
31
45
  }
46
+ if (host.id !== 'opencode')
47
+ await scrubOpencodeDir(dest);
32
48
  if (!dryRun)
33
- console.log(` [ok] ${host.label} extension tree: ${dest}`);
49
+ sayTagged(` [ok] ${host.label} extension tree: ${dest}`);
50
+ }
51
+ }
52
+ // The tree alone does nothing on OpenCode without its entrypoint registered in opencode.json.
53
+ async function fixOpencodeEntry() {
54
+ const { HOME } = await import('./common.js');
55
+ const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
56
+ const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
57
+ const nested = path.join(dir, 'opencode', 'server.ts');
58
+ const nestedText = await readTextIfExists(nested);
59
+ if (!nestedText)
60
+ return;
61
+ // The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
62
+ await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, { dryRun, verbose });
63
+ const raw = await readTextIfExists(configPath);
64
+ let config = {};
65
+ try {
66
+ config = raw ? JSON.parse(raw) : {};
67
+ }
68
+ catch {
69
+ return;
34
70
  }
71
+ const plugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
72
+ const withoutTersio = plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio'));
73
+ if (withoutTersio.includes(dir))
74
+ return;
75
+ withoutTersio.push(dir);
76
+ config.plugins = withoutTersio;
77
+ if (dryRun) {
78
+ sayTagged(` [dry-run] would register tersio in ${configPath}`);
79
+ return;
80
+ }
81
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
82
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
83
+ sayTagged(` [ok] OpenCode plugin entry: ${configPath}`);
35
84
  }
36
85
  async function fixExtensions(pluginsDir) {
37
86
  await fixExtensionTrees();
87
+ await fixOpencodeEntry();
38
88
  console.log(' Doctor --fix: restoring plugin extension files');
39
- const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
40
- const pluginExtDir = path.join(tersioPluginDir, 'extensions');
89
+ const pluginExtDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
41
90
  await fs.mkdir(pluginExtDir, { recursive: true });
42
- for (const [from, to] of TREE_FILES.map((file) => [sourcePath(file), file])) {
43
- const text = await readTextIfExists(from);
91
+ // rule.md is skipped: its live content is the upstream fetch, not the bundle.
92
+ for (const file of TREE_FILES.filter((f) => f !== 'caveman-session/rule.md')) {
93
+ const text = await readTextIfExists(sourcePath(file));
44
94
  if (text === null)
45
- console.log(` [warn] bundled source missing: ${from}`);
95
+ sayTagged(` [warn] bundled source missing: ${sourcePath(file)}`);
46
96
  else
47
- await writeIfChanged(path.join(pluginExtDir, to), text, { dryRun, verbose });
97
+ await writeIfChanged(path.join(pluginExtDir, ...file.split('/')), text, { dryRun, verbose });
48
98
  }
49
99
  const ruleDest = path.join(pluginExtDir, 'caveman-session', 'rule.md');
50
100
  if ((await readTextIfExists(ruleDest)) === null) {
@@ -63,13 +113,21 @@ async function fixExtensions(pluginsDir) {
63
113
  console.log(' [warn] Caveman rule unreachable and no bundled rule exists');
64
114
  }
65
115
  }
116
+ // Writing is not atomic, so re-read rather than assume the tree is whole.
117
+ if (dryRun)
118
+ return;
119
+ const missing = TREE_FILES.filter((file) => !existsSync(path.join(pluginExtDir, ...file.split('/'))));
120
+ if (missing.length > 0)
121
+ sayTagged(` [fail] tree incomplete: ${missing.join(', ')}`);
122
+ else
123
+ sayTagged(` [ok] extension tree: ${TREE_FILES.length} files`);
66
124
  }
67
125
  async function fixRegistrations(agentDir, pluginsDir) {
68
126
  console.log(' Doctor --fix: repairing config.yml registrations');
69
127
  const configPath = path.join(agentDir, 'config.yml');
70
128
  if ((await readTextIfExists(configPath)) === null) {
71
129
  if (dryRun)
72
- console.log(` [dry-run] would create ${configPath}`);
130
+ sayTagged(` [dry-run] would create ${configPath}`);
73
131
  else {
74
132
  await fs.mkdir(agentDir, { recursive: true });
75
133
  await fs.writeFile(configPath, 'extensions:\n', 'utf8');
@@ -88,7 +146,7 @@ async function fixRegistrations(agentDir, pluginsDir) {
88
146
  if (!(PACKAGE_NAME in pkg.dependencies)) {
89
147
  pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
90
148
  if (dryRun)
91
- console.log(` [dry-run] would register ${PACKAGE_NAME} in ${pkgPath}`);
149
+ sayTagged(` [dry-run] would register ${PACKAGE_NAME} in ${pkgPath}`);
92
150
  else {
93
151
  await fs.mkdir(pluginsDir, { recursive: true });
94
152
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
@@ -99,7 +157,7 @@ async function fixRtk(binDir) {
99
157
  console.log(' Doctor --fix: repairing RTK binary + wiring');
100
158
  const binDest = path.join(binDir, RTK_BINARY_NAME);
101
159
  if (dryRun) {
102
- console.log(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into OMP`);
160
+ sayTagged(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into OMP`);
103
161
  return;
104
162
  }
105
163
  const spec = rtkPlatformSpec();
@@ -117,12 +175,16 @@ async function fixRtk(binDir) {
117
175
  httpsGet(checksAsset.browser_download_url).catch(() => null),
118
176
  httpsDownload(asset.browser_download_url, archivePath),
119
177
  ]);
120
- if (checks) {
121
- const expected = parseChecksum(checks, asset.name);
122
- if (expected && await sha256File(archivePath) !== expected)
123
- throw new Error(`checksum mismatch for ${asset.name}`);
124
- debug('RTK checksum verified');
125
- }
178
+ // Same rule as the installer: an unverifiable binary is not installed.
179
+ const unverified = (why) => new Error(`${why} for ${asset.name}. Re-run with --allow-unverified to accept it`);
180
+ if (!checks)
181
+ throw unverified('checksums.txt could not be downloaded');
182
+ const expected = parseChecksum(checks, asset.name);
183
+ if (!expected)
184
+ throw unverified('checksums.txt has no entry');
185
+ if (await sha256File(archivePath) !== expected)
186
+ throw new Error(`checksum mismatch for ${asset.name}`);
187
+ debug('RTK checksum verified');
126
188
  const extractDir = path.join(tmpDir, 'extracted');
127
189
  await fs.mkdir(extractDir, { recursive: true });
128
190
  if (asset.name.endsWith('.zip'))
@@ -144,6 +206,14 @@ async function fixRtk(binDir) {
144
206
  }
145
207
  if (!(await wireRtkOmp(binDest, { dryRun, verbose })))
146
208
  throw new Error('rtk wiring failed');
209
+ // Fail-open: a missing host wire must not fail the repair.
210
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
211
+ if (existsSync(path.join(home, '.config', 'opencode', 'plugins', 'tersio'))) {
212
+ await wireRtkOpencode(binDest, { dryRun, verbose });
213
+ }
214
+ if (existsSync(path.join(home, '.pi', 'agent', 'extensions'))) {
215
+ await wireRtkPi(binDest, { dryRun, verbose });
216
+ }
147
217
  }
148
218
  async function fixPonytail(pluginsDir) {
149
219
  console.log(' Doctor --fix: restoring bundled Ponytail');
@@ -180,11 +250,11 @@ async function runDoctorRepairs(targets) {
180
250
  try {
181
251
  await work();
182
252
  if (!dryRun)
183
- console.log(` [ok] ${label}`);
253
+ sayTagged(` [ok] ${label}`);
184
254
  }
185
255
  catch (e) {
186
256
  failed.push(label);
187
- console.log(` [fail] ${label}: ${(e.message || '').slice(0, 200)}`);
257
+ sayTagged(` [fail] ${label}: ${(e.message || '').slice(0, 200)}`);
188
258
  }
189
259
  };
190
260
  if (want('extensions'))
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, missingTreeFiles, 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,48 +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
- const retiredReinforcement = explicitEntries.filter((entry) => entry.endsWith('/shared/mode-reinforcement.ts')).length;
119
- if (retiredReinforcement === 0)
120
- check('No retired reinforcement', true);
121
- else
122
- warnLine('Retired reinforcement registration', `${retiredReinforcement} found; run doctor --fix registrations`);
123
- 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'));
124
- 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'));
125
- 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'));
126
- 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'));
127
- }
128
- for (const host of hosts) {
129
- if (!host.installed || host.via !== 'tree')
130
- continue;
131
- const missing = missingTreeFiles(host.id);
132
- check(`${host.label} extension tree`, missing.length === 0, missing.length ? `${missing.length} missing in ${host.dir}` : '');
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(', ')}`);
133
125
  }
134
126
  section('Usage & records');
135
127
  console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
@@ -140,6 +132,11 @@ async function runDoctor(recheck = false) {
140
132
  check('Caveman rule', cavemanRuleText !== null, ruleAge);
141
133
  const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
142
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');
143
140
  if (rtkBinText !== null && !rtkVersion)
144
141
  warnLine('RTK version', 'unavailable — binary may not be executable');
145
142
  const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
package/cli/hosts.js CHANGED
@@ -1,15 +1,15 @@
1
- // cli/hosts.ts — the agent hosts Tersio installs into, and what is on disk.
2
- // One detector, shared by the doctor rows, the install menu, and the uninstall
3
- // menu, so the three can never disagree about what is installed.
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.
4
2
  import { existsSync, readFileSync } from 'node:fs';
5
3
  import path from 'node:path';
6
4
  import { HOME, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION } from './common.js';
7
5
  import { TREE_FILES } from './manifest.js';
8
6
  import { piAgentDir } from '../extensions/lib/utils.js';
9
7
  export function hostExtensionsDir(id, agentDir = piAgentDir()) {
10
- return id === 'omp'
11
- ? path.join(HOME, '.omp', 'agent', 'extensions')
12
- : 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');
13
13
  }
14
14
  export function missingTreeFiles(id, agentDir = piAgentDir()) {
15
15
  const dir = hostExtensionsDir(id, agentDir);
@@ -33,9 +33,7 @@ function packageVersion(dir) {
33
33
  return null;
34
34
  }
35
35
  }
36
- // The tersio package pi has declared in its own settings.json, or null. pi
37
- // writes the declaration on install and identifies a source by name, so an npm
38
- // spec, a git URL, and a local path all count when they name this package.
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.
39
37
  export function piTersioSource(agentDir = piAgentDir()) {
40
38
  let settings;
41
39
  try {
@@ -63,32 +61,31 @@ export function piTersioSource(agentDir = piAgentDir()) {
63
61
  const HOSTS = [
64
62
  { id: 'omp', label: 'Oh My Pi', bin: 'omp', installCmd: `omp plugin install ${PACKAGE_NAME}`, removeCmd: `omp plugin remove ${PACKAGE_NAME}` },
65
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` },
66
65
  ];
67
66
  function detect(agentDir = piAgentDir()) {
68
67
  return HOSTS.map((host) => {
69
- const pkgDir = host.id === 'omp' ? ompPackageDir() : piPackageDir(agentDir);
68
+ const pkgDir = host.id === 'omp' ? ompPackageDir() : host.id === 'pi' ? piPackageDir(agentDir) : null;
70
69
  const tree = hostExtensionsDir(host.id, agentDir);
71
- // A tree wins: it is what the installer writes, and it carries no
72
- // 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.
73
71
  const via = missingTreeFiles(host.id, agentDir).length === 0
74
72
  ? 'tree'
75
- : existsSync(pkgDir) ? 'package' : null;
73
+ : pkgDir && existsSync(pkgDir) ? 'package' : null;
76
74
  return {
77
75
  ...host,
78
76
  installed: via !== null,
79
77
  via,
80
78
  declared: host.id === 'omp'
81
- ? (existsSync(pkgDir) ? `plugin ${PACKAGE_NAME}` : null)
82
- : piTersioSource(agentDir),
83
- 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,
84
84
  dir: via === 'tree' ? tree : via === 'package' ? pkgDir : null,
85
85
  };
86
86
  });
87
87
  }
88
- /**
89
- * The menu label, with the current state in brackets: the version when the
90
- * package is already there, so a rerun shows what it would replace.
91
- */
88
+ /** Menu label with current state in brackets, so a rerun shows what it replaces. */
92
89
  export function hostLabel(host) {
93
90
  if (host.installed && host.version)
94
91
  return `${host.label} (installed ${host.version})`;
@@ -98,21 +95,21 @@ export function hostLabel(host) {
98
95
  return `${host.label} (package declared, not on disk)`;
99
96
  return `${host.label} (not installed)`;
100
97
  }
98
+ // Hints render inline, so menus name the install and doctor keeps the path.
101
99
  export function hostHint(host) {
102
- if (host.installed && host.dir)
103
- return host.dir;
100
+ if (host.installed && host.via)
101
+ return host.via === 'tree' ? 'extension tree' : 'plugin package';
104
102
  return host.installCmd;
105
103
  }
106
- // `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown
107
- // 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.
108
105
  export function parseHostArg(argv) {
109
106
  const i = argv.indexOf('--host');
110
107
  const raw = (i === -1 ? undefined : argv[i + 1]) ?? argv.find((a) => a.startsWith('--host='))?.slice('--host='.length);
111
108
  if (raw === undefined)
112
109
  return undefined;
113
110
  const value = raw.trim().toLowerCase();
114
- if (value !== 'omp' && value !== 'pi')
115
- 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`);
116
113
  return value;
117
114
  }
118
115
  export { HOME, detect as detectHosts };