@krtclcdy/tersio 2.24.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -24
- package/cli/banner.js +7 -5
- package/cli/common.js +14 -13
- package/cli/currency.js +2 -7
- package/cli/dashboard.js +220 -13
- package/cli/doctor-fix.js +99 -29
- package/cli/doctor.js +32 -35
- package/cli/hosts.js +23 -26
- package/cli/install.js +228 -115
- package/cli/interactive.js +65 -21
- package/cli/manifest.js +8 -4
- package/cli/profile.js +49 -16
- package/cli/reset.js +3 -7
- package/cli/rtk-wiring.js +29 -32
- package/cli/settings.js +40 -16
- package/cli/uninstall.js +79 -29
- package/cli/update.js +27 -20
- package/cli/usage.js +54 -19
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +75 -20
- package/extensions/ai-addons-updater/index.ts +67 -23
- package/extensions/caveman-session/index.js +33 -75
- package/extensions/caveman-session/index.ts +35 -77
- package/extensions/combo-toggle/index.js +40 -62
- package/extensions/combo-toggle/index.ts +38 -72
- package/extensions/lib/utils.js +64 -15
- package/extensions/lib/utils.ts +62 -15
- package/extensions/opencode/index.js +104 -0
- package/extensions/opencode/server.js +190 -0
- package/extensions/opencode/server.ts +200 -0
- package/extensions/rtk-session/index.js +38 -57
- package/extensions/rtk-session/index.ts +36 -55
- package/extensions/shared/carbon.js +4 -10
- package/extensions/shared/carbon.ts +5 -10
- package/extensions/shared/host.js +95 -56
- package/extensions/shared/host.ts +106 -60
- package/extensions/shared/plugin-settings.js +6 -18
- package/extensions/shared/plugin-settings.ts +6 -18
- package/extensions/shared/pricing.js +79 -27
- package/extensions/shared/pricing.ts +80 -28
- package/extensions/shared/rtk-gain.js +4 -11
- package/extensions/shared/rtk-gain.ts +4 -11
- package/extensions/shared/session-state.js +28 -39
- package/extensions/shared/session-state.ts +29 -38
- package/extensions/shared/status.js +38 -0
- package/extensions/shared/status.ts +45 -0
- package/extensions/shared/types.js +1 -2
- package/extensions/shared/types.ts +2 -2
- package/extensions/shared/usage-ledger.js +312 -57
- package/extensions/shared/usage-ledger.ts +321 -61
- package/extensions/shared/usage-store.js +354 -38
- package/extensions/shared/usage-store.ts +360 -43
- package/extensions/tersio-commands/index.js +5 -14
- package/extensions/tersio-commands/index.ts +5 -14
- package/package.json +12 -7
- package/tersio.js +4 -6
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -24
- package/cli/omp-layer.js +0 -51
- package/cli/pi-layer.js +0 -37
- package/cli/pi-wiring.js +0 -92
- package/extensions/shared/omp-prompt.js +0 -12
package/cli/doctor-fix.js
CHANGED
|
@@ -3,48 +3,98 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
6
|
-
import { execNetwork } from './interactive.js';
|
|
7
|
-
import { wireRtkOmp, ensureRtkInConfig } from './rtk-wiring.js';
|
|
6
|
+
import { execNetwork, sayTagged } from './interactive.js';
|
|
7
|
+
import { wireRtkOmp, wireRtkOpencode, wireRtkPi, ensureRtkInConfig } from './rtk-wiring.js';
|
|
8
8
|
import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
|
|
9
9
|
import { runLatestUpdate } from './update.js';
|
|
10
|
-
import { TREE_FILES, sourcePath } from './manifest.js';
|
|
10
|
+
import { OPENCODE_SERVER_SHIM, TREE_FILES, sourcePath } from './manifest.js';
|
|
11
11
|
import { detectHosts, hostExtensionsDir } from './hosts.js';
|
|
12
12
|
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
|
|
13
|
-
// A written tree is the install for both hosts now, so --fix restores it for
|
|
14
|
-
// whichever hosts have it, alongside the OMP package when that is present.
|
|
13
|
+
// A written tree is the install for both hosts now, so --fix restores it for whichever hosts have it, alongside the OMP package when that is present.
|
|
15
14
|
async function fixExtensionTrees() {
|
|
15
|
+
// The opencode entrypoint is foreign to pi/omp loaders: never copy it there, and remove strays.
|
|
16
|
+
const forHost = (id) => TREE_FILES.filter((file) => id === 'opencode' || !file.startsWith('opencode/'));
|
|
17
|
+
const scrubOpencodeDir = async (dest) => {
|
|
18
|
+
const dir = path.join(dest, 'opencode');
|
|
19
|
+
if (!existsSync(dir))
|
|
20
|
+
return;
|
|
21
|
+
if (dryRun) {
|
|
22
|
+
sayTagged(` [dry-run] would remove foreign ${dir}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
26
|
+
sayTagged(` [rm] foreign ${dir}`);
|
|
27
|
+
};
|
|
16
28
|
for (const host of detectHosts()) {
|
|
17
29
|
const dest = hostExtensionsDir(host.id);
|
|
18
|
-
// Repair a host whose tree is partly there: a half-written tree is exactly
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
// Repair a host whose tree is partly there: a half-written tree is exactly the case this runs for, and it reads as "not installed" to the detector.
|
|
31
|
+
const present = forHost(host.id).filter((file) => existsSync(path.join(dest, file))).length;
|
|
32
|
+
if (present === 0) {
|
|
33
|
+
if (host.id !== 'opencode')
|
|
34
|
+
await scrubOpencodeDir(dest);
|
|
22
35
|
continue;
|
|
36
|
+
}
|
|
23
37
|
await fs.mkdir(dest, { recursive: true });
|
|
24
|
-
for (const file of
|
|
38
|
+
for (const file of forHost(host.id)) {
|
|
25
39
|
const from = path.join(EXT_DIR, ...file.split('/'));
|
|
26
40
|
const text = await readTextIfExists(from);
|
|
27
41
|
if (text === null)
|
|
28
|
-
|
|
42
|
+
sayTagged(` [warn] bundled source missing: ${from}`);
|
|
29
43
|
else
|
|
30
44
|
await writeIfChanged(path.join(dest, file), text, { dryRun, verbose });
|
|
31
45
|
}
|
|
46
|
+
if (host.id !== 'opencode')
|
|
47
|
+
await scrubOpencodeDir(dest);
|
|
32
48
|
if (!dryRun)
|
|
33
|
-
|
|
49
|
+
sayTagged(` [ok] ${host.label} extension tree: ${dest}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// The tree alone does nothing on OpenCode without its entrypoint registered in opencode.json.
|
|
53
|
+
async function fixOpencodeEntry() {
|
|
54
|
+
const { HOME } = await import('./common.js');
|
|
55
|
+
const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
|
|
56
|
+
const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
|
|
57
|
+
const nested = path.join(dir, 'opencode', 'server.ts');
|
|
58
|
+
const nestedText = await readTextIfExists(nested);
|
|
59
|
+
if (!nestedText)
|
|
60
|
+
return;
|
|
61
|
+
// The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
|
|
62
|
+
await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, { dryRun, verbose });
|
|
63
|
+
const raw = await readTextIfExists(configPath);
|
|
64
|
+
let config = {};
|
|
65
|
+
try {
|
|
66
|
+
config = raw ? JSON.parse(raw) : {};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
34
70
|
}
|
|
71
|
+
const plugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
|
|
72
|
+
const withoutTersio = plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio'));
|
|
73
|
+
if (withoutTersio.includes(dir))
|
|
74
|
+
return;
|
|
75
|
+
withoutTersio.push(dir);
|
|
76
|
+
config.plugins = withoutTersio;
|
|
77
|
+
if (dryRun) {
|
|
78
|
+
sayTagged(` [dry-run] would register tersio in ${configPath}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
82
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
83
|
+
sayTagged(` [ok] OpenCode plugin entry: ${configPath}`);
|
|
35
84
|
}
|
|
36
85
|
async function fixExtensions(pluginsDir) {
|
|
37
86
|
await fixExtensionTrees();
|
|
87
|
+
await fixOpencodeEntry();
|
|
38
88
|
console.log(' Doctor --fix: restoring plugin extension files');
|
|
39
|
-
const
|
|
40
|
-
const pluginExtDir = path.join(tersioPluginDir, 'extensions');
|
|
89
|
+
const pluginExtDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
|
|
41
90
|
await fs.mkdir(pluginExtDir, { recursive: true });
|
|
42
|
-
|
|
43
|
-
|
|
91
|
+
// rule.md is skipped: its live content is the upstream fetch, not the bundle.
|
|
92
|
+
for (const file of TREE_FILES.filter((f) => f !== 'caveman-session/rule.md')) {
|
|
93
|
+
const text = await readTextIfExists(sourcePath(file));
|
|
44
94
|
if (text === null)
|
|
45
|
-
|
|
95
|
+
sayTagged(` [warn] bundled source missing: ${sourcePath(file)}`);
|
|
46
96
|
else
|
|
47
|
-
await writeIfChanged(path.join(pluginExtDir,
|
|
97
|
+
await writeIfChanged(path.join(pluginExtDir, ...file.split('/')), text, { dryRun, verbose });
|
|
48
98
|
}
|
|
49
99
|
const ruleDest = path.join(pluginExtDir, 'caveman-session', 'rule.md');
|
|
50
100
|
if ((await readTextIfExists(ruleDest)) === null) {
|
|
@@ -63,13 +113,21 @@ async function fixExtensions(pluginsDir) {
|
|
|
63
113
|
console.log(' [warn] Caveman rule unreachable and no bundled rule exists');
|
|
64
114
|
}
|
|
65
115
|
}
|
|
116
|
+
// Writing is not atomic, so re-read rather than assume the tree is whole.
|
|
117
|
+
if (dryRun)
|
|
118
|
+
return;
|
|
119
|
+
const missing = TREE_FILES.filter((file) => !existsSync(path.join(pluginExtDir, ...file.split('/'))));
|
|
120
|
+
if (missing.length > 0)
|
|
121
|
+
sayTagged(` [fail] tree incomplete: ${missing.join(', ')}`);
|
|
122
|
+
else
|
|
123
|
+
sayTagged(` [ok] extension tree: ${TREE_FILES.length} files`);
|
|
66
124
|
}
|
|
67
125
|
async function fixRegistrations(agentDir, pluginsDir) {
|
|
68
126
|
console.log(' Doctor --fix: repairing config.yml registrations');
|
|
69
127
|
const configPath = path.join(agentDir, 'config.yml');
|
|
70
128
|
if ((await readTextIfExists(configPath)) === null) {
|
|
71
129
|
if (dryRun)
|
|
72
|
-
|
|
130
|
+
sayTagged(` [dry-run] would create ${configPath}`);
|
|
73
131
|
else {
|
|
74
132
|
await fs.mkdir(agentDir, { recursive: true });
|
|
75
133
|
await fs.writeFile(configPath, 'extensions:\n', 'utf8');
|
|
@@ -88,7 +146,7 @@ async function fixRegistrations(agentDir, pluginsDir) {
|
|
|
88
146
|
if (!(PACKAGE_NAME in pkg.dependencies)) {
|
|
89
147
|
pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
|
|
90
148
|
if (dryRun)
|
|
91
|
-
|
|
149
|
+
sayTagged(` [dry-run] would register ${PACKAGE_NAME} in ${pkgPath}`);
|
|
92
150
|
else {
|
|
93
151
|
await fs.mkdir(pluginsDir, { recursive: true });
|
|
94
152
|
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
|
@@ -99,7 +157,7 @@ async function fixRtk(binDir) {
|
|
|
99
157
|
console.log(' Doctor --fix: repairing RTK binary + wiring');
|
|
100
158
|
const binDest = path.join(binDir, RTK_BINARY_NAME);
|
|
101
159
|
if (dryRun) {
|
|
102
|
-
|
|
160
|
+
sayTagged(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into OMP`);
|
|
103
161
|
return;
|
|
104
162
|
}
|
|
105
163
|
const spec = rtkPlatformSpec();
|
|
@@ -117,12 +175,16 @@ async function fixRtk(binDir) {
|
|
|
117
175
|
httpsGet(checksAsset.browser_download_url).catch(() => null),
|
|
118
176
|
httpsDownload(asset.browser_download_url, archivePath),
|
|
119
177
|
]);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
178
|
+
// Same rule as the installer: an unverifiable binary is not installed.
|
|
179
|
+
const unverified = (why) => new Error(`${why} for ${asset.name}. Re-run with --allow-unverified to accept it`);
|
|
180
|
+
if (!checks)
|
|
181
|
+
throw unverified('checksums.txt could not be downloaded');
|
|
182
|
+
const expected = parseChecksum(checks, asset.name);
|
|
183
|
+
if (!expected)
|
|
184
|
+
throw unverified('checksums.txt has no entry');
|
|
185
|
+
if (await sha256File(archivePath) !== expected)
|
|
186
|
+
throw new Error(`checksum mismatch for ${asset.name}`);
|
|
187
|
+
debug('RTK checksum verified');
|
|
126
188
|
const extractDir = path.join(tmpDir, 'extracted');
|
|
127
189
|
await fs.mkdir(extractDir, { recursive: true });
|
|
128
190
|
if (asset.name.endsWith('.zip'))
|
|
@@ -144,6 +206,14 @@ async function fixRtk(binDir) {
|
|
|
144
206
|
}
|
|
145
207
|
if (!(await wireRtkOmp(binDest, { dryRun, verbose })))
|
|
146
208
|
throw new Error('rtk wiring failed');
|
|
209
|
+
// Fail-open: a missing host wire must not fail the repair.
|
|
210
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
211
|
+
if (existsSync(path.join(home, '.config', 'opencode', 'plugins', 'tersio'))) {
|
|
212
|
+
await wireRtkOpencode(binDest, { dryRun, verbose });
|
|
213
|
+
}
|
|
214
|
+
if (existsSync(path.join(home, '.pi', 'agent', 'extensions'))) {
|
|
215
|
+
await wireRtkPi(binDest, { dryRun, verbose });
|
|
216
|
+
}
|
|
147
217
|
}
|
|
148
218
|
async function fixPonytail(pluginsDir) {
|
|
149
219
|
console.log(' Doctor --fix: restoring bundled Ponytail');
|
|
@@ -180,11 +250,11 @@ async function runDoctorRepairs(targets) {
|
|
|
180
250
|
try {
|
|
181
251
|
await work();
|
|
182
252
|
if (!dryRun)
|
|
183
|
-
|
|
253
|
+
sayTagged(` [ok] ${label}`);
|
|
184
254
|
}
|
|
185
255
|
catch (e) {
|
|
186
256
|
failed.push(label);
|
|
187
|
-
|
|
257
|
+
sayTagged(` [fail] ${label}: ${(e.message || '').slice(0, 200)}`);
|
|
188
258
|
}
|
|
189
259
|
};
|
|
190
260
|
if (want('extensions'))
|
package/cli/doctor.js
CHANGED
|
@@ -1,34 +1,31 @@
|
|
|
1
1
|
// cli/doctor.ts — installation health checks.
|
|
2
2
|
import { existsSync, promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
|
|
5
|
-
import { detectHosts,
|
|
4
|
+
import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, fix, yes, execP, parseJsonObject, relTime, } from './common.js';
|
|
5
|
+
import { detectHosts, hostExtensionsDir, ompPackageDir } from './hosts.js';
|
|
6
6
|
import { askInteractiveChoice, askInteractiveConfirm, runInteractivePhase } from './interactive.js';
|
|
7
7
|
import { usageDbPath } from '../extensions/shared/usage-store.js';
|
|
8
8
|
import { pricesCachePath } from '../extensions/shared/pricing.js';
|
|
9
9
|
import { readTextIfExists, resolveRtkBinary } from '../extensions/lib/utils.js';
|
|
10
|
+
import { TREE_FILES } from './manifest.js';
|
|
10
11
|
async function runDoctor(recheck = false) {
|
|
11
12
|
console.log('\n=== Tersio Doctor ===');
|
|
12
|
-
// OMP loads the plugin from its plugins dir; pi loads the same package as a
|
|
13
|
-
// pi package, which its own settings.json declares and its npm dir holds.
|
|
13
|
+
// OMP loads the plugin from its plugins dir; pi loads the same package as a pi package, which its own settings.json declares and its npm dir holds.
|
|
14
14
|
const pluginsDir = OMP_PLUGINS_DIR;
|
|
15
15
|
const rtkBin = resolveRtkBinary();
|
|
16
16
|
// Ponytail
|
|
17
17
|
const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
|
|
18
|
-
const ponytailExt = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js');
|
|
19
18
|
const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
|
|
20
19
|
const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts');
|
|
21
20
|
const cavemanRule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
|
|
22
21
|
const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts');
|
|
23
22
|
const updaterIndex = path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts');
|
|
24
|
-
// Independent probes start concurrently; sections report in fixed order as
|
|
25
|
-
// their data settles. Every probe resolves instead of rejecting.
|
|
23
|
+
// Independent probes start concurrently; sections report in fixed order as their data settles. Every probe resolves instead of rejecting.
|
|
26
24
|
const hosts = detectHosts();
|
|
27
25
|
const probes = {
|
|
28
26
|
ompPkgText: readTextIfExists(path.join(ompPackageDir(), 'package.json')),
|
|
29
27
|
configText: readTextIfExists(path.join(OMP_AGENT_DIR, 'config.yml')),
|
|
30
28
|
ponytailPkgText: readTextIfExists(ponytailPkg),
|
|
31
|
-
ponytailExtText: readTextIfExists(ponytailExt),
|
|
32
29
|
rtkBinText: rtkBin ? readTextIfExists(rtkBin) : Promise.resolve(null),
|
|
33
30
|
cavemanIndexText: readTextIfExists(cavemanIndex),
|
|
34
31
|
cavemanRuleText: readTextIfExists(cavemanRule),
|
|
@@ -43,7 +40,7 @@ async function runDoctor(recheck = false) {
|
|
|
43
40
|
const err = e;
|
|
44
41
|
return err.stdout?.trim() || err.stderr?.trim() || null;
|
|
45
42
|
}) : Promise.resolve(null);
|
|
46
|
-
const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText
|
|
43
|
+
const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
|
|
47
44
|
Promise.all([
|
|
48
45
|
probes.ompPkgText,
|
|
49
46
|
probes.configText,
|
|
@@ -53,7 +50,6 @@ async function runDoctor(recheck = false) {
|
|
|
53
50
|
probes.rtkIndexText,
|
|
54
51
|
probes.updaterIndexText,
|
|
55
52
|
probes.ponytailPkgText,
|
|
56
|
-
probes.ponytailExtText,
|
|
57
53
|
]),
|
|
58
54
|
Promise.all([
|
|
59
55
|
probes.cavemanRuleText,
|
|
@@ -65,8 +61,7 @@ async function runDoctor(recheck = false) {
|
|
|
65
61
|
probes.pricesMtime,
|
|
66
62
|
]),
|
|
67
63
|
]));
|
|
68
|
-
// Categorized output with a tally; success rows stay quiet (no path echoes)
|
|
69
|
-
// while failures print the expected path or fix so they stay actionable.
|
|
64
|
+
// Categorized output with a tally; success rows stay quiet (no path echoes) while failures print the expected path or fix so they stay actionable.
|
|
70
65
|
const tally = { ok: 0, missing: 0, warn: 0 };
|
|
71
66
|
function absDate(ms) {
|
|
72
67
|
const d = new Date(ms);
|
|
@@ -88,48 +83,45 @@ async function runDoctor(recheck = false) {
|
|
|
88
83
|
tally.warn++;
|
|
89
84
|
console.log(` ⚠️ ${label}: warn ${detail}`);
|
|
90
85
|
}
|
|
91
|
-
// A host you do not use is not a fault, so it stays out of the tally and only
|
|
92
|
-
// carries the one line that installs it.
|
|
86
|
+
// A host you do not use is not a fault, so it stays out of the tally and only carries the one line that installs it.
|
|
93
87
|
function unused(label, detail) {
|
|
94
88
|
console.log(` — ${label}: not installed ${detail}`);
|
|
95
89
|
}
|
|
96
90
|
section('Hosts');
|
|
97
91
|
for (const host of hosts) {
|
|
92
|
+
// pi can hold a working tree while reading as not installed; use the tree as ground truth.
|
|
93
|
+
const piTree = (host.id === 'pi' && !host.installed && existsSync(hostExtensionsDir('pi')))
|
|
94
|
+
? hostExtensionsDir('pi')
|
|
95
|
+
: null;
|
|
96
|
+
if (piTree !== null) {
|
|
97
|
+
check(host.label, true, `extensions ${PACKAGE_NAME} ${PACKAGE_VERSION}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
98
100
|
if (!host.installed) {
|
|
99
|
-
// A pi declaration without the tree or the package is a broken install,
|
|
100
|
-
// so it stays a counted row rather than reading as "not installed".
|
|
101
|
+
// A pi declaration without the tree or the package is a broken install, so it stays a counted row rather than reading as "not installed".
|
|
101
102
|
if (host.declared)
|
|
102
103
|
check(host.label, false, `declared (${host.declared}) but not installed`);
|
|
103
104
|
else
|
|
104
105
|
unused(host.label, `· ${host.installCmd}`);
|
|
105
106
|
continue;
|
|
106
107
|
}
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// One line per host, omp style: kind and version, no path echo.
|
|
109
|
+
const kind = host.via === 'tree' ? 'extensions' : 'package';
|
|
110
|
+
check(host.label, true, `${kind} ${PACKAGE_NAME}${host.version ? ` ${host.version}` : ''}`);
|
|
109
111
|
}
|
|
110
112
|
section('Extensions & plugins');
|
|
111
|
-
|
|
112
|
-
if (ompEntry?.via === 'package') {
|
|
113
|
+
if (hosts.find((host) => host.id === 'omp')?.via === 'package') {
|
|
113
114
|
const explicitEntries = (configText ?? '').split('\n')
|
|
114
115
|
.map((line) => line.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, ''))
|
|
115
116
|
.filter((line) => line.startsWith('/') || line.startsWith('.'));
|
|
116
117
|
const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
|
|
117
118
|
check('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : '');
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
check('RTK extension', existsSync(path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts')), existsSync(path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts')) ? '' : path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
|
|
125
|
-
check('Updater extension', existsSync(path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts')), existsSync(path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts')) ? '' : path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts'));
|
|
126
|
-
check('Ponytail extension', existsSync(path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js')), existsSync(path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js')) ? '' : path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'));
|
|
127
|
-
}
|
|
128
|
-
for (const host of hosts) {
|
|
129
|
-
if (!host.installed || host.via !== 'tree')
|
|
130
|
-
continue;
|
|
131
|
-
const missing = missingTreeFiles(host.id);
|
|
132
|
-
check(`${host.label} extension tree`, missing.length === 0, missing.length ? `${missing.length} missing in ${host.dir}` : '');
|
|
119
|
+
// A half-written tree imports a module that is absent; OMP then drops the extension.
|
|
120
|
+
const extDir = path.join(tersioPluginDir, 'extensions');
|
|
121
|
+
const missing = TREE_FILES.filter((file) => !existsSync(path.join(extDir, ...file.split('/'))));
|
|
122
|
+
check('Extension tree', missing.length === 0, missing.length === 0
|
|
123
|
+
? `${TREE_FILES.length} files ok`
|
|
124
|
+
: `${missing.length} of ${TREE_FILES.length} missing: ${missing.join(', ')}`);
|
|
133
125
|
}
|
|
134
126
|
section('Usage & records');
|
|
135
127
|
console.log(` Usage DB (tersio-owned · local hosted): ${usageDbPath()}`);
|
|
@@ -140,6 +132,11 @@ async function runDoctor(recheck = false) {
|
|
|
140
132
|
check('Caveman rule', cavemanRuleText !== null, ruleAge);
|
|
141
133
|
const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
|
|
142
134
|
check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
|
|
135
|
+
// rtk_run needs a host exec API, which doctor cannot assume.
|
|
136
|
+
if (rtkBin === null)
|
|
137
|
+
unused('RTK exec', '· install Tersio to place rtk in ~/.bun/bin');
|
|
138
|
+
else if (!hosts.some((host) => host.installed && (host.id === 'omp' || host.id === 'opencode')))
|
|
139
|
+
warnLine('RTK exec', 'no installed host exposes an exec API — rtk_run is unavailable; call rtk from bash instead');
|
|
143
140
|
if (rtkBinText !== null && !rtkVersion)
|
|
144
141
|
warnLine('RTK version', 'unavailable — binary may not be executable');
|
|
145
142
|
const ponytailAge = ponytailMtime ? `(updated ${relTime(Date.now() - ponytailMtime.mtimeMs)} · ${absDate(ponytailMtime.mtimeMs)})` : '';
|
package/cli/hosts.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
// cli/hosts.ts — the agent hosts Tersio installs into, and what is on disk.
|
|
2
|
-
// One detector, shared by the doctor rows, the install menu, and the uninstall
|
|
3
|
-
// menu, so the three can never disagree about what is installed.
|
|
1
|
+
// cli/hosts.ts — the agent hosts Tersio installs into, and what is on disk. One detector, shared by the doctor and both menus, so they cannot disagree.
|
|
4
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
3
|
import path from 'node:path';
|
|
6
4
|
import { HOME, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION } from './common.js';
|
|
7
5
|
import { TREE_FILES } from './manifest.js';
|
|
8
6
|
import { piAgentDir } from '../extensions/lib/utils.js';
|
|
9
7
|
export function hostExtensionsDir(id, agentDir = piAgentDir()) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
if (id === 'omp')
|
|
9
|
+
return path.join(HOME, '.omp', 'agent', 'extensions');
|
|
10
|
+
if (id === 'opencode')
|
|
11
|
+
return path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
|
|
12
|
+
return path.join(agentDir, 'extensions');
|
|
13
13
|
}
|
|
14
14
|
export function missingTreeFiles(id, agentDir = piAgentDir()) {
|
|
15
15
|
const dir = hostExtensionsDir(id, agentDir);
|
|
@@ -33,9 +33,7 @@ function packageVersion(dir) {
|
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
-
// The tersio package pi
|
|
37
|
-
// writes the declaration on install and identifies a source by name, so an npm
|
|
38
|
-
// spec, a git URL, and a local path all count when they name this package.
|
|
36
|
+
// The tersio package pi declared in its own settings.json, or null. pi identifies a source by name, so an npm spec, git URL, or path all count.
|
|
39
37
|
export function piTersioSource(agentDir = piAgentDir()) {
|
|
40
38
|
let settings;
|
|
41
39
|
try {
|
|
@@ -63,32 +61,31 @@ export function piTersioSource(agentDir = piAgentDir()) {
|
|
|
63
61
|
const HOSTS = [
|
|
64
62
|
{ id: 'omp', label: 'Oh My Pi', bin: 'omp', installCmd: `omp plugin install ${PACKAGE_NAME}`, removeCmd: `omp plugin remove ${PACKAGE_NAME}` },
|
|
65
63
|
{ id: 'pi', label: 'Pi', bin: 'pi', installCmd: `pi install npm:${PACKAGE_NAME}`, removeCmd: `pi remove npm:${PACKAGE_NAME}` },
|
|
64
|
+
{ id: 'opencode', label: 'OpenCode', bin: 'opencode', installCmd: `tersio install --host opencode`, removeCmd: `tersio uninstall --host opencode` },
|
|
66
65
|
];
|
|
67
66
|
function detect(agentDir = piAgentDir()) {
|
|
68
67
|
return HOSTS.map((host) => {
|
|
69
|
-
const pkgDir = host.id === 'omp' ? ompPackageDir() : piPackageDir(agentDir);
|
|
68
|
+
const pkgDir = host.id === 'omp' ? ompPackageDir() : host.id === 'pi' ? piPackageDir(agentDir) : null;
|
|
70
69
|
const tree = hostExtensionsDir(host.id, agentDir);
|
|
71
|
-
// A tree wins: it is what the installer writes, and it carries no
|
|
72
|
-
// package.json, so its version is ours.
|
|
70
|
+
// A tree wins: it is what the installer writes, and it carries no package.json, so its version is ours.
|
|
73
71
|
const via = missingTreeFiles(host.id, agentDir).length === 0
|
|
74
72
|
? 'tree'
|
|
75
|
-
: existsSync(pkgDir) ? 'package' : null;
|
|
73
|
+
: pkgDir && existsSync(pkgDir) ? 'package' : null;
|
|
76
74
|
return {
|
|
77
75
|
...host,
|
|
78
76
|
installed: via !== null,
|
|
79
77
|
via,
|
|
80
78
|
declared: host.id === 'omp'
|
|
81
|
-
? (existsSync(pkgDir) ? `plugin ${PACKAGE_NAME}` : null)
|
|
82
|
-
:
|
|
83
|
-
|
|
79
|
+
? (pkgDir && existsSync(pkgDir) ? `plugin ${PACKAGE_NAME}` : null)
|
|
80
|
+
: host.id === 'pi'
|
|
81
|
+
? piTersioSource(agentDir)
|
|
82
|
+
: existsSync(tree) ? 'plugin tree' : null,
|
|
83
|
+
version: via === 'tree' ? PACKAGE_VERSION : via === 'package' && pkgDir ? packageVersion(pkgDir) : null,
|
|
84
84
|
dir: via === 'tree' ? tree : via === 'package' ? pkgDir : null,
|
|
85
85
|
};
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
|
-
/**
|
|
89
|
-
* The menu label, with the current state in brackets: the version when the
|
|
90
|
-
* package is already there, so a rerun shows what it would replace.
|
|
91
|
-
*/
|
|
88
|
+
/** Menu label with current state in brackets, so a rerun shows what it replaces. */
|
|
92
89
|
export function hostLabel(host) {
|
|
93
90
|
if (host.installed && host.version)
|
|
94
91
|
return `${host.label} (installed ${host.version})`;
|
|
@@ -98,21 +95,21 @@ export function hostLabel(host) {
|
|
|
98
95
|
return `${host.label} (package declared, not on disk)`;
|
|
99
96
|
return `${host.label} (not installed)`;
|
|
100
97
|
}
|
|
98
|
+
// Hints render inline, so menus name the install and doctor keeps the path.
|
|
101
99
|
export function hostHint(host) {
|
|
102
|
-
if (host.installed && host.
|
|
103
|
-
return host.
|
|
100
|
+
if (host.installed && host.via)
|
|
101
|
+
return host.via === 'tree' ? 'extension tree' : 'plugin package';
|
|
104
102
|
return host.installCmd;
|
|
105
103
|
}
|
|
106
|
-
// `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown
|
|
107
|
-
// value so the caller can exit(1) with the message.
|
|
104
|
+
// `--host omp|pi` from raw argv; undefined when absent. Throws on an unknown value so the caller can exit(1) with the message.
|
|
108
105
|
export function parseHostArg(argv) {
|
|
109
106
|
const i = argv.indexOf('--host');
|
|
110
107
|
const raw = (i === -1 ? undefined : argv[i + 1]) ?? argv.find((a) => a.startsWith('--host='))?.slice('--host='.length);
|
|
111
108
|
if (raw === undefined)
|
|
112
109
|
return undefined;
|
|
113
110
|
const value = raw.trim().toLowerCase();
|
|
114
|
-
if (value !== 'omp' && value !== 'pi')
|
|
115
|
-
throw new Error(`Invalid --host: ${raw}. Valid: omp, pi`);
|
|
111
|
+
if (value !== 'omp' && value !== 'pi' && value !== 'opencode')
|
|
112
|
+
throw new Error(`Invalid --host: ${raw}. Valid: omp, pi, opencode`);
|
|
116
113
|
return value;
|
|
117
114
|
}
|
|
118
115
|
export { HOME, detect as detectHosts };
|