@krtclcdy/tersio 2.23.0 → 2.24.1

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.
Files changed (58) hide show
  1. package/README.md +56 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/banner.js +2 -3
  5. package/cli/common.js +16 -20
  6. package/cli/currency.js +11 -19
  7. package/cli/dashboard.js +216 -40
  8. package/cli/doctor-fix.js +40 -26
  9. package/cli/doctor.js +41 -41
  10. package/cli/hosts.js +114 -0
  11. package/cli/install.js +284 -179
  12. package/cli/interactive.js +62 -2
  13. package/cli/layer.js +22 -0
  14. package/cli/manifest.js +33 -0
  15. package/cli/omp-layer.js +51 -0
  16. package/cli/pi-layer.js +37 -0
  17. package/cli/pi-wiring.js +92 -0
  18. package/cli/profile.js +48 -48
  19. package/cli/reset.js +5 -32
  20. package/cli/rtk-wiring.js +7 -17
  21. package/cli/settings.js +3 -8
  22. package/cli/uninstall.js +162 -52
  23. package/cli/update.js +19 -27
  24. package/cli/usage.js +15 -5
  25. package/dashboard/dist/index.html +18 -18
  26. package/extensions/ai-addons-updater/index.js +37 -29
  27. package/extensions/ai-addons-updater/index.ts +39 -38
  28. package/extensions/caveman-session/index.js +19 -34
  29. package/extensions/caveman-session/index.ts +20 -35
  30. package/extensions/combo-toggle/index.js +47 -49
  31. package/extensions/combo-toggle/index.ts +42 -58
  32. package/extensions/lib/utils.js +40 -9
  33. package/extensions/lib/utils.ts +40 -9
  34. package/extensions/rtk-session/index.js +34 -38
  35. package/extensions/rtk-session/index.ts +32 -41
  36. package/extensions/shared/carbon.js +3 -11
  37. package/extensions/shared/carbon.ts +4 -11
  38. package/extensions/shared/host.js +58 -0
  39. package/extensions/shared/host.ts +94 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +33 -30
  42. package/extensions/shared/plugin-settings.ts +35 -26
  43. package/extensions/shared/pricing.js +25 -16
  44. package/extensions/shared/pricing.ts +27 -17
  45. package/extensions/shared/rtk-gain.js +6 -22
  46. package/extensions/shared/rtk-gain.ts +6 -22
  47. package/extensions/shared/session-state.js +44 -36
  48. package/extensions/shared/session-state.ts +48 -35
  49. package/extensions/shared/types.ts +10 -3
  50. package/extensions/shared/usage-ledger.js +160 -51
  51. package/extensions/shared/usage-ledger.ts +170 -67
  52. package/extensions/shared/usage-store.js +178 -24
  53. package/extensions/shared/usage-store.ts +176 -25
  54. package/extensions/tersio-commands/index.js +3 -6
  55. package/extensions/tersio-commands/index.ts +3 -6
  56. package/package.json +6 -5
  57. package/tersio.js +4 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
