@krtclcdy/tersio 2.20.1 → 2.22.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.
@@ -0,0 +1,185 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
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';
6
+ import { execNetwork } from './interactive.js';
7
+ import { wireRtkOmp, ensureRtkInConfig } from './rtk-wiring.js';
8
+ import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
9
+ import { runLatestUpdate } from './update.js';
10
+ 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]}`);
36
+ }
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 });
45
+ else
46
+ console.log(' [warn] Caveman rule unreachable — kept existing rule.md');
47
+ }
48
+ }
49
+ async function fixRegistrations(agentDir, pluginsDir) {
50
+ console.log(' Doctor --fix: repairing config.yml registrations');
51
+ const configPath = path.join(agentDir, 'config.yml');
52
+ if ((await readTextIfExists(configPath)) === null) {
53
+ if (dryRun)
54
+ console.log(` [dry-run] would create ${configPath}`);
55
+ else {
56
+ await fs.mkdir(agentDir, { recursive: true });
57
+ await fs.writeFile(configPath, 'extensions:\n', 'utf8');
58
+ }
59
+ }
60
+ 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
+ const rtkTs = path.join(extDir, 'rtk.ts');
65
+ if ((await readTextIfExists(rtkTs)) !== null)
66
+ await ensureRtkInConfig({ dryRun, verbose });
67
+ await ensureExtensionAfterConfigEntry(configPath, path.join(extDir, 'shared', 'mode-reinforcement.ts'), ponytailExtPath, 'mode reinforcement', { dryRun, verbose });
68
+ const pkgPath = path.join(pluginsDir, 'package.json');
69
+ const pkg = await readPluginsPackage(pkgPath);
70
+ if (!(PACKAGE_NAME in pkg.dependencies)) {
71
+ pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
72
+ if (dryRun)
73
+ console.log(` [dry-run] would register ${PACKAGE_NAME} in ${pkgPath}`);
74
+ else {
75
+ await fs.mkdir(pluginsDir, { recursive: true });
76
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
77
+ }
78
+ }
79
+ }
80
+ async function fixRtk(binDir) {
81
+ console.log(' Doctor --fix: repairing RTK binary + wiring');
82
+ const binDest = path.join(binDir, RTK_BINARY_NAME);
83
+ if (dryRun) {
84
+ console.log(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into OMP`);
85
+ return;
86
+ }
87
+ const spec = rtkPlatformSpec();
88
+ if (!spec)
89
+ throw new Error(`unsupported platform ${process.platform}/${process.arch}`);
90
+ const release = await fetchJson(RTK_RELEASE_API);
91
+ const asset = (release.assets || []).find((a) => a.name === `rtk-${spec.triple}${spec.ext}`);
92
+ const checksAsset = (release.assets || []).find((a) => a.name === 'checksums.txt');
93
+ if (!asset || !checksAsset)
94
+ throw new Error(`required assets missing in release ${release.tag_name}`);
95
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tersio-doctor-rtk-'));
96
+ try {
97
+ const archivePath = path.join(tmpDir, asset.name);
98
+ const [checks] = await Promise.all([
99
+ httpsGet(checksAsset.browser_download_url).catch(() => null),
100
+ httpsDownload(asset.browser_download_url, archivePath),
101
+ ]);
102
+ if (checks) {
103
+ const expected = parseChecksum(checks, asset.name);
104
+ if (expected && await sha256File(archivePath) !== expected)
105
+ throw new Error(`checksum mismatch for ${asset.name}`);
106
+ debug('RTK checksum verified');
107
+ }
108
+ const extractDir = path.join(tmpDir, 'extracted');
109
+ await fs.mkdir(extractDir, { recursive: true });
110
+ if (asset.name.endsWith('.zip'))
111
+ await execP('powershell', ['Expand-Archive', '-Path', archivePath, '-DestinationPath', extractDir, '-Force'], { timeout: 60000 });
112
+ else
113
+ await execP('tar', ['xzf', archivePath, '-C', extractDir], { timeout: 60000 });
114
+ const found = await findFile(extractDir, RTK_BINARY_NAME);
115
+ if (!found)
116
+ throw new Error(`${RTK_BINARY_NAME} not found in ${asset.name}`);
117
+ await fs.mkdir(path.dirname(binDest), { recursive: true });
118
+ await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
119
+ await fs.copyFile(found, binDest);
120
+ if (process.platform !== 'win32')
121
+ await fs.chmod(binDest, 0o755);
122
+ await execP(binDest, ['--version'], { timeout: 30000, shell: false });
123
+ }
124
+ finally {
125
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
126
+ }
127
+ if (!(await wireRtkOmp(binDest, { dryRun, verbose })))
128
+ throw new Error('rtk wiring failed');
129
+ }
130
+ async function fixPonytail(pluginsDir, agentDir) {
131
+ console.log(' Doctor --fix: restoring bundled Ponytail');
132
+ await fs.mkdir(pluginsDir, { recursive: true });
133
+ const pkgPath = path.join(pluginsDir, 'package.json');
134
+ const pkg = await readPluginsPackage(pkgPath);
135
+ // Drop the legacy separate entry: ponytail is a tersio dependency now.
136
+ if ('@dietrichgebert/ponytail' in pkg.dependencies) {
137
+ delete pkg.dependencies['@dietrichgebert/ponytail'];
138
+ console.log(' [migrate] dropped separate @dietrichgebert/ponytail dependency (now bundled with tersio)');
139
+ }
140
+ if (!(PACKAGE_NAME in pkg.dependencies))
141
+ pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
142
+ if (dryRun) {
143
+ console.log(' [dry-run] would run: npm install --no-audit --no-fund (in plugins dir)');
144
+ return;
145
+ }
146
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
147
+ await execNetwork('Restoring bundled Ponytail', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 }).catch((e) => {
148
+ throw new Error(`bundled ponytail restore failed: ${e.message}`);
149
+ });
150
+ await ensureExtensionInConfig(path.join(agentDir, 'config.yml'), path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'), 'ponytail', { dryRun, verbose });
151
+ }
152
+ async function fixCli() {
153
+ console.log(' Doctor --fix: updating CLI + add-ons');
154
+ await runLatestUpdate();
155
+ }
156
+ async function runDoctorRepairs(targets) {
157
+ const all = targets.includes('all');
158
+ const want = (t) => all || targets.includes(t);
159
+ if (dryRun)
160
+ console.log('Preview — no changes will be written.\n');
161
+ const failed = [];
162
+ const attempt = async (label, work) => {
163
+ try {
164
+ await work();
165
+ if (!dryRun)
166
+ console.log(` [ok] ${label}`);
167
+ }
168
+ catch (e) {
169
+ failed.push(label);
170
+ console.log(` [fail] ${label}: ${(e.message || '').slice(0, 200)}`);
171
+ }
172
+ };
173
+ if (want('extensions'))
174
+ await attempt('extensions', () => fixExtensions(path.join(OMP_AGENT_DIR, 'extensions')));
175
+ if (want('registrations'))
176
+ await attempt('registrations', () => fixRegistrations(OMP_AGENT_DIR, OMP_PLUGINS_DIR));
177
+ if (want('rtk'))
178
+ await attempt('rtk', () => fixRtk(BUN_BIN_DIR));
179
+ if (want('ponytail'))
180
+ await attempt('ponytail', () => fixPonytail(OMP_PLUGINS_DIR, OMP_AGENT_DIR));
181
+ if (want('cli'))
182
+ await attempt('cli', fixCli);
183
+ return failed;
184
+ }
185
+ export { runDoctorRepairs };
package/cli/doctor.js CHANGED
@@ -1,14 +1,12 @@
1
1
  // cli/doctor.ts — installation health checks.
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, execP, parseJsonObject, relTime, } from './common.js';
5
- import { runInteractivePhase } from './interactive.js';
6
- import { ledgerPath, readUsage, sessionsDir } from '../extensions/shared/usage-ledger.js';
4
+ import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, RTK_BINARY_NAME, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
5
+ import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
7
6
  import { usageDbPath } from '../extensions/shared/usage-store.js';
