@krtclcdy/tersio 2.25.3 → 2.25.4

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.
@@ -0,0 +1,90 @@
1
+ // pi's built-in grep and glob never reach the bash hook; `read` is absent because rtk has no filter for it.
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { resolveRtkBinary } from '../lib/utils.ts';
6
+ import { readRtkDefault } from '../shared/plugin-settings.ts';
7
+ import { getSharedComboState, lastCustomValue, sessionEntries } from '../shared/session-state.ts';
8
+ import type { ExtensionApi, ExtensionCtx, SessionEntry, ToolResultEvent } from '../shared/types.ts';
9
+
10
+ /** Tool name to `rtk pipe --filter` name. Exported so the OpenCode host reuses one map. */
11
+ export const FILTER_BY_TOOL = new Map([
12
+ ['grep', 'grep'],
13
+ ['glob', 'find'],
14
+ ['find', 'find'],
15
+ ]);
16
+
17
+ /** Below this the rtk spawn costs more than the filter saves. */
18
+ export const MIN_FILTER_BYTES = 1200;
19
+ const FILTER_TIMEOUT_MS = 5000;
20
+
21
+ /** Absolute, so a minimal PATH in the host cannot break the spawn. */
22
+ const SHELL = '/bin/sh';
23
+
24
+ export type FilterExec = (
25
+ cmd: string,
26
+ args: string[],
27
+ opts?: { signal?: AbortSignal; cwd?: string; timeout?: number },
28
+ ) => Promise<{ stdout: string; code: number }>;
29
+
30
+ function modeOf(value: unknown): boolean | null {
31
+ if (value === true) return true;
32
+ if (value === false) return false;
33
+ return null;
34
+ }
35
+
36
+ /** True when rtk is on for the session: a persisted entry wins, then a live switch, then the configured default. */
37
+ export function rtkActive(entries: SessionEntry[] | null | undefined): boolean {
38
+ const persisted = lastCustomValue(entries, 'rtk-mode', (data) => modeOf(data?.enabled));
39
+ if (persisted !== null) return persisted;
40
+ return getSharedComboState().rtk === 'on' || readRtkDefault();
41
+ }
42
+
43
+ /** The text in a result's content parts, or null when the parts are not plain text. */
44
+ export function filterableText(parts: unknown): string | null {
45
+ if (!Array.isArray(parts) || parts.length === 0) return null;
46
+ // An image or a mixed result is not text to filter.
47
+ if (parts.some((part) => (part as { type?: unknown } | null)?.type !== 'text')) return null;
48
+ return parts.map((part) => (part as { text?: unknown } | null)?.text ?? '').join('\n');
49
+ }
50
+
51
+ /** Pipe text through one rtk filter; null on any failure, so the caller keeps its original. */
52
+ export async function filterThroughRtk(
53
+ exec: FilterExec,
54
+ request: { bin: string; filter: string; text: string; signal?: AbortSignal },
55
+ ): Promise<string | null> {
56
+ const { bin, filter, text, signal } = request;
57
+ let dir: string | undefined;
58
+ try {
59
+ dir = mkdtempSync(join(tmpdir(), 'tersio-rtk-'));
60
+ const file = join(dir, 'in.txt');
61
+ writeFileSync(file, text, 'utf8');
62
+ // `rtk pipe` reads stdin only and execFile has no stdin, so one redirect.
63
+ const result = await exec(SHELL, ['-c', `"${bin}" pipe -f ${filter} < "${file}"`], { signal, timeout: FILTER_TIMEOUT_MS });
64
+ if (result.code !== 0) return null;
65
+ const out = result.stdout;
66
+ if (!out.trim() || out.length >= text.length) return null;
67
+ return out;
68
+ } catch {
69
+ return null;
70
+ } finally {
71
+ if (dir) rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ export default function rtkFilterExtension(pi: ExtensionApi): void {
76
+ const bin = resolveRtkBinary();
77
+ const exec = pi.exec;
78
+ if (!bin || !exec) return;
79
+ pi.on<ToolResultEvent>('tool_result', async (event, ctx: ExtensionCtx) => {
80
+ const filter = FILTER_BY_TOOL.get(String(event?.toolName ?? '').toLowerCase());
81
+ if (!filter || event?.isError) return;
82
+ const text = filterableText(event?.content);
83
+ if (text === null || text.length < MIN_FILTER_BYTES) return;
84
+ if (!rtkActive(sessionEntries(ctx))) return;
85
+ const filtered = await filterThroughRtk(exec, { bin, filter, text, signal: ctx?.signal });
86
+ if (!filtered) return;
87
+ // Built-in grep and glob declare no output schema, so no structuredContent is lost.
88
+ return { content: [{ type: 'text', text: filtered }] };
89
+ });
90
+ }
@@ -23,7 +23,8 @@ function setRtkProcessEnabled(enabled: boolean): void {
23
23
  else process.env.RTK_DISABLED = '1';
24
24
  }
25
25
 
26
- const RTK_PROMPT = `RTK guidance active. RTK automatically rewrites eligible Bash calls through the installed rtk hook. Prefer rtk for noisy shell output, but use exact raw output for state changes, checksums, patches, and diagnostics that need full bytes.`;
26
+ // rtk's awareness contract: state the output contract, never tell the model to type `rtk`.
27
+ const RTK_PROMPT = `RTK guidance active. Bash output, and large grep and glob results, are condensed to save tokens: every signal kept, costly noise dropped. Treat a condensed result as complete. Re-run something unfiltered only when its result is empty, garbled, or contradicts its exit code.`;
27
28
 
28
29
  export default function rtkSessionExtension(pi: ExtensionApi): void {
29
30
  let enabled = DEFAULT_ENABLED;
@@ -134,6 +135,6 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
134
135
  if (!active) return;
135
136
  // Skip when already present, so a host that re-presents the prompt cannot stack it.
136
137
  if (systemPromptIncludes(event.systemPrompt, RTK_PROMPT)) return;
137
- return injectPromptText(pi, event, RTK_PROMPT);
138
+ return injectPromptText(pi, event, { text: RTK_PROMPT, sectionTag: 'rtk' });
138
139
  });
139
140
  }
