@krtclcdy/tersio 2.23.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +58 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +16 -20
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +49 -37
  7. package/cli/doctor-fix.js +28 -17
  8. package/cli/doctor.js +52 -41
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +180 -117
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +44 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/uninstall.js +135 -44
  21. package/cli/update.js +12 -19
  22. package/dashboard/dist/index.html +16 -16
  23. package/extensions/ai-addons-updater/index.js +31 -24
  24. package/extensions/ai-addons-updater/index.ts +32 -32
  25. package/extensions/caveman-session/index.js +6 -6
  26. package/extensions/caveman-session/index.ts +6 -6
  27. package/extensions/combo-toggle/index.js +42 -40
  28. package/extensions/combo-toggle/index.ts +36 -47
  29. package/extensions/lib/utils.js +36 -9
  30. package/extensions/lib/utils.ts +37 -9
  31. package/extensions/rtk-session/index.js +9 -9
  32. package/extensions/rtk-session/index.ts +9 -14
  33. package/extensions/shared/carbon.js +5 -8
  34. package/extensions/shared/carbon.ts +5 -8
  35. package/extensions/shared/host.js +68 -0
  36. package/extensions/shared/host.ts +104 -0
  37. package/extensions/shared/omp-prompt.js +12 -0
  38. package/extensions/shared/plugin-settings.js +43 -30
  39. package/extensions/shared/plugin-settings.ts +45 -26
  40. package/extensions/shared/pricing.js +26 -15
  41. package/extensions/shared/pricing.ts +28 -16
  42. package/extensions/shared/rtk-gain.js +11 -22
  43. package/extensions/shared/rtk-gain.ts +11 -22
  44. package/extensions/shared/session-state.js +21 -25
  45. package/extensions/shared/session-state.ts +22 -26
  46. package/extensions/shared/types.ts +10 -3
  47. package/extensions/shared/usage-ledger.js +24 -36
  48. package/extensions/shared/usage-ledger.ts +29 -50
  49. package/extensions/shared/usage-store.js +14 -12
  50. package/extensions/shared/usage-store.ts +16 -14
  51. package/extensions/tersio-commands/index.js +2 -1
  52. package/extensions/tersio-commands/index.ts +2 -1
  53. package/package.json +4 -2
  54. package/tersio.js +4 -4
  55. package/extensions/shared/mode-reinforcement.js +0 -26
package/cli/dashboard.js CHANGED
@@ -1,7 +1,5 @@
1
- // cli/dashboard.ts — serves the built shadcn Dashboard with local usage APIs.
2
- // Binds 127.0.0.1 only; --export writes a file://-ready file instead.
3
- // Note: no Promise.withResolvers here — engines still allow Node 20.12,
4
- // which lacks it; the listening server itself keeps the process alive.
1
+ // Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only;
2
+ // --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
5
3
  import { spawn } from 'node:child_process';
6
4
  import { execFileSync } from 'node:child_process';