package/cli/uninstall.js CHANGED
@@ -1,12 +1,98 @@
1
1
  // cli/uninstall.ts — remove managed extensions, plugins, and binaries.
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { cancel as clackCancel, confirm as clackConfirm } from '@clack/prompts';
5
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
6
- import { ask, closeRL, tty } from './interactive.js';
7
- import { readTextIfExists } from '../extensions/lib/utils.js';
8
- // Read-modify-write a JSON file. mutate returns true when it changed
9
- // something; no change (or unreadable file) means no output at all.
4
+ import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, args, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
5
+ import { askInteractiveChoice, closeRL, confirmDestructive, tty, sayTagged } from './interactive.js';
6
+ import { detectHosts, hostHint, hostLabel, parseHostArg } from './hosts.js';
7
+ import { findHoistedPackage, piAgentDir, readTextIfExists } from '../extensions/lib/utils.js';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
10
+ // The pi tree mirrors the OMP one. Ponytail and rtk stay: a package and a
11
+ // shared binary are not ours to delete.
12
+ const PONYTAIL_PKG = '@dietrichgebert/ponytail';
13
+ const PI_TREE_DIRS = [
14
+ 'caveman-session',
15
+ 'rtk-session',
16
+ 'ai-addons-updater',
17
+ 'combo-toggle',
18
+ 'tersio-commands',
19
+ 'shared',
20
+ 'lib',
21
+ ];
22
+ // One `pi remove`, for both packages the installer adds there.
23
+ async function piRemove(spec, shouldDryRun) {
24
+ if (shouldDryRun) {
25
+ sayTagged(` [dry-run] would run: pi remove ${spec}`);
26
+ return;
27
+ }
28
+ try {
29
+ const { execFile } = await import('node:child_process');
30
+ await new Promise((resolve, reject) => {
31
+ execFile('pi', ['remove', spec], { timeout: 120000 }, (err, _out, errOut) => {
32
+ if (err)
33
+ reject(new Error(String(errOut).trim() || err.message));
34
+ else
35
+ resolve();
36
+ });
37
+ });
38
+ sayTagged(` [ok] removed the pi package ${spec}`);
39
+ }
40
+ catch (e) {
41
+ sayTagged(` [fail] pi remove: ${e.message}`);
42
+ sayTagged(` [hint] Manual: pi remove ${spec}`);
43
+ }
44
+ }
45
+ // Only the skill directories the installer copied, never pi's own skills dir.
46
+ async function removeCopiedPonytailSkills(shouldDryRun) {
47
+ const source = findHoistedPackage(PONYTAIL_PKG, path.dirname(fileURLToPath(import.meta.url)), 'skills');
48
+ if (!source)
49
+ return;
50
+ for (const entry of await fs.readdir(source, { withFileTypes: true })) {
51
+ if (entry.isDirectory())
52
+ await removeUninstallTarget(path.join(piAgentDir(), 'skills', entry.name), shouldDryRun);
53
+ }
54
+ }
55
+ // pi removal delegates to pi remove; always list and confirm first.
56
+ async function removePiLayer(host, shouldDryRun, shouldRemovePonytail, shouldRemoveRtk, confirmed) {
57
+ const targets = PI_TREE_DIRS.map((dir) => path.join(piAgentDir(), 'extensions', dir));
58
+ const rtkWiring = path.join(piAgentDir(), 'extensions', 'rtk.ts');
59
+ if (!host.installed && !host.declared) {
60
+ sayTagged(` [skip] ${host.label} — nothing installed (${host.installCmd})`);
61
+ return false;
62
+ }
63
+ console.log('Will remove:');
64
+ for (const t of targets)
65
+ console.log(` ${t}`);
66
+ if (shouldRemovePonytail)
67
+ console.log(` npm:${PONYTAIL_PKG} (pi package)`);
68
+ if (shouldRemoveRtk)
69
+ console.log(` ${rtkWiring} (rtk wiring)`);
70
+ console.log(` ${tersioSettingsFile()} (session defaults)`);
71
+ if (!confirmed && !(await confirmDestructive(`Remove Tersio from ${host.label}?`))) {
72
+ closeRL();
73
+ return false;
74
+ }
75
+ if (host.declared)
76
+ await piRemove(`npm:${PACKAGE_NAME}`, shouldDryRun);
77
+ if (shouldRemovePonytail) {
78
+ await piRemove(`npm:${PONYTAIL_PKG}`, shouldDryRun);
79
+ await removeCopiedPonytailSkills(shouldDryRun);
80
+ }
81
+ await Promise.all(targets.map((t) => removeUninstallTarget(t, shouldDryRun)));
82
+ // rtk's wiring is a loose file, not one of the tree dirs, so it survived
83
+ // --remove-rtk on pi while OMP removed its copy.
84
+ if (shouldRemoveRtk) {
85
+ await removeUninstallTarget(rtkWiring, shouldDryRun, false);
86
+ await removeUninstallTarget(path.join(BUN_BIN_DIR, RTK_BINARY_NAME), shouldDryRun, false);
87
+ }
88
+ await clearSessionDefaults(shouldDryRun);
89
+ return true;
90
+ }
91
+ // ~/.tersio/settings.json holds nothing but Tersio's values, so it goes whole.
92
+ async function clearSessionDefaults(shouldDryRun) {
93
+ await removeUninstallTarget(tersioSettingsFile(), shouldDryRun, false);
94
+ }
95
+ // Read-modify-write a JSON file; mutate returning false prints nothing.
10
96
  async function updateJsonFile(filePath, mutate, dryRunNote, writeNote, dryRun) {
11
97
  const raw = await readTextIfExists(filePath);
12
98
  if (!raw)
@@ -22,11 +108,11 @@ async function updateJsonFile(filePath, mutate, dryRunNote, writeNote, dryRun) {
22
108
  if (!mutate(data))
23
109
  return;
24
110
  if (dryRun) {
25
- console.log(` [dry-run] ${dryRunNote}`);
111
+ sayTagged(` [dry-run] ${dryRunNote}`);
26
112
  return;
27
113
  }
28
114
  await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
29
- console.log(` [write] ${writeNote}`);
115
+ sayTagged(` [write] ${writeNote}`);
30
116
  }
31
117
  function dropKey(section, key) {
32
118
  if (!section || typeof section !== 'object')
@@ -40,14 +126,14 @@ function dropKey(section, key) {
40
126
  async function removeUninstallTarget(target, shouldDryRun, recursive = true) {
41
127
  try {
42
128
  if (shouldDryRun) {
43
- console.log(` [dry-run] would remove ${target}`);
129
+ sayTagged(` [dry-run] would remove ${target}`);
44
130
  return;
45
131
  }
46
132
  if (recursive)
47
133
  await fs.rm(target, { recursive: true, force: true });
48
134
  else
49
135
  await fs.unlink(target);
50
- console.log(` [rm] ${target}`);
136
+ sayTagged(` [rm] ${target}`);
51
137
  }
52
138
  catch {
53
139
  debug(`Could not remove ${target}`);
@@ -56,11 +142,52 @@ async function removeUninstallTarget(target, shouldDryRun, recursive = true) {
56
142
  async function runUninstall(options = {}) {
57
143
  const shouldDryRun = options.dryRun ?? dryRun;
58
144
  const confirmed = (options.yes ?? yes) || shouldDryRun;
59
- // Ponytail is bundled with Tersio's presets, so a full uninstall removes
60
- // its copy too; --keep-ponytail opts out and reinstall always preserves it.
145
+ // Ponytail ships with Tersio, so a full uninstall removes it.
61
146
  const shouldRemovePonytail = options.removePonytail ?? (removePonytail || !keepPonytail);
62
147
  const shouldRemoveRtk = options.removeRtk ?? removeRtk;
63
148
  console.log('\n=== Tersio Uninstall ===\n');
149
+ // A TTY user picks the host; only hosts with tersio on them are offered.
150
+ const installed = detectHosts().filter((host) => host.installed);
151
+ let host = options.host ?? 'omp';
152
+ try {
153
+ host = parseHostArg(args) ?? host;
154
+ }
155
+ catch (e) {
156
+ console.error(`[fail] ${e.message}`);
157
+ process.exit(1);
158
+ }
159
+ if (!options.host && tty() && !confirmed) {
160
+ if (installed.length === 0) {
161
+ console.log('Tersio is not installed for any agent on this machine. Nothing to remove.');
162
+ closeRL();
163
+ return false;
164
+ }
165
+ if (installed.length > 1) {
166
+ const choice = await askInteractiveChoice('Uninstall Tersio from which agent?', installed.map((h) => ({
167
+ value: h.id,
168
+ label: hostLabel(h),
169
+ hint: hostHint(h),
170
+ })), host);
171
+ if (choice.status !== 'selected') {
172
+ closeRL();
173
+ return false;
174
+ }
175
+ host = choice.value;
176
+ }
177
+ else {
178
+ host = installed[0].id;
179
+ }
180
+ }
181
+ const selected = detectHosts().find((h) => h.id === host);
182
+ if (host === 'pi') {
183
+ const removed = await removePiLayer(selected, shouldDryRun, shouldRemovePonytail, shouldRemoveRtk, confirmed);
184
+ if (removed)
185
+ console.log('\nDone. Restart pi, then /combo medium.');
186
+ closeRL();
187
+ return removed;
188
+ }
189
+ if (!selected.installed)
190
+ sayTagged(` [note] ${selected.label} has no tersio install; removing what is left behind.`);
64
191
  const extDir = path.join(OMP_AGENT_DIR, 'extensions');
65
192
  const configPath = path.join(OMP_AGENT_DIR, 'config.yml');
66
193
  const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
@@ -73,10 +200,9 @@ async function runUninstall(options = {}) {
73
200
  'combo-toggle',
74
201
  'tersio-commands',
75
202
  'shared',
76
- // Only consumed by ai-addons-updater (removed above); otherwise orphaned.
203
+ // Only the updater reads it, and it runs before this.
77
204
  'lib',
78
- // Legacy always-on combo helper; imports shared/session-state.js, so it
79
- // breaks with a module-not-found warning once the shared dir is removed.
205
+ // Legacy: imports session-state.js, so it warns once that module is gone.
80
206
  'aaa-combo-boot',
81
207
  ].map((dir) => path.join(extDir, dir));
82
208
  console.log('Will remove:');
@@ -90,33 +216,11 @@ async function runUninstall(options = {}) {
90
216
  console.log(` ${rtkBin}`);
91
217
  console.log(` ${path.join(extDir, 'rtk.ts')} (rtk OMP wiring)`);
92
218
  }
93
- if (!confirmed) {
94
- if (tty()) {
95
- closeRL();
96
- const confirmedChoice = await clackConfirm({ message: 'Remove the listed Tersio files?', initialValue: false });
97
- if (typeof confirmedChoice !== 'boolean') {
98
- clackCancel('Aborted.');
99
- closeRL();
100
- return false;
101
- }
102
- if (!confirmedChoice) {
103
- console.log('Aborted.');
104
- closeRL();
105
- return false;
106
- }
107
- }
108
- else {
109
- const answer = await ask('\nProceed? [y/N]: ');
110
- if (!answer.toLowerCase().startsWith('y')) {
111
- console.log('Aborted.');
112
- closeRL();
113
- return false;
114
- }
115
- }
116
- }
219
+ if (!confirmed && !(await confirmDestructive('Remove the listed Tersio files?')))
220
+ return false;
117
221
  // Remove extension directories (independent paths, so concurrently).
118
222
  await Promise.all(targets.map((t) => removeUninstallTarget(t, shouldDryRun)));
119
- // Remove Combo and mode-reinforcement registrations; Ponytail only when requested.
223
+ // Remove Combo, mode-reinforcement, and rtk registrations; Ponytail on request.
120
224
  const configRaw = await readTextIfExists(configPath);
121
225
  if (configRaw) {
122
226
  let lines = configRaw.split('\n');
@@ -126,28 +230,36 @@ async function runUninstall(options = {}) {
126
230
  return false;
127
231
  if (shouldRemovePonytail && l.includes('ponytail') && l.includes('pi-extension'))
128
232
  return false;
233
+ // Our rtk wiring, absolute or relative. Leaving it listed would keep
234
+ // OMP loading a hook for a package that is no longer installed.
235
+ const entry = l.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '');
236
+ if (entry.endsWith(`extensions${path.sep}rtk.ts`) || entry.endsWith('extensions/rtk.ts'))
237
+ return false;
129
238
  return true;
130
239
  });
131
240
  if (lines.length !== before) {
132
241
  if (shouldDryRun)
133
- console.log(` [dry-run] would remove ${before - lines.length} config.yml entries`);
242
+ sayTagged(` [dry-run] would remove ${before - lines.length} config.yml entries`);
134
243
  else
135
244
  await writeConfigLines(configPath, lines, ` [write] Updated config.yml (removed ${before - lines.length} entries)`);
136
245
  }
137
246
  }
138
- // Remove the bundled Ponytail copy (dep entry exists only on
139
- // pre-bundle installs); the config.yml entry was filtered above.
247
+ // Cleared last: writeConfigLines above would just make a fresh one.
248
+ for (const backup of [`${configPath}.bak`, path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'extensions', 'caveman-session', 'rule.md.bak')]) {
249
+ await removeUninstallTarget(backup, shouldDryRun, false);
250
+ }
251
+ // Remove the bundled Ponytail copy; its config.yml entry is already filtered.
140
252
  if (shouldRemovePonytail) {
141
253
  const pluginsPkgPath = path.join(pluginsDir, 'package.json');
142
254
  await updateJsonFile(pluginsPkgPath, (data) => dropKey(data.dependencies, '@dietrichgebert/ponytail'), `would remove @dietrichgebert/ponytail from ${pluginsPkgPath}`, 'Removed @dietrichgebert/ponytail from plugins/package.json', shouldDryRun);
143
255
  try {
144
256
  if (shouldDryRun)
145
- console.log(` [dry-run] would remove ${ponytailPkgDir}`);
257
+ sayTagged(` [dry-run] would remove ${ponytailPkgDir}`);
146
258
  else {
147
259
  await fs.rm(ponytailPkgDir, { recursive: true, force: true });
148
- console.log(` [rm] ${ponytailPkgDir}`);
260
+ sayTagged(` [rm] ${ponytailPkgDir}`);
149
261
  await fs.rm(path.dirname(ponytailPkgDir));
150
- console.log(` [rm] ${path.dirname(ponytailPkgDir)} (empty scope)`);
262
+ sayTagged(` [rm] ${path.dirname(ponytailPkgDir)} (empty scope)`);
151
263
  }
152
264
  }
153
265
  catch {
@@ -163,13 +275,11 @@ async function runUninstall(options = {}) {
163
275
  if ((await readTextIfExists(path.join(selfPluginDir, 'package.json'))) !== null) {
164
276
  await removeUninstallTarget(selfPluginDir, shouldDryRun);
165
277
  }
166
- // Remove RTK binary if requested. rtk.ts is rtk-owned but installed by our
167
- // wiring step; with the binary gone it would pass through harmlessly, so
168
- // only drop it on a full rtk removal.
169
- if (shouldRemoveRtk) {
278
+ // Our rtk wiring goes too, or OMP keeps rewriting for a removed package.
279
+ await removeUninstallTarget(path.join(extDir, 'rtk.ts'), shouldDryRun);
280
+ if (shouldRemoveRtk)
170
281
  await removeUninstallTarget(rtkBin, shouldDryRun, false);
171
- await removeUninstallTarget(path.join(extDir, 'rtk.ts'), shouldDryRun);
172
- }
282
+ await clearSessionDefaults(shouldDryRun);
173
283
  console.log('\nDone. Restart OMP for changes to take effect.');
174
284
  return true;
175
285
  }
package/cli/update.js CHANGED
@@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { IS_WINDOWS, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION, dryRun, execP, parseJsonObject, verbose, } from './common.js';
6
- import { execNetwork } from './interactive.js';
6
+ import { execNetwork, sayTagged } from './interactive.js';
7
7
  import { readTextIfExists, resolveRtkBinary, tersioDataPath } from '../extensions/lib/utils.js';
8
8
  import { refreshPrices } from '../extensions/shared/pricing.js';
9
9
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
@@ -34,10 +34,8 @@ async function latestPublishedVersion() {
34
34
  return null;
35
35
  }
36
36
  }
37
- // Returns a newer published version, null when up to date, or 'unknown' when
38
- // the registry cannot be reached. Reads the registry at most once per TTL
39
- // (cached under OMP_PLUGINS_DIR) unless force skips the cache — an explicit
40
- // "check for updates" must never trust a stale cache.
37
+ // A newer published version, null when up to date, 'unknown' when the registry
38
+ // is unreachable. Cached for a TTL unless force, which must not trust a cache.
41
39
  async function checkForUpdate(force = false) {
42
40
  const cachePath = tersioDataPath('update-check.json', 'tersio-update-check.json');
43
41
  const cached = parseJsonObject(await readTextIfExists(cachePath));
@@ -50,12 +48,11 @@ async function checkForUpdate(force = false) {
50
48
  await fs.writeFile(cachePath, JSON.stringify({ latest, lastCheck: Date.now() }) + '\n', 'utf8').catch(() => { });
51
49
  return newerThan(latest, PACKAGE_VERSION) ? latest : null;
52
50
  }
53
- // Registry unreachable: a stale cache naming a newer release is still
54
- // actionable; otherwise report unknown so callers never claim "latest".
51
+ // Unreachable registry: a stale cache naming a newer release is still
52
+ // actionable, else report unknown so nobody claims "latest".
55
53
  return cached?.latest && newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : 'unknown';
56
54
  }
57
- // Race a probe against a timeout; slow or failing probes resolve null so the
58
- // update plan stays best-effort and never blocks the update itself.
55
+ // Race a probe against a timeout; failures resolve null so nothing blocks.
59
56
  async function settle(work, ms) {
60
57
  let timer;
61
58
  try {
@@ -68,9 +65,7 @@ async function settle(work, ms) {
68
65
  clearTimeout(timer);
69
66
  }
70
67
  }
71
- // Run a child with inherited stdio for live output. Used when the child
72
- // renders its own interactive UI (the delegated installer runs Clack
73
- // spinners), which a concurrent outer spinner would corrupt into stray bars.
68
+ // Inherited stdio, so a child spinner cannot corrupt this parent's.
74
69
  async function execInherit(cmd, args) {
75
70
  const code = await new Promise((resolve, reject) => {
76
71
  const child = spawn(cmd, args, { stdio: 'inherit' });
@@ -80,8 +75,8 @@ async function execInherit(cmd, args) {
80
75
  if (code !== 0)
81
76
  throw new Error(`${cmd} exited with code ${code}`);
82
77
  }
83
- // Current → latest per add-on. Every probe is capped and nullable; the plan
84
- // prints `unknown` for anything unreachable and the update proceeds anyway.
78
+ // Current → latest per add-on. Every probe is capped and nullable, and the
79
+ // plan prints `unknown` for anything unreachable.
85
80
  async function probeUpdatePlan(cliLatest) {
86
81
  const rtkBin = resolveRtkBinary();
87
82
  const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
@@ -102,8 +97,8 @@ async function probeUpdatePlan(cliLatest) {
102
97
  ponytail: ponytailLocalText ? parseJsonObject(ponytailLocalText)?.version ?? null : null,
103
98
  };
104
99
  }
105
- // Every add-on prints its status: version jump when stale, "up to date"
106
- // otherwise, honest "unknown" when the probe cannot reach its source.
100
+ // Every add-on prints a version jump when stale, "up to date" otherwise, and
101
+ // "unknown" when its source cannot be reached.
107
102
  function planLines(plan) {
108
103
  const stale = [];
109
104
  const status = [];
@@ -137,18 +132,16 @@ async function runLatestUpdate() {
137
132
  if (verbose)
138
133
  forwardedArgs.push('--verbose');
139
134
  const npmCommand = IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm';
140
- // Resolve the target explicitly: `npm view --prefer-online` beats the local
141
- // metadata cache, so a stale `@latest` can never pin an older release.
135
+ // `--prefer-online` beats the local cache, so a stale @latest cannot pin down.
142
136
  const cliLatest = await latestPublishedVersion();
143
137
  const target = cliLatest && /^\d+\.\d+\.\d+$/.test(cliLatest) ? `@${cliLatest}` : '@latest';
144
- // Current → latest per add-on. Best-effort; unreachable probes print as
145
- // unknown and never block the update.
138
+ // Best-effort: unreachable probes print as unknown.
146
139
  const plan = await probeUpdatePlan(cliLatest);
147
140
  const { stale, status } = planLines(plan);
148
141
  if (dryRun) {
149
142
  console.log(`tersio update (dry-run):\n ${status.join('\n ')}`);
150
- console.log(` [dry-run] would run: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
151
- console.log(` [dry-run] would delegate: npm exec --yes --prefer-online --package=${PACKAGE_NAME}${target} -- tersio --apply-update ${forwardedArgs.join(' ')}`);
143
+ sayTagged(` [dry-run] would run: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
144
+ sayTagged(` [dry-run] would delegate: npm exec --yes --prefer-online --package=${PACKAGE_NAME}${target} -- tersio --apply-update ${forwardedArgs.join(' ')}`);
152
145
  return;
153
146
  }
154
147
  console.log(`Checking for updates:\n ${status.join('\n ')}`);
@@ -193,8 +186,8 @@ async function runLatestUpdate() {
193
186
  process.stderr.write(globalResult.stderr);
194
187
  }
195
188
  catch (e) {
196
- console.log(` [warn] Global CLI update skipped: ${e.message}`);
197
- console.log(` [hint] Manual: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
189
+ sayTagged(` [warn] Global CLI update skipped: ${e.message}`);
190
+ sayTagged(` [hint] Manual: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
198
191
  }
199
192
  const npmArgs = [
200
193
  'exec',
@@ -207,9 +200,8 @@ async function runLatestUpdate() {
207
200
  ...forwardedArgs,
208
201
  ];
209
202
  const npmCommandArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', ...npmArgs] : npmArgs;
210
- // Inherited stdio, no outer spinner. The delegated installer runs quiet
211
- // (banner and per-file writes suppressed); this parent owns both the plan
212
- // line above and the closing summary below — one voice, no repeats.
203
+ // Inherited stdio, no outer spinner; the delegated installer runs quiet and
204
+ // this parent owns both the plan line and the closing summary.
213
205
  try {
214
206
  await execInherit(npmCommand, npmCommandArgs);
215
207
  console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION}. Restart OMP.`);
package/cli/usage.js CHANGED
@@ -113,6 +113,7 @@ function bar(frac, width = 12) {
113
113
  const filled = Math.max(0, Math.min(width, Math.round(frac * width)));
114
114
  return '█'.repeat(filled) + '░'.repeat(width - filled);
115
115
  }
116
+ const ROW_CAP = 15;
116
117
  function pad(s, n) {
117
118
  return s.length >= n ? s : s + ' '.repeat(n - s.length);
118
119
  }
@@ -136,10 +137,15 @@ function printReport(report) {
136
137
  return;
137
138
  }
138
139
  const t = report.tokens;
139
- console.log(` TOKENS ${fmt(t.input)} in · ${fmt(t.output)} out · ${fmt(t.cacheRead)} cache read · ${fmt(t.cacheWrite)} cache write`);
140
- console.log(` COST ${formatCurrency(report.usd, report.currency)}${report.priced ? '' : ' (includes default pricing)'} · ~${formatCurrency(report.savedUsd, report.currency)} cache-saved (est.) · ~${report.co2g.toFixed(1)}g CO2 (est.)`);
141
- console.log(` RTK ${fmt(report.rtkAdoption.rtkCalls)}/${fmt(report.rtkAdoption.eligibleCalls)} eligible OMP Bash calls use RTK (${report.rtkAdoption.adoptionPct.toFixed(1)}%) · ${fmt(report.rtkAdoption.missedCalls)} missed · recall ${report.rtkRecall.available ? `${report.rtkRecall.mode} (${report.rtkRecall.entries})` : 'unavailable'}`);
142
- const models = Object.entries(report.byModel).filter(([, b]) => b.input + b.output + b.cacheRead + b.cacheWrite > 0).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output)).slice(0, 8);
140
+ const a = report.rtkAdoption;
141
+ for (const l of textTable(['Metric', 'Value'], [
142
+ ['Tokens', `${fmt(t.input)} in · ${fmt(t.output)} out · ${fmt(t.cacheRead)} cache read · ${fmt(t.cacheWrite)} cache write`],
143
+ ['Cost', `${formatCurrency(report.usd, report.currency)}${report.priced ? '' : ' (includes default pricing)'} · ~${formatCurrency(report.savedUsd, report.currency)} cache-saved (est.) · ~${report.co2g.toFixed(1)}g CO2 (est.)`],
144
+ ['RTK adoption', `${fmt(a.rtkCalls)}/${fmt(a.eligibleCalls)} eligible Bash calls use RTK (${a.adoptionPct.toFixed(1)}%) · ${fmt(a.missedCalls)} missed · recall ${report.rtkRecall.available ? `${report.rtkRecall.mode} (${report.rtkRecall.entries})` : 'unavailable'}`],
145
+ ], [false, false], 118))
146
+ console.log(l);
147
+ const allModels = Object.entries(report.byModel).filter(([, b]) => b.input + b.output + b.cacheRead + b.cacheWrite > 0).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output));
148
+ const models = allModels.slice(0, ROW_CAP);
143
149
  if (models.length) {
144
150
  console.log(' BY MODEL');
145
151
  const top = models.reduce((m, [, b]) => Math.max(m, b.input + b.output + b.cacheRead + b.cacheWrite), 1);
@@ -152,6 +158,8 @@ function printReport(report) {
152
158
  for (const l of textTable(['Model', 'Input', 'Output', 'Cache r/w', report.currency, 'Hit', 'Share'], mrows, [false, true, true, true, true, true, false])) {
153
159
  console.log(l);
154
160
  }
161
+ if (allModels.length > ROW_CAP)
162
+ console.log(` showing top ${ROW_CAP} of ${fmt(allModels.length)} models — full set in the dashboard`);
155
163
  }
156
164
  const cmdRows = report.byTool.map(([tool, n]) => ({ name: tool, count: n, saved: null, avgPct: null, avgMs: null }));
157
165
  for (const r of report.rtkGain.byCommand) {
@@ -165,7 +173,7 @@ function printReport(report) {
165
173
  : ' COMMAND TOOLS';
166
174
  console.log(scope);
167
175
  const top = cmdRows.reduce((m, r) => Math.max(m, r.count), 1);
168
- const crows = cmdRows.slice(0, 15).map((r, idx) => [
176
+ const crows = cmdRows.slice(0, ROW_CAP).map((r, idx) => [
169
177
  String(idx + 1),
170
178
  r.name,
171
179
  fmt(r.count),
@@ -177,6 +185,8 @@ function printReport(report) {
177
185
  for (const l of textTable(['#', 'Tool/Command', 'Count', 'Saved', 'Avg%', 'Time', 'Impact'], crows, [true, false, true, true, true, true, false], 30)) {
178
186
  console.log(l);
179
187
  }
188
+ if (cmdRows.length > ROW_CAP)
189
+ console.log(` showing top ${ROW_CAP} of ${fmt(cmdRows.length)} commands — full set in the dashboard`);
180
190
  }
181
191
  }
182
192
  async function runUsage() {