@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.
- package/README.md +58 -45
- package/cli/agent-hosts.js +42 -0
- package/cli/agents.js +266 -0
- package/cli/common.js +16 -20
- package/cli/currency.js +8 -12
- package/cli/dashboard.js +49 -37
- package/cli/doctor-fix.js +28 -17
- package/cli/doctor.js +52 -41
- package/cli/hosts.js +118 -0
- package/cli/install.js +180 -117
- package/cli/interactive.js +27 -1
- package/cli/layer.js +24 -0
- package/cli/manifest.js +36 -0
- package/cli/omp-layer.js +51 -0
- package/cli/pi-layer.js +37 -0
- package/cli/pi-wiring.js +92 -0
- package/cli/profile.js +44 -46
- package/cli/reset.js +3 -26
- package/cli/rtk-wiring.js +10 -15
- package/cli/uninstall.js +135 -44
- package/cli/update.js +12 -19
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +31 -24
- package/extensions/ai-addons-updater/index.ts +32 -32
- package/extensions/caveman-session/index.js +6 -6
- package/extensions/caveman-session/index.ts +6 -6
- package/extensions/combo-toggle/index.js +42 -40
- package/extensions/combo-toggle/index.ts +36 -47
- package/extensions/lib/utils.js +36 -9
- package/extensions/lib/utils.ts +37 -9
- package/extensions/rtk-session/index.js +9 -9
- package/extensions/rtk-session/index.ts +9 -14
- package/extensions/shared/carbon.js +5 -8
- package/extensions/shared/carbon.ts +5 -8
- package/extensions/shared/host.js +68 -0
- package/extensions/shared/host.ts +104 -0
- package/extensions/shared/omp-prompt.js +12 -0
- package/extensions/shared/plugin-settings.js +43 -30
- package/extensions/shared/plugin-settings.ts +45 -26
- package/extensions/shared/pricing.js +26 -15
- package/extensions/shared/pricing.ts +28 -16
- package/extensions/shared/rtk-gain.js +11 -22
- package/extensions/shared/rtk-gain.ts +11 -22
- package/extensions/shared/session-state.js +21 -25
- package/extensions/shared/session-state.ts +22 -26
- package/extensions/shared/types.ts +10 -3
- package/extensions/shared/usage-ledger.js +24 -36
- package/extensions/shared/usage-ledger.ts +29 -50
- package/extensions/shared/usage-store.js +14 -12
- package/extensions/shared/usage-store.ts +16 -14
- package/extensions/tersio-commands/index.js +2 -1
- package/extensions/tersio-commands/index.ts +2 -1
- package/package.json +4 -2
- package/tersio.js +4 -4
- package/extensions/shared/mode-reinforcement.js +0 -26
package/cli/dashboard.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
241
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
281
|
-
//
|
|
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
|
-
//
|
|
308
|
-
//
|
|
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:
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
//
|
|
12
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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 [[
|
|
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.
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 };
|