8
- import { readRtkGain, rtkDbPath } from '../extensions/shared/rtk-gain.js';
9
- import { checkForUpdate } from './update.js';
7
+ import { pricesCachePath } from '../extensions/shared/pricing.js';
10
8
  import { readTextIfExists } from '../extensions/lib/utils.js';
11
- async function runDoctor() {
9
+ async function runDoctor(recheck = false) {
12
10
  console.log('\n=== Tersio Doctor ===');
13
11
  // Directories
14
12
  const agentDir = OMP_AGENT_DIR;
@@ -23,60 +21,43 @@ async function runDoctor() {
23
21
  const cavemanRule = path.join(extDir, 'caveman-session', 'rule.md');
24
22
  const rtkIndex = path.join(extDir, 'rtk-session', 'index.ts');
25
23
  const updaterIndex = path.join(extDir, 'ai-addons-updater', 'index.ts');
26
- const comboIndex = path.join(extDir, 'combo-toggle', 'index.ts');
27
- const tersioIndex = path.join(extDir, 'tersio-commands', 'index.ts');
28
- const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.ts');
29
- const selfPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
30
24
  // Independent probes start concurrently; sections report in fixed order as
31
25
  // their data settles. Every probe resolves instead of rejecting.
32
26
  const probes = {
33
- ompVersion: execP(OMP_BIN, ['--version']).then((r) => r.stdout.trim(), () => null),
34
27
  agentEntries: fs.readdir(agentDir).catch(() => null),
35
28
  extEntries: fs.readdir(extDir).catch(() => null),
36
29
  sharedStateText: readTextIfExists(path.join(extDir, 'shared', 'session-state.ts')),
37
30
  configText: readTextIfExists(configPath),
38
31
  ponytailPkgText: readTextIfExists(ponytailPkg),
39
32
  ponytailExtText: readTextIfExists(ponytailExt),
40
- pluginsPkgRaw: readTextIfExists(path.join(pluginsDir, 'package.json')),
41
- selfPkgText: readTextIfExists(selfPkg),
42
33
  rtkBinText: readTextIfExists(rtkBin),
43
34
  rtkOmpText: readTextIfExists(path.join(extDir, 'rtk.ts')),
44
35
  cavemanIndexText: readTextIfExists(cavemanIndex),
45
36
  cavemanRuleText: readTextIfExists(cavemanRule),
46
37
  rtkIndexText: readTextIfExists(rtkIndex),
47
38
  updaterIndexText: readTextIfExists(updaterIndex),
48
- comboIndexText: readTextIfExists(comboIndex),
49
- tersioIndexText: readTextIfExists(tersioIndex),
50
- modeReinforcementText: readTextIfExists(modeReinforcement),
51
39
  ruleMtime: fs.stat(cavemanRule).catch(() => null),
52
40
  rtkMtime: fs.stat(rtkBin).catch(() => null),
53
41
  ponytailMtime: fs.stat(ponytailPkg).catch(() => null),
54
- updateVersion: checkForUpdate(),
42
+ pricesMtime: fs.stat(pricesCachePath()).catch(() => null),
55
43
  };
56
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) => {
57
45
  const err = e;
58
46
  return err.stdout?.trim() || err.stderr?.trim() || null;
59
47
  }));