@@ -36,7 +36,7 @@ interface HostAdapter {
36
36
  label(pi: HostHandle, text: string): void;
37
37
  on<E>(pi: ExtensionApi, event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;
38
38
  select(ui: UiApi | undefined, title: string, options: SelectOption[], dialogOptions?: Record<string, unknown>): Promise<string | undefined>;
39
- inject(event: SystemPromptEvent, text: string, staleMarker?: string): PromptInjection | undefined;
39
+ inject(event: SystemPromptEvent, block: PromptBlock): PromptInjection | undefined;
40
40
  stringArray(pi: ExtensionApi, name: string, spec: StringArraySpec): ToolParams;
41
41
  }
42
42
 
@@ -53,15 +53,21 @@ const piAdapter: HostAdapter = {
53
53
  if (picked === undefined) return undefined;
54
54
  return options[shown.indexOf(picked)]?.label ?? picked;
55
55
  },
56
- inject(event, text, staleMarker) {
57
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
56
+ inject(event, block) {
57
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
58
58
  const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
59
59
  const options = event.systemPromptOptions;
60
+ // A named section is replaced in place; returning systemPrompt replaces the whole prompt.
61
+ const sections = options?.sections;
62
+ if (block.sectionTag && sections && typeof sections === 'object') {
63
+ sections[block.sectionTag] = block.text;
64
+ return undefined;
65
+ }
60
66
  if (options && typeof options.appendSystemPrompt === 'string') {
61
- options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(text).filter(Boolean).join('\n\n');
67
+ options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(block.text).filter(Boolean).join('\n\n');
62
68
  return undefined;
63
69
  }
64
- return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${text}` };
70
+ return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${block.text}` };
65
71
  },
66
72
  stringArray(_pi, name, spec) {
67
73
  const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
@@ -78,10 +84,10 @@ const ompAdapter: HostAdapter = {
78
84
  if (!ui?.select) return undefined;
79
85
  return ui.select(title, options, dialogOptions);
80
86
  },
81
- inject(event, text, staleMarker) {
82
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
87
+ inject(event, block) {
88
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
83
89
  const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
84
- return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
90
+ return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
85
91
  },
86
92
  stringArray(pi, name, spec) {
87
93
  const { z } = pi.zod as { z: ZodFactory['z'] };
@@ -98,10 +104,10 @@ const opencodeAdapter: HostAdapter = {
98
104
  if (!ui?.select) return undefined;
99
105
  return ui.select(title, options, dialogOptions);
100
106
  },
101
- inject(event, text, staleMarker) {
102
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
107
+ inject(event, block) {
108
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
103
109
  const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
104
- return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
110
+ return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
105
111
  },
106
112
  stringArray(_pi, name, spec) {
107
113
  const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
@@ -141,8 +147,15 @@ export async function hostSelect(
141
147
  return adapterFor(pi).select(ui, title, options, dialogOptions);
142
148
  }
143
149
 
144
- export function injectPromptText(pi: HostHandle, event: SystemPromptEvent, text: string, staleMarker?: string): PromptInjection | undefined {
145
- return adapterFor(pi).inject(event, text, staleMarker);
150
+ /** One prompt block: its text, the section tag that owns it, and prior text to drop. */
151
+ export interface PromptBlock {
152
+ text: string;
153
+ sectionTag?: string;
154
+ staleMarker?: string;
155
+ }
156
+
157
+ export function injectPromptText(pi: HostHandle, event: SystemPromptEvent, block: PromptBlock): PromptInjection | undefined {
158
+ return adapterFor(pi).inject(event, block);
146
159
  }
147
160
 
148
161
  export function stringArrayToolParams(pi: ExtensionApi, name: string, spec: StringArraySpec = {}): ToolParams {
@@ -95,7 +95,9 @@ export function normalizeInputCommand(value: unknown): string {
95
95
  }
96
96
 
97
97
  export function asPromptArray(systemPrompt: string | string[]): string[] {
98
- return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
98
+ const parts = Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
99
+ // OMP can hand back a hole in the array; a non-string breaks every marker check.
100
+ return parts.filter((part): part is string => typeof part === 'string');
99
101
  }
100
102
 
101
103
  export function systemPromptIncludes(systemPrompt: string | string[], marker: string): boolean {
@@ -36,6 +36,7 @@ export interface UiApi {
36
36
  export interface ExtensionCtx {
37
37
  hasUI?: boolean;
38
38
  cwd?: string;
39
+ signal?: AbortSignal;
39
40
  sessionManager?: {
40
41
  getBranch?: () => SessionEntry[];
41
42
  getEntries?: () => SessionEntry[];
@@ -46,13 +47,20 @@ export interface ExtensionCtx {
46
47
 
47
48
  export interface SystemPromptEvent {
48
49
  systemPrompt: string | string[];
49
- // pi only: the mutable prompt sections. OMP does not provide it.
50
- systemPromptOptions?: { appendSystemPrompt?: string };
50
+ // pi only: the mutable prompt sections and append slot. OMP does not provide them.
51
+ systemPromptOptions?: { appendSystemPrompt?: string; sections?: Record<string, string> };
51
52
  }
52
53
 
53
54
  /** What a `before_agent_start` handler hands back. OMP takes an array, pi a string. */
54
55
  export type PromptInjection = { systemPrompt: string[] } | { systemPrompt: string };
55
56
 
57
+ /** A finished tool call, before its result reaches the model. pi and OMP provide it. */
58
+ export interface ToolResultEvent {
59
+ toolName?: string;
60
+ content?: Array<{ type?: string; text?: string }>;
61
+ isError?: boolean;
62
+ }
63
+
56
64
  /** A tool `parameters` object. OMP wants zod, pi wants JSON Schema. */
57
65
  export type ToolParams = Record<string, unknown>;
58
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.25.3",
3
+ "version": "2.25.4",
4
4
  "description": "✂️ Token saver and 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.",
5
5
  "keywords": [
6
6
  "token-saving",
@@ -32,6 +32,7 @@
32
32
  "extensions": [
33
33
  "./extensions/caveman-session/index.ts",
34
34
  "./extensions/rtk-session/index.ts",
35
+ "./extensions/rtk-filter/index.ts",
35
36
  "./extensions/combo-toggle/index.ts",
36
37
  "./extensions/tersio-commands/index.ts"
37
38
  ],
@@ -1,266 +0,0 @@
1
- // The Headroom bundle: Tersio installs, updates, and removes the add-on so the user never runs a Python tool by hand.
2
- // A Tersio-owned venv at ~/.tersio/headroom stays inside our home, so uninstall is one directory removal.
3
- import { existsSync } from 'node:fs';
4
- import { promises as fs } from 'node:fs';
5
- import path from 'node:path';
6
- import { execP } from './common.js';
7
- import { sayTagged } from './interactive.js';
8
- import { findFile, headroomBundleDir, headroomBundledBinary, httpsDownload, httpsGet, parseChecksum, sha256File, } from '../extensions/lib/utils.js';
9
- import { HEADROOM_TESTED_VERSION, parseHeadroomVersion } from '../extensions/shared/headroom.js';
10
- /** The extra that carries the proxy. No `[ml]`: torch plus model weights are opt-in, later. */
11
- const HEADROOM_SPEC = 'headroom-ai[proxy]';
12
- /** uv resolves and, when needed, downloads a managed CPython for this request. */
13
- const PYTHON_REQUEST = '>=3.10';
14
- const UV_RELEASE_BASE = 'https://github.com/astral-sh/uv/releases/latest/download';
15
- const IS_WINDOWS = process.platform === 'win32';
16
- /** CI and tests set this so an install never reaches the network. Not a user-facing option. */
17
- function bundleDisabled() {
18
- return process.env.TERSIO_SKIP_HEADROOM_BUNDLE === '1';
19
- }
20
- function skippedResult() {
21
- sayTagged(' [skip] Headroom bundle skipped (TERSIO_SKIP_HEADROOM_BUNDLE=1)');
22
- return { ok: true, bin: null, version: null, detail: 'skipped', skipped: true };
23
- }
24
- // --- layout ----------------------------------------------------------------------------
25
- export function headroomVenvDir() {
26
- return path.join(headroomBundleDir(), 'venv');
27
- }
28
- function headroomVenvPython() {
29
- return IS_WINDOWS
30
- ? path.join(headroomVenvDir(), 'Scripts', 'python.exe')
31
- : path.join(headroomVenvDir(), 'bin', 'python');
32
- }
33
- /** The bundled entrypoint. `resolveHeadroomBinary` prefers it, so Tersio updates what Tersio installed. */
34
- export const headroomBundledBin = headroomBundledBinary;
35
- function uvManagedDir() {
36
- return path.join(headroomBundleDir(), 'bin');
37
- }
38
- function uvManagedFile() {
39
- return path.join(uvManagedDir(), IS_WINDOWS ? 'uv.exe' : 'uv');
40
- }
41
- /** Written only when Tersio downloaded uv itself, so uninstall knows what it may delete. */
42
- function uvOwnedMarker() {
43
- return path.join(headroomBundleDir(), 'uv-managed');
44
- }
45
- export function headroomBundleInstalled() {
46
- return existsSync(headroomBundledBin());
47
- }
48
- const UV_PLATFORM_SPECS = {
49
- 'win32/x64': { triple: 'x86_64-pc-windows-msvc', ext: '.zip' },
50
- 'linux/x64': { triple: 'x86_64-unknown-linux-gnu', ext: '.tar.gz' },
51
- 'linux/arm64': { triple: 'aarch64-unknown-linux-gnu', ext: '.tar.gz' },
52
- 'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz' },
53
- 'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz' },
54
- };
55
- export function uvPlatformSpec(platform = process.platform, arch = process.arch) {
56
- return UV_PLATFORM_SPECS[`${platform}/${arch}`] ?? null;
57
- }
58
- async function onPath(name) {
59
- try {
60
- if (IS_WINDOWS) {
61
- const found = await execP('where', [name], { timeout: 5000 });
62
- return found.stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? null;
63
- }
64
- const found = await execP('sh', ['-c', `command -v ${name}`], { timeout: 5000 });
65
- return found.stdout.trim() || null;
66
- }
67
- catch {
68
- return null;
69
- }
70
- }
71
- /** A usable uv: the Tersio copy first (so tests and CI are deterministic), then PATH. */
72
- export async function resolveUv() {
73
- if (existsSync(uvManagedFile()))
74
- return uvManagedFile();
75
- return onPath('uv');
76
- }
77
- async function extractArchive(archivePath, extractDir) {
78
- await fs.mkdir(extractDir, { recursive: true });
79
- let attempts;
80
- if (archivePath.endsWith('.zip')) {
81
- attempts = IS_WINDOWS
82
- ? [['powershell', ['Expand-Archive', '-Path', archivePath, '-DestinationPath', extractDir, '-Force']]]
83
- : [['unzip', ['-o', archivePath, '-d', extractDir]]];
84
- }
85
- else {
86
- attempts = [['tar', ['xzf', archivePath, '-C', extractDir]]];
87
- }
88
- for (const [cmd, args] of attempts) {
89
- try {
90
- await execP(cmd, args, { timeout: 60000 });
91
- return true;
92
- }
93
- catch { /* try the next extractor */ }
94
- }
95
- return false;
96
- }
97
- /** Downloads uv into the Tersio home, checksum-verified, when the machine has none. */
98
- async function installUv(options) {
99
- const spec = uvPlatformSpec();
100
- if (!spec) {
101
- sayTagged(` [fail] No uv build for ${process.platform}/${process.arch}.`);
102
- return null;
103
- }
104
- if (options.dryRun) {
105
- sayTagged(` [dry-run] would download uv-${spec.triple}${spec.ext} into ${uvManagedDir()}`);
106
- return uvManagedFile();
107
- }
108
- const asset = `uv-${spec.triple}${spec.ext}`;
109
- // The parent must exist before mkdtemp runs inside it.
110
- await fs.mkdir(headroomBundleDir(), { recursive: true });
111
- const tmpDir = await fs.mkdtemp(path.join(headroomBundleDir(), 'tmp-'));
112
- try {
113
- const archivePath = path.join(tmpDir, asset);
114
- sayTagged(` [get] ${asset}`);
115
- await httpsDownload(`${UV_RELEASE_BASE}/${asset}`, archivePath, { maxRedirects: 5 });
116
- // Fails closed: an unverified executable would run with the user's privileges.
117
- const checksums = await httpsGet(`${UV_RELEASE_BASE}/${asset}.sha256`).catch(() => null);
118
- const expected = checksums ? parseChecksum(checksums, asset) : null;
119
- if (!expected) {
120
- sayTagged(` [fail] No checksum published for ${asset} — refusing to install uv.`);
121
- return null;
122
- }
123
- if (await sha256File(archivePath) !== expected) {
124
- sayTagged(` [fail] Checksum mismatch for ${asset} — refusing to install uv.`);
125
- return null;
126
- }
127
- const extractDir = path.join(tmpDir, 'extracted');
128
- if (!await extractArchive(archivePath, extractDir)) {
129
- sayTagged(` [fail] Could not extract ${asset}`);
130
- return null;
131
- }
132
- const found = await findFile(extractDir, IS_WINDOWS ? 'uv.exe' : 'uv');
133
- if (!found) {
134
- sayTagged(` [fail] Could not find uv in ${asset}`);
135
- return null;
136
- }
137
- await fs.mkdir(uvManagedDir(), { recursive: true });
138
- await fs.copyFile(found, uvManagedFile());
139
- await fs.chmod(uvManagedFile(), 0o755).catch(() => { });
140
- await fs.writeFile(uvOwnedMarker(), `${new Date().toISOString()}\n`, 'utf8');
141
- return uvManagedFile();
142
- }
143
- catch (e) {
144
- sayTagged(` [fail] Could not install uv: ${e.message}`);
145
- return null;
146
- }
147
- finally {
148
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
149
- }
150
- }
151
- async function ensureUv(options) {
152
- const existing = await resolveUv();
153
- if (existing)
154
- return existing;
155
- if (!options.quiet)
156
- sayTagged(' [info] No uv found — Tersio installs its own copy under ~/.tersio/headroom');
157
- return installUv(options);
158
- }
159
- // --- install / update ------------------------------------------------------------------
160
- async function runUv(uv, args, options) {
161
- if (options.dryRun) {
162
- sayTagged(` [dry-run] would run: uv ${args.join(' ')}`);
163
- return true;
164
- }
165
- if (options.verbose || !options.quiet)
166
- sayTagged(` [run] uv ${args.join(' ')}`);
167
- try {
168
- const execOptions = { timeout: options.timeout ?? 600000 };
169
- const result = await execP(uv, args, execOptions);
170
- if (options.verbose && result.stdout.trim())
171
- sayTagged(` ${result.stdout.trim().slice(-400)}`);
172
- return true;
173
- }
174
- catch (e) {
175
- const err = e;
176
- sayTagged(` [fail] uv ${args[0]}: ${(err.stderr || err.message || '').trim().slice(0, 300)}`);
177
- return false;
178
- }
179
- }
180
- /** Builds the venv if it is missing, then installs or upgrades the package inside it. */
181
- async function provision(options, upgrade) {
182
- const uv = await ensureUv(options);
183
- if (!uv)
184
- return { ok: false, bin: null, version: null, detail: 'uv unavailable' };
185
- if (!existsSync(headroomVenvPython())) {
186
- // uv fetches a managed CPython when the machine has no interpreter in range.
187
- if (!await runUv(uv, ['venv', headroomVenvDir(), '--python', PYTHON_REQUEST], { ...options, timeout: 900000 })) {
188
- return { ok: false, bin: null, version: null, detail: 'venv creation failed' };
189
- }
190
- }
191
- const installArgs = ['pip', 'install', '--python', headroomVenvPython()];
192
- if (upgrade)
193
- installArgs.push('--upgrade');
194
- installArgs.push(HEADROOM_SPEC);
195
- if (!await runUv(uv, installArgs, options)) {
196
- return { ok: false, bin: null, version: null, detail: `could not install ${HEADROOM_SPEC}` };
197
- }
198
- if (options.dryRun)
199
- return { ok: true, bin: headroomBundledBin(), version: null, detail: 'dry run' };
200
- const version = await bundledVersion();
201
- if (!existsSync(headroomBundledBin())) {
202
- return { ok: false, bin: null, version, detail: 'the package installed, but no headroom entrypoint appeared' };
203
- }
204
- return { ok: true, bin: headroomBundledBin(), version, detail: upgrade ? 'updated' : 'installed' };
205
- }
206
- async function bundledVersion() {
207
- try {
208
- const result = await execP(headroomBundledBin(), ['--version'], { timeout: 30000 });
209
- return parseHeadroomVersion(result.stdout || result.stderr);
210
- }
211
- catch (e) {
212
- const err = e;
213
- return parseHeadroomVersion(err.stdout || err.stderr);
214
- }
215
- }
216
- /** Install the bundled Headroom. Idempotent: an existing venv is reused. */
217
- export async function installHeadroom(options = {}) {
218
- if (bundleDisabled())
219
- return skippedResult();
220
- if (headroomBundleInstalled() && !options.dryRun) {
221
- const version = await bundledVersion();
222
- return { ok: true, bin: headroomBundledBin(), version, detail: 'already installed' };
223
- }
224
- return provision(options, false);
225
- }
226
- /** Refresh the bundled Headroom to the newest release. */
227
- export async function updateHeadroom(options = {}) {
228
- if (bundleDisabled())
229
- return skippedResult();
230
- if (!headroomBundleInstalled() && !options.dryRun && !existsSync(headroomVenvPython())) {
231
- return installHeadroom(options);
232
- }
233
- const result = await provision(options, true);
234
- if (result.ok && result.version) {
235
- const beyond = parseHeadroomVersion(result.version);
236
- if (beyond && beyond !== HEADROOM_TESTED_VERSION && beyond > HEADROOM_TESTED_VERSION) {
237
- sayTagged(` [warn] Headroom ${beyond} is newer than the tested ${HEADROOM_TESTED_VERSION}`);
238
- }
239
- }
240
- return result;
241
- }
242
- // --- remove ----------------------------------------------------------------------------
243
- /** Removes the venv and anything else the bundle created, including a uv copy Tersio downloaded. */
244
- export async function removeHeadroomBundle(options = {}) {
245
- const root = headroomBundleDir();
246
- if (!existsSync(root))
247
- return true;
248
- // Removal still happens under the skip flag: an uninstall must not leave the bundle behind.
249
- if (options.dryRun) {
250
- sayTagged(` [dry-run] would remove ${root}`);
251
- return true;
252
- }
253
- try {
254
- await fs.rm(root, { recursive: true, force: true });
255
- sayTagged(` [rm] ${root}`);
256
- return true;
257
- }
258
- catch (e) {
259
- sayTagged(` [fail] could not remove ${root}: ${e.message}`);
260
- return false;
261
- }
262
- }
263
- /** True when the bundle holds a uv copy Tersio downloaded (as opposed to one the user had). */
264
- export function headroomOwnsUv() {
265
- return existsSync(uvOwnedMarker());
266
- }