@krtclcdy/tersio 2.5.0 → 2.7.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 +21 -61
- package/cli/brand.webp +0 -0
- package/cli/common.js +7 -2
- package/cli/dashboard.html +404 -0
- package/cli/dashboard.js +72 -0
- package/cli/doctor.js +9 -1
- package/cli/install.js +115 -12
- package/cli/interactive.js +13 -2
- package/cli/uninstall.js +1 -0
- package/cli/usage.js +71 -0
- package/extensions/ai-addons-updater/index.js +28 -23
- package/extensions/shared/usage-ledger.js +156 -0
- package/extensions/tersio-commands/index.js +171 -0
- package/package.json +10 -1
- package/tersio.js +18 -1
package/cli/doctor.js
CHANGED
|
@@ -3,6 +3,7 @@ import { promises as fs } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { BUN_BIN_DIR, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, execP, parseJsonObject, relTime, } from './common.js';
|
|
5
5
|
import { runInteractivePhase } from './interactive.js';
|
|
6
|
+
import { ledgerPath, readUsage } from '../extensions/shared/usage-ledger.js';
|
|
6
7
|
import { checkForUpdate } from './update.js';
|
|
7
8
|
import { readTextIfExists } from '../extensions/lib/utils.js';
|
|
8
9
|
async function runDoctor() {
|
|
@@ -21,6 +22,7 @@ async function runDoctor() {
|
|
|
21
22
|
const rtkIndex = path.join(extDir, 'rtk-session', 'index.js');
|
|
22
23
|
const updaterIndex = path.join(extDir, 'ai-addons-updater', 'index.js');
|
|
23
24
|
const comboIndex = path.join(extDir, 'combo-toggle', 'index.js');
|
|
25
|
+
const tersioIndex = path.join(extDir, 'tersio-commands', 'index.js');
|
|
24
26
|
const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.js');
|
|
25
27
|
const selfPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
|
|
26
28
|
// Independent probes start concurrently; sections report in fixed order as
|
|
@@ -41,6 +43,7 @@ async function runDoctor() {
|
|
|
41
43
|
rtkIndexText: readTextIfExists(rtkIndex),
|
|
42
44
|
updaterIndexText: readTextIfExists(updaterIndex),
|
|
43
45
|
comboIndexText: readTextIfExists(comboIndex),
|
|
46
|
+
tersioIndexText: readTextIfExists(tersioIndex),
|
|
44
47
|
modeReinforcementText: readTextIfExists(modeReinforcement),
|
|
45
48
|
ruleMtime: fs.stat(cavemanRule).catch(() => null),
|
|
46
49
|
rtkMtime: fs.stat(rtkBin).catch(() => null),
|
|
@@ -59,11 +62,12 @@ async function runDoctor() {
|
|
|
59
62
|
probes.configText,
|
|
60
63
|
probes.updateVersion,
|
|
61
64
|
]));
|
|
62
|
-
const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
|
|
65
|
+
const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, tersioIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
|
|
63
66
|
probes.cavemanIndexText,
|
|
64
67
|
probes.rtkIndexText,
|
|
65
68
|
probes.updaterIndexText,
|
|
66
69
|
probes.comboIndexText,
|
|
70
|
+
probes.tersioIndexText,
|
|
67
71
|
probes.modeReinforcementText,
|
|
68
72
|
probes.ponytailPkgText,
|
|
69
73
|
probes.ponytailExtText,
|
|
@@ -112,6 +116,7 @@ async function runDoctor() {
|
|
|
112
116
|
check('RTK extension', rtkIndexText !== null);
|
|
113
117
|
check('Updater extension', updaterIndexText !== null);
|
|
114
118
|
check('Combo extension', comboIndexText !== null);
|
|
119
|
+
check('Tersio commands extension', tersioIndexText !== null);
|
|
115
120
|
check('Mode reinforcement extension', modeReinforcementText !== null);
|
|
116
121
|
section('Plugins');
|
|
117
122
|
check('Ponytail package', ponytailPkgText !== null);
|
|
@@ -123,6 +128,9 @@ async function runDoctor() {
|
|
|
123
128
|
check('Self plugin package', selfPkgText !== null, parseJsonObject(selfPkgText)?.version ?? '');
|
|
124
129
|
const selfDep = PACKAGE_NAME in (parseJsonObject(pluginsPkgRaw)?.dependencies || {});
|
|
125
130
|
check('Self plugin in plugins/package.json', selfDep);
|
|
131
|
+
section('Usage');
|
|
132
|
+
const usageRows = readUsage();
|
|
133
|
+
check('Usage ledger', usageRows.length > 0, usageRows.length ? `${usageRows.length} rows · ${ledgerPath()}` : ledgerPath());
|
|
126
134
|
section('Add-ons');
|
|
127
135
|
const ruleAge = ruleMtime ? `updated ${relTime(Date.now() - ruleMtime.mtimeMs)}` : '';
|
|
128
136
|
check('Caveman rule', cavemanRuleText !== null, ruleAge);
|
package/cli/install.js
CHANGED
|
@@ -3,10 +3,13 @@ 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, EXTENSIONS_KEY_RE, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, SCOPE_MAP, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, scopeFlag, verbose, yes, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
7
|
-
import { ask, askInteractiveChoice, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
|
|
8
|
-
import { checkForUpdate } from './update.js';
|
|
6
|
+
import { BUN_BIN_DIR, COMBO_PRESET_MODES, EXTENSIONS_KEY_RE, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, SCOPE_MAP, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, scopeFlag, verbose, yes, dashboardExport, dashboardOpen, dashboardPort, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
7
|
+
import { ask, askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
|
|
8
|
+
import { checkForUpdate, runLatestUpdate } from './update.js';
|
|
9
9
|
import { runUninstall } from './uninstall.js';
|
|
10
|
+
import { runDoctor } from './doctor.js';
|
|
11
|
+
import { runUsage } from './usage.js';
|
|
12
|
+
import { runDashboard } from './dashboard.js';
|
|
10
13
|
import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
|
|
11
14
|
// Paths to extension source files (relative to this script)
|
|
12
15
|
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
|
|
@@ -15,6 +18,7 @@ const CAVEMAN_INDEX = path.join(EXT_DIR, 'caveman-session', 'index.js');
|
|
|
15
18
|
const RTK_SESSION_INDEX = path.join(EXT_DIR, 'rtk-session', 'index.js');
|
|
16
19
|
const UPDATER_INDEX = path.join(EXT_DIR, 'ai-addons-updater', 'index.js');
|
|
17
20
|
const COMBO_TOGGLE_INDEX = path.join(EXT_DIR, 'combo-toggle', 'index.js');
|
|
21
|
+
const TERSIO_COMMANDS_INDEX = path.join(EXT_DIR, 'tersio-commands', 'index.js');
|
|
18
22
|
const MODE_REINFORCEMENT_INDEX = path.join(EXT_DIR, 'shared', 'mode-reinforcement.js');
|
|
19
23
|
const SHARED_TYPES = path.join(EXT_DIR, 'shared', 'types.js');
|
|
20
24
|
const LIB_UTILS = path.join(EXT_DIR, 'lib', 'utils.js');
|
|
@@ -22,7 +26,7 @@ const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.js'
|
|
|
22
26
|
const PONYTAIL_GITHUB_SPEC = 'github:DietrichGebert/ponytail';
|
|
23
27
|
const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
|
|
24
28
|
async function stepPonytail(pluginsDir, userDir, options) {
|
|
25
|
-
console.log('\n[1/
|
|
29
|
+
console.log('\n[1/8] Installing Ponytail plugin...');
|
|
26
30
|
await fs.mkdir(pluginsDir, { recursive: true });
|
|
27
31
|
const pkgPath = path.join(pluginsDir, 'package.json');
|
|
28
32
|
const pkg = await readPluginsPackage(pkgPath);
|
|
@@ -124,7 +128,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
|
|
|
124
128
|
// Settings → Plugins page (OMP enumerates plugins/package.json dependencies).
|
|
125
129
|
// Returns true when the package is verified in plugins/node_modules.
|
|
126
130
|
async function stepSelfPlugin(pluginsDir, options) {
|
|
127
|
-
console.log('\n[2/
|
|
131
|
+
console.log('\n[2/8] Registering tersio as OMP plugin...');
|
|
128
132
|
const pkgPath = path.join(pluginsDir, 'package.json');
|
|
129
133
|
const pkg = await readPluginsPackage(pkgPath);
|
|
130
134
|
pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
|
|
@@ -253,7 +257,7 @@ async function extractRtkArchive(archivePath, extractDir) {
|
|
|
253
257
|
return false;
|
|
254
258
|
}
|
|
255
259
|
async function stepRtk(binDir, options) {
|
|
256
|
-
console.log('\n[3/
|
|
260
|
+
console.log('\n[3/8] Installing RTK binary...');
|
|
257
261
|
try {
|
|
258
262
|
const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
|
|
259
263
|
const triple = resolveRtkTriple();
|
|
@@ -342,14 +346,14 @@ async function stepSharedSessionState(extDir, options) {
|
|
|
342
346
|
], 'shared/session-state.js', options);
|
|
343
347
|
}
|
|
344
348
|
async function stepModeReinforcement(extDir, ponytailExtPath, options) {
|
|
345
|
-
console.log('\n[
|
|
349
|
+
console.log('\n[8/8] Installing mode reinforcement extension...');
|
|
346
350
|
const dest = path.join(extDir, 'shared', 'mode-reinforcement.js');
|
|
347
351
|
if (!await copySources(extDir, [[MODE_REINFORCEMENT_INDEX, path.join('shared', 'mode-reinforcement.js')]], 'shared/mode-reinforcement.js', options))
|
|
348
352
|
return;
|
|
349
353
|
await ensureExtensionAfterConfigEntry(path.join(path.dirname(extDir), 'config.yml'), dest, ponytailExtPath, 'mode reinforcement', options);
|
|
350
354
|
}
|
|
351
355
|
async function stepRtkSession(extDir, options) {
|
|
352
|
-
console.log('\n[4/
|
|
356
|
+
console.log('\n[4/8] Installing RTK session extension...');
|
|
353
357
|
await copySources(extDir, [[RTK_SESSION_INDEX, path.join('rtk-session', 'index.js')]], 'rtk-session/index.js', options);
|
|
354
358
|
}
|
|
355
359
|
// One rule fetch serves user- and project-level installs; dry runs stay offline
|
|
@@ -366,7 +370,7 @@ async function fetchCavemanRule(options) {
|
|
|
366
370
|
}
|
|
367
371
|
}
|
|
368
372
|
async function stepCaveman(extDir, rule, options) {
|
|
369
|
-
console.log('\n[5/
|
|
373
|
+
console.log('\n[5/8] Installing Caveman session extension...');
|
|
370
374
|
const cavemanDir = path.join(extDir, 'caveman-session');
|
|
371
375
|
if (!options.dryRun)
|
|
372
376
|
await fs.mkdir(cavemanDir, { recursive: true });
|
|
@@ -383,11 +387,15 @@ async function stepCaveman(extDir, rule, options) {
|
|
|
383
387
|
}
|
|
384
388
|
await copySources(extDir, [[CAVEMAN_INDEX, path.join('caveman-session', 'index.js')]], 'caveman-session/index.js', options);
|
|
385
389
|
}
|
|
390
|
+
async function stepTersioCommands(extDir, options) {
|
|
391
|
+
console.log('\n[7/8] Installing Tersio root-command extension...');
|
|
392
|
+
await copySources(extDir, [[TERSIO_COMMANDS_INDEX, path.join('tersio-commands', 'index.js')]], 'tersio-commands/index.js', options);
|
|
393
|
+
}
|
|
386
394
|
async function stepUpdater(extDir, options) {
|
|
387
395
|
await copySources(extDir, [[UPDATER_INDEX, path.join('ai-addons-updater', 'index.js')]], 'ai-addons-updater/index.js', options);
|
|
388
396
|
}
|
|
389
397
|
async function stepCombo(extDir, options) {
|
|
390
|
-
console.log('\n[6/
|
|
398
|
+
console.log('\n[6/8] Installing Combo toggle extension...');
|
|
391
399
|
const dest = path.join(extDir, 'combo-toggle', 'index.js');
|
|
392
400
|
if (!await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.js')]], 'combo-toggle/index.js', options))
|
|
393
401
|
return;
|
|
@@ -536,7 +544,81 @@ async function writePluginSettings(profile, options) {
|
|
|
536
544
|
await fs.writeFile(lockPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
537
545
|
console.log(` [write] Plugin settings in ${lockPath}`);
|
|
538
546
|
}
|
|
547
|
+
let updatePromptDone = false;
|
|
548
|
+
// Bare `tersio` at a terminal is a command picker, not an install run:
|
|
549
|
+
// update offer first (when pending), then a Clack menu over every command.
|
|
550
|
+
// Scripts, pipes, --yes, and --dry-run keep the old straight-to-install path.
|
|
551
|
+
async function runCommandMenu() {
|
|
552
|
+
const newer = await checkForUpdate();
|
|
553
|
+
if (newer && !dryRun) {
|
|
554
|
+
const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
|
|
555
|
+
if (answer.status === 'confirmed' && answer.value) {
|
|
556
|
+
await runLatestUpdate();
|
|
557
|
+
closeRL();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (answer.status === 'cancelled') {
|
|
561
|
+
closeRL();
|
|
562
|
+
process.exit(130);
|
|
563
|
+
}
|
|
564
|
+
console.log(`\n [update] staying on ${PACKAGE_VERSION} — run ` + '`tersio update`' + ` anytime`);
|
|
565
|
+
}
|
|
566
|
+
updatePromptDone = true;
|
|
567
|
+
const choice = await askInteractiveChoice('Tersio — what next?', [
|
|
568
|
+
{ value: 'install', label: 'Install add-ons', hint: 'user/project scope + combo defaults' },
|
|
569
|
+
{ value: 'check', label: 'Check for updates', hint: 'compare installed vs latest release' },
|
|
570
|
+
{ value: 'update', label: 'Update everything', hint: 'CLI plus all ai-addons' },
|
|
571
|
+
{ value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
|
|
572
|
+
{ value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
|
|
573
|
+
{ value: 'gain', label: 'Gain dashboard', hint: 'open the report in your browser' },
|
|
574
|
+
{ value: 'dashboard', label: 'Serve dashboard', hint: 'localhost server, no browser' },
|
|
575
|
+
{ value: 'uninstall', label: 'Uninstall', hint: 'remove tersio' },
|
|
576
|
+
], 'install');
|
|
577
|
+
if (choice.status !== 'selected') {
|
|
578
|
+
closeRL();
|
|
579
|
+
process.exit(130);
|
|
580
|
+
}
|
|
581
|
+
switch (choice.value) {
|
|
582
|
+
case 'install':
|
|
583
|
+
await runInstall();
|
|
584
|
+
break;
|
|
585
|
+
case 'check': {
|
|
586
|
+
const latest = await checkForUpdate();
|
|
587
|
+
console.log(latest ? ` [update] tersio ${latest} available (installed ${PACKAGE_VERSION})` : ` [ok] tersio ${PACKAGE_VERSION} is the latest`);
|
|
588
|
+
closeRL();
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
case 'update':
|
|
592
|
+
await runLatestUpdate();
|
|
593
|
+
closeRL();
|
|
594
|
+
break;
|
|
595
|
+
case 'doctor':
|
|
596
|
+
await runDoctor();
|
|
597
|
+
closeRL();
|
|
598
|
+
break;
|
|
599
|
+
case 'usage':
|
|
600
|
+
await runUsage();
|
|
601
|
+
closeRL();
|
|
602
|
+
break;
|
|
603
|
+
case 'gain':
|
|
604
|
+
await runDashboard({ port: dashboardPort, open: true, exportFile: null });
|
|
605
|
+
closeRL();
|
|
606
|
+
break;
|
|
607
|
+
case 'dashboard':
|
|
608
|
+
await runDashboard({ port: dashboardPort, open: dashboardOpen, exportFile: dashboardExport });
|
|
609
|
+
closeRL();
|
|
610
|
+
break;
|
|
611
|
+
case 'uninstall':
|
|
612
|
+
await runUninstall();
|
|
613
|
+
closeRL();
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
539
617
|
async function runInstall() {
|
|
618
|
+
if (command === null && tty() && !yes) {
|
|
619
|
+
await runCommandMenu();
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
540
622
|
if (reinstall) {
|
|
541
623
|
await runUninstall({ yes: true, removePonytail: false, removeRtk: true });
|
|
542
624
|
}
|
|
@@ -550,8 +632,27 @@ async function runInstall() {
|
|
|
550
632
|
// the apply-update payload, which is itself an update run.
|
|
551
633
|
if (tty() && !applyUpdate && command !== 'uninstall') {
|
|
552
634
|
const newer = await checkForUpdate();
|
|
553
|
-
if (newer)
|
|
554
|
-
|
|
635
|
+
if (newer) {
|
|
636
|
+
// Bare `tersio` with an update pending: offer it now (Y/n) instead of
|
|
637
|
+
// burying the banner above the install prompts. Yes runs the full
|
|
638
|
+
// update and stops here — the fresh binary owns what follows.
|
|
639
|
+
if (command === null && !dryRun && !yes && !updatePromptDone) {
|
|
640
|
+
const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
|
|
641
|
+
if (answer.status === 'confirmed' && answer.value) {
|
|
642
|
+
await runLatestUpdate();
|
|
643
|
+
closeRL();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (answer.status === 'cancelled') {
|
|
647
|
+
closeRL();
|
|
648
|
+
process.exit(130);
|
|
649
|
+
}
|
|
650
|
+
console.log(`\n [update] staying on ${PACKAGE_VERSION} — run \`tersio update\` anytime`);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
console.log(`\n [update] tersio ${newer} available (installed ${PACKAGE_VERSION}) — run \`tersio update\``);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
555
656
|
}
|
|
556
657
|
const scope = await resolveScope();
|
|
557
658
|
// Resolve session defaults: flags > interactive prompt > defaults.
|
|
@@ -583,6 +684,7 @@ async function runInstall() {
|
|
|
583
684
|
await stepRtkSession(userExtDir, options);
|
|
584
685
|
await stepCaveman(userExtDir, cavemanRule, options);
|
|
585
686
|
await stepCombo(userExtDir, options);
|
|
687
|
+
await stepTersioCommands(userExtDir, options);
|
|
586
688
|
await stepModeReinforcement(userExtDir, ponytailExtPath, options);
|
|
587
689
|
await stepUpdater(userExtDir, options);
|
|
588
690
|
if (selfPlugin)
|
|
@@ -594,6 +696,7 @@ async function runInstall() {
|
|
|
594
696
|
await stepSharedSessionState(projectExtDir, options);
|
|
595
697
|
await stepRtkSession(projectExtDir, options);
|
|
596
698
|
await stepCaveman(projectExtDir, cavemanRule, options);
|
|
699
|
+
await stepTersioCommands(projectExtDir, options);
|
|
597
700
|
await stepUpdater(projectExtDir, options);
|
|
598
701
|
console.log(' [note] Ponytail, RTK binary, and Combo toggle require user-level (global) install');
|
|
599
702
|
}
|
package/cli/interactive.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// cli/interactive.ts — TTY layer: readline, Clack spinners, selects, task phases.
|
|
2
2
|
import readline from 'node:readline';
|
|
3
|
-
import { cancel as clackCancel, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
|
|
3
|
+
import { cancel as clackCancel, confirm as clackConfirm, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
|
|
4
4
|
import { execP } from './common.js';
|
|
5
5
|
const RL = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
6
6
|
let rlOpen = true;
|
|
@@ -56,6 +56,17 @@ async function askInteractiveChoice(message, options, initialValue) {
|
|
|
56
56
|
}
|
|
57
57
|
return { status: 'selected', value: choice };
|
|
58
58
|
}
|
|
59
|
+
async function askInteractiveConfirm(message, initialValue = true) {
|
|
60
|
+
if (!tty())
|
|
61
|
+
return { status: 'unavailable' };
|
|
62
|
+
closeRL();
|
|
63
|
+
const answer = await clackConfirm({ message, initialValue });
|
|
64
|
+
if (typeof answer !== 'boolean') {
|
|
65
|
+
clackCancel('Aborted.');
|
|
66
|
+
return { status: 'cancelled' };
|
|
67
|
+
}
|
|
68
|
+
return { status: 'confirmed', value: answer };
|
|
69
|
+
}
|
|
59
70
|
// Run collecting work under one TTY-only Clack task. Callers print after the
|
|
60
71
|
// task completes, keeping normal output out of the spinner animation.
|
|
61
72
|
async function runInteractivePhase(title, collect) {
|
|
@@ -69,4 +80,4 @@ async function runInteractivePhase(title, collect) {
|
|
|
69
80
|
async function execNetwork(label, cmd, args, opts = {}) {
|
|
70
81
|
return withInteractiveSpinner(label, () => execP(cmd, args, opts));
|
|
71
82
|
}
|
|
72
|
-
export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, runInteractivePhase, };
|
|
83
|
+
export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, askInteractiveConfirm, runInteractivePhase, };
|
package/cli/uninstall.js
CHANGED
package/cli/usage.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// cli/usage.ts — ledger + session-token usage report, tokscale-style.
|
|
2
|
+
import { co2Grams, importSessionTokens, readUsage, usdCost, } from '../extensions/shared/usage-ledger.js';
|
|
3
|
+
import { withInteractiveSpinner } from './interactive.js';
|
|
4
|
+
export function summarizeUsage(rows) {
|
|
5
|
+
const byKind = {};
|
|
6
|
+
const detailCounts = {};
|
|
7
|
+
for (const r of rows) {
|
|
8
|
+
byKind[r.kind] = (byKind[r.kind] ?? 0) + 1;
|
|
9
|
+
detailCounts[r.detail] = (detailCounts[r.detail] ?? 0) + 1;
|
|
10
|
+
}
|
|
11
|
+
const byDetail = Object.entries(detailCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
12
|
+
const session = importSessionTokens();
|
|
13
|
+
let usd = 0;
|
|
14
|
+
let priced = true;
|
|
15
|
+
for (const [model, t] of Object.entries(session.byModel)) {
|
|
16
|
+
const c = usdCost(t, model);
|
|
17
|
+
usd += c.usd;
|
|
18
|
+
if (!c.priced)
|
|
19
|
+
priced = false;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
total: rows.length,
|
|
23
|
+
byKind,
|
|
24
|
+
byDetail,
|
|
25
|
+
lastWrite: rows.length ? new Date(rows[rows.length - 1].ts).toISOString() : null,
|
|
26
|
+
empty: rows.length === 0 && session.messages === 0,
|
|
27
|
+
messages: session.messages,
|
|
28
|
+
tokens: session.totals,
|
|
29
|
+
byModel: session.byModel,
|
|
30
|
+
byDay: session.byDay,
|
|
31
|
+
usd,
|
|
32
|
+
priced,
|
|
33
|
+
co2g: co2Grams(session.totals.output),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function fmt(n) {
|
|
37
|
+
return n.toLocaleString('en-US');
|
|
38
|
+
}
|
|
39
|
+
function printReport(report) {
|
|
40
|
+
console.log('\n=== Tersio Usage ===');
|
|
41
|
+
if (report.empty) {
|
|
42
|
+
console.log(' No ledger rows or session tokens yet — run session commands first.');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const t = report.tokens;
|
|
46
|
+
console.log(` Tokens input ${fmt(t.input)} · output ${fmt(t.output)} · cache read ${fmt(t.cacheRead)} · cache write ${fmt(t.cacheWrite)} (${report.messages} assistant messages)`);
|
|
47
|
+
console.log(` Cost $${report.usd.toFixed(2)}${report.priced ? '' : ' (includes default pricing for unknown models)'} · CO2 ~${report.co2g.toFixed(1)}g (est.)`);
|
|
48
|
+
const models = Object.entries(report.byModel).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output)).slice(0, 5);
|
|
49
|
+
if (models.length) {
|
|
50
|
+
console.log(' By model');
|
|
51
|
+
for (const [model, b] of models) {
|
|
52
|
+
console.log(` ${model}: in ${fmt(b.input)} · out ${fmt(b.output)} · cache r/w ${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (report.total) {
|
|
56
|
+
console.log(' Activity');
|
|
57
|
+
for (const [kind, n] of Object.entries(report.byKind)) {
|
|
58
|
+
console.log(` ${kind}: ${n}`);
|
|
59
|
+
}
|
|
60
|
+
console.log(' Top commands');
|
|
61
|
+
for (const [detail, n] of report.byDetail) {
|
|
62
|
+
console.log(` ${n}× ${detail}`);
|
|
63
|
+
}
|
|
64
|
+
console.log(` Total rows: ${report.total} · last write: ${report.lastWrite}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function runUsage() {
|
|
68
|
+
const report = await withInteractiveSpinner('Reading usage ledger', async () => summarizeUsage(readUsage()));
|
|
69
|
+
printReport(report);
|
|
70
|
+
}
|
|
71
|
+
export { runUsage };
|
|
@@ -82,6 +82,33 @@ function checkCaveman() {
|
|
|
82
82
|
return `Caveman ${status}: local=${localHash || '—'} remote=${remoteHash}`;
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
|
+
export async function checkAddonsSummary(ctx) {
|
|
86
|
+
return checkAddons(ctx);
|
|
87
|
+
}
|
|
88
|
+
export async function runAddonUpdate(pi, ctx, target, dryRun = false) {
|
|
89
|
+
const results = [];
|
|
90
|
+
const updaters = {
|
|
91
|
+
ponytail: () => updatePonytail(pi, ctx, dryRun),
|
|
92
|
+
rtk: () => updateRtk(ctx, dryRun),
|
|
93
|
+
caveman: () => updateCaveman(ctx, dryRun),
|
|
94
|
+
};
|
|
95
|
+
if (target === 'all') {
|
|
96
|
+
notify(ctx, `ai-addons update all${dryRun ? ' dry-run' : ''}: starting ponytail → rtk → caveman sequentially…`, 'info');
|
|
97
|
+
for (const name of ['ponytail', 'rtk', 'caveman'])
|
|
98
|
+
results.push(await updaters[name]());
|
|
99
|
+
if (!dryRun)
|
|
100
|
+
results.push(RELOAD_MSG);
|
|
101
|
+
notify(ctx, `ai-addons update all ${dryRun ? 'dry-run ' : ''}complete.${dryRun ? '' : ` ${RELOAD_MSG}`}`, 'info');
|
|
102
|
+
}
|
|
103
|
+
else if (Object.hasOwn(updaters, target)) {
|
|
104
|
+
results.push(await updaters[target]());
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const m = 'Usage: /ai-addons update <ponytail|rtk|caveman|all> [--dry-run]';
|
|
108
|
+
return report(ctx, m, 'warning');
|
|
109
|
+
}
|
|
110
|
+
return results.join('\n\n');
|
|
111
|
+
}
|
|
85
112
|
async function checkAddons(ctx) {
|
|
86
113
|
const [ponytail, rtk, caveman] = await Promise.all([checkPonytail(), checkRtk(), checkCaveman()]);
|
|
87
114
|
const lines = [];
|
|
@@ -294,29 +321,7 @@ export default function aiAddonsUpdaterExtension(pi) {
|
|
|
294
321
|
return summary;
|
|
295
322
|
}
|
|
296
323
|
if (sub === 'update' && cleanParts[1]) {
|
|
297
|
-
|
|
298
|
-
const results = [];
|
|
299
|
-
const updaters = {
|
|
300
|
-
ponytail: () => updatePonytail(pi, ctx, dryRun),
|
|
301
|
-
rtk: () => updateRtk(ctx, dryRun),
|
|
302
|
-
caveman: () => updateCaveman(ctx, dryRun),
|
|
303
|
-
};
|
|
304
|
-
if (target === 'all') {
|
|
305
|
-
notify(ctx, `ai-addons update all${dryRun ? ' dry-run' : ''}: starting ponytail → rtk → caveman sequentially…`, 'info');
|
|
306
|
-
for (const name of ['ponytail', 'rtk', 'caveman'])
|
|
307
|
-
results.push(await updaters[name]());
|
|
308
|
-
if (!dryRun)
|
|
309
|
-
results.push(RELOAD_MSG);
|
|
310
|
-
notify(ctx, `ai-addons update all ${dryRun ? 'dry-run ' : ''}complete.${dryRun ? '' : ` ${RELOAD_MSG}`}`, 'info');
|
|
311
|
-
}
|
|
312
|
-
else if (Object.hasOwn(updaters, target)) {
|
|
313
|
-
results.push(await updaters[target]());
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
const m = 'Usage: /ai-addons update <ponytail|rtk|caveman|all> [--dry-run]';
|
|
317
|
-
return report(ctx, m, 'warning');
|
|
318
|
-
}
|
|
319
|
-
return results.join('\n\n');
|
|
324
|
+
return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
|
|
320
325
|
}
|
|
321
326
|
const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
|
|
322
327
|
return report(ctx, m, 'warning');
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Shared usage ledger: append-only JSON-lines file. One source of truth read
|
|
2
|
+
// by `tersio usage`, `/tersio usage`, and the gain dashboard.
|
|
3
|
+
// Best-effort by design: a ledger failure never breaks the caller, and
|
|
4
|
+
// corrupt lines are skipped on read (same pattern as the config normalizer).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
export function ledgerPath() {
|
|
9
|
+
const override = process.env.TERSIO_USAGE_FILE;
|
|
10
|
+
if (override)
|
|
11
|
+
return override;
|
|
12
|
+
return path.join(os.homedir(), '.omp', 'plugins', 'tersio-usage.jsonl');
|
|
13
|
+
}
|
|
14
|
+
export function appendUsage(kind, detail) {
|
|
15
|
+
const row = JSON.stringify({ ts: Date.now(), kind, detail }) + '\n';
|
|
16
|
+
try {
|
|
17
|
+
fs.mkdirSync(path.dirname(ledgerPath()), { recursive: true });
|
|
18
|
+
fs.appendFileSync(ledgerPath(), row, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch { /* ledger is best-effort; never break the caller */ }
|
|
21
|
+
}
|
|
22
|
+
function zeroBreakdown() {
|
|
23
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
24
|
+
}
|
|
25
|
+
function addInto(into, u) {
|
|
26
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
27
|
+
const v = u[k];
|
|
28
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0)
|
|
29
|
+
into[k] += Math.floor(v);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function sessionsDir() {
|
|
33
|
+
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
34
|
+
if (override)
|
|
35
|
+
return override;
|
|
36
|
+
return path.join(os.homedir(), '.omp', 'agent', 'sessions');
|
|
37
|
+
}
|
|
38
|
+
function dayKey(ts) {
|
|
39
|
+
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
40
|
+
if (!Number.isFinite(ms))
|
|
41
|
+
return null;
|
|
42
|
+
const d = new Date(ms);
|
|
43
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
44
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
45
|
+
}
|
|
46
|
+
function walkJsonl(dir, out, cap) {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (out.length >= cap)
|
|
56
|
+
return;
|
|
57
|
+
const full = path.join(dir, e.name);
|
|
58
|
+
if (e.isDirectory())
|
|
59
|
+
walkJsonl(full, out, cap);
|
|
60
|
+
else if (e.isFile() && e.name.endsWith('.jsonl'))
|
|
61
|
+
out.push(full);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function importSessionTokens() {
|
|
65
|
+
const byModel = {};
|
|
66
|
+
const byDay = {};
|
|
67
|
+
const totals = zeroBreakdown();
|
|
68
|
+
let messages = 0;
|
|
69
|
+
const files = [];
|
|
70
|
+
walkJsonl(sessionsDir(), files, 2000);
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = fs.readFileSync(file, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const line of text.split('\n')) {
|
|
80
|
+
if (!line.trim())
|
|
81
|
+
continue;
|
|
82
|
+
try {
|
|
83
|
+
const row = JSON.parse(line);
|
|
84
|
+
const msg = row.message;
|
|
85
|
+
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
86
|
+
continue;
|
|
87
|
+
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
88
|
+
addInto(totals, msg.usage);
|
|
89
|
+
byModel[model] ??= zeroBreakdown();
|
|
90
|
+
addInto(byModel[model], msg.usage);
|
|
91
|
+
const day = row.timestamp !== undefined ? dayKey(row.timestamp) : null;
|
|
92
|
+
if (day) {
|
|
93
|
+
byDay[day] ??= zeroBreakdown();
|
|
94
|
+
addInto(byDay[day], msg.usage);
|
|
95
|
+
}
|
|
96
|
+
messages += 1;
|
|
97
|
+
}
|
|
98
|
+
catch { /* skip corrupt lines */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { messages, totals, byModel, byDay };
|
|
102
|
+
}
|
|
103
|
+
const PRICE_TABLE = [
|
|
104
|
+
{ match: ':free', price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
105
|
+
{ match: 'opus', price: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 } },
|
|
106
|
+
{ match: 'sonnet', price: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } },
|
|
107
|
+
{ match: 'haiku', price: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 } },
|
|
108
|
+
{ match: 'gpt', price: { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 } },
|
|
109
|
+
{ match: 'gemini', price: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 } },
|
|
110
|
+
{ match: 'glm', price: { input: 0.5, output: 1, cacheRead: 0.05, cacheWrite: 0.5 } },
|
|
111
|
+
];
|
|
112
|
+
const DEFAULT_PRICE = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
|
|
113
|
+
export function priceFor(model) {
|
|
114
|
+
const name = model.toLowerCase();
|
|
115
|
+
for (const row of PRICE_TABLE) {
|
|
116
|
+
if (name.includes(row.match))
|
|
117
|
+
return { price: row.price, known: true };
|
|
118
|
+
}
|
|
119
|
+
return { price: DEFAULT_PRICE, known: false };
|
|
120
|
+
}
|
|
121
|
+
export function usdCost(t, model) {
|
|
122
|
+
const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
|
|
123
|
+
const m = 1 / 1_000_000;
|
|
124
|
+
return {
|
|
125
|
+
usd: (t.input * price.input + t.output * price.output + t.cacheRead * price.cacheRead + t.cacheWrite * price.cacheWrite) * m,
|
|
126
|
+
priced: model ? known : false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// ponytail: CO2 is a rough server-energy multiple on output tokens, not metered.
|
|
130
|
+
// Always render with ~est. and never merge with measured figures.
|
|
131
|
+
export const CO2_G_PER_1K_OUTPUT = 0.2;
|
|
132
|
+
export function co2Grams(outputTokens) {
|
|
133
|
+
return (outputTokens / 1000) * CO2_G_PER_1K_OUTPUT;
|
|
134
|
+
}
|
|
135
|
+
export function readUsage() {
|
|
136
|
+
let text;
|
|
137
|
+
try {
|
|
138
|
+
text = fs.readFileSync(ledgerPath(), 'utf8');
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const rows = [];
|
|
144
|
+
for (const line of text.split('\n')) {
|
|
145
|
+
if (!line.trim())
|
|
146
|
+
continue;
|
|
147
|
+
try {
|
|
148
|
+
const row = JSON.parse(line);
|
|
149
|
+
if (typeof row.ts === 'number' && typeof row.kind === 'string' && typeof row.detail === 'string') {
|
|
150
|
+
rows.push({ ts: row.ts, kind: row.kind, detail: row.detail });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch { /* skip corrupt lines */ }
|
|
154
|
+
}
|
|
155
|
+
return rows;
|
|
156
|
+
}
|