@krtclcdy/tersio 2.22.1 → 2.24.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 +62 -46
- package/cli/agent-hosts.js +42 -0
- package/cli/agents.js +266 -0
- package/cli/common.js +55 -55
- package/cli/currency.js +8 -12
- package/cli/dashboard.js +76 -49
- package/cli/doctor-fix.js +61 -44
- package/cli/doctor.js +66 -44
- package/cli/hosts.js +118 -0
- package/cli/install.js +187 -143
- package/cli/interactive.js +27 -1
- package/cli/layer.js +24 -0
- package/cli/manifest.js +36 -0
- package/cli/omp-layer.js +51 -0
- package/cli/pi-layer.js +37 -0
- package/cli/pi-wiring.js +92 -0
- package/cli/profile.js +45 -46
- package/cli/reset.js +3 -26
- package/cli/rtk-wiring.js +10 -15
- package/cli/settings.js +1 -1
- package/cli/uninstall.js +135 -44
- package/cli/update.js +24 -30
- package/cli/usage.js +6 -1
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +36 -25
- package/extensions/ai-addons-updater/index.ts +36 -24
- package/extensions/caveman-session/index.js +23 -18
- package/extensions/caveman-session/index.ts +23 -18
- package/extensions/caveman-session/rule.md +90 -0
- package/extensions/combo-toggle/index.js +63 -41
- package/extensions/combo-toggle/index.ts +61 -48
- package/extensions/lib/utils.js +56 -13
- package/extensions/lib/utils.ts +55 -13
- package/extensions/rtk-session/index.js +20 -19
- package/extensions/rtk-session/index.ts +18 -24
- package/extensions/shared/carbon.js +5 -8
- package/extensions/shared/carbon.ts +5 -8
- package/extensions/shared/host.js +68 -0
- package/extensions/shared/host.ts +104 -0
- package/extensions/shared/omp-prompt.js +12 -0
- package/extensions/shared/plugin-settings.js +63 -16
- package/extensions/shared/plugin-settings.ts +67 -17
- package/extensions/shared/pricing.js +26 -15
- package/extensions/shared/pricing.ts +28 -16
- package/extensions/shared/rtk-gain.js +13 -24
- package/extensions/shared/rtk-gain.ts +14 -25
- package/extensions/shared/session-state.js +26 -28
- package/extensions/shared/session-state.ts +26 -29
- package/extensions/shared/types.ts +15 -3
- package/extensions/shared/usage-ledger.js +136 -38
- package/extensions/shared/usage-ledger.ts +149 -52
- package/extensions/shared/usage-store.js +14 -12
- package/extensions/shared/usage-store.ts +16 -14
- package/extensions/tersio-commands/index.js +2 -1
- package/extensions/tersio-commands/index.ts +2 -1
- package/package.json +15 -7
- package/tersio.js +6 -6
- package/extensions/shared/mode-reinforcement.js +0 -26
- package/extensions/shared/mode-reinforcement.ts +0 -29
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|

