@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/install.js
CHANGED
|
@@ -3,8 +3,8 @@ import { promises as fs } from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { BUN_BIN_DIR, COMBO_PRESET_MODES, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, args, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
7
|
-
import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
|
|
6
|
+
import { BUN_BIN_DIR, COMBO_PRESET_MODES, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, args, allowUnverified, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
7
|
+
import { askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, withInteractiveTask, sayTagged } from './interactive.js';
|
|
8
8
|
import { printWelcome } from './banner.js';
|
|
9
9
|
import { checkForUpdate, runLatestUpdate } from './update.js';
|
|
10
10
|
import { runUninstall } from './uninstall.js';
|
|
@@ -12,10 +12,12 @@ import { runDoctor } from './doctor.js';
|
|
|
12
12
|
import { runReset } from './reset.js';
|
|
13
13
|
import { runUsage } from './usage.js';
|
|
14
14
|
import { runDashboard } from './dashboard.js';
|
|
15
|
-
import { wireRtkOmp } from './rtk-wiring.js';
|
|
15
|
+
import { wireRtkOmp, wireRtkOpencode, wireRtkPi } from './rtk-wiring.js';
|
|
16
16
|
import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
|
|
17
|
-
import { storedProfile, writePluginSettings } from './profile.js';
|
|
18
|
-
import {
|
|
17
|
+
import { formatCliStatus, storedProfile, storedProfileSync, writePluginSettings } from './profile.js';
|
|
18
|
+
import { runSettings } from './settings.js';
|
|
19
|
+
import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
|
|
20
|
+
import { OPENCODE_SERVER_SHIM, filesUnder, sourcePath } from './manifest.js';
|
|
19
21
|
import { detectHosts, hostHint, hostLabel, parseHostArg, piTersioSource } from './hosts.js';
|
|
20
22
|
async function stepPonytail(pluginsDir, options) {
|
|
21
23
|
if (!options.quiet)
|
|
@@ -31,9 +33,9 @@ async function stepPonytail(pluginsDir, options) {
|
|
|
31
33
|
}
|
|
32
34
|
if (options.dryRun) {
|
|
33
35
|
if (verbose && !options.quiet)
|
|
34
|
-
|
|
36
|
+
sayTagged(` [dry-run] would write ${pkgPath}`);
|
|
35
37
|
if (migrated && verbose && !options.quiet)
|
|
36
|
-
|
|
38
|
+
sayTagged(` [dry-run] would drop the separate @dietrichgebert/ponytail dependency (bundled with tersio)`);
|
|
37
39
|
}
|
|
38
40
|
else if (migrated) {
|
|
39
41
|
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
@@ -63,7 +65,7 @@ async function stepPonytail(pluginsDir, options) {
|
|
|
63
65
|
await execNetwork('Installing bundled Ponytail', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
|
|
64
66
|
}
|
|
65
67
|
catch (e) {
|
|
66
|
-
|
|
68
|
+
sayTagged(` [fail] Could not install bundled ponytail: ${e.message}`);
|
|
67
69
|
console.log(' [hint] Manual: cd ~/.omp/plugins && npm install --no-audit --no-fund');
|
|
68
70
|
}
|
|
69
71
|
}
|
|
@@ -91,14 +93,14 @@ async function stepSelfPlugin(pluginsDir, options) {
|
|
|
91
93
|
if (legacy in pkg.dependencies) {
|
|
92
94
|
delete pkg.dependencies[legacy];
|
|
93
95
|
if (!options.dryRun && !options.quiet)
|
|
94
|
-
|
|
96
|
+
sayTagged(` [migrate] dropped legacy ${legacy} dependency`);
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
if (options.dryRun) {
|
|
98
100
|
if (verbose && !options.quiet)
|
|
99
|
-
|
|
101
|
+
sayTagged(` [dry-run] would add ${PACKAGE_NAME}@^${PACKAGE_VERSION} to ${pkgPath}`);
|
|
100
102
|
if (verbose && !options.quiet)
|
|
101
|
-
|
|
103
|
+
sayTagged(` [dry-run] would run: npm install --no-audit --no-fund (in ${pluginsDir})`);
|
|
102
104
|
return false;
|
|
103
105
|
}
|
|
104
106
|
// pkg.dependencies[PACKAGE_NAME] was assigned above, so only reinstall skips this fast path.
|
|
@@ -119,14 +121,14 @@ async function stepSelfPlugin(pluginsDir, options) {
|
|
|
119
121
|
await execNetwork('Installing Tersio plugin dependencies', 'bun', ['install'], { cwd: pluginsDir, timeout: 180000 });
|
|
120
122
|
}
|
|
121
123
|
catch (e) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
sayTagged(` [fail] Could not install ${PACKAGE_NAME} into plugins dir: ${e.message}`);
|
|
125
|
+
sayTagged(` [hint] Manual: cd ~/.omp/plugins && npm install ${PACKAGE_NAME}@^${PACKAGE_VERSION} --save --no-audit --no-fund`);
|
|
124
126
|
return false;
|
|
125
127
|
}
|
|
126
128
|
}
|
|
127
129
|
const installedPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
|
|
128
130
|
if ((await readTextIfExists(installedPkg)) === null) {
|
|
129
|
-
|
|
131
|
+
sayTagged(` [warn] ${PACKAGE_NAME} not found in plugins/node_modules after install`);
|
|
130
132
|
return false;
|
|
131
133
|
}
|
|
132
134
|
debug('tersio listed in OMP plugins');
|
|
@@ -136,7 +138,7 @@ function resolveRtkTriple() {
|
|
|
136
138
|
const triple = rtkPlatformSpec()?.triple;
|
|
137
139
|
if (triple)
|
|
138
140
|
return triple;
|
|
139
|
-
|
|
141
|
+
sayTagged(` [fail] Unsupported platform: ${process.platform}/${process.arch}`);
|
|
140
142
|
console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
|
|
141
143
|
return null;
|
|
142
144
|
}
|
|
@@ -145,8 +147,8 @@ function findRtkAsset(release, triple) {
|
|
|
145
147
|
const asset = assets.find((a) => a.name === `rtk-${triple}.zip` || a.name === `rtk-${triple}.tar.gz`);
|
|
146
148
|
if (asset)
|
|
147
149
|
return asset;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
+
sayTagged(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
|
|
151
|
+
sayTagged(` [hint] Available: ${assets.map((a) => a.name).filter((n) => n.startsWith('rtk-')).join(', ')}`);
|
|
150
152
|
return null;
|
|
151
153
|
}
|
|
152
154
|
async function downloadRtkChecksums(release) {
|
|
@@ -161,26 +163,30 @@ async function downloadRtkChecksums(release) {
|
|
|
161
163
|
return null;
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
// Fails closed: a missing checksum is worse than a failed install, since an unverifiable RTK binary runs with the user's privileges. `--allow-unverified` opts out.
|
|
167
|
+
async function verifyRtkArchive(archivePath, assetName, checksumsText) {
|
|
168
|
+
const unverified = (reason) => {
|
|
169
|
+
if (allowUnverified) {
|
|
170
|
+
sayTagged(` [warn] ${reason} — installing anyway (--allow-unverified)`);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
sayTagged(` [fail] ${reason}`);
|
|
174
|
+
sayTagged(' [hint] RTK not installed. Retry with --allow-unverified to accept an unverified binary.');
|
|
175
|
+
return false;
|
|
176
|
+
};
|
|
177
|
+
if (!checksumsText)
|
|
178
|
+
return unverified(`No checksums.txt published for ${assetName}`);
|
|
170
179
|
const expected = parseChecksum(checksumsText, assetName);
|
|
180
|
+
if (!expected)
|
|
181
|
+
return unverified(`checksums.txt has no entry for ${assetName}`);
|
|
171
182
|
const actual = await sha256File(archivePath);
|
|
172
|
-
if (!expected) {
|
|
173
|
-
if (!options.quiet)
|
|
174
|
-
console.log(` [warn] checksums.txt missing entry for ${assetName} — skipping verification`);
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
183
|
if (actual === expected) {
|
|
178
184
|
debug(`Checksum verified for ${assetName}`);
|
|
179
185
|
return true;
|
|
180
186
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
187
|
+
sayTagged(` [fail] Checksum mismatch for ${assetName}`);
|
|
188
|
+
sayTagged(` [fail] Expected: ${expected}`);
|
|
189
|
+
sayTagged(` [fail] Got: ${actual}`);
|
|
184
190
|
// Caller finally removes the temp dir.
|
|
185
191
|
return false;
|
|
186
192
|
}
|
|
@@ -214,110 +220,119 @@ async function extractRtkArchive(archivePath, extractDir) {
|
|
|
214
220
|
}
|
|
215
221
|
catch (e) {
|
|
216
222
|
debug(`tar xzf failed: ${shortError(e)}`);
|
|
217
|
-
|
|
223
|
+
sayTagged(` [fail] tar could not extract ${path.basename(archivePath)}`);
|
|
218
224
|
console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
|
|
219
225
|
return false;
|
|
220
226
|
}
|
|
221
227
|
return true;
|
|
222
228
|
}
|
|
223
|
-
|
|
229
|
+
sayTagged(` [fail] Unknown archive format: ${archivePath}`);
|
|
224
230
|
return false;
|
|
225
231
|
}
|
|
226
|
-
// The binary is machine-wide
|
|
227
|
-
// finds one present only rebinds. `tersio update` still refreshes it.
|
|
232
|
+
// The binary is machine-wide; hosts without a hook only rebind. `tersio update` still refreshes it.
|
|
228
233
|
async function stepRtk(binDir, options, target = 'omp') {
|
|
229
234
|
const binDest = path.join(binDir, RTK_BINARY_NAME);
|
|
230
235
|
const found = resolveRtkBinary();
|
|
236
|
+
const hostName = target === 'omp' ? 'OMP' : target === 'opencode' ? 'OpenCode' : 'pi';
|
|
231
237
|
const bind = async (binary) => {
|
|
232
|
-
if (target !== 'omp')
|
|
233
|
-
return;
|
|
234
238
|
if (!(await fileExists(binary))) {
|
|
235
|
-
console.log(
|
|
239
|
+
console.log(` [skip] no rtk binary to wire — install rtk, then run: rtk init -g ${target === 'opencode' ? '--opencode' : `--agent ${target}`}`);
|
|
236
240
|
return;
|
|
237
241
|
}
|
|
238
|
-
|
|
242
|
+
if (target === 'opencode')
|
|
243
|
+
await wireRtkOpencode(binary, options);
|
|
244
|
+
else if (target === 'pi')
|
|
245
|
+
await wireRtkPi(binary, options);
|
|
246
|
+
else
|
|
247
|
+
await wireRtkOmp(binary, options);
|
|
239
248
|
};
|
|
240
249
|
if (found && !applyUpdate) {
|
|
241
|
-
// The path only under --verbose: a plain run, and a dry run, stay free of
|
|
242
|
-
// real user paths.
|
|
250
|
+
// The path only under --verbose: a plain run, and a dry run, stay free of real user paths.
|
|
243
251
|
if (!options.quiet) {
|
|
244
252
|
const where = verbose ? ` (${found})` : '';
|
|
245
|
-
console.log(` RTK — already installed${where}
|
|
253
|
+
console.log(` RTK — already installed${where}, binding into ${hostName}`);
|
|
246
254
|
}
|
|
247
255
|
await bind(found);
|
|
248
256
|
return;
|
|
249
257
|
}
|
|
250
258
|
if (!options.quiet)
|
|
251
|
-
console.log(` RTK — download binary and
|
|
252
|
-
// A failed download must not skip wiring: a pre-existing binary serves the
|
|
253
|
-
// OMP hook just as well. Dry runs stay offline.
|
|
259
|
+
console.log(` RTK — download binary and wire into ${hostName}`);
|
|
260
|
+
// A failed download must not skip wiring: a pre-existing binary serves the host hook just as well. Dry runs stay offline.
|
|
254
261
|
if (options.dryRun) {
|
|
255
262
|
if (verbose && !options.quiet)
|
|
256
|
-
|
|
263
|
+
sayTagged(` [dry-run] would download rtk binary and install to ${binDest}`);
|
|
257
264
|
await bind(found ?? binDest);
|
|
258
265
|
return;
|
|
259
266
|
}
|
|
260
267
|
try {
|
|
261
|
-
const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
|
|
262
268
|
const triple = resolveRtkTriple();
|
|
263
269
|
if (!triple)
|
|
264
270
|
return;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const checksumsText = await withInteractiveSpinner('Downloading RTK binary', async (update) => {
|
|
272
|
-
const [downloadedChecksums] = await Promise.all([
|
|
273
|
-
downloadRtkChecksums(release),
|
|
274
|
-
httpsDownload(asset.browser_download_url, archivePath),
|
|
275
|
-
]);
|
|
276
|
-
update('Verifying RTK checksum');
|
|
277
|
-
return downloadedChecksums;
|
|
278
|
-
});
|
|
279
|
-
if (!await verifyRtkArchive(archivePath, asset.name, checksumsText, options))
|
|
271
|
+
// One task with streamed sub-steps: release lookup, download, checksum, extract, install. Non-TTY keeps the plain lines each step already prints.
|
|
272
|
+
await withInteractiveTask('Installing RTK', async (update) => {
|
|
273
|
+
const release = await fetchJson(RTK_RELEASE_API);
|
|
274
|
+
const asset = findRtkAsset(release, triple);
|
|
275
|
+
if (!asset) {
|
|
276
|
+
sayTagged(` [fail] No rtk-${triple}.<zip|tar.gz> in release ${release.tag_name}`);
|
|
280
277
|
return;
|
|
281
|
-
const extractDir = path.join(tmpDir, 'extracted');
|
|
282
|
-
if (!await extractRtkArchive(archivePath, extractDir))
|
|
283
|
-
return;
|
|
284
|
-
const found = await findFile(extractDir, RTK_BINARY_NAME);
|
|
285
|
-
if (!found) {
|
|
286
|
-
console.log(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
await fs.mkdir(path.dirname(binDest), { recursive: true });
|
|
290
|
-
await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
|
|
291
|
-
await fs.copyFile(found, binDest);
|
|
292
|
-
if (!IS_WINDOWS) {
|
|
293
|
-
await fs.chmod(binDest, 0o755);
|
|
294
|
-
debug(`chmod 755 ${binDest}`);
|
|
295
278
|
}
|
|
279
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'omp-rtk-'));
|
|
296
280
|
try {
|
|
297
|
-
|
|
281
|
+
const archivePath = path.join(tmpDir, asset.name);
|
|
282
|
+
update(`Downloading ${asset.name}`);
|
|
283
|
+
const [checksumsText] = await Promise.all([
|
|
284
|
+
downloadRtkChecksums(release),
|
|
285
|
+
httpsDownload(asset.browser_download_url, archivePath),
|
|
286
|
+
]);
|
|
287
|
+
update('Verifying checksum');
|
|
288
|
+
if (!await verifyRtkArchive(archivePath, asset.name, checksumsText)) {
|
|
289
|
+
sayTagged(' [hint] RTK install skipped for this run');
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
update('Extracting');
|
|
293
|
+
const extractDir = path.join(tmpDir, 'extracted');
|
|
294
|
+
if (!await extractRtkArchive(archivePath, extractDir)) {
|
|
295
|
+
sayTagged(` [fail] Could not extract ${asset.name}`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
update('Installing binary');
|
|
299
|
+
const found = await findFile(extractDir, RTK_BINARY_NAME);
|
|
300
|
+
if (!found) {
|
|
301
|
+
sayTagged(` [fail] Could not find ${RTK_BINARY_NAME} in extracted archive`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await fs.mkdir(path.dirname(binDest), { recursive: true });
|
|
305
|
+
await fs.copyFile(binDest, `${binDest}.bak`).catch(() => { });
|
|
306
|
+
await fs.copyFile(found, binDest);
|
|
307
|
+
if (!IS_WINDOWS) {
|
|
308
|
+
await fs.chmod(binDest, 0o755);
|
|
309
|
+
debug(`chmod 755 ${binDest}`);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
await execP(binDest, ['--version'], { timeout: 30000, shell: false });
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
sayTagged(` [hint] Verify manually: ${binDest} --version`);
|
|
316
|
+
}
|
|
298
317
|
}
|
|
299
|
-
|
|
300
|
-
|
|
318
|
+
finally {
|
|
319
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
301
320
|
}
|
|
302
|
-
}
|
|
303
|
-
finally {
|
|
304
|
-
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
305
|
-
}
|
|
321
|
+
});
|
|
306
322
|
}
|
|
307
323
|
catch (e) {
|
|
308
|
-
|
|
324
|
+
sayTagged(` [fail] RTK: ${e.message}`);
|
|
309
325
|
console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
|
|
310
326
|
}
|
|
311
327
|
// Wire rtk's tool_call extension: the binary alone never meters a session.
|
|
312
328
|
await bind(binDest);
|
|
313
329
|
}
|
|
314
|
-
// Copy sources into the target extension dir. First entry is required; the
|
|
315
|
-
// rest are optional companions.
|
|
330
|
+
// Copy sources into the target extension dir. First entry is required; the rest are optional companions.
|
|
316
331
|
async function copySources(extDir, files, skipLabel, options) {
|
|
317
332
|
const src = await readTextIfExists(files[0][0]);
|
|
318
333
|
if (!src) {
|
|
319
334
|
if (!options.quiet && options.dryRun)
|
|
320
|
-
|
|
335
|
+
sayTagged(` [skip] ${skipLabel} not found in repo`);
|
|
321
336
|
return false;
|
|
322
337
|
}
|
|
323
338
|
await writeIfChanged(path.join(extDir, files[0][1]), src, options);
|
|
@@ -341,8 +356,7 @@ async function stepRtkSession(extDir, options) {
|
|
|
341
356
|
console.log(' RTK session — install session mode');
|
|
342
357
|
await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
|
|
343
358
|
}
|
|
344
|
-
// One fetch serves the install; dry runs stay offline and preview the bundled
|
|
345
|
-
// rule's destination.
|
|
359
|
+
// One fetch serves the install; dry runs stay offline and preview the bundled rule's destination.
|
|
346
360
|
async function fetchCavemanRule(options) {
|
|
347
361
|
const bundled = await readTextIfExists(sourcePath('caveman-session/rule.md'));
|
|
348
362
|
if (options.dryRun)
|
|
@@ -355,7 +369,7 @@ async function fetchCavemanRule(options) {
|
|
|
355
369
|
debug(`Using bundled Caveman rule: ${e.message}`);
|
|
356
370
|
return bundled;
|
|
357
371
|
}
|
|
358
|
-
|
|
372
|
+
sayTagged(` [warn] Could not fetch caveman rule: ${e.message}`);
|
|
359
373
|
return null;
|
|
360
374
|
}
|
|
361
375
|
}
|
|
@@ -371,7 +385,7 @@ async function stepCaveman(extDir, rule, options) {
|
|
|
371
385
|
}
|
|
372
386
|
else if (rule === null) {
|
|
373
387
|
console.log(' [skip] Caveman rule.md unavailable');
|
|
374
|
-
|
|
388
|
+
sayTagged(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
|
|
375
389
|
}
|
|
376
390
|
else {
|
|
377
391
|
await writeIfChanged(ruleDest, rule, options);
|
|
@@ -391,11 +405,72 @@ async function stepCombo(extDir, options) {
|
|
|
391
405
|
console.log(' Combo — install preset switch');
|
|
392
406
|
await copySources(extDir, filesUnder('combo-toggle'), 'combo-toggle/index.ts', options);
|
|
393
407
|
}
|
|
408
|
+
// The tree ships no node_modules, so the full ruleset rides along as files.
|
|
409
|
+
const PONYTAIL_BUNDLE_FILES = [
|
|
410
|
+
'hooks/ponytail-instructions.js',
|
|
411
|
+
'hooks/ponytail-config.js',
|
|
412
|
+
'skills/ponytail/SKILL.md',
|
|
413
|
+
];
|
|
414
|
+
async function stepPonytailBundle(treeDir, options) {
|
|
415
|
+
const root = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)));
|
|
416
|
+
if (!root) {
|
|
417
|
+
if (!options.quiet)
|
|
418
|
+
console.log(' [skip] Ponytail package not found next to the CLI — OpenCode keeps the built-in text');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!options.quiet)
|
|
422
|
+
console.log(' Ponytail — bundle the full ruleset for OpenCode');
|
|
423
|
+
for (const rel of PONYTAIL_BUNDLE_FILES) {
|
|
424
|
+
const src = path.join(root, ...rel.split('/'));
|
|
425
|
+
let text;
|
|
426
|
+
try {
|
|
427
|
+
text = await fs.readFile(src, 'utf8');
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
sayTagged(` [fail] Ponytail bundle file missing: ${rel}`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
await writeIfChanged(path.join(treeDir, 'ponytail-bundle', ...rel.split('/')), text, options);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// OpenCode loads imported .ts plugins from the config plugins dir, so a plain tree copy + one config entry is enough.
|
|
437
|
+
async function stepOpencode(options) {
|
|
438
|
+
const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
|
|
439
|
+
if (!options.quiet)
|
|
440
|
+
console.log(` OpenCode — write the plugin tree (${dir})`);
|
|
441
|
+
await stepSharedSessionState(dir, options);
|
|
442
|
+
await copySources(dir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
|
|
443
|
+
await copySources(dir, filesUnder('caveman-session'), 'caveman-session/index.ts', options);
|
|
444
|
+
await stepCombo(dir, options);
|
|
445
|
+
await copySources(dir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
|
|
446
|
+
await stepUpdater(dir, options);
|
|
447
|
+
await copySources(dir, filesUnder('opencode'), 'opencode/server.ts', options);
|
|
448
|
+
await stepPonytailBundle(dir, options);
|
|
449
|
+
// The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
|
|
450
|
+
await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, options);
|
|
451
|
+
if (!options.dryRun)
|
|
452
|
+
await fs.rm(path.join(dir, 'opencode', 'index.ts'), { force: true }).catch(() => { });
|
|
453
|
+
const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
|
|
454
|
+
let config = {};
|
|
455
|
+
try {
|
|
456
|
+
config = JSON.parse((await readTextIfExists(configPath)) ?? '{}');
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const plugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
|
|
462
|
+
const withoutTersio = plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio'));
|
|
463
|
+
if (!withoutTersio.includes(dir))
|
|
464
|
+
withoutTersio.push(dir);
|
|
465
|
+
config.plugins = withoutTersio;
|
|
466
|
+
if (!options.dryRun)
|
|
467
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
468
|
+
await writeIfChanged(configPath, `${JSON.stringify(config, null, 2)}\n`, options);
|
|
469
|
+
}
|
|
394
470
|
async function resolveProfile(opts = {}) {
|
|
395
471
|
// Seed from the stored defaults so flag-less runs keep them.
|
|
396
472
|
const profile = await storedProfile();
|
|
397
|
-
// One numbered prompt for all three modes, for a real terminal user with no
|
|
398
|
-
// default flags. Never for --apply-update, and never for a script.
|
|
473
|
+
// One numbered prompt for all three modes, for a real terminal user with no default flags. Never for --apply-update, and never for a script.
|
|
399
474
|
if (tty() && !profileFlagsGiven && !applyUpdate) {
|
|
400
475
|
const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
|
|
401
476
|
{ value: 'off', label: 'off' },
|
|
@@ -446,10 +521,11 @@ async function runCommandMenu() {
|
|
|
446
521
|
console.log(`\n [update] staying on ${PACKAGE_VERSION} — run ` + '`tersio update`' + ` anytime`);
|
|
447
522
|
}
|
|
448
523
|
updatePromptDone = true;
|
|
449
|
-
const choice = await askInteractiveChoice(
|
|
524
|
+
const choice = await askInteractiveChoice(`Tersio — what next?\n${formatCliStatus(storedProfileSync())}`, [
|
|
450
525
|
{ value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
|
|
451
526
|
{ value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
|
|
452
527
|
{ value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
|
|
528
|
+
{ value: 'settings', label: 'Settings', hint: 'default combo mode, currency, diagnosis schedule' },
|
|
453
529
|
{ value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
|
|
454
530
|
{ value: 'dashboard', label: 'Dashboard', hint: 'open the report in your browser' },
|
|
455
531
|
{ value: 'reset', label: 'Reset statistics', hint: 'clear statistics; transcripts and RTK history stay' },
|
|
@@ -464,8 +540,7 @@ async function runCommandMenu() {
|
|
|
464
540
|
await runInstall();
|
|
465
541
|
break;
|
|
466
542
|
case 'update': {
|
|
467
|
-
// One bound flow: the version check already ran above, so report it
|
|
468
|
-
// and offer the refresh in the same breath — no second "update" quiz.
|
|
543
|
+
// One bound flow: the version check already ran above, so report it and offer the refresh in the same breath — no second "update" quiz.
|
|
469
544
|
if (typeof newer === 'string')
|
|
470
545
|
console.log(` tersio ${newer} available (installed ${PACKAGE_VERSION})`);
|
|
471
546
|
else
|
|
@@ -503,6 +578,10 @@ async function runCommandMenu() {
|
|
|
503
578
|
closeRL();
|
|
504
579
|
break;
|
|
505
580
|
}
|
|
581
|
+
case 'settings':
|
|
582
|
+
await runSettings();
|
|
583
|
+
closeRL();
|
|
584
|
+
break;
|
|
506
585
|
case 'usage':
|
|
507
586
|
await runUsage();
|
|
508
587
|
closeRL();
|
|
@@ -521,16 +600,24 @@ async function runCommandMenu() {
|
|
|
521
600
|
break;
|
|
522
601
|
}
|
|
523
602
|
}
|
|
524
|
-
// Which host this run targets. --host pins it; a TTY user picks; scripts,
|
|
525
|
-
// pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
|
|
603
|
+
// Which host this run targets. --host pins it; a TTY user picks; scripts, pipes, --yes, and --dry-run keep the pre-multi-host default of Oh My Pi.
|
|
526
604
|
let targetHost = 'omp';
|
|
605
|
+
// Hosts the menu advertises but cannot install yet. Listed disabled so the roadmap is visible without offering a target that does nothing.
|
|
606
|
+
const SOON_HOSTS = [
|
|
607
|
+
{ value: 'claude', label: 'Claude Code', hint: 'Coming soon' },
|
|
608
|
+
{ value: 'codex', label: 'Codex', hint: 'Coming soon' },
|
|
609
|
+
];
|
|
527
610
|
async function chooseHost(title, only) {
|
|
528
611
|
const hosts = detectHosts().filter((host) => !only || only.includes(host.id));
|
|
529
|
-
const
|
|
612
|
+
const options = hosts.map((host) => ({
|
|
530
613
|
value: host.id,
|
|
531
614
|
label: hostLabel(host),
|
|
532
615
|
hint: hostHint(host),
|
|
533
|
-
}))
|
|
616
|
+
}));
|
|
617
|
+
// A scoped menu (uninstall --host) lists only real hosts.
|
|
618
|
+
if (!only)
|
|
619
|
+
options.push(...SOON_HOSTS.map((h) => ({ ...h, disabled: true })));
|
|
620
|
+
const choice = await askInteractiveChoice(title, options, targetHost);
|
|
534
621
|
if (choice.status !== 'selected') {
|
|
535
622
|
closeRL();
|
|
536
623
|
process.exit(130);
|
|
@@ -540,14 +627,13 @@ async function chooseHost(title, only) {
|
|
|
540
627
|
function hostEntry(id) {
|
|
541
628
|
return detectHosts().find((host) => host.id === id);
|
|
542
629
|
}
|
|
543
|
-
// pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes
|
|
544
|
-
|
|
545
|
-
async function stepPiLayer(profile, options) {
|
|
630
|
+
// pi auto-discovers `<agent-dir>/extensions/**/index.ts`, so this route writes the same tree as OMP's and registers nothing.
|
|
631
|
+
async function stepPiLayer(options) {
|
|
546
632
|
const agentDir = piAgentDir();
|
|
547
633
|
const extDir = path.join(agentDir, 'extensions');
|
|
548
634
|
const declared = piTersioSource(agentDir);
|
|
549
635
|
if (declared)
|
|
550
|
-
|
|
636
|
+
sayTagged(` [note] pi also has a tersio package (${declared}) — remove one, or both copies load: pi remove npm:${PACKAGE_NAME}`);
|
|
551
637
|
if (!options.quiet)
|
|
552
638
|
console.log(` Pi — write the extension tree (${extDir})`);
|
|
553
639
|
const cavemanRule = await fetchCavemanRule(options);
|
|
@@ -570,7 +656,7 @@ async function stepPonytailForPi(agentDir, options) {
|
|
|
570
656
|
return;
|
|
571
657
|
}
|
|
572
658
|
catch (e) {
|
|
573
|
-
|
|
659
|
+
sayTagged(` [fail] pi install ponytail: ${shortError(e)}`);
|
|
574
660
|
}
|
|
575
661
|
}
|
|
576
662
|
const skills = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)), 'skills');
|
|
@@ -582,7 +668,7 @@ async function stepPonytailForPi(agentDir, options) {
|
|
|
582
668
|
const dest = path.join(agentDir, 'skills');
|
|
583
669
|
if (options.dryRun) {
|
|
584
670
|
if (verbose && !options.quiet)
|
|
585
|
-
|
|
671
|
+
sayTagged(` [dry-run] would copy Ponytail skills to ${dest}`);
|
|
586
672
|
return;
|
|
587
673
|
}
|
|
588
674
|
for (const entry of await fs.readdir(skills, { withFileTypes: true })) {
|
|
@@ -592,12 +678,11 @@ async function stepPonytailForPi(agentDir, options) {
|
|
|
592
678
|
await fs.rm(to, { recursive: true, force: true });
|
|
593
679
|
await fs.cp(path.join(skills, entry.name), to, { recursive: true });
|
|
594
680
|
if (!options.quiet)
|
|
595
|
-
|
|
681
|
+
sayTagged(` [write] skill ${entry.name} → ${to}`);
|
|
596
682
|
}
|
|
597
683
|
}
|
|
598
684
|
async function runInstall() {
|
|
599
|
-
// Without this guard a picked command re-enters here with command === null
|
|
600
|
-
// and loops straight back into the picker.
|
|
685
|
+
// Without this guard a picked command re-enters here with command === null and loops straight back into the picker.
|
|
601
686
|
if (command === null && tty() && !yes && !updatePromptDone) {
|
|
602
687
|
await runCommandMenu();
|
|
603
688
|
return;
|
|
@@ -616,7 +701,6 @@ async function runInstall() {
|
|
|
616
701
|
else if (tty() && !yes && !dryRun && !applyUpdate && command !== 'uninstall') {
|
|
617
702
|
targetHost = await chooseHost('Install Tersio into which agent?');
|
|
618
703
|
}
|
|
619
|
-
const host = hostEntry(targetHost);
|
|
620
704
|
// apply-update stays silent: the parent printed the plan.
|
|
621
705
|
const quiet = applyUpdate;
|
|
622
706
|
if (!quiet)
|
|
@@ -630,8 +714,7 @@ async function runInstall() {
|
|
|
630
714
|
if (tty() && !applyUpdate && command !== 'uninstall') {
|
|
631
715
|
const newer = await checkForUpdate();
|
|
632
716
|
if (typeof newer === 'string') {
|
|
633
|
-
// Update pending: offer it here instead of burying it above the install
|
|
634
|
-
// prompts. Yes runs the full update and stops.
|
|
717
|
+
// Update pending: offer it here instead of burying it above the install prompts. Yes runs the full update and stops.
|
|
635
718
|
if (command === null && !dryRun && !yes && !updatePromptDone) {
|
|
636
719
|
const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
|
|
637
720
|
if (answer.status === 'confirmed' && answer.value) {
|
|
@@ -656,6 +739,25 @@ async function runInstall() {
|
|
|
656
739
|
const userExtDir = path.join(userDir, 'extensions');
|
|
657
740
|
// apply-update refreshes every add-on like a reinstall.
|
|
658
741
|
const installOptions = { dryRun, verbose, yes, reinstall: applyUpdate, quiet };
|
|
742
|
+
// Point of no return: disk writes start here; pipes stay unattended.
|
|
743
|
+
if (!yes && !dryRun && !applyUpdate && tty()) {
|
|
744
|
+
const target = hostEntry(targetHost);
|
|
745
|
+
const where = targetHost === 'pi'
|
|
746
|
+
? path.join(piAgentDir(), 'extensions')
|
|
747
|
+
: targetHost === 'opencode'
|
|
748
|
+
? path.join(HOME, '.config', 'opencode', 'plugins', 'tersio')
|
|
749
|
+
: path.join(OMP_AGENT_DIR, 'extensions');
|
|
750
|
+
console.log(`\nWill install into ${target.label}:`);
|
|
751
|
+
console.log(` ${where}`);
|
|
752
|
+
console.log(' caveman · rtk · ponytail session modes');
|
|
753
|
+
console.log(` session defaults → ${tersioSettingsFile()}`);
|
|
754
|
+
const go = await askInteractiveConfirm(`Install Tersio into ${target.label}?`);
|
|
755
|
+
if (go.status !== 'confirmed' || !go.value) {
|
|
756
|
+
closeRL();
|
|
757
|
+
console.log('\nAborted. Nothing was changed.');
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
659
761
|
try {
|
|
660
762
|
const v = (await execP(OMP_BIN, ['--version'])).stdout.trim();
|
|
661
763
|
if (verbose && !quiet)
|
|
@@ -672,11 +774,22 @@ async function runInstall() {
|
|
|
672
774
|
}
|
|
673
775
|
catch (e) {
|
|
674
776
|
failures.push(label);
|
|
675
|
-
|
|
777
|
+
sayTagged(` [fail] ${label}: ${shortError(e)}`);
|
|
676
778
|
}
|
|
677
779
|
};
|
|
780
|
+
if (targetHost === 'opencode') {
|
|
781
|
+
await capture('opencode tree', () => stepOpencode(installOptions));
|
|
782
|
+
await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'opencode'));
|
|
783
|
+
await capture('settings', () => writePluginSettings(profile, installOptions));
|
|
784
|
+
if (failures.length > 0)
|
|
785
|
+
console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
|
|
786
|
+
else
|
|
787
|
+
console.log('\nDone — restart OpenCode, then /combo balanced.');
|
|
788
|
+
closeRL();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
678
791
|
if (targetHost === 'pi') {
|
|
679
|
-
await capture('pi tree', () => stepPiLayer(
|
|
792
|
+
await capture('pi tree', () => stepPiLayer(installOptions));
|
|
680
793
|
await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions, 'pi'));
|
|
681
794
|
await capture('settings', () => writePluginSettings(profile, installOptions));
|
|
682
795
|
if (failures.length > 0)
|
|
@@ -708,4 +821,4 @@ async function runInstall() {
|
|
|
708
821
|
}
|
|
709
822
|
closeRL();
|
|
710
823
|
}
|
|
711
|
-
export { runInstall };
|
|
824
|
+
export { runInstall, verifyRtkArchive };
|