@krtclcdy/tersio 2.22.1 → 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 (59) hide show
  1. package/README.md +62 -46
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +55 -55
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +76 -49
  7. package/cli/doctor-fix.js +61 -44
  8. package/cli/doctor.js +66 -44
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +187 -143
  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 +45 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/settings.js +1 -1
  21. package/cli/uninstall.js +135 -44
  22. package/cli/update.js +24 -30
  23. package/cli/usage.js +6 -1
  24. package/dashboard/dist/index.html +16 -16
  25. package/extensions/ai-addons-updater/index.js +36 -25
  26. package/extensions/ai-addons-updater/index.ts +36 -24
  27. package/extensions/caveman-session/index.js +23 -18
  28. package/extensions/caveman-session/index.ts +23 -18
  29. package/extensions/caveman-session/rule.md +90 -0
  30. package/extensions/combo-toggle/index.js +63 -41
  31. package/extensions/combo-toggle/index.ts +61 -48
  32. package/extensions/lib/utils.js +56 -13
  33. package/extensions/lib/utils.ts +55 -13
  34. package/extensions/rtk-session/index.js +20 -19
  35. package/extensions/rtk-session/index.ts +18 -24
  36. package/extensions/shared/carbon.js +5 -8
  37. package/extensions/shared/carbon.ts +5 -8
  38. package/extensions/shared/host.js +68 -0
  39. package/extensions/shared/host.ts +104 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +63 -16
  42. package/extensions/shared/plugin-settings.ts +67 -17
  43. package/extensions/shared/pricing.js +26 -15
  44. package/extensions/shared/pricing.ts +28 -16
  45. package/extensions/shared/rtk-gain.js +13 -24
  46. package/extensions/shared/rtk-gain.ts +14 -25
  47. package/extensions/shared/session-state.js +26 -28
  48. package/extensions/shared/session-state.ts +26 -29
  49. package/extensions/shared/types.ts +15 -3
  50. package/extensions/shared/usage-ledger.js +136 -38
  51. package/extensions/shared/usage-ledger.ts +149 -52
  52. package/extensions/shared/usage-store.js +14 -12
  53. package/extensions/shared/usage-store.ts +16 -14
  54. package/extensions/tersio-commands/index.js +2 -1
  55. package/extensions/tersio-commands/index.ts +2 -1
  56. package/package.json +15 -7
  57. package/tersio.js +6 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
  59. package/extensions/shared/mode-reinforcement.ts +0 -29
package/cli/doctor-fix.js CHANGED
@@ -1,49 +1,67 @@
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';
5
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, dryRun, verbose, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, execP, readPluginsPackage, writeIfChanged, } from './common.js';
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
6
  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, 'rtk-session', 'index.ts'), path.join('rtk-session', 'index.ts')],
