@xynogen/pix-core 0.2.4 → 0.3.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.
- package/README.md +24 -21
- package/package.json +11 -17
- package/skills/ask-user/SKILL.md +0 -48
- package/src/commands/agent-sop/agent-sop.ts +0 -58
- package/src/commands/clear/clear.ts +0 -32
- package/src/commands/diff/diff.ts +0 -32
- package/src/commands/models/models.test.ts +0 -95
- package/src/commands/models/models.ts +0 -367
- package/src/commands/models/patch-builtin.test.ts +0 -66
- package/src/commands/models/patch-builtin.ts +0 -120
- package/src/commands/tools.test.ts +0 -15
- package/src/commands/update/update.test.ts +0 -112
- package/src/commands/update/update.ts +0 -271
- package/src/index.ts +0 -45
- package/src/lib/data.ts +0 -33
- package/src/nudge/capability.test.ts +0 -258
- package/src/nudge/capability.ts +0 -189
- package/src/nudge/index.ts +0 -17
- package/src/nudge/tools.test.ts +0 -157
- package/src/nudge/tools.ts +0 -212
- package/src/tool/ask/ask.test.ts +0 -243
- package/src/tool/ask/components.ts +0 -55
- package/src/tool/ask/helpers.ts +0 -77
- package/src/tool/ask/index.ts +0 -130
- package/src/tool/ask/questionnaire.ts +0 -693
- package/src/tool/ask/rpc.ts +0 -84
- package/src/tool/ask/schema.ts +0 -69
- package/src/tool/ask/single-select-layout.test.ts +0 -124
- package/src/tool/ask/single-select-layout.ts +0 -237
- package/src/tool/ask/types.ts +0 -17
- package/src/tool/todo/todo.test.ts +0 -646
- package/src/tool/todo/todo.ts +0 -218
- package/src/tool/toolbox/toolbox.test.ts +0 -314
- package/src/tool/toolbox/toolbox.ts +0 -570
- package/src/ui/diagnostics.ts +0 -145
- package/src/ui/footer.ts +0 -513
- package/src/ui/welcome.test.ts +0 -124
- package/src/ui/welcome.ts +0 -369
|
@@ -1,367 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* models.ts — enhanced /models command with benchlm rank + score
|
|
3
|
-
*
|
|
4
|
-
* Replaces (or supplements) the built-in /model selector by registering
|
|
5
|
-
* /models (plural). Each row shows:
|
|
6
|
-
* <name> <provider> · <ctx> · <cost> · 🏅 #rank score
|
|
7
|
-
*
|
|
8
|
-
* Sorted by benchlm rank when available (best first), then alphabetical.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type {
|
|
12
|
-
ExtensionAPI,
|
|
13
|
-
ExtensionContext,
|
|
14
|
-
} from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import {
|
|
17
|
-
Container,
|
|
18
|
-
fuzzyFilter,
|
|
19
|
-
Input,
|
|
20
|
-
matchesKey,
|
|
21
|
-
type SelectItem,
|
|
22
|
-
SelectList,
|
|
23
|
-
Text,
|
|
24
|
-
visibleWidth,
|
|
25
|
-
} from "@earendil-works/pi-tui";
|
|
26
|
-
import { lookupBenchmark, lookupModelsDev } from "../../lib/data";
|
|
27
|
-
import { patchOutBuiltinModelCommand } from "./patch-builtin";
|
|
28
|
-
|
|
29
|
-
// ─── Pure logic (exported for tests) ─────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
export function fmtCtx(n: number): string {
|
|
32
|
-
if (!n || n < 1_000) return `${n}`;
|
|
33
|
-
if (n >= 1_000_000) {
|
|
34
|
-
const m = n / 1_000_000;
|
|
35
|
-
return `${Number.isInteger(m) ? m.toFixed(0) : m.toFixed(1).replace(/\.0$/, "")}M`;
|
|
36
|
-
}
|
|
37
|
-
return `${Math.round(n / 1_000)}k`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function fmtCost(
|
|
41
|
-
entry: { cost?: { input?: number; output?: number } } | undefined,
|
|
42
|
-
): string {
|
|
43
|
-
if (!entry?.cost) return "\u2014";
|
|
44
|
-
const i = entry.cost.input ?? 0;
|
|
45
|
-
const o = entry.cost.output ?? 0;
|
|
46
|
-
if (i === 0 && o === 0) return "free";
|
|
47
|
-
return `${i.toFixed(2)}/${o.toFixed(2)}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function benchStars(score: number | null | undefined): {
|
|
51
|
-
filled: number;
|
|
52
|
-
empty: number;
|
|
53
|
-
} {
|
|
54
|
-
const total = 5;
|
|
55
|
-
let filled = 1;
|
|
56
|
-
if (typeof score === "number") {
|
|
57
|
-
if (score >= 90) filled = 5;
|
|
58
|
-
else if (score >= 80) filled = 4;
|
|
59
|
-
else if (score >= 70) filled = 3;
|
|
60
|
-
else if (score >= 50) filled = 2;
|
|
61
|
-
}
|
|
62
|
-
return { filled, empty: total - filled };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export type SortableModel = {
|
|
66
|
-
provider: string;
|
|
67
|
-
id: string;
|
|
68
|
-
name?: string;
|
|
69
|
-
score?: number | null;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
export function sortModels<T extends SortableModel>(models: T[]): T[] {
|
|
73
|
-
return [...models].sort((a, b) => {
|
|
74
|
-
const sa = a.score ?? -1;
|
|
75
|
-
const sb = b.score ?? -1;
|
|
76
|
-
if (sa !== sb) return sb - sa;
|
|
77
|
-
return (a.name ?? a.id).localeCompare(b.name ?? b.id);
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export async function showEnhancedPicker(
|
|
82
|
-
pi: ExtensionAPI,
|
|
83
|
-
ctx: ExtensionContext,
|
|
84
|
-
): Promise<void> {
|
|
85
|
-
// Mirror the built-in /model selector, which calls refresh() then awaits
|
|
86
|
-
// getAvailable() (see model-selector.js). Without refresh(), this extension
|
|
87
|
-
// reads whatever `this.models` was last loaded into — which, depending on
|
|
88
|
-
// extension load order vs oauth/auth resolution, can omit oauth providers
|
|
89
|
-
// whose models were registered as built-ins but resolved after the last
|
|
90
|
-
// load (notably `openai-codex`). 9router survives because it's registered
|
|
91
|
-
// as a custom provider with an env-key apiKey. refresh() rebuilds the model
|
|
92
|
-
// list (resetOAuthProviders → loadModels → re-apply registered providers)
|
|
93
|
-
// so oauth-backed codex models reappear, exactly as the built-in does.
|
|
94
|
-
//
|
|
95
|
-
// The public ExtensionContext type narrows modelRegistry to a sync
|
|
96
|
-
// getAvailable() only; at runtime ctx.modelRegistry is the full
|
|
97
|
-
// ModelRegistry instance (verified in runner.js) with refresh() and an
|
|
98
|
-
// async-capable getAvailable(). Reach through the narrowed type.
|
|
99
|
-
type AvailableModels = ReturnType<typeof ctx.modelRegistry.getAvailable>;
|
|
100
|
-
const registry = ctx.modelRegistry as unknown as {
|
|
101
|
-
refresh?: () => void;
|
|
102
|
-
getAvailable(): AvailableModels | Promise<AvailableModels>;
|
|
103
|
-
};
|
|
104
|
-
registry.refresh?.();
|
|
105
|
-
const available = await registry.getAvailable();
|
|
106
|
-
if (available.length === 0) {
|
|
107
|
-
ctx.ui.notify("No models with configured auth.", "warning");
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const current = ctx.model;
|
|
112
|
-
|
|
113
|
-
// Build items with benchmark data
|
|
114
|
-
type Row = {
|
|
115
|
-
m: (typeof available)[number];
|
|
116
|
-
dev: ReturnType<typeof lookupModelsDev>;
|
|
117
|
-
bench: ReturnType<typeof lookupBenchmark>;
|
|
118
|
-
};
|
|
119
|
-
const rows: Row[] = available.map((m) => ({
|
|
120
|
-
m,
|
|
121
|
-
dev: lookupModelsDev(m.provider, m.id),
|
|
122
|
-
bench: lookupBenchmark(m.name ?? m.id),
|
|
123
|
-
}));
|
|
124
|
-
|
|
125
|
-
// Sort: by score desc (highest first), unscored last alphabetical
|
|
126
|
-
rows.sort((a, b) => {
|
|
127
|
-
const sa = a.bench?.overallScore ?? -1;
|
|
128
|
-
const sb = b.bench?.overallScore ?? -1;
|
|
129
|
-
if (sa !== sb) return sb - sa;
|
|
130
|
-
return (a.m.name ?? a.m.id).localeCompare(b.m.name ?? b.m.id);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Show all models (no deduplication)
|
|
134
|
-
const dedupedRows = rows;
|
|
135
|
-
|
|
136
|
-
// items built inside the custom() factory so we have theme access for colors
|
|
137
|
-
|
|
138
|
-
const result = await ctx.ui.custom<string | null>(
|
|
139
|
-
(_tui, theme, _kb, done) => {
|
|
140
|
-
const container = new Container();
|
|
141
|
-
const accent = "accent";
|
|
142
|
-
|
|
143
|
-
// Find max rank width across all benchmarked rows for # padding
|
|
144
|
-
const maxRankWidth = Math.max(
|
|
145
|
-
...dedupedRows.map((r) => (r.bench ? String(r.bench.rank).length : 0)),
|
|
146
|
-
1,
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
// Mute low-info parts (separators, padding, #, ☆) so the actual values pop.
|
|
150
|
-
const mute = (s: string) => theme.fg("muted", s);
|
|
151
|
-
const sep = mute(" · ");
|
|
152
|
-
|
|
153
|
-
// Track rank per item value so fuzzy results can prioritize ranked models.
|
|
154
|
-
const rankByValue = new Map<string, number>();
|
|
155
|
-
for (const { m, bench } of dedupedRows) {
|
|
156
|
-
if (bench) rankByValue.set(`${m.provider}/${m.id}`, bench.rank);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const items: SelectItem[] = dedupedRows.map(({ m, dev, bench }) => {
|
|
160
|
-
const isCurrent =
|
|
161
|
-
current && m.provider === current.provider && m.id === current.id;
|
|
162
|
-
|
|
163
|
-
// Label: marker + muted '#' + bright rank + accent-colored model name
|
|
164
|
-
const marker = isCurrent ? theme.fg(accent, "▶") : " ";
|
|
165
|
-
let rankPrefix: string;
|
|
166
|
-
if (bench) {
|
|
167
|
-
const rankStr = String(bench.rank).padEnd(maxRankWidth);
|
|
168
|
-
rankPrefix = mute("#") + theme.fg("warning", rankStr);
|
|
169
|
-
} else {
|
|
170
|
-
rankPrefix = " ".repeat(maxRankWidth + 1);
|
|
171
|
-
}
|
|
172
|
-
// Display model id only; m.provider is routing provider, not part of id.
|
|
173
|
-
const idColored = theme.fg(accent, m.id);
|
|
174
|
-
const label = `${marker} ${rankPrefix} ${idColored}`;
|
|
175
|
-
|
|
176
|
-
// Description: ctx · cost · score stars
|
|
177
|
-
// Colors: ctx muted · cost success (free muted) · score+stars warning
|
|
178
|
-
const ctxRaw = fmtCtx(dev?.limit?.context ?? 0);
|
|
179
|
-
const ctxStr = mute(ctxRaw.padStart(4));
|
|
180
|
-
const rawCost = fmtCost(dev);
|
|
181
|
-
let costSeg: string;
|
|
182
|
-
if (rawCost === "—") {
|
|
183
|
-
costSeg = theme.fg("dim", "—".padEnd(10));
|
|
184
|
-
} else if (rawCost === "free") {
|
|
185
|
-
costSeg = mute("free".padEnd(10));
|
|
186
|
-
} else {
|
|
187
|
-
costSeg = theme.fg("success", rawCost.padEnd(10));
|
|
188
|
-
}
|
|
189
|
-
let benchSeg = "";
|
|
190
|
-
if (bench) {
|
|
191
|
-
const score = bench.overallScore ?? "?";
|
|
192
|
-
const s = bench.overallScore;
|
|
193
|
-
let filled = 1;
|
|
194
|
-
if (typeof s === "number") {
|
|
195
|
-
if (s >= 90) filled = 5;
|
|
196
|
-
else if (s >= 80) filled = 4;
|
|
197
|
-
else if (s >= 70) filled = 3;
|
|
198
|
-
else if (s >= 50) filled = 2;
|
|
199
|
-
}
|
|
200
|
-
const starBar =
|
|
201
|
-
theme.fg("warning", "★".repeat(filled)) +
|
|
202
|
-
mute("☆".repeat(5 - filled));
|
|
203
|
-
benchSeg = `⚡${theme.fg("warning", String(score))} ${starBar}`;
|
|
204
|
-
}
|
|
205
|
-
const desc = [ctxStr, costSeg, benchSeg].filter(Boolean).join(sep);
|
|
206
|
-
|
|
207
|
-
return {
|
|
208
|
-
value: `${m.provider}/${m.id}`,
|
|
209
|
-
label,
|
|
210
|
-
description: desc,
|
|
211
|
-
};
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
const currentIdx = current
|
|
215
|
-
? items.findIndex(
|
|
216
|
-
(it) => it.value === `${current.provider}/${current.id}`,
|
|
217
|
-
)
|
|
218
|
-
: 0;
|
|
219
|
-
|
|
220
|
-
container.addChild(new DynamicBorder((s) => theme.fg(accent, s)));
|
|
221
|
-
container.addChild(
|
|
222
|
-
new Text(theme.fg(accent, theme.bold(" Select model"))),
|
|
223
|
-
);
|
|
224
|
-
container.addChild(
|
|
225
|
-
new Text(
|
|
226
|
-
theme.fg(
|
|
227
|
-
"dim",
|
|
228
|
-
"context & pricing from models.dev · ranks from benchlm.ai",
|
|
229
|
-
),
|
|
230
|
-
),
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
// Widest label (visible width, ANSI-stripped) so the model name
|
|
234
|
-
// column never truncates to "…". Add gap headroom.
|
|
235
|
-
const widestLabel = items.reduce(
|
|
236
|
-
(w, it) => Math.max(w, visibleWidth(it.label)),
|
|
237
|
-
0,
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
const search = new Input();
|
|
241
|
-
const list = new SelectList(
|
|
242
|
-
items,
|
|
243
|
-
Math.min(items.length, 14),
|
|
244
|
-
{
|
|
245
|
-
selectedPrefix: (t) => theme.fg(accent, t),
|
|
246
|
-
selectedText: (t) => theme.fg(accent, t),
|
|
247
|
-
description: (t) => t, // raw — per-segment colors set in items.map
|
|
248
|
-
scrollInfo: (t) => theme.fg("dim", t),
|
|
249
|
-
noMatch: (t) => theme.fg("warning", t),
|
|
250
|
-
},
|
|
251
|
-
{
|
|
252
|
-
minPrimaryColumnWidth: widestLabel + 2,
|
|
253
|
-
maxPrimaryColumnWidth: widestLabel + 2,
|
|
254
|
-
},
|
|
255
|
-
);
|
|
256
|
-
if (currentIdx >= 0) list.setSelectedIndex(currentIdx);
|
|
257
|
-
|
|
258
|
-
list.onSelect = (item) => done(item.value);
|
|
259
|
-
list.onCancel = () => done(null);
|
|
260
|
-
search.onEscape = () => done(null);
|
|
261
|
-
|
|
262
|
-
const applyFuzzy = (query: string) => {
|
|
263
|
-
const internal = list as unknown as {
|
|
264
|
-
items: SelectItem[];
|
|
265
|
-
filteredItems: SelectItem[];
|
|
266
|
-
selectedIndex: number;
|
|
267
|
-
invalidate(): void;
|
|
268
|
-
};
|
|
269
|
-
const q = query.trim();
|
|
270
|
-
let next: SelectItem[];
|
|
271
|
-
if (q.length === 0) {
|
|
272
|
-
next = internal.items;
|
|
273
|
-
} else if (/^\d+$/.test(q)) {
|
|
274
|
-
// Pure number → match by benchlm rank, not name.
|
|
275
|
-
const wanted = Number(q);
|
|
276
|
-
next = internal.items.filter(
|
|
277
|
-
(it) => rankByValue.get(it.value) === wanted,
|
|
278
|
-
);
|
|
279
|
-
} else {
|
|
280
|
-
next = fuzzyFilter(
|
|
281
|
-
internal.items,
|
|
282
|
-
q,
|
|
283
|
-
(it) => `${it.label} ${it.description ?? ""}`,
|
|
284
|
-
);
|
|
285
|
-
// Stable sort: ranked models (by rank asc) before unranked.
|
|
286
|
-
next = next
|
|
287
|
-
.map((it, i) => ({ it, i }))
|
|
288
|
-
.sort((a, b) => {
|
|
289
|
-
const ra = rankByValue.get(a.it.value) ?? Infinity;
|
|
290
|
-
const rb = rankByValue.get(b.it.value) ?? Infinity;
|
|
291
|
-
if (ra !== rb) return ra - rb;
|
|
292
|
-
return a.i - b.i;
|
|
293
|
-
})
|
|
294
|
-
.map(({ it }) => it);
|
|
295
|
-
}
|
|
296
|
-
internal.filteredItems = next;
|
|
297
|
-
internal.selectedIndex = 0;
|
|
298
|
-
internal.invalidate();
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
container.addChild(new Text(theme.fg("muted", "Search:")));
|
|
302
|
-
container.addChild(search);
|
|
303
|
-
container.addChild(list);
|
|
304
|
-
container.addChild(
|
|
305
|
-
new Text(
|
|
306
|
-
theme.fg(
|
|
307
|
-
"dim",
|
|
308
|
-
"fuzzy search · ↑↓ navigate · enter select · esc cancel",
|
|
309
|
-
),
|
|
310
|
-
),
|
|
311
|
-
);
|
|
312
|
-
container.addChild(new DynamicBorder((s) => theme.fg(accent, s)));
|
|
313
|
-
|
|
314
|
-
return {
|
|
315
|
-
render(w: number) {
|
|
316
|
-
return container.render(w);
|
|
317
|
-
},
|
|
318
|
-
invalidate() {
|
|
319
|
-
container.invalidate();
|
|
320
|
-
},
|
|
321
|
-
handleInput(data: string) {
|
|
322
|
-
// Detect keys via pi-tui's own parser — the same recognition
|
|
323
|
-
// SelectList uses. Arrows arrive as named keys ("up"/"down"),
|
|
324
|
-
// not raw escape sequences, so string-equality checks fail.
|
|
325
|
-
const isNav = matchesKey(data, "up") || matchesKey(data, "down");
|
|
326
|
-
if (isNav || matchesKey(data, "enter")) {
|
|
327
|
-
list.handleInput?.(data);
|
|
328
|
-
} else if (matchesKey(data, "escape")) {
|
|
329
|
-
done(null);
|
|
330
|
-
} else {
|
|
331
|
-
search.handleInput?.(data);
|
|
332
|
-
applyFuzzy(search.getValue?.() ?? "");
|
|
333
|
-
}
|
|
334
|
-
container.invalidate();
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
},
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
if (!result) return;
|
|
341
|
-
|
|
342
|
-
// Apply selection
|
|
343
|
-
const [provider, ...rest] = result.split("/");
|
|
344
|
-
const id = rest.join("/");
|
|
345
|
-
const picked = available.find((m) => m.provider === provider && m.id === id);
|
|
346
|
-
if (!picked) {
|
|
347
|
-
ctx.ui.notify(`Model not found: ${result}`, "error");
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
const ok = await pi.setModel(picked);
|
|
351
|
-
if (ok) ctx.ui.notify(`Switched to ${picked.name ?? picked.id}`, "info");
|
|
352
|
-
else ctx.ui.notify(`Failed to switch to ${picked.id}`, "error");
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
export default function modelPickerExtension(pi: ExtensionAPI) {
|
|
356
|
-
// Remove Pi's built-in /model so only the enhanced /models picker remains.
|
|
357
|
-
// Self-healing: re-applies on every load, so a Pi upgrade can't restore it.
|
|
358
|
-
patchOutBuiltinModelCommand();
|
|
359
|
-
|
|
360
|
-
const handler = async (_args: unknown, ctx: ExtensionContext) => {
|
|
361
|
-
await showEnhancedPicker(pi, ctx);
|
|
362
|
-
};
|
|
363
|
-
pi.registerCommand("models", {
|
|
364
|
-
description: "Enhanced model picker — shows benchlm rank + score",
|
|
365
|
-
handler,
|
|
366
|
-
});
|
|
367
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
// Pure replacement tested in isolation (the exported fn resolves the host
|
|
7
|
-
// package, which isn't present in the test sandbox).
|
|
8
|
-
const MODEL_COMMAND_LINE =
|
|
9
|
-
'{ name: "model", description: "Select model (opens selector UI)" },';
|
|
10
|
-
|
|
11
|
-
function escapeRegExp(text: string): string {
|
|
12
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function patchSource(source: string): string {
|
|
16
|
-
if (!source.includes(MODEL_COMMAND_LINE)) return source;
|
|
17
|
-
return source.replace(
|
|
18
|
-
new RegExp(`[ \\t]*${escapeRegExp(MODEL_COMMAND_LINE)}\\n?`),
|
|
19
|
-
"",
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const UNPATCHED = `export const BUILTIN_SLASH_COMMANDS = [
|
|
24
|
-
{ name: "settings", description: "Open settings menu" },
|
|
25
|
-
{ name: "model", description: "Select model (opens selector UI)" },
|
|
26
|
-
{ name: "login", description: "Configure provider authentication" },
|
|
27
|
-
];
|
|
28
|
-
`;
|
|
29
|
-
|
|
30
|
-
describe("patch-builtin /model removal", () => {
|
|
31
|
-
it("removes the built-in /model line and keeps neighbors", () => {
|
|
32
|
-
const out = patchSource(UNPATCHED);
|
|
33
|
-
expect(out).not.toContain('name: "model"');
|
|
34
|
-
expect(out).toContain('name: "settings"');
|
|
35
|
-
expect(out).toContain('name: "login"');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("is idempotent — second pass is a no-op", () => {
|
|
39
|
-
const once = patchSource(UNPATCHED);
|
|
40
|
-
const twice = patchSource(once);
|
|
41
|
-
expect(twice).toBe(once);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("leaves an already-clean file untouched", () => {
|
|
45
|
-
const clean = `export const X = [\n { name: "login" },\n];\n`;
|
|
46
|
-
expect(patchSource(clean)).toBe(clean);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("does not strip the plural /models entry", () => {
|
|
50
|
-
const withPlural = `[
|
|
51
|
-
{ name: "models", description: "Enhanced picker" },
|
|
52
|
-
{ name: "model", description: "Select model (opens selector UI)" },
|
|
53
|
-
]`;
|
|
54
|
-
const out = patchSource(withPlural);
|
|
55
|
-
expect(out).toContain('name: "models"');
|
|
56
|
-
expect(out).not.toContain('{ name: "model", description');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("round-trips through disk", () => {
|
|
60
|
-
const dir = mkdtempSync(join(tmpdir(), "pix-patch-"));
|
|
61
|
-
const file = join(dir, "slash-commands.js");
|
|
62
|
-
writeFileSync(file, UNPATCHED, "utf8");
|
|
63
|
-
writeFileSync(file, patchSource(readFileSync(file, "utf8")), "utf8");
|
|
64
|
-
expect(readFileSync(file, "utf8")).not.toContain('name: "model"');
|
|
65
|
-
});
|
|
66
|
-
});
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* patch-builtin.ts — strip Pi's built-in /model slash command at load time.
|
|
3
|
-
*
|
|
4
|
-
* Built-in commands can't be removed via the extension API, so we edit Pi's
|
|
5
|
-
* compiled slash-commands.js directly. Done on every load: idempotent and
|
|
6
|
-
* self-healing across Pi upgrades, so no manual repatch is ever needed.
|
|
7
|
-
*
|
|
8
|
-
* Resolution strategy (in order):
|
|
9
|
-
* 1. Locate the `pi` binary via PATH → infer package root from its realpath.
|
|
10
|
-
* The binary is always at <pkg>/dist/cli.js so ../../ is the package root.
|
|
11
|
-
* 2. Probe well-known global install locations (bun, npm).
|
|
12
|
-
* 3. Fall back to createRequire against the extension's own node_modules
|
|
13
|
-
* (works when pi and the extension share the same install tree).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { execSync } from "node:child_process";
|
|
17
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
-
import { createRequire } from "node:module";
|
|
19
|
-
import { homedir } from "node:os";
|
|
20
|
-
import { dirname, join, resolve } from "node:path";
|
|
21
|
-
|
|
22
|
-
const MODEL_COMMAND_LINE =
|
|
23
|
-
'{ name: "model", description: "Select model (opens selector UI)" },';
|
|
24
|
-
|
|
25
|
-
/** Candidate slash-commands.js paths, most-specific first. */
|
|
26
|
-
function candidatePaths(): string[] {
|
|
27
|
-
const paths: string[] = [];
|
|
28
|
-
|
|
29
|
-
// 1. Resolve via the running `pi` binary → its realpath gives the dist dir.
|
|
30
|
-
try {
|
|
31
|
-
const piReal = execSync("realpath $(which pi)", {
|
|
32
|
-
encoding: "utf8",
|
|
33
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
34
|
-
}).trim();
|
|
35
|
-
if (piReal) {
|
|
36
|
-
// piReal = /.../pi-coding-agent/dist/cli.js → dist/ → ../dist/core/
|
|
37
|
-
const distCore = resolve(dirname(piReal), "core");
|
|
38
|
-
paths.push(join(distCore, "slash-commands.js"));
|
|
39
|
-
}
|
|
40
|
-
} catch {
|
|
41
|
-
// `pi` not on PATH or `which`/`realpath` unavailable — skip
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// 2. Well-known global install locations.
|
|
45
|
-
const home = homedir();
|
|
46
|
-
const globalRoots = [
|
|
47
|
-
join(home, ".bun", "install", "global", "node_modules"),
|
|
48
|
-
join(home, ".npm-global", "lib", "node_modules"),
|
|
49
|
-
"/usr/local/lib/node_modules",
|
|
50
|
-
"/usr/lib/node_modules",
|
|
51
|
-
];
|
|
52
|
-
for (const root of globalRoots) {
|
|
53
|
-
paths.push(
|
|
54
|
-
join(
|
|
55
|
-
root,
|
|
56
|
-
"@earendil-works",
|
|
57
|
-
"pi-coding-agent",
|
|
58
|
-
"dist",
|
|
59
|
-
"core",
|
|
60
|
-
"slash-commands.js",
|
|
61
|
-
),
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// 3. Fallback: createRequire from this file (works when extension is co-installed).
|
|
66
|
-
try {
|
|
67
|
-
const require = createRequire(import.meta.url);
|
|
68
|
-
const entry = require.resolve("@earendil-works/pi-coding-agent");
|
|
69
|
-
paths.push(resolve(dirname(entry), "core", "slash-commands.js"));
|
|
70
|
-
} catch {
|
|
71
|
-
// local resolution failed — skip
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return paths;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Locate the host's compiled slash-commands.js, or null if not found. */
|
|
78
|
-
function findSlashCommandsFile(): string | null {
|
|
79
|
-
for (const p of candidatePaths()) {
|
|
80
|
-
if (existsSync(p)) return p;
|
|
81
|
-
}
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Remove the built-in /model command line from Pi's slash-commands.js.
|
|
87
|
-
* Idempotent: returns silently if the file is missing or already patched.
|
|
88
|
-
*/
|
|
89
|
-
export function patchOutBuiltinModelCommand(): void {
|
|
90
|
-
const file = findSlashCommandsFile();
|
|
91
|
-
if (!file) return;
|
|
92
|
-
|
|
93
|
-
let source: string;
|
|
94
|
-
try {
|
|
95
|
-
source = readFileSync(file, "utf8");
|
|
96
|
-
} catch {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!source.includes(MODEL_COMMAND_LINE)) return; // already patched
|
|
101
|
-
|
|
102
|
-
const patched = source.replace(
|
|
103
|
-
new RegExp(`[ \\t]*${escapeRegExp(MODEL_COMMAND_LINE)}\\n?`),
|
|
104
|
-
"",
|
|
105
|
-
);
|
|
106
|
-
if (patched === source) return;
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
writeFileSync(file, patched, "utf8");
|
|
110
|
-
} catch {
|
|
111
|
-
// Read-only install — leave /model in place rather than crash.
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function escapeRegExp(text: string): string {
|
|
116
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Export for tests
|
|
120
|
-
export { candidatePaths, findSlashCommandsFile };
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Smoke tests for the command extensions merged from pix-tools.
|
|
3
|
-
* Each default export must be a registrable (pi) => void function.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { describe, expect, it } from "bun:test";
|
|
7
|
-
|
|
8
|
-
describe("merged pix-tools commands", () => {
|
|
9
|
-
for (const name of ["diff"]) {
|
|
10
|
-
it(`${name} exports a register function`, async () => {
|
|
11
|
-
const mod = await import(`./${name}/${name}.ts`);
|
|
12
|
-
expect(mod.default).toBeFunction();
|
|
13
|
-
});
|
|
14
|
-
}
|
|
15
|
-
});
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
commandFor,
|
|
4
|
-
formatUpdateSummary,
|
|
5
|
-
type InstallMethod,
|
|
6
|
-
isTransient,
|
|
7
|
-
PACKAGE_NAME,
|
|
8
|
-
} from "./update.ts";
|
|
9
|
-
|
|
10
|
-
describe("isTransient", () => {
|
|
11
|
-
it("matches network errors", () => {
|
|
12
|
-
expect(isTransient("ETIMEDOUT")).toBe(true);
|
|
13
|
-
expect(isTransient("ECONNRESET")).toBe(true);
|
|
14
|
-
expect(isTransient("ECONNREFUSED")).toBe(true);
|
|
15
|
-
expect(isTransient("socket hang up")).toBe(true);
|
|
16
|
-
expect(isTransient("network error occurred")).toBe(true);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("matches HTTP status codes", () => {
|
|
20
|
-
expect(isTransient("Error 429: Too many requests")).toBe(true);
|
|
21
|
-
expect(isTransient("502 Bad Gateway")).toBe(true);
|
|
22
|
-
expect(isTransient("503 Service Unavailable")).toBe(true);
|
|
23
|
-
expect(isTransient("504 Gateway Timeout")).toBe(true);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("matches timeout/temporary", () => {
|
|
27
|
-
expect(isTransient("Request timeout after 30s")).toBe(true);
|
|
28
|
-
expect(isTransient("temporary failure")).toBe(true);
|
|
29
|
-
expect(isTransient("EAI_AGAIN")).toBe(true);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("returns false for permanent errors", () => {
|
|
33
|
-
expect(isTransient("permission denied")).toBe(false);
|
|
34
|
-
expect(isTransient("command not found")).toBe(false);
|
|
35
|
-
expect(isTransient("syntax error")).toBe(false);
|
|
36
|
-
expect(isTransient("")).toBe(false);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("is case-insensitive", () => {
|
|
40
|
-
expect(isTransient("NETWORK FAILURE")).toBe(true);
|
|
41
|
-
expect(isTransient("Timeout after 30s")).toBe(true);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe("commandFor", () => {
|
|
46
|
-
it("returns correct command for each method", () => {
|
|
47
|
-
const methods: InstallMethod[] = ["vp", "bun", "npm", "brew"];
|
|
48
|
-
for (const m of methods) {
|
|
49
|
-
const spec = commandFor(m);
|
|
50
|
-
expect(spec).toBeDefined();
|
|
51
|
-
expect(spec?.command).toBeTruthy();
|
|
52
|
-
expect(spec?.label).toBeTruthy();
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("vp uses vp add -g", () => {
|
|
57
|
-
const spec = commandFor("vp")!;
|
|
58
|
-
expect(spec.command).toBe("vp");
|
|
59
|
-
expect(spec.args).toContain(`${PACKAGE_NAME}@latest`);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("bun uses bun add -g", () => {
|
|
63
|
-
const spec = commandFor("bun")!;
|
|
64
|
-
expect(spec.command).toBe("bun");
|
|
65
|
-
expect(spec.args).toContain(`${PACKAGE_NAME}@latest`);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("npm uses npm install -g", () => {
|
|
69
|
-
const spec = commandFor("npm")!;
|
|
70
|
-
expect(spec.command).toBe("npm");
|
|
71
|
-
expect(spec.args).toContain("-g");
|
|
72
|
-
expect(spec.args).toContain(`${PACKAGE_NAME}@latest`);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("brew uses sh -lc", () => {
|
|
76
|
-
const spec = commandFor("brew")!;
|
|
77
|
-
expect(spec.command).toBe("/bin/sh");
|
|
78
|
-
expect(spec.label).toContain("brew upgrade");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("native returns undefined", () => {
|
|
82
|
-
expect(commandFor("native")).toBeUndefined();
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe("formatUpdateSummary", () => {
|
|
87
|
-
it("shows updated message when version changed", () => {
|
|
88
|
-
const msg = formatUpdateSummary("0.75.0", "0.76.0", 1);
|
|
89
|
-
expect(msg).toContain("0.75.0 → 0.76.0");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("shows up-to-date when version unchanged", () => {
|
|
93
|
-
const msg = formatUpdateSummary("0.76.0", "0.76.0", 1);
|
|
94
|
-
expect(msg).toContain("up to date");
|
|
95
|
-
expect(msg).toContain("0.76.0");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("shows retry count when attempts > 1", () => {
|
|
99
|
-
const msg = formatUpdateSummary("0.75.0", "0.76.0", 3);
|
|
100
|
-
expect(msg).toContain("Retried 2 transient failure");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("no retry mention when attempts = 1", () => {
|
|
104
|
-
const msg = formatUpdateSummary("0.75.0", "0.76.0", 1);
|
|
105
|
-
expect(msg).not.toContain("Retried");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("handles unknown versions gracefully", () => {
|
|
109
|
-
const msg = formatUpdateSummary("unknown", "unknown", 1);
|
|
110
|
-
expect(msg).toContain("up to date");
|
|
111
|
-
});
|
|
112
|
-
});
|