|
|
2
2
|
|
|
3
|
-
# ✂️ Tersio — Token
|
|
3
|
+
# ✂️ Tersio — Token Saver OMP & Usage Tracker
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@krtclcdy/tersio)
|
|
6
6
|
[](https://nodejs.org)
|
|
@@ -8,29 +8,38 @@
|
|
|
8
8
|
[](https://github.com/KurutoDenzeru/tersio/actions)
|
|
9
9
|
[](./LICENSE)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Token-saving add-ons for Oh My Pi and pi: terse caveman replies, compact
|
|
12
|
+
RTK shell output, lean Ponytail code calls, usage tracking with a gain
|
|
13
|
+
dashboard — one-command combo presets, one shared extension tree.
|
|
12
14
|
|
|
13
15
|
## ⚡ Getting Started
|
|
14
16
|
|
|
17
|
+
All install paths in one table, with update and remove commands: [INSTALL.md](./INSTALL.md).
|
|
18
|
+
|
|
15
19
|
**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
20
|
|
|
17
21
|
```bash
|
|
18
22
|
curl -fsSL https://github.com/KurutoDenzeru/tersio/releases/latest/download/install.sh | sh
|
|
19
23
|
```
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
Then run the CLI. Bare `tersio` opens a menu and asks which agent to install into:
|
|
22
26
|
|
|
23
27
|
```bash
|
|
24
|
-
|
|
28
|
+
tersio # menu — picks Oh My Pi or pi
|
|
29
|
+
tersio install --host pi # pi, non-interactive
|
|
30
|
+
tersio install --host omp # Oh My Pi, non-interactive
|
|
25
31
|
```
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
Or let the host install the package itself. Same result, host-managed updates:
|
|
28
34
|
|
|
29
35
|
```bash
|
|
30
|
-
omp plugin
|
|
36
|
+
omp plugin install @krtclcdy/tersio
|
|
37
|
+
pi install npm:@krtclcdy/tersio
|
|
31
38
|
```
|
|
32
39
|
|
|
33
|
-
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
Then restart the agent and enable a preset:
|
|
34
43
|
|
|
35
44
|
```text
|
|
36
45
|
/combo balanced
|
|
@@ -38,13 +47,7 @@ Then restart OMP and enable a preset:
|
|
|
38
47
|
|
|
39
48
|
Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything starts off until you enable it.
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
tersio install --combo-default balanced --caveman-default lite --rtk-default on
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
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
|
+
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.
|
|
48
51
|
|
|
49
52
|
One-off use without installing:
|
|
50
53
|
|
|
@@ -54,30 +57,33 @@ npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio insta
|
|
|
54
57
|
|
|
55
58
|
### Requirements
|
|
56
59
|
|
|
57
|
-
- [OMP](https://github.com/can1357/oh-my-pi)
|
|
60
|
+
- [OMP](https://github.com/can1357/oh-my-pi) or [pi](https://github.com/earendil-works/pi)
|
|
58
61
|
- Node.js 20.12+ with npm
|
|
59
62
|
|
|
60
63
|
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/`.
|
|
61
64
|
|
|
62
65
|
## 📊 Benchmarks
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
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).
|
|
65
68
|
|
|
66
|
-
|
|
|
67
|
-
|
|
68
|
-
| Terse reply,
|
|
69
|
-
| Terse reply,
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
|
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
| Mode | Surface (n) | Base → Tersio | Δ |
|
|
70
|
+
|---|---|---|---|
|
|
71
|
+
| `/caveman full` | Terse reply, o200k tokens (3 samples, p50) | 124 → 107 | **−13.7% reply text** |
|
|
72
|
+
| `/caveman ultra` | Terse reply, o200k tokens (3 samples, p50) | 124 → 109 | **−12.1% reply text** |
|
|
73
|
+
| `/ponytail lite` | Code reply, o200k tokens (3 samples, p50) | 306 → 177 | **−42.2% code** |
|
|
74
|
+
| `/ponytail full` | Code reply, o200k tokens (3 samples, p50) | 306 → 208 | **−32.0% code** |
|
|
75
|
+
| `/ponytail ultra` | Code reply, o200k tokens (3 samples, p50) | 306 → 112 | **−63.4% code** |
|
|
76
|
+
| `/combo medium` | Code reply, o200k tokens (3 samples, p50) | 306 → 132 | **−56.9% code** |
|
|
77
|
+
| `/rtk on` — `bun run test` | Command output (3 runs, p50) | 4,003 → 54 | **−98.7% output** |
|
|
78
|
+
| `/rtk on` — `find . -name '*.test.ts'` | Command output (3 runs, p50) | 10,908 → 248 | **−97.7% output** |
|
|
79
|
+
| `/rtk on` — `ls -la .` | Command output (3 runs, p50) | 983 → 275 | **−72.0% output** |
|
|
80
|
+
| `/rtk on` — `git status` | Command output (3 runs, p50) | 72 → 18 | **−75.0% output** |
|
|
81
|
+
| `/rtk on` — `bun run build` | Command output (3 runs, p50) | 140 → 114 | **−18.6% output** |
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
The Command tools dashboard reports weighted RTK command-output savings and OMP-specific adoption from executed Bash records. It does not estimate provider billing.
|
|
86
|
+
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.
|
|
81
87
|
|
|
82
88
|
## 📈 Dashboard
|
|
83
89
|
|
|
@@ -106,12 +112,11 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn c
|
|
|
106
112
|
|---|---|
|
|
107
113
|
| `tersio install` | Install (user scope: all OMP sessions) |
|
|
108
114
|
| `tersio update` | Refresh the CLI, extensions, and add-ons (RTK binary, Caveman rule, Ponytail) |
|
|
109
|
-
| `tersio reinstall` | Fresh install, preserving the Ponytail package |
|
|
110
115
|
| `tersio doctor` | Check OMP, extension, Ponytail, and RTK health (`--fix` repairs, `--fix=<scope>` one scope, `--dry-run` previews) |
|
|
111
116
|
| `tersio usage` | Ledger-backed usage + savings report |
|
|
112
117
|
| `tersio dashboard` | Open the Dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
|
|
113
118
|
| `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 |
|
|
114
|
-
| `tersio uninstall` | Remove extensions,
|
|
119
|
+
| `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) |
|
|
115
120
|
| `tersio version` | Print version |
|
|
116
121
|
|
|
117
122
|
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.
|
|
@@ -123,9 +128,9 @@ Mode switches live on their own commands; bare `/tersio` prints status.
|
|
|
123
128
|
| Command | Purpose |
|
|
124
129
|
|---|---|
|
|
125
130
|
| `/combo off\|medium\|balanced\|max\|status` | One preset for all three (start here): medium = lite/lite/on, balanced = full/full/on, max = ultra/ultra/on; per-mode tweaks drop to `custom`. |
|
|
126
|
-
| `/caveman lite\|full\|ultra\|wenyan\|off\|status` | Terse replies
|
|
127
|
-
| `/rtk on\|off\|status` |
|
|
128
|
-
| `/ponytail off\|lite\|full\|ultra\|
|
|
131
|
+
| `/caveman lite\|full\|ultra\|wenyan-lite\|wenyan-full\|wenyan-ultra\|off\|status` | Terse replies with upstream-aligned Caveman levels. Legacy `wenyan` restores as `wenyan-full`. |
|
|
132
|
+
| `/rtk on\|off\|status` | Controls RTK session guidance, `rtk_run`, and the automatic hook through shared `RTK_DISABLED` state. Exact bytes bypass RTK. |
|
|
133
|
+
| `/ponytail off\|lite\|full\|ultra\|status` | Minimal code using upstream Ponytail modes. Review runs separately through `/ponytail-review`. |
|
|
129
134
|
| `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
|
|
130
135
|
| `/tersio check` | Add-on version check |
|
|
131
136
|
| `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
|
|
@@ -136,25 +141,36 @@ Mode switches live on their own commands; bare `/tersio` prints status.
|
|
|
136
141
|
|
|
137
142
|
## 🗂️ Files and backups
|
|
138
143
|
|
|
144
|
+
Shared, and identical for every host:
|
|
145
|
+
|
|
139
146
|
| What | Path |
|
|
140
147
|
|---|---|
|
|
141
|
-
|
|
|
142
|
-
| RTK
|
|
143
|
-
|
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
| Session-start defaults | `~/.tersio/settings.json` — combo, caveman, rtk, ponytail, and the display currency |
|
|
149
|
+
| RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows). The runtime resolves `PATH` first, then this managed path. |
|
|
150
|
+
| Usage ledger and price cache | `~/.tersio/` — local only, nothing leaves the machine |
|
|
151
|
+
|
|
152
|
+
Per host, written by `tersio install --host <omp|pi>`:
|
|
146
153
|
|
|
147
|
-
|
|
154
|
+
| Host | Extensions | Ponytail |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| 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 |
|
|
157
|
+
| 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 |
|
|
158
|
+
|
|
159
|
+
Both hosts carry the same `shared/` and `lib/` modules beside those directories, and both load the same five extensions from the same sources.
|
|
148
160
|
|
|
149
|
-
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
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.
|
|
150
164
|
|
|
151
165
|
## 🛠️ Troubleshooting
|
|
152
166
|
|
|
153
|
-
**Ponytail or Combo command missing:**
|
|
167
|
+
**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`.
|
|
168
|
+
|
|
169
|
+
**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.
|
|
154
170
|
|
|
155
|
-
**RTK missing or not executable:** run `tersio
|
|
171
|
+
**RTK missing or not executable:** run `tersio install`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
|
|
156
172
|
|
|
157
|
-
**RTK commands not metered in the Dashboard:**
|
|
173
|
+
**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.
|
|
158
174
|
|
|
159
175
|
**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.
|
|
160
176
|
|
|
@@ -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 };
|