21
- [path.join(EXT_DIR, 'ai-addons-updater', 'index.ts'), path.join('ai-addons-updater', 'index.ts')],
22
- [path.join(EXT_DIR, 'combo-toggle', 'index.ts'), path.join('combo-toggle', 'index.ts')],
23
- [path.join(EXT_DIR, 'tersio-commands', 'index.ts'), path.join('tersio-commands', 'index.ts')],
24
- [path.join(EXT_DIR, 'shared', 'mode-reinforcement.ts'), path.join('shared', 'mode-reinforcement.ts')],
25
- ];
26
- async function fixExtensions(extDir) {
27
- console.log(' Doctor --fix: restoring extension files');
28
- await fs.mkdir(extDir, { recursive: true });
29
- const texts = await Promise.all(SOURCES.map(([from]) => readTextIfExists(from)));
30
- const writes = [];
31
- for (let i = 0; i < SOURCES.length; i++) {
32
- if (texts[i] !== null)
33
- writes.push(writeIfChanged(path.join(extDir, SOURCES[i][1]), texts[i], { dryRun, verbose }));
34
- else
35
- console.log(` [warn] bundled source missing: ${SOURCES[i][0]}`);
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}`);
36
34
  }
37
- await Promise.all(writes);
38
- if ((await readTextIfExists(path.join(extDir, 'caveman-session', 'rule.md'))) === null) {
39
- const rule = await Promise.race([
40
- httpsGet(CAVEMAN_REMOTE_RULE).catch(() => null),
41
- new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
42
- ]);
43
- if (rule !== null)
44
- await writeIfChanged(path.join(extDir, 'caveman-session', 'rule.md'), rule, { dryRun, verbose });
35
+ }
36
+ async function fixExtensions(pluginsDir) {
37
+ await fixExtensionTrees();
38
+ 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');
41
+ 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);
44
+ if (text === null)
45
+ console.log(` [warn] bundled source missing: ${from}`);
45
46
  else
46
- console.log(' [warn] Caveman rule unreachable — kept existing rule.md');
47
+ await writeIfChanged(path.join(pluginExtDir, to), text, { dryRun, verbose });
48
+ }
49
+ const ruleDest = path.join(pluginExtDir, 'caveman-session', 'rule.md');
50
+ if ((await readTextIfExists(ruleDest)) === null) {
51
+ const bundled = await readTextIfExists(path.join(EXT_DIR, 'caveman-session', 'rule.md'));
52
+ if (bundled !== null) {
53
+ await writeIfChanged(ruleDest, bundled, { dryRun, verbose });
54
+ }
55
+ else {
56
+ const rule = await Promise.race([
57
+ httpsGet(CAVEMAN_REMOTE_RULE).catch(() => null),
58
+ new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
59
+ ]);
60
+ if (rule !== null)
61
+ await writeIfChanged(ruleDest, rule, { dryRun, verbose });
62
+ else
63
+ console.log(' [warn] Caveman rule unreachable and no bundled rule exists');
64
+ }
47
65
  }
48
66
  }
49
67
  async function fixRegistrations(agentDir, pluginsDir) {
@@ -58,13 +76,13 @@ async function fixRegistrations(agentDir, pluginsDir) {
58
76
  }
59
77
  }
60
78
  const extDir = path.join(agentDir, 'extensions');
61
- const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
62
- await ensureExtensionInConfig(configPath, ponytailExtPath, 'ponytail', { dryRun, verbose });
63
- await ensureExtensionInConfig(configPath, path.join(extDir, 'combo-toggle', 'index.ts'), 'combo', { dryRun, verbose });
64
79
  const rtkTs = path.join(extDir, 'rtk.ts');
65
80
  if ((await readTextIfExists(rtkTs)) !== null)
66
81
  await ensureRtkInConfig({ dryRun, verbose });
67
- await ensureExtensionAfterConfigEntry(configPath, path.join(extDir, 'shared', 'mode-reinforcement.ts'), ponytailExtPath, 'mode reinforcement', { dryRun, verbose });
82
+ const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
83
+ await removeExtensionFromConfig(configPath, path.join(extDir, 'combo-toggle', 'index.ts'), 'manifest-loaded combo', { dryRun, verbose });
84
+ await removeExtensionFromConfig(configPath, ponytailExtPath, 'manifest-loaded ponytail', { dryRun, verbose });
85
+ await removeExtensionFromConfig(configPath, path.join(extDir, 'shared', 'mode-reinforcement.ts'), 'retired mode reinforcement', { dryRun, verbose });
68
86
  const pkgPath = path.join(pluginsDir, 'package.json');
69
87
  const pkg = await readPluginsPackage(pkgPath);
70
88
  if (!(PACKAGE_NAME in pkg.dependencies)) {
@@ -127,7 +145,7 @@ async function fixRtk(binDir) {
127
145
  if (!(await wireRtkOmp(binDest, { dryRun, verbose })))
128
146
  throw new Error('rtk wiring failed');
129
147
  }
130
- async function fixPonytail(pluginsDir, agentDir) {
148
+ async function fixPonytail(pluginsDir) {
131
149
  console.log(' Doctor --fix: restoring bundled Ponytail');
132
150
  await fs.mkdir(pluginsDir, { recursive: true });
133
151
  const pkgPath = path.join(pluginsDir, 'package.json');
@@ -147,7 +165,6 @@ async function fixPonytail(pluginsDir, agentDir) {
147
165
  await execNetwork('Restoring bundled Ponytail', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 }).catch((e) => {
148
166
  throw new Error(`bundled ponytail restore failed: ${e.message}`);
149
167
  });
150
- await ensureExtensionInConfig(path.join(agentDir, 'config.yml'), path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'), 'ponytail', { dryRun, verbose });
151
168
  }
152
169
  async function fixCli() {
153
170
  console.log(' Doctor --fix: updating CLI + add-ons');
@@ -171,13 +188,13 @@ async function runDoctorRepairs(targets) {
171
188
  }
172
189
  };
173
190
  if (want('extensions'))
174
- await attempt('extensions', () => fixExtensions(path.join(OMP_AGENT_DIR, 'extensions')));
191
+ await attempt('extensions', () => fixExtensions(OMP_PLUGINS_DIR));
175
192
  if (want('registrations'))
176
193
  await attempt('registrations', () => fixRegistrations(OMP_AGENT_DIR, OMP_PLUGINS_DIR));
177
194
  if (want('rtk'))
178
195
  await attempt('rtk', () => fixRtk(BUN_BIN_DIR));
179
196
  if (want('ponytail'))
180
- await attempt('ponytail', () => fixPonytail(OMP_PLUGINS_DIR, OMP_AGENT_DIR));
197
+ await attempt('ponytail', () => fixPonytail(OMP_PLUGINS_DIR));
181
198
  if (want('cli'))
182
199
  await attempt('cli', fixCli);
183
200
  return failed;
package/cli/doctor.js CHANGED
@@ -1,55 +1,51 @@
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 { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, RTK_BINARY_NAME, 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
- import { readTextIfExists } from '../extensions/lib/utils.js';
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
- const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
15
+ const rtkBin = resolveRtkBinary();
17
16
  // Ponytail
18
17
  const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
19
18
  const ponytailExt = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
20
- const cavemanIndex = path.join(extDir, 'caveman-session', 'index.ts');
21
- const cavemanRule = path.join(extDir, 'caveman-session', 'rule.md');
22
- const rtkIndex = path.join(extDir, 'rtk-session', 'index.ts');
23
- const updaterIndex = path.join(extDir, 'ai-addons-updater', 'index.ts');
19
+ const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
20
+ const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts');
21
+ const cavemanRule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
22
+ const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts');
23
+ const updaterIndex = path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts');
24
24
  // Independent probes start concurrently; sections report in fixed order as
25
25
  // their data settles. Every probe resolves instead of rejecting.
26
+ const hosts = detectHosts();
26
27
  const probes = {
27
- agentEntries: fs.readdir(agentDir).catch(() => null),
28
- extEntries: fs.readdir(extDir).catch(() => null),
29
- sharedStateText: readTextIfExists(path.join(extDir, 'shared', 'session-state.ts')),
30
- configText: readTextIfExists(configPath),
28
+ ompPkgText: readTextIfExists(path.join(ompPackageDir(), 'package.json')),
29
+ configText: readTextIfExists(path.join(OMP_AGENT_DIR, 'config.yml')),
31
30
  ponytailPkgText: readTextIfExists(ponytailPkg),
32
31
  ponytailExtText: readTextIfExists(ponytailExt),
33
- rtkBinText: readTextIfExists(rtkBin),
34
- rtkOmpText: readTextIfExists(path.join(extDir, 'rtk.ts')),
32
+ rtkBinText: rtkBin ? readTextIfExists(rtkBin) : Promise.resolve(null),
35
33
  cavemanIndexText: readTextIfExists(cavemanIndex),
36
34
  cavemanRuleText: readTextIfExists(cavemanRule),
37
35
  rtkIndexText: readTextIfExists(rtkIndex),
38
36
  updaterIndexText: readTextIfExists(updaterIndex),
37
+ rtkMtime: rtkBin ? fs.stat(rtkBin).catch(() => null) : Promise.resolve(null),
39
38
  ruleMtime: fs.stat(cavemanRule).catch(() => null),
40
- rtkMtime: fs.stat(rtkBin).catch(() => null),
41
39
  ponytailMtime: fs.stat(ponytailPkg).catch(() => null),
42
40
  pricesMtime: fs.stat(pricesCachePath()).catch(() => null),
43
41
  };
44
- const rtkVersionProbe = probes.rtkBinText.then((text) => text === null ? null : execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => r.stdout.trim() || r.stderr.trim() || null, (e) => {
42
+ const rtkVersionProbe = rtkBin ? execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => r.stdout.trim() || r.stderr.trim() || null, (e) => {
45
43
  const err = e;
46
44
  return err.stdout?.trim() || err.stderr?.trim() || null;
47
- }));
48
- const [[agentEntries, extEntries, sharedStateText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText, ponytailExtText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, rtkOmpText, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
45
+ }) : 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([
49
47
  Promise.all([
50
- probes.agentEntries,
51
- probes.extEntries,
52
- probes.sharedStateText,
48
+ probes.ompPkgText,
53
49
  probes.configText,
54
50
  ]),
55
51
  Promise.all([
@@ -65,7 +61,6 @@ async function runDoctor(recheck = false) {
65
61
  probes.rtkBinText,
66
62
  probes.rtkMtime,
67
63
  rtkVersionProbe,
68
- probes.rtkOmpText,
69
64
  probes.ponytailMtime,
70
65
  probes.pricesMtime,
71
66
  ]),
@@ -93,18 +88,49 @@ async function runDoctor(recheck = false) {
93
88
  tally.warn++;
94
89
  console.log(` ⚠️ ${label}: warn ${detail}`);
95
90
  }
96
- section('Environment');
97
- check('Node', true, process.version);
98
- section('Installation');
99
- check('OMP agent dir', agentEntries !== null, agentEntries === null ? agentDir : '');
100
- check('OMP extensions dir', extEntries !== null, extEntries === null ? extDir : '');
101
- check('OMP config.yml', configText !== null, configText === null ? configPath : '');
102
- 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
+ }
103
110
  section('Extensions & plugins');
104
- check('Caveman extension', cavemanIndexText !== null);
105
- check('RTK extension', rtkIndexText !== null);
106
- check('Updater extension', updaterIndexText !== null);
107
- 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
+ }
108
134
  section('Usage & records');
109
135
  console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
110
136
  const pricesAge = pricesMtime ? `(pulled ${relTime(Date.now() - pricesMtime.mtimeMs)} · ${absDate(pricesMtime.mtimeMs)})` : '';
@@ -113,13 +139,9 @@ async function runDoctor(recheck = false) {
113
139
  const ruleAge = ruleMtime ? `(updated ${relTime(Date.now() - ruleMtime.mtimeMs)} · ${absDate(ruleMtime.mtimeMs)})` : '';
114
140
  check('Caveman rule', cavemanRuleText !== null, ruleAge);
115
141
  const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
116
- check('RTK binary', rtkBinText !== null, rtkBinText === null ? rtkBin : [rtkVersion, rtkAge].filter(Boolean).join(' '));
142
+ check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
117
143
  if (rtkBinText !== null && !rtkVersion)
118
144
  warnLine('RTK version', 'unavailable — binary may not be executable');
119
- const rtkRegistered = rtkOmpText !== null && (configText ?? '').includes('extensions/rtk.ts');
120
- check('RTK OMP wiring (rtk.ts)', rtkOmpText !== null, rtkOmpText === null ? 'run: rtk init -g --agent omp' : '');
121
- if (rtkOmpText !== null && !rtkRegistered)
122
- warnLine('RTK in config.yml', 'rtk.ts not listed — OMP will not load it; rerun install');
123
145
  const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
124
146
  const ponytailVer = parseJsonObject(ponytailPkgText)?.version ?? '';
125
147
  check('Ponytail', ponytailPkgText !== null, [ponytailVer, ponytailAge].filter(Boolean).join(' '));
@@ -164,7 +186,7 @@ async function runDoctorFix(tally) {
164
186
  if (tally.warn > 0)
165
187
  problems.push(`${tally.warn} warn`);
166
188
  if (dryRun) {
167
- console.log(`\n [dry-run] would repair ${problems.join(' + ')} (extensions, config.yml registrations, rtk wiring, ponytail refresh, CLI update)`);
189
+ console.log(`\n [dry-run] would repair ${problems.join(' + ')} (extensions, stale config.yml entries, rtk wiring, ponytail refresh, CLI update)`);
168
190
  return;
169
191
  }
170
192
  const scoped = fixScope();
@@ -186,7 +208,7 @@ async function runDoctorFix(tally) {
186
208
  const picked = await askInteractiveChoice('Doctor — what should I repair?', [
187
209
  { value: 'all', label: 'Fix everything', hint: `${tally.missing + tally.warn} rows` },
188
210
  { value: 'extensions', label: 'Missing extension files', hint: 'copy sources from this CLI' },
189
- { value: 'registrations', label: 'config.yml registrations', hint: 'combo, ponytail, rtk.ts entries' },
211
+ { value: 'registrations', label: 'config.yml registrations', hint: 'remove stale entries; keep manifest-owned extensions out' },
190
212
  { value: 'rtk', label: 'RTK binary + wiring', hint: 'download checksum-verified binary, rtk init' },
191
213
  { value: 'ponytail', label: 'Ponytail package', hint: 'bundled reinstall via tersio dep' },
192
214
  { value: 'cli', label: 'CLI update', hint: 'latest tersio + delegated refresh' },
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 };