7
5
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -52,10 +50,9 @@ async function brandDataUri() {
52
50
  function dataJson() {
53
51
  return JSON.stringify(summarizeUsage(readUsage()));
54
52
  }
55
- // Local-only health + diagnosis for the settings modal. No network: version
56
- // probes run with short timeouts, file checks are existsSync.
57
- function ompPath() {
58
- const names = process.platform === 'win32' ? ['omp.cmd', 'omp.exe', 'omp.bat', 'omp'] : ['omp'];
53
+ // Local-only health: find each agent on PATH, run `--version`, report both.
54
+ function binOnPath(bin) {
55
+ const names = process.platform === 'win32' ? [`${bin}.cmd`, `${bin}.exe`, `${bin}.bat`, bin] : [bin];
59
56
  for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
60
57
  for (const name of names) {
61
58
  const candidate = path.join(dir, name);
@@ -68,15 +65,28 @@ function ompPath() {
68
65
  }
69
66
  return null;
70
67
  }
71
- function ompVersion(bin = ompPath()) {
68
+ function ompPath() {
69
+ return binOnPath('omp');
70
+ }
71
+ function piPath() {
72
+ return binOnPath('pi');
73
+ }
74
+ // omp prints `omp/18.4.1`, pi prints a bare `0.87.1`; both read as name/version.
75
+ function versionLabel(bin, raw) {
76
+ const out = raw.trim();
77
+ return out.includes('/') ? out : `${bin}/${out}`;
78
+ }
79
+ function agentVersion(bin) {
72
80
  if (!bin)
73
81
  return null;
74
82
  try {
75
83
  if (process.platform === 'win32') {
76
84
  const exe = process.env.ComSpec || 'cmd.exe';
77
- return execFileSync(exe, ['/d', '/s', '/c', bin, '--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
85
+ const out = execFileSync(exe, ['/d', '/s', '/c', bin, '--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
86
+ return out.trim() ? versionLabel(path.basename(bin), out) : null;
78
87
  }
79
- return execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
88
+ const out = execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
89
+ return out.trim() ? versionLabel(path.basename(bin), out) : null;
80
90
  }
81
91
  catch {
82
92
  return null;
@@ -104,12 +114,15 @@ function healthJson() {
104
114
  const rtkBin = resolveRtkBinary();
105
115
  const rtkPresent = rtkBin !== null;
106
116
  const detectedOmpPath = ompPath();
117
+ const detectedPiPath = piPath();
107
118
  return JSON.stringify({
108
119
  tersio: PACKAGE_VERSION,
109
120
  node: process.version,
110
121
  platform: `${process.platform}/${process.arch}`,
111
- omp: ompVersion(detectedOmpPath),
122
+ omp: agentVersion(detectedOmpPath),
112
123
  ompPath: detectedOmpPath,
124
+ pi: agentVersion(detectedPiPath),
125
+ piPath: detectedPiPath,
113
126
  provider: ompDefaultModel(),
114
127
  rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin || 'not found in PATH' },
115
128
  home: tersioHomePath(),
@@ -125,8 +138,7 @@ const DIAG_TTL_MS = {
125
138
  monthly: 30 * 24 * 3600 * 1000,
126
139
  };
127
140
  function diagPaths() {
128
- // Hermetic env (tests) overrides the DB path: keep the report next to it
129
- // so test runs never touch the real ~/.tersio/diag.json.
141
+ // Keep the report beside the overridden DB so tests never touch the real one.
130
142
  const dbOverride = process.env.TERSIO_USAGE_DB;
131
143
  if (dbOverride)
132
144
  return { report: path.join(path.dirname(dbOverride), 'diag.json') };
@@ -185,7 +197,6 @@ function relAge(ms) {
185
197
  }
186
198
  function computeDoctorRows() {
187
199
  const rows = [];
188
- const extDir = path.join(OMP_AGENT_DIR, 'extensions');
189
200
  const rtkBin = resolveRtkBinary();
190
201
  const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
191
202
  const tersioPluginDir = path.join(OMP_PLUGINS_DIR, 'node_modules', ...PACKAGE_NAME.split('/'));
@@ -219,8 +230,6 @@ function computeDoctorRows() {
219
230
  const rtkAge = rtkBin ? ageStr(rtkBin) : null;
220
231
  const rtkAt = rtkBin ? absTime(rtkBin) : null;
221
232
  addon('RTK binary', rtkVer !== null, rtkVer && rtkAge && rtkAt ? `${rtkVer} (updated ${rtkAge} · ${rtkAt})` : rtkBin || 'not found in PATH');
222
- const wiring = path.join(extDir, 'rtk.ts');
223
- addon('RTK OMP wiring (rtk.ts)', ok(wiring), ok(wiring) ? 'loaded' : wiring);
224
233
  let ponytailVer = null;
225
234
  try {
226
235
  ponytailVer = JSON.parse(readFileSync(ponytailPkg, 'utf8')).version ?? null;
@@ -237,10 +246,8 @@ function computeDoctorRows() {
237
246
  addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
238
247
  return rows;
239
248
  }
240
- // Retained diagnosis: recompute when forced, missing, or older than the
241
- // schedule. Otherwise return the saved report so users never re-run.
242
- // Labels retired from the report. A saved report containing any of them
243
- // is stale by definition and always recomputes.
249
+ // Recompute when forced, missing, or past the schedule; a report holding a
250
+ // retired label is stale by definition.
244
251
  const RETIRED_DIAG_LABELS = new Set([
245
252
  'OMP CLI',
246
253
  'Tersio CLI',
@@ -255,13 +262,11 @@ const RETIRED_DIAG_LABELS = new Set([
255
262
  function isRetiredReport(report) {
256
263
  return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
257
264
  }
258
- // Retained diagnosis: recompute when forced, missing, retired, or older
259
- // than the schedule. Otherwise return the saved report.
265
+ // Recompute when forced, missing, retired, or past the schedule.
260
266
  function getDoctorReport(force) {
261
267
  const saved = readDiagReport();
262
268
  const ttl = saved ? DIAG_TTL_MS[saved.schedule] : 0;
263
- // Manual always recomputes on open; dated schedules reuse the saved
264
- // report until stale. Otherwise a retained snapshot hides new rows.
269
+ // Manual recomputes on open; a dated schedule reuses the saved report.
265
270
  if (!force && saved && !isRetiredReport(saved) && ttl > 0 && Date.now() - saved.checkedAt < ttl)
266
271
  return saved;
267
272
  const report = { rows: computeDoctorRows(), checkedAt: Date.now(), schedule: saved?.schedule ?? 'manual' };
@@ -277,10 +282,8 @@ function setDiagSchedule(schedule) {
277
282
  function readDiagSchedule() {
278
283
  return readDiagReport()?.schedule ?? 'manual';
279
284
  }
280
- // Persist the dashboard's picker choice so close → reopen keeps it: each
281
- // `tersio dashboard` run serves a fresh ephemeral port (a new origin), so the
282
- // browser's localStorage alone cannot survive a restart. The stored default
283
- // feeds data.json and `tersio usage` on the next run.
285
+ // Persist the picker choice: every run serves a fresh port, so localStorage
286
+ // alone cannot survive a restart.
284
287
  async function saveDashboardCurrency(raw) {
285
288
  if (typeof raw !== 'string')
286
289
  return null;
@@ -304,21 +307,30 @@ function openBrowser(url) {
304
307
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
305
308
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
306
309
  }
307
- // Replacer functions throughout: session data routinely contains `$'`
308
- // sequences (shell quoting in tool details), which String.replace would
309
- // expand as match-suffix patterns and corrupt the file.
310
- // RTK-metered command lines routinely contain literal `</script>` (Vue
311
- // SFC probes), which would close the inlined <script> early and dump the
312
- // rest of the JSON as page text. Escape it; JSON.parse never sees the
313
- // backslash form inside a string literal, the browser decodes it first.
310
+ // Replacers throughout: session data holds `$'` (shell quoting) and literal
311
+ // `</script>`, which String.replace would mangle.
314
312
  function escapeInline(json) {
315
313
  return json.replace(/<\/(script)/gi, '<\\/$1');
316
314
  }
315
+ // Our own JSON.stringify output, so a parse failure means a bad value: name it.
316
+ function parseOwnJson(json, field) {
317
+ let parsed;
318
+ try {
319
+ parsed = JSON.parse(json);
320
+ }
321
+ catch {
322
+ throw new Error(`dashboard snapshot ${field} is not valid JSON`);
323
+ }
324
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
325
+ throw new Error(`dashboard snapshot ${field} is not an object`);
326
+ }
327
+ return parsed;
328
+ }
317
329
  async function exportDashboard(exportFile) {
318
330
  requireDashboardBundle();
319
331
  const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
320
332
  const inline = bundle
321
- .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: JSON.parse(dataJson()), health: JSON.parse(healthJson()), doctor: getDoctorReport(false) }))};`)
333
+ .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: parseOwnJson(dataJson(), 'usage data'), health: parseOwnJson(healthJson(), 'health'), doctor: getDoctorReport(false) }))};`)
322
334
  .replace(/href="brand\.webp"/g, () => `href="${icon}"`)
323
335
  .replace(/src="brand\.webp"/g, () => `src="${icon}"`)
324
336
  .replace('fetch("brand.webp")', () => `Promise.resolve({ ok: true, blob: async () => new Blob([atob("${icon.split(',')[1]}")], { type: "image/webp" }) })`);
package/cli/doctor-fix.js CHANGED
@@ -1,4 +1,4 @@
1
- import { promises as fs } from 'node:fs';
1
+ import { existsSync, promises as fs } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
@@ -7,28 +7,39 @@ import { execNetwork } from './interactive.js';
7
7
  import { wireRtkOmp, 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';
11
+ import { detectHosts, hostExtensionsDir } from './hosts.js';
10
12
  const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
11
- const SOURCES = [
12
- [path.join(EXT_DIR, 'shared', 'session-state.ts'), path.join('shared', 'session-state.ts')],
13
- [path.join(EXT_DIR, 'shared', 'types.ts'), path.join('shared', 'types.ts')],
14
- [path.join(EXT_DIR, 'lib', 'utils.ts'), path.join('lib', 'utils.ts')],
15
- [path.join(EXT_DIR, 'shared', 'plugin-settings.ts'), path.join('shared', 'plugin-settings.ts')],
16
- [path.join(EXT_DIR, 'shared', 'usage-ledger.ts'), path.join('shared', 'usage-ledger.ts')],
17
- [path.join(EXT_DIR, 'shared', 'pricing.ts'), path.join('shared', 'pricing.ts')],
18
- [path.join(EXT_DIR, 'shared', 'carbon.ts'), path.join('shared', 'carbon.ts')],
19
- [path.join(EXT_DIR, 'caveman-session', 'index.ts'), path.join('caveman-session', 'index.ts')],
20
- [path.join(EXT_DIR, 'caveman-session', 'rule.md'), path.join('caveman-session', 'rule.md')],
21
- [path.join(EXT_DIR, 'rtk-session', 'index.ts'), path.join('rtk-session', 'index.ts')],
22
- [path.join(EXT_DIR, 'ai-addons-updater', 'index.ts'), path.join('ai-addons-updater', 'index.ts')],
23
- [path.join(EXT_DIR, 'combo-toggle', 'index.ts'), path.join('combo-toggle', 'index.ts')],
24
- [path.join(EXT_DIR, 'tersio-commands', 'index.ts'), path.join('tersio-commands', 'index.ts')],
25
- ];
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.
15
+ async function fixExtensionTrees() {
16
+ for (const host of detectHosts()) {
17
+ 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)
22
+ continue;
23
+ await fs.mkdir(dest, { recursive: true });
24
+ for (const file of TREE_FILES) {
25
+ const from = path.join(EXT_DIR, ...file.split('/'));
26
+ const text = await readTextIfExists(from);
27
+ if (text === null)
28
+ console.log(` [warn] bundled source missing: ${from}`);
29
+ else
30
+ await writeIfChanged(path.join(dest, file), text, { dryRun, verbose });
31
+ }
32
+ if (!dryRun)
33
+ console.log(` [ok] ${host.label} extension tree: ${dest}`);
34
+ }
35
+ }
26
36
  async function fixExtensions(pluginsDir) {
37
+ await fixExtensionTrees();
27
38
  console.log(' Doctor --fix: restoring plugin extension files');
28
39
  const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
29
40
  const pluginExtDir = path.join(tersioPluginDir, 'extensions');
30
41
  await fs.mkdir(pluginExtDir, { recursive: true });
31
- for (const [from, to] of SOURCES) {
42
+ for (const [from, to] of TREE_FILES.map((file) => [sourcePath(file), file])) {
32
43
  const text = await readTextIfExists(from);
33
44
  if (text === null)
34
45
  console.log(` [warn] bundled source missing: ${from}`);
package/cli/doctor.js CHANGED
@@ -1,17 +1,16 @@
1
1
  // cli/doctor.ts — installation health checks.
2
- import { promises as fs } from 'node:fs';
2
+ import { existsSync, promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
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';
5
6
  import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
6
7
  import { usageDbPath } from '../extensions/shared/usage-store.js';
7
8
  import { pricesCachePath } from '../extensions/shared/pricing.js';
8
9
  import { readTextIfExists, resolveRtkBinary } from '../extensions/lib/utils.js';
9
10
  async function runDoctor(recheck = false) {
10
11
  console.log('\n=== Tersio Doctor ===');
11
- // Directories
12
- const agentDir = OMP_AGENT_DIR;
13
- const extDir = path.join(agentDir, 'extensions');
14
- const configPath = path.join(agentDir, 'config.yml');
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.
15
14
  const pluginsDir = OMP_PLUGINS_DIR;
16
15
  const rtkBin = resolveRtkBinary();
17
16
  // Ponytail
@@ -24,15 +23,13 @@ async function runDoctor(recheck = false) {
24
23
  const updaterIndex = path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts');
25
24
  // Independent probes start concurrently; sections report in fixed order as
26
25
  // their data settles. Every probe resolves instead of rejecting.
26
+ const hosts = detectHosts();
27
27
  const probes = {
28
- agentEntries: fs.readdir(agentDir).catch(() => null),
29
- extEntries: fs.readdir(extDir).catch(() => null),
30
- sharedStateText: readTextIfExists(path.join(tersioPluginDir, 'extensions', 'shared', 'session-state.ts')),
31
- configText: readTextIfExists(configPath),
28
+ ompPkgText: readTextIfExists(path.join(ompPackageDir(), 'package.json')),
29
+ configText: readTextIfExists(path.join(OMP_AGENT_DIR, 'config.yml')),
32
30
  ponytailPkgText: readTextIfExists(ponytailPkg),
33
31
  ponytailExtText: readTextIfExists(ponytailExt),
34
32
  rtkBinText: rtkBin ? readTextIfExists(rtkBin) : Promise.resolve(null),
35
- rtkOmpText: readTextIfExists(path.join(extDir, 'rtk.ts')),
36
33
  cavemanIndexText: readTextIfExists(cavemanIndex),
37
34
  cavemanRuleText: readTextIfExists(cavemanRule),
38
35
  rtkIndexText: readTextIfExists(rtkIndex),
@@ -46,11 +43,9 @@ async function runDoctor(recheck = false) {
46
43
  const err = e;
47
44
  return err.stdout?.trim() || err.stderr?.trim() || null;
48
45
  }) : Promise.resolve(null);
49
- const [[agentEntries, extEntries, sharedStateText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText, ponytailExtText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, rtkOmpText, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
46
+ const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText, ponytailExtText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
50
47
  Promise.all([
51
- probes.agentEntries,
52
- probes.extEntries,
53
- probes.sharedStateText,
48
+ probes.ompPkgText,
54
49
  probes.configText,
55
50
  ]),
56
51
  Promise.all([
@@ -66,7 +61,6 @@ async function runDoctor(recheck = false) {
66
61
  probes.rtkBinText,
67
62
  probes.rtkMtime,
68
63
  rtkVersionProbe,
69
- probes.rtkOmpText,
70
64
  probes.ponytailMtime,
71
65
  probes.pricesMtime,
72
66
  ]),
@@ -94,28 +88,49 @@ async function runDoctor(recheck = false) {
94
88
  tally.warn++;
95
89
  console.log(` ⚠️ ${label}: warn ${detail}`);
96
90
  }
97
- section('Environment');
98
- check('Node', true, process.version);
99
- section('Installation');
100
- check('OMP agent dir', agentEntries !== null, agentEntries === null ? agentDir : '');
101
- check('OMP extensions dir', extEntries !== null, extEntries === null ? extDir : '');
102
- check('OMP config.yml', configText !== null, configText === null ? configPath : '');
103
- check('Shared session bridge', sharedStateText !== null);
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.
93
+ function unused(label, detail) {
94
+ console.log(` — ${label}: not installed ${detail}`);
95
+ }
96
+ section('Hosts');
97
+ for (const host of hosts) {
98
+ 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
+ if (host.declared)
102
+ check(host.label, false, `declared (${host.declared}) but not installed`);
103
+ else
104
+ unused(host.label, `· ${host.installCmd}`);
105
+ continue;
106
+ }
107
+ const where = host.via === 'tree' ? `extensions in ${host.dir}` : `package ${PACKAGE_NAME}`;
108
+ check(host.label, true, `${where}${host.version ? ` ${host.version}` : ''}`);
109
+ }
104
110
  section('Extensions & plugins');
105
- const explicitEntries = (configText ?? '').split('\n')
106
- .map((line) => line.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, ''))
107
- .filter((line) => line.startsWith('/') || line.startsWith('.'));
108
- const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
109
- const retiredReinforcement = explicitEntries.filter((entry) => entry.endsWith('/shared/mode-reinforcement.ts')).length;
110
- check('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.join(', '));
111
- if (retiredReinforcement === 0)
112
- check('No retired reinforcement', true);
113
- else
114
- warnLine('Retired reinforcement registration', `${retiredReinforcement} found; run doctor --fix registrations`);
115
- check('Caveman extension', cavemanIndexText !== null);
116
- check('RTK extension', rtkIndexText !== null);
117
- check('Updater extension', updaterIndexText !== null);
118
- check('Ponytail extension', ponytailExtText !== null);
111
+ const ompEntry = hosts.find((host) => host.id === 'omp');
112
+ if (ompEntry?.via === 'package') {
113
+ const explicitEntries = (configText ?? '').split('\n')
114
+ .map((line) => line.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, ''))
115
+ .filter((line) => line.startsWith('/') || line.startsWith('.'));
116
+ const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
117
+ 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}` : '');
133
+ }
119
134
  section('Usage & records');
120
135
  console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
121
136
  const pricesAge = pricesMtime ? `(pulled ${relTime(Date.now() - pricesMtime.mtimeMs)} · ${absDate(pricesMtime.mtimeMs)})` : '';
@@ -127,10 +142,6 @@ async function runDoctor(recheck = false) {
127
142
  check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
128
143
  if (rtkBinText !== null && !rtkVersion)
129
144
  warnLine('RTK version', 'unavailable — binary may not be executable');
130
- const rtkRegistered = rtkOmpText !== null && (configText ?? '').includes('extensions/rtk.ts');
131
- check('RTK OMP wiring (rtk.ts)', rtkOmpText !== null, rtkOmpText === null ? 'run: rtk init -g --agent omp' : '');
132
- if (rtkOmpText !== null && !rtkRegistered)
133
- warnLine('RTK in config.yml', 'rtk.ts not listed — OMP will not load it; rerun install');
134
145
  const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
135
146
  const ponytailVer = parseJsonObject(ponytailPkgText)?.version ?? '';
136
147
  check('Ponytail', ponytailPkgText !== null, [ponytailVer, ponytailAge].filter(Boolean).join(' '));
package/cli/hosts.js ADDED
@@ -0,0 +1,118 @@
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.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { HOME, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION } from './common.js';
7
+ import { TREE_FILES } from './manifest.js';
8
+ import { piAgentDir } from '../extensions/lib/utils.js';
9
+ export function hostExtensionsDir(id, agentDir = piAgentDir()) {
10
+ return id === 'omp'
11
+ ? path.join(HOME, '.omp', 'agent', 'extensions')
12
+ : path.join(agentDir, 'extensions');
13
+ }
14
+ export function missingTreeFiles(id, agentDir = piAgentDir()) {
15
+ const dir = hostExtensionsDir(id, agentDir);
16
+ return TREE_FILES.filter((file) => !existsSync(path.join(dir, file)));
17
+ }
18
+ export function ompPackageDir() {
19
+ return path.join(OMP_PLUGINS_DIR, 'node_modules', ...PACKAGE_NAME.split('/'));
20
+ }
21
+ function piPackageDir(agentDir = piAgentDir()) {
22
+ return path.join(agentDir, 'npm', 'node_modules', ...PACKAGE_NAME.split('/'));
23
+ }
24
+ function piSettingsFile(agentDir = piAgentDir()) {
25
+ return path.join(agentDir, 'settings.json');
26
+ }
27
+ function packageVersion(dir) {
28
+ try {
29
+ const parsed = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
30
+ return typeof parsed.version === 'string' ? parsed.version : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
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.
39
+ export function piTersioSource(agentDir = piAgentDir()) {
40
+ let settings;
41
+ try {
42
+ const parsed = JSON.parse(readFileSync(piSettingsFile(agentDir), 'utf8'));
43
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
44
+ return null;
45
+ settings = parsed;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ if (!Array.isArray(settings.packages))
51
+ return null;
52
+ for (const entry of settings.packages) {
53
+ const source = typeof entry === 'string' ? entry : entry?.source;
54
+ if (typeof source !== 'string')
55
+ continue;
56
+ const trimmed = source.trim().replace(/[/\\]+$/, '');
57
+ if (trimmed.includes(PACKAGE_NAME) || /(^|[/\\])tersio(\.git)?$/i.test(trimmed))
58
+ return source.trim();
59
+ }
60
+ return null;
61
+ }
62
+ // The hosts we install into, and the commands that add and remove each.
63
+ const HOSTS = [
64
+ { id: 'omp', label: 'Oh My Pi', bin: 'omp', installCmd: `omp plugin install ${PACKAGE_NAME}`, removeCmd: `omp plugin remove ${PACKAGE_NAME}` },
65
+ { id: 'pi', label: 'Pi', bin: 'pi', installCmd: `pi install npm:${PACKAGE_NAME}`, removeCmd: `pi remove npm:${PACKAGE_NAME}` },
66
+ ];
67
+ function detect(agentDir = piAgentDir()) {
68
+ return HOSTS.map((host) => {
69
+ const pkgDir = host.id === 'omp' ? ompPackageDir() : piPackageDir(agentDir);
70
+ 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.
73
+ const via = missingTreeFiles(host.id, agentDir).length === 0
74
+ ? 'tree'
75
+ : existsSync(pkgDir) ? 'package' : null;
76
+ return {
77
+ ...host,
78
+ installed: via !== null,
79
+ via,
80
+ 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,
84
+ dir: via === 'tree' ? tree : via === 'package' ? pkgDir : null,
85
+ };
86
+ });
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
+ */
92
+ export function hostLabel(host) {
93
+ if (host.installed && host.version)
94
+ return `${host.label} (installed ${host.version})`;
95
+ if (host.installed)
96
+ return `${host.label} (installed)`;
97
+ if (host.declared)
98
+ return `${host.label} (package declared, not on disk)`;
99
+ return `${host.label} (not installed)`;
100
+ }
101
+ export function hostHint(host) {
102
+ if (host.installed && host.dir)
103
+ return host.dir;
104
+ return host.installCmd;
105
+ }
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.
108
+ export function parseHostArg(argv) {
109
+ const i = argv.indexOf('--host');
110
+ const raw = (i === -1 ? undefined : argv[i + 1]) ?? argv.find((a) => a.startsWith('--host='))?.slice('--host='.length);
111
+ if (raw === undefined)
112
+ return undefined;
113
+ const value = raw.trim().toLowerCase();
114
+ if (value !== 'omp' && value !== 'pi')
115
+ throw new Error(`Invalid --host: ${raw}. Valid: omp, pi`);
116
+ return value;
117
+ }
118
+ export { HOME, detect as detectHosts };