60
- const [[ompVersion, agentEntries, extEntries, sharedStateText, configText, updateVersion], [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, tersioIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, rtkOmpText, ponytailMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
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([
61
49
  Promise.all([
62
- probes.ompVersion,
63
50
  probes.agentEntries,
64
51
  probes.extEntries,
65
52
  probes.sharedStateText,
66
53
  probes.configText,
67
- probes.updateVersion,
68
54
  ]),
69
55
  Promise.all([
70
56
  probes.cavemanIndexText,
71
57
  probes.rtkIndexText,
72
58
  probes.updaterIndexText,
73
- probes.comboIndexText,
74
- probes.tersioIndexText,
75
- probes.modeReinforcementText,
76
59
  probes.ponytailPkgText,
77
60
  probes.ponytailExtText,
78
- probes.pluginsPkgRaw,
79
- probes.selfPkgText,
80
61
  ]),
81
62
  Promise.all([
82
63
  probes.cavemanRuleText,
@@ -86,11 +67,18 @@ async function runDoctor() {
86
67
  rtkVersionProbe,
87
68
  probes.rtkOmpText,
88
69
  probes.ponytailMtime,
70
+ probes.pricesMtime,
89
71
  ]),
90
72
  ]));
91
73
  // Categorized output with a tally; success rows stay quiet (no path echoes)
92
74
  // while failures print the expected path or fix so they stay actionable.
93
75
  const tally = { ok: 0, missing: 0, warn: 0 };
76
+ function absDate(ms) {
77
+ const d = new Date(ms);
78
+ const q = (n) => String(n).padStart(2, '0');
79
+ const h24 = d.getHours();
80
+ return `${q(d.getMonth() + 1)}-${q(d.getDate())}-${d.getFullYear()}, ${q(h24 % 12 || 12)}:${q(d.getMinutes())} ${h24 >= 12 ? 'PM' : 'AM'}`;
81
+ }
94
82
  function check(label, ok, detail = '') {
95
83
  if (ok) {
96
84
  tally.ok++;
@@ -107,13 +95,6 @@ async function runDoctor() {
107
95
  }
108
96
  section('Environment');
109
97
  check('Node', true, process.version);
110
- check('OMP CLI', ompVersion !== null, ompVersion ?? '');
111
- if (typeof updateVersion === 'string')
112
- warnLine('Tersio CLI', `${updateVersion} available — run tersio update`);
113
- else if (updateVersion === 'unknown')
114
- warnLine('Tersio CLI', `${PACKAGE_VERSION} (version check unreachable)`);
115
- else
116
- check('Tersio CLI', true, PACKAGE_VERSION);
117
98
  section('Installation');
118
99
  check('OMP agent dir', agentEntries !== null, agentEntries === null ? agentDir : '');
119
100
  check('OMP extensions dir', extEntries !== null, extEntries === null ? extDir : '');
@@ -123,39 +104,125 @@ async function runDoctor() {
123
104
  check('Caveman extension', cavemanIndexText !== null);
124
105
  check('RTK extension', rtkIndexText !== null);
125
106
  check('Updater extension', updaterIndexText !== null);
126
- check('Combo extension', comboIndexText !== null);
127
- check('Tersio commands extension', tersioIndexText !== null);
128
- check('Mode reinforcement extension', modeReinforcementText !== null);
129
107
  check('Ponytail extension', ponytailExtText !== null);
130
- check('Ponytail in config.yml', (configText ?? '').includes('ponytail') && (configText ?? '').includes('pi-extension'));
131
- check('Combo in config.yml', (configText ?? '').includes('combo-toggle'));
132
- check('Self plugin package', selfPkgText !== null, parseJsonObject(selfPkgText)?.version ?? '');
133
- const selfDep = PACKAGE_NAME in (parseJsonObject(pluginsPkgRaw)?.dependencies || {});
134
- check('Self plugin in plugins/package.json', selfDep);
135
108
  section('Usage & records');
136
- const usageRows = readUsage();
137
- check('Usage ledger', true, usageRows.length ? `${usageRows.length} rows` : 'empty — no records yet');
138
- console.log(` Usage ledger (tersio-owned, tersio reset clears): ${ledgerPath()} · ${usageRows.length} rows`);
139
- console.log(` Session transcripts (host-owned, never touched): ${sessionsDir()}`);
140
- console.log(` Usage DB (tersio-owned, tersio reset clears): ${usageDbPath()}`);
141
- const rtkDb = rtkDbPath();
142
- const rtk = readRtkGain();
143
- console.log(` RTK history (rtk-owned, never touched): ${rtkDb}${rtk.commands ? ` · ${rtk.commands} commands` : ''}`);
109
+ console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
110
+ const pricesAge = pricesMtime ? `(pulled ${relTime(Date.now() - pricesMtime.mtimeMs)} · ${absDate(pricesMtime.mtimeMs)})` : '';
111
+ check('Prices feed', pricesMtime !== null, pricesAge || pricesCachePath());
144
112
  section('Add-ons');
145
- const ruleAge = ruleMtime ? `updated ${relTime(Date.now() - ruleMtime.mtimeMs)}` : '';
113
+ const ruleAge = ruleMtime ? `(updated ${relTime(Date.now() - ruleMtime.mtimeMs)} · ${absDate(ruleMtime.mtimeMs)})` : '';
146
114
  check('Caveman rule', cavemanRuleText !== null, ruleAge);
147
- const rtkAge = rtkMtime ? `updated ${relTime(Date.now() - rtkMtime.mtimeMs)}` : '';
148
- check('RTK binary', rtkBinText !== null, rtkBinText === null ? rtkBin : [rtkVersion, rtkAge].filter(Boolean).join(', '));
115
+ 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(' '));
149
117
  if (rtkBinText !== null && !rtkVersion)
150
118
  warnLine('RTK version', 'unavailable — binary may not be executable');
151
119
  const rtkRegistered = rtkOmpText !== null && (configText ?? '').includes('extensions/rtk.ts');
152
120
  check('RTK OMP wiring (rtk.ts)', rtkOmpText !== null, rtkOmpText === null ? 'run: rtk init -g --agent omp' : '');
153
121
  if (rtkOmpText !== null && !rtkRegistered)
154
122
  warnLine('RTK in config.yml', 'rtk.ts not listed — OMP will not load it; rerun install');
155
- const ponytailAge = ponytailMtime ? `updated ${relTime(Date.now() - ponytailMtime.mtimeMs)}` : '';
156
- check('Ponytail', ponytailPkgText !== null, [parseJsonObject(ponytailPkgText)?.version ?? '', ponytailAge].filter(Boolean).join(', '));
123
+ const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
124
+ const ponytailVer = parseJsonObject(ponytailPkgText)?.version ?? '';
125
+ check('Ponytail', ponytailPkgText !== null, [ponytailVer, ponytailAge].filter(Boolean).join(' '));
157
126
  const total = tally.ok + tally.missing + tally.warn;
158
127
  console.log(`\n Summary: ${total} checks — ✅ ${tally.ok} ok, ⚠️ ${tally.warn} warn, ❌ ${tally.missing} missing`);
128
+ if (!recheck && tally.missing + tally.warn > 0 && (fix || dryRun)) {
129
+ await runDoctorFix(tally);
130
+ }
131
+ else if (!recheck && tally.missing + tally.warn > 0) {
132
+ console.log('\n Run `tersio doctor --fix` to repair the rows above.');
133
+ }
134
+ return tally;
135
+ }
136
+ function fixScope() {
137
+ const eq = args.find((a) => a.startsWith('--fix='));
138
+ if (eq !== undefined) {
139
+ const raw = eq.slice('--fix='.length).trim().toLowerCase();
140
+ if (raw === '')
141
+ return null;
142
+ return checkFixScope(raw);
143
+ }
144
+ const i = args.indexOf('--fix');
145
+ if (i === -1)
146
+ return null;
147
+ const next = args[i + 1];
148
+ if (next === undefined || next.startsWith('-'))
149
+ return null;
150
+ return checkFixScope(next.trim().toLowerCase());
151
+ }
152
+ function checkFixScope(raw) {
153
+ const valid = ['extensions', 'registrations', 'rtk', 'ponytail', 'cli'];
154
+ if (!valid.includes(raw)) {
155
+ console.error(`[fail] Invalid --fix scope: ${raw}. Valid: ${valid.join(', ')}`);
156
+ process.exit(1);
157
+ }
158
+ return raw;
159
+ }
160
+ async function runDoctorFix(tally) {
161
+ const problems = [];
162
+ if (tally.missing > 0)
163
+ problems.push(`${tally.missing} missing`);
164
+ if (tally.warn > 0)
165
+ problems.push(`${tally.warn} warn`);
166
+ if (dryRun) {
167
+ console.log(`\n [dry-run] would repair ${problems.join(' + ')} (extensions, config.yml registrations, rtk wiring, ponytail refresh, CLI update)`);
168
+ return;
169
+ }
170
+ const scoped = fixScope();
171
+ let targets;
172
+ if (scoped) {
173
+ targets = [scoped];
174
+ if (!yes) {
175
+ const go = await askInteractiveConfirm(`Repair "${scoped}" now?`);
176
+ if (go.status !== 'confirmed' || !go.value) {
177
+ process.exitCode = 130;
178
+ return;
179
+ }
180
+ }
181
+ }
182
+ else if (yes) {
183
+ targets = ['all'];
184
+ }
185
+ else {
186
+ const picked = await askInteractiveChoice('Doctor — what should I repair?', [
187
+ { value: 'all', label: 'Fix everything', hint: `${tally.missing + tally.warn} rows` },
188
+ { 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' },
190
+ { value: 'rtk', label: 'RTK binary + wiring', hint: 'download checksum-verified binary, rtk init' },
191
+ { value: 'ponytail', label: 'Ponytail package', hint: 'bundled reinstall via tersio dep' },
192
+ { value: 'cli', label: 'CLI update', hint: 'latest tersio + delegated refresh' },
193
+ ], 'all');
194
+ if (picked.status !== 'selected') {
195
+ process.exitCode = 130;
196
+ return;
197
+ }
198
+ if (picked.value === 'all')
199
+ targets = ['all'];
200
+ else {
201
+ const go = await askInteractiveConfirm(`Repair "${picked.value}" now?`);
202
+ if (go.status !== 'confirmed' || !go.value) {
203
+ process.exitCode = 130;
204
+ return;
205
+ }
206
+ targets = [picked.value];
207
+ }
208
+ }
209
+ const { runDoctorRepairs } = await import('./doctor-fix.js');
210
+ const failed = await runDoctorRepairs(targets);
211
+ if (failed.length > 0) {
212
+ console.log(`\n Doctor --fix: ${failed.length} repair(s) failed (${failed.join(', ')}) — rerun or see [fail] lines above.`);
213
+ process.exitCode = 1;
214
+ return;
215
+ }
216
+ if (targets.includes('cli')) {
217
+ console.log('\n Doctor --fix: CLI refresh delegated — rerun `tersio doctor` after it lands.');
218
+ return;
219
+ }
220
+ console.log('\n Doctor --fix: repairs done — rechecking.');
221
+ const again = await runDoctor(true);
222
+ if (again.missing + again.warn === 0)
223
+ console.log(' Doctor --fix: all checks pass. Restart OMP.');
224
+ else
225
+ console.log(` Doctor --fix: still ${again.missing} missing + ${again.warn} warn — rerun or repair manually.`);
159
226
  }
160
227
  function section(name) {
161
228
  console.log(`\n${name}`);
package/cli/install.js CHANGED
@@ -31,98 +31,69 @@ const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.ts'
31
31
  const SHARED_USAGE_LEDGER = path.join(EXT_DIR, 'shared', 'usage-ledger.ts');
32
32
  const SHARED_PRICING = path.join(EXT_DIR, 'shared', 'pricing.ts');
33
33
  const SHARED_CARBON = path.join(EXT_DIR, 'shared', 'carbon.ts');
34
- const PONYTAIL_GITHUB_SPEC = 'github:DietrichGebert/ponytail';
35
- const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
36
34
  async function stepPonytail(pluginsDir, userDir, options) {
37
35
  if (!options.quiet)
38
- console.log(' Ponytail — refresh plugin');
36
+ console.log(' Ponytail — ensure bundled plugin');
39
37
  await fs.mkdir(pluginsDir, { recursive: true });
40
38
  const pkgPath = path.join(pluginsDir, 'package.json');
41
39
  const pkg = await readPluginsPackage(pkgPath);
42
- pkg.dependencies['@dietrichgebert/ponytail'] = PONYTAIL_GITHUB_SPEC;
40
+ // Migration: ponytail was a separate plugin row. It is now a tersio
41
+ // dependency, so drop the legacy row. Runs after stepSelfPlugin.
42
+ let migrated = false;
43
+ if ('@dietrichgebert/ponytail' in pkg.dependencies) {
44
+ delete pkg.dependencies['@dietrichgebert/ponytail'];
45
+ migrated = true;
46
+ }
43
47
  if (options.dryRun) {
44
48
  if (verbose && !options.quiet)
45
49
  console.log(` [dry-run] would write ${pkgPath}`);
50
+ if (migrated && verbose && !options.quiet)
51
+ console.log(` [dry-run] would drop the separate @dietrichgebert/ponytail dependency (bundled with tersio)`);
46
52
  }
47
- else {
53
+ else if (migrated) {
48
54
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
55
+ if (!options.quiet)
56
+ console.log(' [migrate] dropped separate @dietrichgebert/ponytail dependency (now bundled with tersio)');
49
57
  }
50
58
  const ponytailExtPath = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
51
59
  const probeExt = async () => (await readTextIfExists(ponytailExtPath)) !== null;
52
- // Fast path: extension already present and no refresh asked — skip network.
53
- let ponytailExtExists = !options.reinstall && !options.dryRun ? await probeExt() : false;
60
+ // Fast path: bundled copy already present and no refresh asked — skip
61
+ // network. Migration always reinstalls: the probe may hit the stale legacy
62
+ // copy, and only npm install prunes it into the tersio-owned one.
63
+ let ponytailExtExists = !options.reinstall && !options.dryRun && !migrated ? await probeExt() : false;
54
64
  if (ponytailExtExists) {
55
- debug('Ponytail pi-extension already installed; skipping network refresh');
65
+ debug('Bundled Ponytail pi-extension already installed; skipping network refresh');
56
66
  }
57
67
  else if (options.dryRun) {
58
68
  // Dry runs preview the wiring below without touching the network.
59
69
  if (verbose && !options.quiet)
60
- console.log(` [dry-run] would run: omp plugin install ${PONYTAIL_GITHUB_SPEC}`);
61
- if (options.reinstall && verbose && !options.quiet) {
62
- console.log(` [dry-run] would run: npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund`);
63
- }
70
+ console.log(' [dry-run] would run: npm install --no-audit --no-fund (in plugins dir)');
64
71
  ponytailExtExists = true;
65
72
  }
66
73
  else {
67
- // Try omp plugin install first
74
+ // Self-plugin npm install already materialized the bundled copy.
75
+ // Reinstall here only when the probe still misses.
68
76
  try {
69
- await execNetwork('Installing Ponytail plugin', OMP_BIN, ['plugin', 'install', PONYTAIL_GITHUB_SPEC], { cwd: pluginsDir });
70
- }
71
- catch (e) {
72
- console.log(` [warn] omp plugin install failed: ${e.message}`);
77
+ await execNetwork('Installing bundled Ponytail', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 });
73
78
  }
74
- if (options.reinstall) {
79
+ catch {
75
80
  try {
76
- await execNetwork('Refreshing Ponytail package', 'npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 120000 });
81
+ await execNetwork('Installing bundled Ponytail', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
77
82
  }
78
83
  catch (e) {
79
- console.log(` [fail] Could not refresh ponytail: ${e.message}`);
80
- console.log(` [hint] Manual: cd ~/.omp/plugins && npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund`);
84
+ console.log(` [fail] Could not install bundled ponytail: ${e.message}`);
85
+ console.log(' [hint] Manual: cd ~/.omp/plugins && npm install --no-audit --no-fund');
81
86
  }
82
87
  }
83
- // Verify the pi-extension/index.js actually exists
84
88
  ponytailExtExists = await probeExt();
85
- // Fallback: try bun install or npm install
86
- if (!ponytailExtExists) {
87
- if (!options.quiet)
88
- console.log(' [info] pi-extension/index.js not found after omp plugin install — trying npm/bun install...');
89
- try {
90
- await execNetwork('Installing Ponytail dependencies', 'npm', ['install'], { cwd: pluginsDir, timeout: 120000 });
91
- }
92
- catch {
93
- try {
94
- await execNetwork('Installing Ponytail dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 120000 });
95
- }
96
- catch (e2) {
97
- console.log(` [fail] Could not install ponytail: ${e2.message}`);
98
- console.log(' [hint] Manual: cd ~/.omp/plugins && npm install');
99
- }
100
- }
101
- ponytailExtExists = await probeExt();
102
- }
103
- // Last-resort fallback: git clone the repo into node_modules
104
- if (!ponytailExtExists) {
105
- if (!options.quiet)
106
- console.log(' [info] npm/bun did not produce pi-extension — trying git clone...');
107
- try {
108
- const dest = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail');
109
- await fs.mkdir(path.dirname(dest), { recursive: true });
110
- await execNetwork('Cloning Ponytail repository', 'git', ['clone', '--depth', '1', 'https://github.com/DietrichGebert/ponytail.git', dest], { timeout: 180000 });
111
- ponytailExtExists = await probeExt();
112
- }
113
- catch (e3) {
114
- console.log(` [fail] git clone failed: ${e3.message}`);
115
- console.log(' [hint] Install git or check network: https://github.com/DietrichGebert/ponytail');
116
- }
117
- }
118
89
  }
119
90
  if (!ponytailExtExists) {
120
- console.log(' [skip] Ponytail pi-extension/index.js still not found — skill-only mode');
91
+ console.log(' [skip] Bundled Ponytail pi-extension/index.js still not found — skill-only mode');
121
92
  console.log(' [hint] The /ponytail command won\'t work, but ponytail skills will still load');
122
93
  }
123
94
  else if (!options.dryRun && !options.quiet) {
124
95
  // Wire extension into config.yml so /ponytail command loads
125
- debug('Ponytail pi-extension found');
96
+ debug('Bundled Ponytail pi-extension found');
126
97
  }
127
98
  // Still set Ponytail config defaults even without the extension command
128
99
  const configPath = path.join(userDir, 'config.yml');
@@ -551,10 +522,25 @@ async function runCommandMenu() {
551
522
  await runInstall({ reinstall: true });
552
523
  closeRL();
553
524
  break;
554
- case 'doctor':
555
- await runDoctor();
525
+ case 'doctor': {
526
+ const summary = await runDoctor();
527
+ if (!dryRun && summary.missing + summary.warn > 0) {
528
+ const fixIt = await askInteractiveConfirm('Doctor found problems — repair them now?');
529
+ if (fixIt.status === 'confirmed' && fixIt.value) {
530
+ const { runDoctorRepairs } = await import('./doctor-fix.js');
531
+ const failed = await runDoctorRepairs(['all']);
532
+ if (failed.length === 0) {
533
+ console.log('\n Repairs done — rechecking.');
534
+ await runDoctor();
535
+ }
536
+ else {
537
+ console.log(`\n ${failed.length} repair(s) failed (${failed.join(', ')}) — see [fail] lines above.`);
538
+ }
539
+ }
540
+ }
556
541
  closeRL();
557
542
  break;
543
+ }
558
544
  case 'usage':
559
545
  await runUsage();
560
546
  closeRL();
@@ -656,9 +642,9 @@ async function runInstall(overrides = {}) {
656
642
  }
657
643
  };
658
644
  await capture('shared', () => stepSharedSessionState(userExtDir, options));
659
- await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, userDir, options));
660
645
  let selfPlugin = false;
661
646
  await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, options); });
647
+ await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, userDir, options));
662
648
  const ponytailExtPath = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
663
649
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, options));
664
650
  await capture('rtk session', () => stepRtkSession(userExtDir, options));
package/cli/rtk-wiring.js CHANGED
@@ -55,7 +55,7 @@ function rtkExtensionPath() {
55
55
  }
56
56
  // OMP loads only listed extensions: append rtk.ts after the existing entries
57
57
  // (or create the key) so a fresh wire takes effect on next OMP start.
58
- async function ensureRtkInConfig(options) {
58
+ export async function ensureRtkInConfig(options) {
59
59
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
60
60
  const configPath = path.join(home, '.omp', 'agent', 'config.yml');
61
61
  const extPath = rtkExtensionPath().replace(/\\/g, '/');