pi-editor-footer 0.1.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.
Files changed (46) hide show
  1. package/AGENTS.md +19 -0
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +29 -0
  4. package/README.md +84 -0
  5. package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
  6. package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
  7. package/docs/agents/domain.md +51 -0
  8. package/docs/agents/issue-tracker.md +45 -0
  9. package/docs/agents/triage-labels.md +15 -0
  10. package/docs/reference/pi-tui-internals.md +144 -0
  11. package/docs/specs/01-config.md +57 -0
  12. package/docs/specs/02-identity.md +20 -0
  13. package/docs/specs/03-border-telemetry.md +48 -0
  14. package/docs/specs/04-header.md +30 -0
  15. package/docs/specs/05-footer.md +30 -0
  16. package/docs/specs/06-git.md +36 -0
  17. package/docs/specs/07-runtime.md +28 -0
  18. package/docs/specs/theme-overview.md +116 -0
  19. package/package.json +16 -0
  20. package/src/config.ts +184 -0
  21. package/src/detail-render.ts +119 -0
  22. package/src/footer.ts +479 -0
  23. package/src/git.ts +170 -0
  24. package/src/header.ts +185 -0
  25. package/src/icons.ts +197 -0
  26. package/src/index.ts +607 -0
  27. package/src/model-info.ts +341 -0
  28. package/src/runtime.ts +318 -0
  29. package/src/state.ts +144 -0
  30. package/src/telemetry.ts +437 -0
  31. package/src/theme-settings.ts +461 -0
  32. package/src/tracking-editor.ts +352 -0
  33. package/src/utils-workspace.ts +48 -0
  34. package/src/utils.ts +388 -0
  35. package/src/window-presentation.ts +56 -0
  36. package/test/config.test.ts +146 -0
  37. package/test/detail-render.test.ts +202 -0
  38. package/test/footer.test.ts +86 -0
  39. package/test/git.test.ts +45 -0
  40. package/test/header.test.ts +169 -0
  41. package/test/icons.test.ts +24 -0
  42. package/test/runtime.test.ts +71 -0
  43. package/test/telemetry.test.ts +199 -0
  44. package/test/utils.test.ts +71 -0
  45. package/test/window-presentation.test.ts +73 -0
  46. package/tsconfig.json +13 -0
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Model-info border glow + label.
3
+ *
4
+ * Ported from the user's `model-info-widget` extension (~/.pi/agent/extensions/
5
+ * model-info-widget/index.ts, MIT-style personal extension) so that the
6
+ * TrackingEditor — which owns the editor slot for this extension — can keep
7
+ * rendering the model label and thinking-level border glow that the original
8
+ * widget provided. Self-contained: no imports beyond pi-tui's width utils.
9
+ *
10
+ * The port is intentional: pi allows exactly ONE custom editor (last
11
+ * `setEditorComponent` writer wins). pi-skill-desc must own the slot to track
12
+ * the completion popup, so model-info-widget's editor install becomes inert and
13
+ * its visual behavior lives here instead. See docs/adr/0001.
14
+ */
15
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
+
17
+ /** Structural subset of pi's Theme used by the glow/label rendering. */
18
+ export interface ThemeLike {
19
+ getFgAnsi(color: string): string;
20
+ getColorMode(): string;
21
+ getThinkingBorderColor(level: string): (s: string) => string;
22
+ fg(style: string, s: string): string;
23
+ bold(s: string): string;
24
+ dim(s: string): string;
25
+ muted(s: string): string;
26
+ }
27
+
28
+ /** The model/thinking info the border label shows. */
29
+ export interface ModelInfo {
30
+ provider: string;
31
+ modelId: string;
32
+ level: string;
33
+ contextWindow: number;
34
+ }
35
+
36
+ type ThinkingLevel =
37
+ | "off"
38
+ | "minimal"
39
+ | "low"
40
+ | "medium"
41
+ | "high"
42
+ | "xhigh"
43
+ | "max";
44
+
45
+ const LEVEL_INDEX: Record<ThinkingLevel, number> = {
46
+ off: 0,
47
+ minimal: 1,
48
+ low: 2,
49
+ medium: 3,
50
+ high: 4,
51
+ xhigh: 5,
52
+ max: 6,
53
+ };
54
+
55
+ const THINKING_COLORS: Record<ThinkingLevel, string> = {
56
+ off: "thinkingOff",
57
+ minimal: "thinkingMinimal",
58
+ low: "thinkingLow",
59
+ medium: "thinkingMedium",
60
+ high: "thinkingHigh",
61
+ xhigh: "thinkingXhigh",
62
+ max: "thinkingMax",
63
+ };
64
+
65
+ /** How much the glow brightens toward white at the top level (0..1). */
66
+ const GLOW_FACTOR = 0.55;
67
+
68
+ /** Space padding around the label inside the border (each side). */
69
+ const LABEL_PAD = 1;
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Color helpers: theme ANSI → RGB → boosted glow ANSI
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function stripAnsi(s: string): string {
76
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
77
+ }
78
+
79
+ const BASIC16: Array<[number, number, number]> = [
80
+ [0, 0, 0],
81
+ [128, 0, 0],
82
+ [0, 128, 0],
83
+ [128, 128, 0],
84
+ [0, 0, 128],
85
+ [128, 0, 128],
86
+ [0, 128, 128],
87
+ [192, 192, 192],
88
+ [128, 128, 128],
89
+ [255, 0, 0],
90
+ [0, 255, 0],
91
+ [255, 255, 0],
92
+ [0, 0, 255],
93
+ [255, 0, 255],
94
+ [0, 255, 255],
95
+ [255, 255, 255],
96
+ ];
97
+ const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
98
+ const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
99
+
100
+ function indexToRgb(n: number): { r: number; g: number; b: number } | null {
101
+ if (n >= 0 && n < 16) {
102
+ const [r, g, b] = BASIC16[n] ?? [0, 0, 0];
103
+ return { r, g, b };
104
+ }
105
+ if (n >= 16 && n <= 231) {
106
+ const v = n - 16;
107
+ return {
108
+ r: CUBE_VALUES[Math.floor(v / 36)] ?? 0,
109
+ g: CUBE_VALUES[Math.floor(v / 6) % 6] ?? 0,
110
+ b: CUBE_VALUES[v % 6] ?? 0,
111
+ };
112
+ }
113
+ if (n >= 232 && n <= 255) {
114
+ const gray = 8 + (n - 232) * 10;
115
+ return { r: gray, g: gray, b: gray };
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** Parse a Theme.getFgAnsi() escape back into RGB. */
121
+ function parseFgAnsiToRgb(
122
+ theme: ThemeLike,
123
+ color: string,
124
+ ): { r: number; g: number; b: number } | null {
125
+ const ansi = theme.getFgAnsi(color);
126
+ const trueColor = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
127
+ if (trueColor)
128
+ return {
129
+ r: Number(trueColor[1]),
130
+ g: Number(trueColor[2]),
131
+ b: Number(trueColor[3]),
132
+ };
133
+ const palette = ansi.match(/38;5;(\d+)/);
134
+ if (palette) return indexToRgb(Number(palette[1]));
135
+ return null;
136
+ }
137
+
138
+ function findClosestCubeIndex(value: number): number {
139
+ let minDist = Infinity;
140
+ let minIdx = 0;
141
+ for (let i = 0; i < CUBE_VALUES.length; i++) {
142
+ const dist = Math.abs(value - (CUBE_VALUES[i] ?? 0));
143
+ if (dist < minDist) {
144
+ minDist = dist;
145
+ minIdx = i;
146
+ }
147
+ }
148
+ return minIdx;
149
+ }
150
+
151
+ function findClosestGrayIndex(gray: number): number {
152
+ let minDist = Infinity;
153
+ let minIdx = 0;
154
+ for (let i = 0; i < GRAY_VALUES.length; i++) {
155
+ const dist = Math.abs(gray - (GRAY_VALUES[i] ?? 0));
156
+ if (dist < minDist) {
157
+ minDist = dist;
158
+ minIdx = i;
159
+ }
160
+ }
161
+ return minIdx;
162
+ }
163
+
164
+ function colorDistance(
165
+ r1: number,
166
+ g1: number,
167
+ b1: number,
168
+ r2: number,
169
+ g2: number,
170
+ b2: number,
171
+ ): number {
172
+ const dr = r1 - r2;
173
+ const dg = g1 - g2;
174
+ const db = b1 - b2;
175
+ return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
176
+ }
177
+
178
+ /** Quantize an RGB value to the closest xterm-256 index (same as the theme loader). */
179
+ function rgbTo256(r: number, g: number, b: number): number {
180
+ const rIdx = findClosestCubeIndex(r);
181
+ const gIdx = findClosestCubeIndex(g);
182
+ const bIdx = findClosestCubeIndex(b);
183
+ const cubeR = CUBE_VALUES[rIdx] ?? 0;
184
+ const cubeG = CUBE_VALUES[gIdx] ?? 0;
185
+ const cubeB = CUBE_VALUES[bIdx] ?? 0;
186
+ const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;
187
+ const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);
188
+ const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
189
+ const grayIdx = findClosestGrayIndex(gray);
190
+ const grayValue = GRAY_VALUES[grayIdx] ?? 0;
191
+ const grayIndex = 232 + grayIdx;
192
+ const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
193
+ const maxC = Math.max(r, g, b);
194
+ const minC = Math.min(r, g, b);
195
+ const spread = maxC - minC;
196
+ if (spread < 10 && grayDist < cubeDist) return grayIndex;
197
+ return cubeIndex;
198
+ }
199
+
200
+ /**
201
+ * Build a border color function for a thinking level: takes the theme's
202
+ * per-level color and brightens it toward white proportionally to the level,
203
+ * so higher levels "glow" more intensely.
204
+ */
205
+ function buildGlow(theme: ThemeLike, level: string): (s: string) => string {
206
+ const base = parseFgAnsiToRgb(
207
+ theme,
208
+ THINKING_COLORS[level as ThinkingLevel],
209
+ ) ?? {
210
+ r: 140,
211
+ g: 140,
212
+ b: 140,
213
+ };
214
+ const t = ((LEVEL_INDEX[level as ThinkingLevel] ?? 0) / 6) * GLOW_FACTOR;
215
+ const r = Math.round(base.r + (255 - base.r) * t);
216
+ const g = Math.round(base.g + (255 - base.g) * t);
217
+ const b = Math.round(base.b + (255 - base.b) * t);
218
+ const ansi =
219
+ theme.getColorMode() === "truecolor"
220
+ ? `\x1b[38;2;${r};${g};${b}m`
221
+ : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
222
+ return (s: string) => `${ansi}${s}\x1b[39m`;
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Label
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /** Format a context window size in tokens as a compact string (e.g. 128k, 32.8k, 2m). */
230
+ function formatContextWindow(tokens: number): string {
231
+ if (!Number.isFinite(tokens) || tokens <= 0) return "";
232
+ if (tokens >= 1_000_000) {
233
+ const m = tokens / 1_000_000;
234
+ return `${Number.isInteger(m) ? m : m.toFixed(1)}m`;
235
+ }
236
+ const k = tokens / 1000;
237
+ return `${Number.isInteger(k) ? k : k.toFixed(1)}k`;
238
+ }
239
+
240
+ function buildLabel(
241
+ theme: ThemeLike,
242
+ provider: string,
243
+ modelId: string,
244
+ level: string,
245
+ contextWindow: number,
246
+ ): string {
247
+ const providerPart = provider !== "" ? theme.fg("dim", `${provider}/`) : "";
248
+ const modelPart = theme.fg(
249
+ "accent",
250
+ theme.bold(modelId !== "" ? modelId : "unknown"),
251
+ );
252
+ const levelPart = theme.getThinkingBorderColor(level)(level);
253
+ const ctxText =
254
+ formatContextWindow(contextWindow) !== ""
255
+ ? `${theme.fg("dim", " · ")}${theme.fg("muted", formatContextWindow(contextWindow))}`
256
+ : "";
257
+ return `${providerPart}${modelPart}${theme.fg("dim", " · ")}${levelPart}${ctxText}`;
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Border detection & label embedding
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /** Plain border line: entirely ─. */
265
+ function isPlainBorder(line: string): boolean {
266
+ return /^─+$/.test(stripAnsi(line));
267
+ }
268
+
269
+ /** Scroll-indicator border: `─── ↑ 5 more ─────…` (created by pi's Editor). */
270
+ function isScrollBorder(line: string): boolean {
271
+ return /^─── [↑↓] \d+ more/.test(stripAnsi(line));
272
+ }
273
+
274
+ /** Replace the left part of a plain top border with the label. */
275
+ function embedLabel(
276
+ width: number,
277
+ label: string,
278
+ glow: (s: string) => string,
279
+ ): string {
280
+ // Padding collapses gracefully on very narrow terminals.
281
+ const padCount = Math.min(
282
+ LABEL_PAD,
283
+ Math.max(0, Math.floor((width - 1) / 2)),
284
+ );
285
+ const padding = padCount * 2;
286
+ // Reserve at least one border char on each side.
287
+ const labelText = truncateToWidth(
288
+ label,
289
+ Math.max(0, width - 2 - padding),
290
+ "",
291
+ );
292
+ const lw = visibleWidth(labelText);
293
+ const leftWidth = Math.max(0, Math.min(2, width - lw - padding));
294
+ const rightWidth = Math.max(0, width - lw - leftWidth - padding);
295
+ const pad = " ".repeat(padCount);
296
+ return (
297
+ glow("─".repeat(leftWidth)) +
298
+ pad +
299
+ labelText +
300
+ pad +
301
+ glow("─".repeat(rightWidth))
302
+ );
303
+ }
304
+
305
+ /**
306
+ * Apply the glow + embedded model label to the editor's rendered lines
307
+ * (same transformation model-info-widget's ModelInfoEditor.render applied).
308
+ */
309
+ export function applyModelInfo(
310
+ lines: string[],
311
+ width: number,
312
+ theme: ThemeLike,
313
+ info: ModelInfo,
314
+ ): string[] {
315
+ const glow = buildGlow(theme, info.level);
316
+ const label = buildLabel(
317
+ theme,
318
+ info.provider,
319
+ info.modelId,
320
+ info.level,
321
+ info.contextWindow,
322
+ );
323
+
324
+ // Top border (always lines[0]): embed the label into plain borders,
325
+ // keep scroll indicators but recolor them.
326
+ const top = lines[0];
327
+ lines[0] = isScrollBorder(top)
328
+ ? glow(stripAnsi(top))
329
+ : embedLabel(width, label, glow);
330
+
331
+ // Bottom border: the last border-like line (autocomplete lines may follow).
332
+ for (let i = lines.length - 1; i >= 0; i--) {
333
+ const line = lines[i];
334
+ if (isPlainBorder(line) || isScrollBorder(line)) {
335
+ lines[i] = glow(stripAnsi(line));
336
+ break;
337
+ }
338
+ }
339
+
340
+ return lines;
341
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const VERSION_TIMEOUT_MS = 2500;
8
+
9
+ export interface RuntimeInfo {
10
+ name: string;
11
+ version?: string;
12
+ }
13
+
14
+ interface RuntimeDef {
15
+ name: string;
16
+ files: readonly string[];
17
+ folders?: readonly string[];
18
+ extensions?: readonly string[];
19
+ env?: string;
20
+ versionCommand?: { cmd: string; args?: string[]; pattern?: RegExp };
21
+ }
22
+
23
+ const RUNTIMES: readonly RuntimeDef[] = [
24
+ {
25
+ name: "nodejs",
26
+ files: ["package.json", ".nvmrc", ".node-version"],
27
+ versionCommand: {
28
+ cmd: "node",
29
+ args: ["--version"],
30
+ pattern: /v(\d+\.\d+\.\d+)/,
31
+ },
32
+ },
33
+ {
34
+ name: "rust",
35
+ files: ["Cargo.toml"],
36
+ versionCommand: {
37
+ cmd: "rustc",
38
+ args: ["--version"],
39
+ pattern: /rustc\s+(\d+\.\d+\.\d+)/,
40
+ },
41
+ },
42
+ {
43
+ name: "go",
44
+ files: ["go.mod"],
45
+ versionCommand: {
46
+ cmd: "go",
47
+ args: ["version"],
48
+ pattern: /go(\d+\.\d+\.\d+)/,
49
+ },
50
+ },
51
+ {
52
+ name: "python",
53
+ files: [
54
+ "pyproject.toml",
55
+ "requirements.txt",
56
+ "setup.py",
57
+ "Pipfile",
58
+ ".python-version",
59
+ ],
60
+ versionCommand: {
61
+ cmd: "python3",
62
+ args: ["--version"],
63
+ pattern: /Python\s+(\d+\.\d+\.\d+)/,
64
+ },
65
+ },
66
+ {
67
+ name: "ruby",
68
+ files: ["Gemfile", ".ruby-version"],
69
+ versionCommand: {
70
+ cmd: "ruby",
71
+ args: ["--version"],
72
+ pattern: /ruby\s+(\d+\.\d+\.\d+)/,
73
+ },
74
+ },
75
+ {
76
+ name: "java",
77
+ files: ["pom.xml", "build.gradle", "build.gradle.kts", ".java-version"],
78
+ versionCommand: {
79
+ cmd: "java",
80
+ args: ["-version"],
81
+ pattern: /version\s+"(\d+\.\d+[.\d]*)"/,
82
+ },
83
+ },
84
+ {
85
+ name: "swift",
86
+ files: ["Package.swift"],
87
+ versionCommand: {
88
+ cmd: "swift",
89
+ args: ["--version"],
90
+ pattern: /Swift\s+(\d+\.\d+)/,
91
+ },
92
+ },
93
+ { name: "kotlin", files: ["build.gradle.kts", "settings.gradle.kts"] },
94
+ { name: "cpp", files: ["CMakeLists.txt", "Makefile"] },
95
+ { name: "c", files: ["Makefile", "CMakeLists.txt"] },
96
+ {
97
+ name: "deno",
98
+ files: ["deno.json", "deno.jsonc", "deno.lock"],
99
+ versionCommand: {
100
+ cmd: "deno",
101
+ args: ["--version"],
102
+ pattern: /deno\s+(\d+\.\d+\.\d+)/,
103
+ },
104
+ },
105
+ {
106
+ name: "bun",
107
+ files: ["bun.lock", "bun.lockb"],
108
+ versionCommand: {
109
+ cmd: "bun",
110
+ args: ["--version"],
111
+ pattern: /(\d+\.\d+\.\d+)/,
112
+ },
113
+ },
114
+ {
115
+ name: "php",
116
+ files: ["composer.json"],
117
+ versionCommand: {
118
+ cmd: "php",
119
+ args: ["--version"],
120
+ pattern: /PHP\s+(\d+\.\d+\.\d+)/,
121
+ },
122
+ },
123
+ {
124
+ name: "haskell",
125
+ files: ["stack.yaml", "cabal.project"],
126
+ versionCommand: {
127
+ cmd: "ghc",
128
+ args: ["--version"],
129
+ pattern: /(\d+\.\d+\.\d+)/,
130
+ },
131
+ },
132
+ {
133
+ name: "julia",
134
+ files: ["Project.toml", "Manifest.toml"],
135
+ versionCommand: {
136
+ cmd: "julia",
137
+ args: ["--version"],
138
+ pattern: /julia\s+(\d+\.\d+\.\d+)/,
139
+ },
140
+ },
141
+ {
142
+ name: "lua",
143
+ files: ["stylua.toml", ".luarc.json"],
144
+ versionCommand: { cmd: "lua", args: ["-v"], pattern: /Lua\s+(\d+\.\d+)/ },
145
+ },
146
+ {
147
+ name: "elixir",
148
+ files: ["mix.exs"],
149
+ versionCommand: {
150
+ cmd: "elixir",
151
+ args: ["--version"],
152
+ pattern: /Elixir\s+(\d+\.\d+\.\d+)/,
153
+ },
154
+ },
155
+ { name: "erlang", files: ["rebar.config", "erlang.mk"] },
156
+ {
157
+ name: "gleam",
158
+ files: ["gleam.toml"],
159
+ versionCommand: {
160
+ cmd: "gleam",
161
+ args: ["--version"],
162
+ pattern: /gleam\s+(\d+\.\d+\.\d+)/,
163
+ },
164
+ },
165
+ {
166
+ name: "crystal",
167
+ files: ["shard.yml"],
168
+ versionCommand: {
169
+ cmd: "crystal",
170
+ args: ["--version"],
171
+ pattern: /Crystal\s+(\d+\.\d+\.\d+)/,
172
+ },
173
+ },
174
+ {
175
+ name: "dart",
176
+ files: ["pubspec.yaml"],
177
+ versionCommand: {
178
+ cmd: "dart",
179
+ args: ["--version"],
180
+ pattern: /Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/,
181
+ },
182
+ },
183
+ { name: "nim", files: ["nim.cfg", ".nimble"] },
184
+ {
185
+ name: "zig",
186
+ files: ["build.zig"],
187
+ versionCommand: {
188
+ cmd: "zig",
189
+ args: ["version"],
190
+ pattern: /(\d+\.\d+\.\d+)/,
191
+ },
192
+ },
193
+ { name: "ocaml", files: [".opam", "dune", "dune-project"] },
194
+ { name: "clojure", files: ["project.clj", "deps.edn"] },
195
+ { name: "scala", files: ["build.sbt"] },
196
+ { name: "perl", files: ["Makefile.PL", "cpanfile"] },
197
+ { name: "r", files: [".Rproj", "DESCRIPTION"] },
198
+ { name: "elm", files: ["elm.json"] },
199
+ { name: "haxe", files: ["haxelib.json"] },
200
+ { name: "vagrant", files: ["Vagrantfile"] },
201
+ {
202
+ name: "terraform",
203
+ files: ["main.tf", "variables.tf"],
204
+ folders: [".terraform"],
205
+ },
206
+ { name: "helm", files: ["Chart.yaml"] },
207
+ { name: "solidity", files: [], extensions: [".sol"] },
208
+ { name: "cobol", files: [], extensions: [".cbl", ".cob"] },
209
+ ];
210
+
211
+ interface CacheEntry {
212
+ fingerprint: string;
213
+ runtime: RuntimeInfo | null;
214
+ }
215
+
216
+ const cache = new Map<string, CacheEntry>();
217
+ const CACHE_MAX = 32;
218
+
219
+ function fingerprint(cwd: string, def: RuntimeDef): string {
220
+ const parts: string[] = [];
221
+ for (const f of def.files) {
222
+ try {
223
+ const stat = statSync(join(cwd, f));
224
+ parts.push(`${f}:${stat.mtimeMs}`);
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+ }
229
+ if (def.extensions || def.folders) {
230
+ try {
231
+ const entries = readdirSync(cwd);
232
+ parts.push(...entries.slice().sort());
233
+ } catch {
234
+ /* ignore */
235
+ }
236
+ }
237
+ if (def.env && process.env[def.env]) {
238
+ parts.push(`${def.env}=${process.env[def.env]}`);
239
+ }
240
+ return parts.join("\0");
241
+ }
242
+
243
+ function matchesDef(cwd: string, def: RuntimeDef): boolean {
244
+ if (def.env && process.env[def.env]) return true;
245
+ if (def.files.some((f) => existsSync(join(cwd, f)))) return true;
246
+ if (def.folders?.some((f) => existsSync(join(cwd, f)))) return true;
247
+ if (def.extensions) {
248
+ try {
249
+ const entries = readdirSync(cwd);
250
+ if (entries.some((e) => def.extensions!.some((ext) => e.endsWith(ext))))
251
+ return true;
252
+ } catch {
253
+ /* ignore */
254
+ }
255
+ }
256
+ return false;
257
+ }
258
+
259
+ async function fetchVersion(
260
+ def: RuntimeDef,
261
+ cwd: string,
262
+ ): Promise<string | undefined> {
263
+ if (!def.versionCommand) return undefined;
264
+ try {
265
+ const { stdout } = await execFileAsync(
266
+ def.versionCommand.cmd,
267
+ def.versionCommand.args ?? [],
268
+ {
269
+ cwd,
270
+ timeout: VERSION_TIMEOUT_MS,
271
+ maxBuffer: 64 * 1024,
272
+ },
273
+ );
274
+ if (def.versionCommand.pattern) {
275
+ const match = stdout.match(def.versionCommand.pattern);
276
+ return match?.[1];
277
+ }
278
+ return stdout.trim() || undefined;
279
+ } catch {
280
+ return undefined;
281
+ }
282
+ }
283
+
284
+ export async function readRuntimeInfo(
285
+ cwd: string,
286
+ ): Promise<RuntimeInfo | null> {
287
+ for (const def of RUNTIMES) {
288
+ if (!matchesDef(cwd, def)) continue;
289
+ const fp = fingerprint(cwd, def);
290
+ const cacheKey = `${cwd}\0${def.name}`;
291
+ const cached = cache.get(cacheKey);
292
+ if (cached && cached.fingerprint === fp) {
293
+ return cached.runtime;
294
+ }
295
+
296
+ for (const key of cache.keys()) {
297
+ if (key === cacheKey || key.startsWith(`${cwd}\0`)) cache.delete(key);
298
+ }
299
+
300
+ const version = await fetchVersion(def, cwd);
301
+ const info: RuntimeInfo = {
302
+ name: def.name,
303
+ version,
304
+ };
305
+ cache.set(cacheKey, { fingerprint: fp, runtime: info });
306
+ while (cache.size > CACHE_MAX) {
307
+ const oldest = cache.keys().next().value;
308
+ if (oldest === undefined) break;
309
+ cache.delete(oldest);
310
+ }
311
+ return info;
312
+ }
313
+ return null;
314
+ }
315
+
316
+ export function clearRuntimeCache(): void {
317
+ cache.clear();
318
+ }