@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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ![Banner](/assets/Banner.webp)
2
2
 
3
- # ✂️ Tersio — Token-saving OMP Add-ons
3
+ # ✂️ Tersio — Token Saver OMP & Usage Tracker
4
4
 
5
5
  [![npm version](https://shieldcn.dev/npm/@krtclcdy%2Ftersio.svg?variant=branded&size=xs&logo=npm)](https://www.npmjs.com/package/@krtclcdy/tersio)
6
6
  [![node](https://shieldcn.dev/badge/node-%3E%3D20.12-22c55e.svg?variant=branded&size=xs&logo=nodedotjs)](https://nodejs.org)
@@ -8,29 +8,36 @@
8
8
  [![Build](https://shieldcn.dev/github/ci/KurutoDenzeru/tersio.svg?variant=branded&size=xs&logo=githubactions&label=Build)](https://github.com/KurutoDenzeru/tersio/actions)
9
9
  [![MIT](https://shieldcn.dev/badge/license-MIT-2563eb.svg?variant=branded&size=xs&logo=opensourceinitiative)](./LICENSE)
10
10
 
11
- Terse replies, compact shell output, and minimal code decisions for [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi) — one-command combo presets.
11
+ Token saver & usage tracker for coding agents: terse caveman replies, compact RTK shell output, lean Ponytail code calls, and a gain dashboard — one-command combo presets over one shared extension tree.
12
12
 
13
13
  ## ⚡ Getting Started
14
14
 
15
+ All install paths in one table, with update and remove commands: [INSTALL.md](./INSTALL.md).
16
+
15
17
  **Default** (macOS/Linux/WSL) — one line. Installs the CLI via npm, then runs the main installer (scope + Combo preset menus) in the same pass:
16
18
 
17
19
  ```bash
18
20
  curl -fsSL https://github.com/KurutoDenzeru/tersio/releases/latest/download/install.sh | sh
19
21
  ```
20
22
 
21
- Prefer OMP-managed updates? Install as an OMP plugin instead:
23
+ Then run the CLI. Bare `tersio` opens a menu and asks which agent to install into:
22
24
 
23
25
  ```bash
24
- omp plugin install @krtclcdy/tersio
26
+ tersio # menu — picks Oh My Pi or pi
27
+ tersio install --host pi # pi, non-interactive
28
+ tersio install --host omp # Oh My Pi, non-interactive
25
29
  ```
26
30
 
27
- All modes load always. Only the updater stays toggleable:
31
+ Or let the host install the package itself. Same result, host-managed updates:
28
32
 
29
33
  ```bash
30
- omp plugin features @krtclcdy/tersio --disable updater
34
+ omp plugin install @krtclcdy/tersio
35
+ pi install npm:@krtclcdy/tersio
31
36
  ```
32
37
 
33
- Then restart OMP and enable a preset:
38
+ Both hosts load the same five extensions from the same sources, and both read their session-start defaults from `~/.tersio/settings.json`. All five load always.
39
+
40
+ Then restart the agent and enable a preset:
34
41
 
35
42
  ```text
36
43
  /combo balanced
@@ -40,14 +47,6 @@ Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything
40
47
 
41
48
  When Tersio is installed through OMP, the first interactive launch asks for a session-start Combo preset once (`off`, `medium`, `balanced`, or `max`) and saves it as `comboDefault`. Running `tersio install` performs the same setup through the CLI.
42
49
 
43
- Session-start defaults (prompted during `install`, or flags):
44
-
45
- ```bash
46
- tersio install --combo-default balanced --caveman-default lite --rtk-default on
47
- ```
48
-
49
- Defaults apply to fresh sessions only — anything persisted with `/combo`, `/caveman`, or `/rtk` wins. Stored as plugin settings (`omp plugin config get @krtclcdy/tersio comboDefault`).
50
-
51
50
  One-off use without installing:
52
51
 
53
52
  ```bash
@@ -56,32 +55,34 @@ npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio insta
56
55
 
57
56
  ### Requirements
58
57
 
59
- - [OMP](https://github.com/can1357/oh-my-pi)
58
+ - [OMP](https://github.com/can1357/oh-my-pi) or [pi](https://github.com/earendil-works/pi)
60
59
  - Node.js 20.12+ with npm
61
60
 
62
61
  Windows/WSL have separate OMP homes — install from the environment where OMP runs. Inside WSL, `command -v npm` must resolve to a Linux path, not `/mnt/c/`.
63
62
 
64
63
  ## 📊 Benchmarks
65
64
 
66
- Measured surfaces use different boundaries. Do not treat reply-text, code-diff, and shell-output reductions as total bill savings. Full protocol and caveats are in [BENCHMARK.md](./BENCHMARK.md).
67
-
68
- | Measured surface (n) | Baseline → Tersio | Δ |
69
- |---|---|---|
70
- | Terse reply, caveman lite — BPE tokens (3 samples, p50) | 173 → 69 | **−60.1% reply text** |
71
- | Terse reply, caveman full — BPE tokens (3 samples, p50) | 173 → 49 | **−71.7% reply text** |
72
- | Terse reply, caveman ultra — BPE tokens (3 samples, p50) | 173 → 25 | **−85.5% reply text** |
73
- | Code diff, ponytail lite — BPE tokens (2 tasks) | 326–462 → 57–121 | **−73.8…−82.5% diff** |
74
- | Code diff, ponytail full — BPE tokens (2 tasks) | 326–462 → 55–88 | **−81.0…−83.1% diff** |
75
- | Code diff, ponytail ultra — BPE tokens (2 tasks) | 326–462 → 40–88 | **−81.0…−87.7% diff** |
76
- | Shell output, rtk `git status` | 182 → 72 | **−60.4% command output** |
77
- | Shell output, rtk repo `grep` | 376 → 274 | **−27.1% command output** |
78
- | Shell output, rtk `find` | 205 → 168 | **−18.0% command output** |
79
- | Shell output, rtk `git diff` | 12,684 → 8,504 | **−33.0% command output** |
80
-
81
- The Command tools table reports weighted RTK command-output savings and OMP-specific adoption from executed Bash records. It does not estimate provider billing.
65
+ Every mode is measured against a base run with all modes off. Measured surfaces use different boundaries. Do not treat reply-text, code-output, and shell-output reductions as total bill savings. Full protocol and caveats are in [BENCHMARK.md](./BENCHMARK.md).
66
+
67
+ | Mode | Surface (n) | Base → Tersio | Δ |
68
+ |---|---|---|---|
69
+ | `/caveman full` | Terse reply, o200k tokens (3 samples, p50) | 124 → 107 | **−13.7% reply text** |
70
+ | `/caveman ultra` | Terse reply, o200k tokens (3 samples, p50) | 124 → 109 | **−12.1% reply text** |
71
+ | `/ponytail lite` | Code reply, o200k tokens (3 samples, p50) | 306 → 177 | **−42.2% code** |
72
+ | `/ponytail full` | Code reply, o200k tokens (3 samples, p50) | 306 → 208 | **−32.0% code** |
73
+ | `/ponytail ultra` | Code reply, o200k tokens (3 samples, p50) | 306 → 112 | **−63.4% code** |
74
+ | `/combo medium` | Code reply, o200k tokens (3 samples, p50) | 306 → 132 | **−56.9% code** |
75
+ | `/rtk on` — `bun run test` | Command output (3 runs, p50) | 4,003 → 54 | **−98.7% output** |
76
+ | `/rtk on` — `find . -name '*.test.ts'` | Command output (3 runs, p50) | 10,908 → 248 | **−97.7% output** |
77
+ | `/rtk on` — `ls -la .` | Command output (3 runs, p50) | 983 → 275 | **−72.0% output** |
78
+ | `/rtk on` — `git status` | Command output (3 runs, p50) | 72 → 18 | **−75.0% output** |
79
+ | `/rtk on` — `bun run build` | Command output (3 runs, p50) | 140 → 114 | **−18.6% output** |
80
+
81
+ Prompt overhead is included in [BENCHMARK.md](./BENCHMARK.md) with a full prompt-base example. Summary: `/rtk on` pays back immediately, `/ponytail ultra` repays in about 7 code tasks, `/combo medium` in about 8, and every Caveman level costs more in prompt than it saves in reply text.
82
+
83
+ The Command tools dashboard reports weighted RTK command-output savings and OMP-specific adoption from executed Bash records. It does not estimate provider billing.
82
84
  Upstream Ponytail's fair agentic benchmark reports 54% less code, 22% fewer tokens, 20% lower cost, and 100% retained safety. RTK estimates command-output tokens, not bill savings. Caveman and Ponytail compliance varies by model. Measure your own sessions with `tersio usage` and the dashboard.
83
85
 
84
-
85
86
  ## 📈 Dashboard
86
87
 
87
88
  `tersio dashboard --open` serves a local dashboard (127.0.0.1 only) that charts your own savings from `~/.tersio/usage.db`. Three views:
@@ -109,12 +110,11 @@ Upstream Ponytail's fair agentic benchmark reports 54% less code, 22% fewer toke
109
110
  |---|---|
110
111
  | `tersio install` | Install (user scope: all OMP sessions) |
111
112
  | `tersio update` | Refresh the CLI, extensions, and add-ons (RTK binary, Caveman rule, Ponytail) |
112
- | `tersio reinstall` | Fresh install, preserving the Ponytail package |
113
113
  | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health (`--fix` repairs, `--fix=<scope>` one scope, `--dry-run` previews) |
114
114
  | `tersio usage` | Ledger-backed usage + savings report |
115
115
  | `tersio dashboard` | Open the Dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
116
116
  | `tersio reset` | Clear tersio statistics: usage ledger + a watermark that hides pre-reset rows from every derived view (Y/N confirm, `--dry-run`, `--yes`) — session transcripts and RTK history stay intact on disk |
117
- | `tersio uninstall` | Remove extensions, registration, and the Ponytail plugin (`--keep-ponytail` keeps Ponytail; `--remove-rtk` also removes the RTK binary and its `rtk.ts` OMP wiring) |
117
+ | `tersio uninstall` | Remove extensions, registrations, the Ponytail plugin, and the `rtk.ts` OMP wiring (`--keep-ponytail` keeps Ponytail; `--remove-rtk` also removes the RTK binary) |
118
118
  | `tersio version` | Print version |
119
119
 
120
120
  Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--combo-default`/`--caveman-default`/`--rtk-default`/`--ponytail-default`, `--currency <code>` (usage/dashboard display currency; flag wins, then the `tersio settings` default, then USD). Legacy `--doctor` / `--uninstall` forms still work.
@@ -139,25 +139,36 @@ Mode switches live on their own commands; bare `/tersio` prints status.
139
139
 
140
140
  ## 🗂️ Files and backups
141
141
 
142
+ Shared, and identical for every host:
143
+
142
144
  | What | Path |
143
145
  |---|---|
144
- | Caveman / RTK / Combo / Tersio commands | `~/.omp/plugins/node_modules/@krtclcdy/tersio/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands}/` — loaded from Tersio's OMP plugin manifest |
145
- | RTK OMP wiring (rtk-owned) | `~/.omp/agent/extensions/rtk.ts` — written by the installer via `rtk init -g --agent omp`; auto-loads, no config entry |
146
- | Ponytail package (bundled Tersio dependency — one Plugins row, updates with `tersio update`) | `~/.omp/plugins/node_modules/@dietrichgebert/ponytail/` — loaded as a nested plugin dependency |
147
- | RTK binary | Installer writes `~/.bun/bin/rtk` (`rtk.exe` on Windows). Runtime resolves `PATH` first, then this managed path. |
148
- | Legacy config cleanup | `~/.omp/agent/config.yml` — doctor removes retired or duplicate extension entries |
146
+ | Session-start defaults | `~/.tersio/settings.json` — combo, caveman, rtk, ponytail, and the display currency |
147
+ | RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows). The runtime resolves `PATH` first, then this managed path. |
148
+ | Usage ledger and price cache | `~/.tersio/` — local only, nothing leaves the machine |
149
149
 
150
- The installer writes `<file>.bak` before replacing an extension source; the updater keeps `rtk.bak` / `rule.md.bak` and restores them if the replacement fails validation.
150
+ Per host, written by `tersio install --host <omp|pi>`:
151
151
 
152
- User-level installs also register the package in `~/.omp/plugins` (visible in OMP **Settings → Plugins**), directly through the plugin manifest. Active modes reassert after Ponytail's prompt block on every top-level turn, including after history compaction.
152
+ | Host | Extensions | Ponytail |
153
+ |---|---|---|
154
+ | Oh My Pi | `~/.omp/agent/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands,ai-addons-updater}/` | nested plugin dependency under `~/.omp/plugins/node_modules/`, one Plugins row |
155
+ | pi | `~/.pi/agent/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands,ai-addons-updater}/` — pi auto-discovers `<agent-dir>/extensions`, so there is nothing to register | `npm:@dietrichgebert/ponytail`, installed as a pi package |
156
+
157
+ Both hosts carry the same `shared/` and `lib/` modules beside those directories, and both load the same five extensions from the same sources.
158
+
159
+ The installer writes `<file>.bak` before replacing an extension source; the updater keeps `rtk.bak` / `rule.md.bak` and restores them if the replacement fails validation. `tersio doctor --fix extensions` restores a damaged tree, and `doctor --fix registrations` drops retired or duplicate `config.yml` entries.
160
+
161
+ An OMP install also registers the package in `~/.omp/plugins`, so it shows under OMP **Settings → Plugins**. Active modes reassert after Ponytail's prompt block on every top-level turn, including after history compaction.
153
162
 
154
163
  ## 🛠️ Troubleshooting
155
164
 
156
- **Ponytail or Combo command missing:** confirm `~/.omp/plugins/package.json` lists `@krtclcdy/tersio`, then run `tersio reinstall`, restart OMP, and check `tersio doctor`.
165
+ **Ponytail or Combo command missing on OMP:** confirm `~/.omp/plugins/package.json` lists `@krtclcdy/tersio`, then run `tersio install`, restart OMP, and check `tersio doctor`. A damaged tree is repaired by `tersio doctor --fix extensions`.
166
+
167
+ **Commands missing on pi:** check the `Hosts` section of `tersio doctor` — it reports whether pi is installed and where. Then run `tersio install --host pi` and restart pi.
157
168
 
158
- **RTK missing or not executable:** run `tersio reinstall`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
169
+ **RTK missing or not executable:** run `tersio install`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
159
170
 
160
- **RTK commands not metered in the Dashboard:** check `tersio doctor` — the `RTK OMP wiring (rtk.ts)` row must be ok. Missing: run `rtk init -g --agent omp` (needs rtk ≥ 0.49) and restart OMP. Native tool calls (`read`/`edit`/`eval`) stay unmetered — only bash tool calls pass through rtk.
171
+ **RTK commands not metered in the Dashboard:** only bash calls routed through rtk's OMP wiring (`~/.omp/agent/extensions/rtk.ts`, written by `rtk init -g --agent omp`) are metered. Missing: run that command (needs rtk ≥ 0.49) or `tersio doctor --fix rtk`, then restart OMP. Native tool calls (`read`/`edit`/`eval`) stay unmetered.
161
172
 
162
173
  **Checksum warning or failure:** the installer aborts on RTK checksum mismatch but warns and continues when checksum metadata is unavailable; `/tersio update rtk` aborts when metadata is missing.
163
174
 
@@ -0,0 +1,42 @@
1
+ // The host registry: one entry per agent, carrying only what the CLI needs to find it, install it and undo it.
2
+ // How the shell rewrite reaches the model. Every host routes it through rtk's own module (`rtk init -g --agent <id>`), so...
3
+ export const REWRITE_WIRING = 'rtk extension · auto-rewrite';
4
+ const HOSTS = [
5
+ {
6
+ id: 'omp',
7
+ label: 'Oh My Pi (OMP)',
8
+ autoDetect: false,
9
+ configDir: '.omp',
10
+ binaries: ['omp'],
11
+ // Earlier versions wrote these. The tree injects the modes every turn, so a skill is a second copy and a rules file is...
12
+ retired: [
13
+ { path: '.omp/agent/AGENTS.md', kind: 'merged' },
14
+ { path: '.omp/agent/skills/tersio-caveman', kind: 'ours' },
15
+ { path: '.omp/agent/skills/tersio-ponytail', kind: 'ours' },
16
+ { path: '.omp/agent/skills/tersio-rtk', kind: 'ours' },
17
+ ],
18
+ caveats: 'Reference host. The modes are injected by the extension tree in ~/.omp/agent/extensions, so no static copy is written and the rewrite is rtk\'s own module (rtk init -g --agent omp).',
19
+ source: 'https://omp.sh/docs/context-files',
20
+ nativeInstall: { command: 'omp install @krtclcdy/tersio', note: 'or let `tersio install` write the layer for you' },
21
+ },
22
+ {
23
+ id: 'pi',
24
+ label: 'Pi',
25
+ configDir: '.pi/agent',
26
+ configDirEnv: 'PI_CODING_AGENT_DIR',
27
+ binaries: ['pi'],
28
+ retired: [
29
+ { path: '.pi/agent/AGENTS.md', kind: 'merged' },
30
+ { path: '.pi/agent/skills/tersio-caveman', kind: 'ours' },
31
+ { path: '.pi/agent/skills/tersio-ponytail', kind: 'ours' },
32
+ { path: '.pi/agent/skills/tersio-rtk', kind: 'ours' },
33
+ ],
34
+ caveats: 'AGENTS.override.md replaces rather than merges. Never write SYSTEM.md — it replaces the default system prompt outright. The rewrite is rtk\'s own module, written by `rtk init -g --agent pi`; rtk owns that format, so no tersio release is needed when it changes.',
35
+ source: 'https://pi.dev/docs/latest/extensions',
36
+ nativeInstall: { command: 'pi install npm:@krtclcdy/tersio', note: 'declares the `pi` manifest in package.json' },
37
+ },
38
+ ];
39
+ function byId(id) {
40
+ return HOSTS.find((h) => h.id === id);
41
+ }
42
+ export { HOSTS, byId };
package/cli/agents.js ADDED
@@ -0,0 +1,266 @@
1
+ // Host selection, persistence, and the filesystem side of asking what a host has on disk.
2
+ import fsSync from 'node:fs';
3
+ import path from 'node:path';
4
+ import { HOSTS, REWRITE_WIRING } from './agent-hosts.js';
5
+ import { reportOmpLayer } from './omp-layer.js';
6
+ import { reportPiLayer } from './pi-layer.js';
7
+ const SELECTION_FILE = 'agents.json';
8
+ // Marker pair for the one file we still merge into: a user's own AGENTS.md.
9
+ const START = '<!-- tersio:start -->';
10
+ const END = '<!-- tersio:end -->';
11
+ function selectionPath(home) {
12
+ return path.join(home, '.tersio', SELECTION_FILE);
13
+ }
14
+ // Host ids only, in registry order, de-duplicated, unknown ids dropped.
15
+ export function normalizeIds(ids) {
16
+ const wanted = new Set(ids);
17
+ return HOSTS.filter((h) => wanted.has(h.id)).map((h) => h.id);
18
+ }
19
+ // `$HOME`-relative form of a path, for previews that must stay readable.
20
+ export function displayPath(target, home) {
21
+ if (!home)
22
+ return target;
23
+ const prefix = home.endsWith(path.sep) ? home : `${home}${path.sep}`;
24
+ if (!target.startsWith(prefix))
25
+ return target;
26
+ return `~/${target.slice(prefix.length).split(path.sep).join('/')}`;
27
+ }
28
+ // The install marker goes in the *label*: clack renders hints on the cursor row only.
29
+ export function agentChoices(state) {
30
+ return HOSTS.map((host) => {
31
+ const info = state?.get(host.id);
32
+ if (!info) {
33
+ return {
34
+ value: host.id,
35
+ label: host.id === 'omp' ? `${host.label} (extensions + Ponytail)` : host.label,
36
+ hint: REWRITE_WIRING,
37
+ };
38
+ }
39
+ const mark = info.installed ? 'installed' : 'not installed';
40
+ const size = info.dirs > 0 ? `${info.dirs} dir${info.dirs === 1 ? '' : 's'}` : 'nothing';
41
+ return { value: host.id, label: `${host.label} — ${size} · ${mark}`, hint: REWRITE_WIRING };
42
+ });
43
+ }
44
+ // An uninstall row for a host with nothing of ours answers a question nobody asked.
45
+ export function installedRows(state) {
46
+ return agentChoices(state).filter((choice) => state.get(choice.value)?.installed !== false);
47
+ }
48
+ // True when the path is a directory. The layer's artifacts are directories.
49
+ function isDir(target) {
50
+ try {
51
+ return fsSync.statSync(target).isDirectory();
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ // Reads one host's layer off the filesystem.
58
+ export function hostLayer(host, home) {
59
+ if (host.id === 'omp') {
60
+ const report = reportOmpLayer(home, isDir);
61
+ return {
62
+ extDir: path.join(home, '.omp', 'agent', 'extensions'),
63
+ plugin: report.pluginPath,
64
+ present: report.extensions,
65
+ missing: report.missing,
66
+ installed: report.installed,
67
+ };
68
+ }
69
+ const report = reportPiLayer(home, isDir);
70
+ return {
71
+ extDir: path.join(home, '.pi', 'agent', 'extensions'),
72
+ plugin: null,
73
+ present: report.extensions,
74
+ missing: report.missing,
75
+ installed: report.installed,
76
+ };
77
+ }
78
+ // Both menus seed from the disk, not from the saved selection.
79
+ export function installedState(home) {
80
+ const state = new Map();
81
+ for (const host of HOSTS) {
82
+ const layer = hostLayer(host, home);
83
+ // A partial tree is a partial install; omp's row also needs its plugin registration, which can be pruned while the...
84
+ state.set(host.id, {
85
+ dirs: layer.present.length,
86
+ installed: layer.installed && layer.present.length > 0,
87
+ });
88
+ }
89
+ return state;
90
+ }
91
+ // Host ids tersio has already installed something for.
92
+ export function installedHostIds(state) {
93
+ return normalizeIds([...state].filter(([, info]) => info.installed).map(([id]) => id));
94
+ }
95
+ // An explicit `--agent` wins outright, even for a host not installed.
96
+ export async function resolveAgentSelection(opts) {
97
+ const fromFlag = normalizeIds(opts.flag ?? []);
98
+ if (fromFlag.length > 0) {
99
+ return { ids: fromFlag, source: 'flag', addedByDetection: [] };
100
+ }
101
+ const stored = normalizeIds(opts.stored);
102
+ const detected = normalizeIds(opts.detected);
103
+ const union = normalizeIds([...stored, ...detected]);
104
+ const addedByDetection = union.filter((id) => !stored.includes(id));
105
+ if (opts.ask) {
106
+ const answer = await opts.ask();
107
+ if (answer !== null) {
108
+ return { ids: normalizeIds(answer), source: 'prompt', addedByDetection };
109
+ }
110
+ }
111
+ return { ids: union, source: union.length > 0 ? 'auto' : 'none', addedByDetection };
112
+ }
113
+ export function readSelection(home) {
114
+ try {
115
+ const raw = fsSync.readFileSync(selectionPath(home), 'utf8');
116
+ const parsed = JSON.parse(raw);
117
+ if (parsed === null || typeof parsed !== 'object')
118
+ return { hosts: [], updatedAt: 0 };
119
+ const record = parsed;
120
+ const hosts = Array.isArray(record.hosts) ? record.hosts.filter((h) => typeof h === 'string') : [];
121
+ const updatedAt = typeof record.updatedAt === 'number' ? record.updatedAt : 0;
122
+ return { hosts: normalizeIds(hosts), updatedAt };
123
+ }
124
+ catch {
125
+ return { hosts: [], updatedAt: 0 };
126
+ }
127
+ }
128
+ export function writeSelection(home, ids) {
129
+ const target = selectionPath(home);
130
+ fsSync.mkdirSync(path.dirname(target), { recursive: true });
131
+ const payload = { hosts: normalizeIds(ids), updatedAt: Date.now() };
132
+ fsSync.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
133
+ }
134
+ // A config dir, or a binary on PATH. Detection only widens the default set.
135
+ export function detectHosts(home, env = process.env) {
136
+ const found = [];
137
+ for (const host of HOSTS) {
138
+ if (host.autoDetect === false)
139
+ continue;
140
+ const dir = path.join(home, host.configDir);
141
+ if (isDir(dir)) {
142
+ found.push(host.id);
143
+ continue;
144
+ }
145
+ const relocated = host.configDirEnv ? env[host.configDirEnv] : undefined;
146
+ const onPath = relocated && relocated.trim() !== ''
147
+ ? false
148
+ : host.binaries.some((b) => hasBinary(b, env));
149
+ if (onPath)
150
+ found.push(host.id);
151
+ }
152
+ return found;
153
+ }
154
+ // A host's binary on PATH, or null. Shared with the dashboard.
155
+ export function findHostBinary(host, env = process.env) {
156
+ if (process.platform === 'win32') {
157
+ const relocated = host.configDirEnv ? env[host.configDirEnv] : undefined;
158
+ if (relocated && relocated.trim() !== '')
159
+ return null;
160
+ }
161
+ const raw = env.PATH ?? env.Path ?? env.path ?? '';
162
+ if (!raw)
163
+ return null;
164
+ const sep = process.platform === 'win32' ? ';' : ':';
165
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
166
+ for (const binary of host.binaries) {
167
+ if (process.platform === 'win32' && binary === 'cmd')
168
+ continue;
169
+ for (const dir of raw.split(sep)) {
170
+ if (!dir)
171
+ continue;
172
+ for (const ext of exts) {
173
+ const candidate = path.join(dir, binary + ext);
174
+ try {
175
+ if (fsSync.statSync(candidate).isFile())
176
+ return candidate;
177
+ }
178
+ catch { /* keep looking */ }
179
+ }
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+ function hasBinary(name, env) {
185
+ // A bare `cmd` resolves to the system shell on Windows.
186
+ if (process.platform === 'win32' && name === 'cmd')
187
+ return false;
188
+ const raw = env.PATH ?? env.Path ?? env.path ?? '';
189
+ if (!raw)
190
+ return false;
191
+ const sep = process.platform === 'win32' ? ';' : ':';
192
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
193
+ for (const dir of raw.split(sep)) {
194
+ if (!dir)
195
+ continue;
196
+ for (const ext of exts) {
197
+ try {
198
+ if (fsSync.statSync(path.join(dir, name + ext)).isFile())
199
+ return true;
200
+ }
201
+ catch { /* keep looking */ }
202
+ }
203
+ }
204
+ return false;
205
+ }
206
+ // Paths an earlier version wrote and this one no longer does. `ours` is removed outright;
207
+ export function clearRetiredPaths(host, home, options = {}) {
208
+ const removed = [];
209
+ for (const entry of host.retired ?? []) {
210
+ const target = path.join(home, entry.path);
211
+ if (entry.kind === 'merged') {
212
+ let current = null;
213
+ try {
214
+ current = fsSync.readFileSync(target, 'utf8');
215
+ }
216
+ catch {
217
+ current = null;
218
+ }
219
+ if (current === null)
220
+ continue;
221
+ const stripped = removeMarkedBlock(current);
222
+ if (stripped === current)
223
+ continue;
224
+ if (!options.dryRun) {
225
+ if (stripped === null)
226
+ fsSync.rmSync(target, { force: true });
227
+ else
228
+ fsSync.writeFileSync(target, stripped, 'utf8');
229
+ }
230
+ removed.push(target);
231
+ continue;
232
+ }
233
+ if (!isDir(target) && !existsSyncSafe(target))
234
+ continue;
235
+ if (!options.dryRun) {
236
+ try {
237
+ fsSync.rmSync(target, { recursive: true, force: true });
238
+ }
239
+ catch {
240
+ continue;
241
+ }
242
+ }
243
+ removed.push(target);
244
+ }
245
+ return removed;
246
+ }
247
+ // Removes the marked block and the blank line it introduced. Null when nothing is left.
248
+ function removeMarkedBlock(existing) {
249
+ const from = existing.indexOf(START);
250
+ const to = existing.indexOf(END);
251
+ if (from === -1 || to === -1 || to <= from)
252
+ return existing;
253
+ const before = existing.slice(0, from).replace(/\n+$/, '\n');
254
+ const after = existing.slice(to + END.length).replace(/^\n+/, '');
255
+ const merged = `${before}${after}`;
256
+ return merged.trim() ? merged : null;
257
+ }
258
+ function existsSyncSafe(p) {
259
+ try {
260
+ return fsSync.existsSync(p);
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
266
+ export { selectionPath };
package/cli/banner.js CHANGED
@@ -1,6 +1,5 @@
1
- // cli/banner.ts — mini pixel-scissor welcome (16x12 px -> 6 half-block rows).
2
- // OMP-style launch moment for interactive runs: clack intro + note box.
3
- // TTY-only and skipped on --dry-run, so piped output stays byte-identical.
1
+ // cli/banner.ts — mini pixel-scissor welcome. TTY-only and skipped on
2
+ // --dry-run, so piped output stays byte-identical.
4
3
  import { intro as clackIntro, note as clackNote } from '@clack/prompts';
5
4
  import { PACKAGE_VERSION, dryRun } from './common.js';
6
5
  function tty() {
package/cli/common.js CHANGED
@@ -20,8 +20,8 @@ const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan-lite',
20
20
  const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra']);
21
21
  const RTK_DEFAULTS = new Set(['on', 'off']);
22
22
  const DIAG_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
23
- // ponytail: Combo preset implies all three modes. Mirrors COMBO_LEVELS in
24
- // extensions/shared/session-state.ts (rtk as boolean here). Single source.
23
+ // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts (rtk as a
24
+ // boolean here), so the combo preset implies all three modes.
25
25
  const COMBO_PRESET_MODES = {
26
26
  off: { caveman: 'off', rtk: false, ponytail: 'off' },
27
27
  medium: { caveman: 'lite', rtk: true, ponytail: 'lite' },
@@ -47,13 +47,12 @@ function flagValue(name) {
47
47
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
48
48
  }
49
49
  // --- CLI flags ---
50
- const COMMANDS = { install: true, update: true, reinstall: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
50
+ const COMMANDS = { install: true, update: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
51
51
  const args = process.argv.slice(2);
52
52
  const commandArg = args.find((arg) => !arg.startsWith('-'));
53
53
  const command = commandArg?.toLowerCase() || null;
54
- // Positional after the command: `tersio settings diagnosis` jumps straight
55
- // to one prompt instead of walking the whole chain. Index-based so flag
56
- // values (e.g. `--combo-default balanced`) never count as positionals.
54
+ // `tersio settings diagnosis` jumps to one prompt instead of walking the
55
+ // chain. Index-based, so flag values never count as positionals.
57
56
  const settingArg = (() => {
58
57
  const at = args.indexOf(commandArg ?? '');
59
58
  const next = at >= 0 ? args[at + 1] : undefined;
@@ -64,12 +63,11 @@ const settingArg = (() => {
64
63
  const unknownCommand = command !== null && !COMMANDS[command];
65
64
  const install = command === 'install';
66
65
  const update = command === 'update';
67
- const reinstall = command === 'reinstall';
68
66
  const showVersion = command === 'version' || args.includes('--version') || args.includes('-v');
69
67
  const showHelp = command === 'help' || args.includes('--help') || args.includes('-h');
70
68
  const applyUpdate = args.includes('--apply-update');
71
69
  const dryRun = args.includes('--dry-run');
72
- const yes = args.includes('--yes') || args.includes('-y') || install || update || reinstall || applyUpdate;
70
+ const yes = args.includes('--yes') || args.includes('-y') || install || update || applyUpdate;
73
71
  const verbose = args.includes('--verbose');
74
72
  const doctor = command === 'doctor' || args.includes('--doctor');
75
73
  const fix = args.includes('--fix') || args.some((a) => a.startsWith('--fix='));
@@ -80,16 +78,15 @@ const settings = command === 'settings';
80
78
  const dashboardPort = Number.parseInt(flagValue('--port') ?? '', 10) || 0;
81
79
  const dashboardOpen = args.includes('--open');
82
80
  const dashboardExport = flagValue('--export') ?? null;
83
- // Display currency for usage/dashboard: --currency flag wins, then the stored
84
- // plugin default (tersio settings), then USD.
81
+ // Display currency: --currency flag, then the stored default, then USD.
85
82
  const currency = parseCurrencyFlag(flagValue('--currency')) ?? readStoredCurrency();
86
83
  const currencyGiven = flagValue('--currency') !== undefined;
87
84
  const uninstall = command === 'uninstall' || args.includes('--uninstall');
88
85
  const removePonytail = args.includes('--remove-ponytail');
89
86
  const keepPonytail = args.includes('--keep-ponytail');
90
87
  const removeRtk = args.includes('--remove-rtk');
91
- // Project scope was removed: tersio installs user-level (all OMP sessions)
92
- // only. A bare `--scope user` still parses (old scripts); anything else fails.
88
+ // User scope only (all OMP sessions). A bare `--scope user` still parses for
89
+ // old scripts; anything else fails.
93
90
  const legacyScope = flagValue('--scope')?.toLowerCase() ?? null;
94
91
  if (legacyScope !== null && legacyScope !== 'user') {
95
92
  console.error(`[fail] Invalid --scope: ${legacyScope}. Project scope was removed; tersio installs user-level only.`);
@@ -136,8 +133,8 @@ async function writeIfChanged(dest, content, options = {}) {
136
133
  console.log(` [write] ${dest}`);
137
134
  return true;
138
135
  }
139
- // omp ships 'extensions: null'; appending list items under a null scalar breaks
140
- // YAML parsing. Normalize null/~/[]/empty to a mapping key first.
136
+ // omp ships 'extensions: null', and list items under a null scalar break YAML:
137
+ // normalize null/~/[]/empty to a mapping key first.
141
138
  const EXTENSIONS_KEY_RE = /^\s*extensions\s*:/i;
142
139
  const EXTENSIONS_NULL_RE = /^\s*extensions\s*:\s*(?:\[\s*\]|null|~)?\s*$/i;
143
140
  function normalizeExtensionsKey(lines) {
@@ -159,9 +156,8 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
159
156
  const line = ` - ${normalizedPath}`;
160
157
  const raw = await readTextIfExists(configPath);
161
158
  let lines = (raw || '').split('\n');
162
- // Drop legacy compiled twins (same dir/name, .js): previous installs
163
- // registered them, and OMP must never load both copies. Match on the
164
- // trailing path so absolute and relative entries are both caught.
159
+ // Drop legacy compiled twins (same dir/name, .js): OMP must never load both
160
+ // copies. Matched on the trailing path so relative entries are caught too.
165
161
  if (normalizedPath.endsWith('.ts')) {
166
162
  const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
167
163
  lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
@@ -281,8 +277,8 @@ async function ensurePonytailConfigValue(key, value, options = {}) {
281
277
  return true;
282
278
  }, `would set Ponytail ${key}=${value}`, `Set Ponytail ${key}=${value}`, options);
283
279
  }
284
- // Read plugins/package.json tolerantly; corrupt or missing files start fresh
285
- // with installer-managed defaults.
280
+ // Read plugins/package.json tolerantly; a corrupt or missing file starts
281
+ // fresh with installer-managed defaults.
286
282
  async function readPluginsPackage(pkgPath) {
287
283
  const pkg = parseJsonObject(await readTextIfExists(pkgPath)) ?? {};
288
284
  return { ...pkg, name: pkg.name || 'omp-plugins', private: true, dependencies: pkg.dependencies || {} };
@@ -302,4 +298,4 @@ function relTime(ageMs) {
302
298
  const weeks = Math.floor(days / 7);
303
299
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
304
300
  }
305
- export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, update, reinstall, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
301
+ export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, update, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };