@olahulleberg/infer 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 (3) hide show
  1. package/README.md +72 -0
  2. package/package.json +28 -0
  3. package/src/cli.js +760 -0
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # infer
2
+
3
+ A tiny, no‑TUI CLI for asking a quick agentic question from your terminal.
4
+
5
+ > Not a replacement for large agentic CLIs. Use those for workflows, long sessions, and heavy automation. Use `infer` for fast, focused answers with light tool use.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ bun add -g @olahulleberg/infer @mariozechner/pi-coding-agent
13
+ ```
14
+
15
+ To get the latest pi without reinstalling infer:
16
+
17
+ ```bash
18
+ bun add -g @mariozechner/pi-coding-agent
19
+ ```
20
+
21
+ **Local dev:**
22
+
23
+ ```bash
24
+ bun install
25
+ bun link
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ ```bash
31
+ infer "Summarize this repo"
32
+ infer -c "Continue from last session"
33
+ infer --provider openai --model gpt-4o "Explain this error"
34
+ infer config
35
+ infer config --source models.dev
36
+ echo "What files changed?" | infer
37
+ ```
38
+
39
+ ## Why use it
40
+
41
+ - Minimal surface area and zero TUI overhead
42
+ - Shows tool actions, then prints the final answer
43
+ - Ideal for short, agentic questions in a shell
44
+
45
+ ## How it behaves
46
+
47
+ **Sessions**
48
+ - Default: starts fresh and clears previous sessions
49
+ - Continue: `-c` or `-r`
50
+ - Storage: `~/.infer/agent/sessions/last.jsonl`
51
+
52
+ **Bash approval**
53
+ Every `bash` tool call asks for approval:
54
+ - **Accept**: run once
55
+ - **Reject**: block
56
+ - **Dangerous Accept All**: run all future bash commands in this process
57
+
58
+ **Config & auth**
59
+ - Config dir: `~/.infer/agent` (override with `INFER_AGENT_DIR`)
60
+ - API keys: env vars (e.g. `OPENAI_API_KEY`) or `~/.infer/agent/auth.json`
61
+ - Setup: `infer config`
62
+
63
+ ## Flags
64
+
65
+ | Flag | Description |
66
+ | --- | --- |
67
+ | `-c`, `--continue`, `-r`, `--resume` | Continue last session |
68
+ | `-p`, `--provider <name>` | Model provider |
69
+ | `-m`, `--model <id>` | Model id |
70
+ | `--thinking <level>` | off \| minimal \| low \| medium \| high \| xhigh |
71
+ | `--source <local\|models.dev>` | Model source for `infer config` |
72
+ | `-h`, `--help` | Show help |
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@olahulleberg/infer",
3
+ "version": "0.1.0",
4
+ "description": "Minimal Pi-powered CLI for one-shot prompts",
5
+ "type": "module",
6
+ "bin": {
7
+ "infer": "src/cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/cli.js"
11
+ },
12
+ "dependencies": {
13
+ "@clack/prompts": "^1.0.0",
14
+ "@inquirer/select": "^5.0.4"
15
+ },
16
+ "peerDependencies": {
17
+ "@mariozechner/pi-coding-agent": ">=0.52.7"
18
+ },
19
+ "files": [
20
+ "src/"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "node": ">=20.0.0"
27
+ }
28
+ }
package/src/cli.js ADDED
@@ -0,0 +1,760 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ AuthStorage,
4
+ createAgentSession,
5
+ DefaultResourceLoader,
6
+ ModelRegistry,
7
+ SessionManager,
8
+ SettingsManager,
9
+ } from "@mariozechner/pi-coding-agent";
10
+ import {
11
+ autocomplete,
12
+ confirm,
13
+ intro,
14
+ isCancel,
15
+ note,
16
+ outro,
17
+ password,
18
+ select,
19
+ spinner,
20
+ } from "@clack/prompts";
21
+ import inquirerSelect from "@inquirer/select";
22
+ import { homedir } from "os";
23
+ import { join, resolve } from "path";
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "fs";
25
+
26
+ const VALID_THINKING_LEVELS = new Set([
27
+ "off",
28
+ "minimal",
29
+ "low",
30
+ "medium",
31
+ "high",
32
+ "xhigh",
33
+ ]);
34
+ const DIM = "\x1b[90m";
35
+ const RESET = "\x1b[0m";
36
+
37
+ const COMMAND_NAME = "infer";
38
+ const args = process.argv.slice(2);
39
+ const options = {
40
+ command: "prompt",
41
+ continue: false,
42
+ provider: undefined,
43
+ model: undefined,
44
+ thinking: undefined,
45
+ source: undefined,
46
+ help: false,
47
+ version: false,
48
+ };
49
+ const promptParts = [];
50
+
51
+ for (let i = 0; i < args.length; i += 1) {
52
+ const arg = args[i];
53
+ if (arg === "--") {
54
+ promptParts.push(...args.slice(i + 1));
55
+ break;
56
+ }
57
+ if (arg === "-c" || arg === "--continue" || arg === "-r" || arg === "--resume") {
58
+ options.continue = true;
59
+ continue;
60
+ }
61
+ if (arg === "-p" || arg === "--provider") {
62
+ options.provider = requireValue(arg, args[i + 1]);
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (arg === "-m" || arg === "--model") {
67
+ options.model = requireValue(arg, args[i + 1]);
68
+ i += 1;
69
+ continue;
70
+ }
71
+ if (arg === "--thinking") {
72
+ options.thinking = requireValue(arg, args[i + 1]);
73
+ i += 1;
74
+ continue;
75
+ }
76
+ if (arg === "--source") {
77
+ options.source = requireValue(arg, args[i + 1]);
78
+ i += 1;
79
+ continue;
80
+ }
81
+ if (arg === "config") {
82
+ options.command = "config";
83
+ continue;
84
+ }
85
+ if (arg === "-h" || arg === "--help") {
86
+ options.help = true;
87
+ continue;
88
+ }
89
+ if (arg === "-v" || arg === "--version") {
90
+ options.version = true;
91
+ continue;
92
+ }
93
+ if (arg.startsWith("-")) {
94
+ fail(`Unknown flag: ${arg}`);
95
+ }
96
+ promptParts.push(arg);
97
+ }
98
+
99
+ if (options.help) {
100
+ printHelp();
101
+ process.exit(0);
102
+ }
103
+
104
+ if (options.version) {
105
+ printVersion();
106
+ process.exit(0);
107
+ }
108
+
109
+ if (options.thinking && !VALID_THINKING_LEVELS.has(options.thinking)) {
110
+ fail(`Invalid --thinking value: ${options.thinking}`);
111
+ }
112
+
113
+ if (options.source && options.source !== "local" && options.source !== "models.dev") {
114
+ fail(`Invalid --source value: ${options.source}`);
115
+ }
116
+
117
+ if (options.source && options.command !== "config") {
118
+ fail("--source is only valid with the config command.");
119
+ }
120
+
121
+ const agentDir = resolveAgentDir();
122
+ const sessionDir = join(agentDir, "sessions");
123
+ const sessionFile = join(sessionDir, "last.jsonl");
124
+
125
+ ensureDir(agentDir);
126
+ ensureDir(sessionDir);
127
+
128
+ const settingsManager = SettingsManager.create(process.cwd(), agentDir);
129
+ const authStorage = new AuthStorage(join(agentDir, "auth.json"));
130
+ const modelRegistry = new ModelRegistry(authStorage, join(agentDir, "models.json"));
131
+
132
+ if (options.command === "config") {
133
+ if (promptParts.length > 0) {
134
+ fail("The config command does not accept a prompt.");
135
+ }
136
+ await runConfigurator({
137
+ agentDir,
138
+ settingsManager,
139
+ authStorage,
140
+ modelRegistry,
141
+ source: options.source,
142
+ });
143
+ process.exit(0);
144
+ }
145
+
146
+ const prompt = await resolvePrompt(promptParts);
147
+ if (!prompt) {
148
+ printHelp();
149
+ process.exit(1);
150
+ }
151
+
152
+ if (!options.continue) {
153
+ clearSessions(sessionDir);
154
+ }
155
+ const resourceLoader = new DefaultResourceLoader({
156
+ cwd: process.cwd(),
157
+ agentDir,
158
+ settingsManager,
159
+ extensionFactories: [createBashApprovalExtension()],
160
+ });
161
+
162
+ await resourceLoader.reload();
163
+
164
+ const model = resolveModel({
165
+ provider: options.provider,
166
+ modelId: options.model,
167
+ settingsManager,
168
+ modelRegistry,
169
+ });
170
+
171
+ const sessionManager = SessionManager.open(sessionFile, sessionDir);
172
+ const { session } = await createAgentSession({
173
+ cwd: process.cwd(),
174
+ agentDir,
175
+ authStorage,
176
+ modelRegistry,
177
+ resourceLoader,
178
+ settingsManager,
179
+ sessionManager,
180
+ model: model ?? undefined,
181
+ thinkingLevel: options.thinking,
182
+ });
183
+
184
+ let lastAssistantText = "";
185
+ let printedToolLine = false;
186
+ let suppressBashToolLine = false;
187
+
188
+ session.subscribe((event) => {
189
+ if (event.type === "tool_execution_start") {
190
+ if (event.toolName === "bash" && suppressBashToolLine) {
191
+ return;
192
+ }
193
+ const line = formatToolLine(event.toolName, event.args);
194
+ if (line) {
195
+ printedToolLine = true;
196
+ process.stdout.write(`${line}\n`);
197
+ }
198
+ }
199
+
200
+ if (event.type === "message_end") {
201
+ if (isAssistantMessage(event.message)) {
202
+ lastAssistantText = extractText(event.message);
203
+ }
204
+ }
205
+ });
206
+
207
+ try {
208
+ await session.prompt(prompt);
209
+ if (printedToolLine) {
210
+ process.stdout.write("\n");
211
+ }
212
+ if (lastAssistantText) {
213
+ process.stdout.write(`${lastAssistantText.trimEnd()}\n`);
214
+ }
215
+ } catch (error) {
216
+ const message = error instanceof Error ? error.message : String(error);
217
+ process.stderr.write(`${message}\n`);
218
+ process.exit(1);
219
+ } finally {
220
+ session.dispose();
221
+ }
222
+
223
+ function resolveAgentDir() {
224
+ const envDir = process.env.INFER_AGENT_DIR;
225
+ if (envDir) {
226
+ return expandHome(envDir);
227
+ }
228
+ return join(homedir(), ".infer", "agent");
229
+ }
230
+
231
+ function expandHome(targetPath) {
232
+ if (targetPath === "~") {
233
+ return homedir();
234
+ }
235
+ if (targetPath.startsWith("~/")) {
236
+ return join(homedir(), targetPath.slice(2));
237
+ }
238
+ return resolve(targetPath);
239
+ }
240
+
241
+ function ensureDir(pathname) {
242
+ if (!existsSync(pathname)) {
243
+ mkdirSync(pathname, { recursive: true });
244
+ }
245
+ }
246
+
247
+ function clearSessions(sessionDirPath) {
248
+ if (!existsSync(sessionDirPath)) {
249
+ return;
250
+ }
251
+ for (const entry of readdirSync(sessionDirPath)) {
252
+ if (entry.endsWith(".jsonl")) {
253
+ unlinkSync(join(sessionDirPath, entry));
254
+ }
255
+ }
256
+ }
257
+
258
+ function resolveModel({ provider, modelId, settingsManager, modelRegistry }) {
259
+ if (!provider && !modelId) {
260
+ return undefined;
261
+ }
262
+
263
+ const resolvedProvider = provider ?? settingsManager.getDefaultProvider();
264
+ if (!resolvedProvider) {
265
+ fail("Missing provider. Use --provider or set defaultProvider in settings.");
266
+ }
267
+
268
+ const resolvedModelId = modelId ?? settingsManager.getDefaultModel();
269
+ if (!resolvedModelId) {
270
+ fail("Missing model. Use --model or set defaultModel in settings.");
271
+ }
272
+
273
+ const model = modelRegistry.find(resolvedProvider, resolvedModelId);
274
+ if (!model) {
275
+ fail(`Model not found: ${resolvedProvider}/${resolvedModelId}`);
276
+ }
277
+
278
+ return model;
279
+ }
280
+
281
+ function formatToolLine(toolName, args) {
282
+ if (toolName === "read" && args?.path) {
283
+ return gray(`Read ${args.path}`);
284
+ }
285
+ if (toolName === "edit" && args?.path) {
286
+ return gray(`Edit ${args.path}`);
287
+ }
288
+ if (toolName === "write" && args?.path) {
289
+ return gray(`Write ${args.path}`);
290
+ }
291
+ if (toolName === "bash" && args?.command) {
292
+ return gray(`! ${args.command}`);
293
+ }
294
+ return null;
295
+ }
296
+
297
+ function isAssistantMessage(message) {
298
+ return typeof message === "object" && message !== null && message.role === "assistant";
299
+ }
300
+
301
+ function extractText(message) {
302
+ const content = message.content;
303
+ if (typeof content === "string") {
304
+ return content;
305
+ }
306
+ if (!Array.isArray(content)) {
307
+ return "";
308
+ }
309
+ return content
310
+ .filter((block) => block && block.type === "text")
311
+ .map((block) => block.text)
312
+ .join("");
313
+ }
314
+
315
+ async function resolvePrompt(parts) {
316
+ if (parts.length > 0) {
317
+ return parts.join(" ").trim();
318
+ }
319
+
320
+ if (process.stdin.isTTY) {
321
+ return "";
322
+ }
323
+
324
+ const chunks = [];
325
+ for await (const chunk of process.stdin) {
326
+ chunks.push(chunk);
327
+ }
328
+ return Buffer.concat(chunks).toString("utf-8").trim();
329
+ }
330
+
331
+ function printVersion() {
332
+ const pkg = readPackageJson();
333
+ process.stdout.write(`${COMMAND_NAME} ${pkg.version}\n`);
334
+ }
335
+
336
+ function printHelp() {
337
+ process.stdout.write(
338
+ `Usage: ${COMMAND_NAME} [options] <prompt>\n\n` +
339
+ `Commands:\n` +
340
+ ` config Interactive configuration\n\n` +
341
+ `Options:\n` +
342
+ ` -c, --continue, -r, --resume Continue last session\n` +
343
+ ` -p, --provider <name> Model provider\n` +
344
+ ` -m, --model <id> Model id\n` +
345
+ ` --thinking <level> off|minimal|low|medium|high|xhigh\n` +
346
+ ` --source <local|models.dev> Model source for config\n` +
347
+ ` -h, --help Show help\n` +
348
+ ` -v, --version Show version\n`,
349
+ );
350
+ }
351
+
352
+ function readPackageJson() {
353
+ const pkgUrl = new URL("../package.json", import.meta.url);
354
+ return JSON.parse(readFileSync(pkgUrl, "utf-8"));
355
+ }
356
+
357
+ function requireValue(flag, value) {
358
+ if (!value || value.startsWith("-")) {
359
+ fail(`Missing value for ${flag}`);
360
+ }
361
+ return value;
362
+ }
363
+
364
+ async function runConfigurator({ agentDir, settingsManager, authStorage, modelRegistry, source }) {
365
+ intro("infer config");
366
+
367
+ const localCatalog = buildLocalCatalog(modelRegistry);
368
+ if (localCatalog.models.length === 0) {
369
+ outro("No local models found. Check your installation.");
370
+ return;
371
+ }
372
+
373
+ const selectedSource =
374
+ source ??
375
+ (await select({
376
+ message: "Model source",
377
+ options: [
378
+ { value: "local", label: "Local Pi registry", hint: "Fast, tool-calling models only" },
379
+ { value: "models.dev", label: "models.dev", hint: "Filtered to models supported here" },
380
+ ],
381
+ initialValue: "local",
382
+ }));
383
+
384
+ if (isCancel(selectedSource)) {
385
+ outro("Canceled.");
386
+ return;
387
+ }
388
+
389
+ let catalog = localCatalog;
390
+ if (selectedSource === "models.dev") {
391
+ const spin = spinner();
392
+ spin.start("Fetching models.dev");
393
+ try {
394
+ catalog = await buildModelsDevCatalog(modelRegistry, localCatalog.index);
395
+ spin.stop("Loaded models.dev");
396
+ } catch (error) {
397
+ spin.stop("Failed to load models.dev");
398
+ const message = error instanceof Error ? error.message : String(error);
399
+ note(message, "Using local registry instead");
400
+ catalog = localCatalog;
401
+ }
402
+ }
403
+
404
+ const reasoningOnly = await confirm({
405
+ message: "Require reasoning support?",
406
+ initialValue: false,
407
+ });
408
+ if (isCancel(reasoningOnly)) {
409
+ outro("Canceled.");
410
+ return;
411
+ }
412
+
413
+ const minContext = await select({
414
+ message: "Minimum context window",
415
+ options: [
416
+ { value: 0, label: "No minimum" },
417
+ { value: 32000, label: "32k" },
418
+ { value: 128000, label: "128k" },
419
+ { value: 256000, label: "256k" },
420
+ { value: 1000000, label: "1M" },
421
+ ],
422
+ initialValue: 0,
423
+ });
424
+ if (isCancel(minContext)) {
425
+ outro("Canceled.");
426
+ return;
427
+ }
428
+
429
+ const filtered = filterCatalog(catalog.models, {
430
+ reasoningOnly,
431
+ minContext,
432
+ });
433
+
434
+ if (filtered.length === 0) {
435
+ note("No models matched those filters.", "No matches");
436
+ outro("Canceled.");
437
+ return;
438
+ }
439
+
440
+ const providerOptions = buildProviderOptions(filtered);
441
+ const providerId = await autocomplete({
442
+ message: "Provider",
443
+ options: providerOptions,
444
+ maxItems: 12,
445
+ });
446
+ if (isCancel(providerId)) {
447
+ outro("Canceled.");
448
+ return;
449
+ }
450
+
451
+ const modelOptions = buildModelOptions(filtered, providerId);
452
+ if (modelOptions.length === 0) {
453
+ note("No models found for that provider.", "No matches");
454
+ outro("Canceled.");
455
+ return;
456
+ }
457
+
458
+ const modelId = await autocomplete({
459
+ message: "Model",
460
+ options: modelOptions,
461
+ maxItems: 12,
462
+ });
463
+ if (isCancel(modelId)) {
464
+ outro("Canceled.");
465
+ return;
466
+ }
467
+
468
+ const currentThinking = settingsManager.getDefaultThinkingLevel() ?? "off";
469
+ const thinkingLevel = await select({
470
+ message: "Default thinking level",
471
+ options: [
472
+ { value: "off", label: "off" },
473
+ { value: "minimal", label: "minimal" },
474
+ { value: "low", label: "low" },
475
+ { value: "medium", label: "medium" },
476
+ { value: "high", label: "high" },
477
+ { value: "xhigh", label: "xhigh" },
478
+ ],
479
+ initialValue: currentThinking,
480
+ });
481
+ if (isCancel(thinkingLevel)) {
482
+ outro("Canceled.");
483
+ return;
484
+ }
485
+
486
+ const saveDefaults = await confirm({
487
+ message: `Save ${providerId}/${modelId} as defaults?`,
488
+ initialValue: true,
489
+ });
490
+ if (isCancel(saveDefaults) || !saveDefaults) {
491
+ outro("No changes saved.");
492
+ return;
493
+ }
494
+
495
+ settingsManager.setDefaultModelAndProvider(providerId, modelId);
496
+ settingsManager.setDefaultThinkingLevel(thinkingLevel);
497
+
498
+ const storeKey = await confirm({
499
+ message: "Store an API key now?",
500
+ initialValue: false,
501
+ });
502
+ if (isCancel(storeKey)) {
503
+ outro("Saved defaults without API key.");
504
+ return;
505
+ }
506
+
507
+ if (storeKey) {
508
+ const apiKey = await password({
509
+ message: "API key (stored in auth.json)",
510
+ mask: "*",
511
+ });
512
+ if (isCancel(apiKey)) {
513
+ outro("Saved defaults without API key.");
514
+ return;
515
+ }
516
+ if (apiKey) {
517
+ authStorage.set(providerId, { type: "api_key", key: apiKey });
518
+ }
519
+ }
520
+
521
+ note(
522
+ `Defaults saved to ${join(agentDir, "settings.json")}.\nAuth stored in ${join(
523
+ agentDir,
524
+ "auth.json",
525
+ )}.`,
526
+ "Done",
527
+ );
528
+ outro("Configuration complete.");
529
+ }
530
+
531
+ function buildLocalCatalog(modelRegistry) {
532
+ const models = modelRegistry.getAll().map((model) => ({
533
+ providerId: model.provider,
534
+ providerName: model.provider,
535
+ modelId: model.id,
536
+ name: model.name ?? model.id,
537
+ reasoning: Boolean(model.reasoning),
538
+ contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : 0,
539
+ maxTokens: typeof model.maxTokens === "number" ? model.maxTokens : 0,
540
+ }));
541
+ return {
542
+ source: "local",
543
+ models,
544
+ index: indexModels(models),
545
+ };
546
+ }
547
+
548
+ async function buildModelsDevCatalog(modelRegistry, localIndex) {
549
+ const response = await fetch("https://models.dev/api.json");
550
+ if (!response.ok) {
551
+ throw new Error(`models.dev request failed: ${response.status}`);
552
+ }
553
+ const data = await response.json();
554
+ const providerAliases = {
555
+ azure: "azure-openai-responses",
556
+ "kimi-for-coding": "kimi-coding",
557
+ vercel: "vercel-ai-gateway",
558
+ };
559
+
560
+ const models = [];
561
+ const providers = Object.values(data);
562
+ for (const provider of providers) {
563
+ if (!provider || !provider.id || !provider.models) {
564
+ continue;
565
+ }
566
+ const rawProviderId = String(provider.id);
567
+ const providerId = providerAliases[rawProviderId] ?? rawProviderId;
568
+ const providerModels = provider.models;
569
+ if (!localIndex.has(providerId)) {
570
+ continue;
571
+ }
572
+ for (const model of Object.values(providerModels)) {
573
+ if (!model || !model.id) {
574
+ continue;
575
+ }
576
+ if (model.tool_call === false) {
577
+ continue;
578
+ }
579
+ const modelId = String(model.id);
580
+ const localProviderModels = localIndex.get(providerId);
581
+ if (!localProviderModels || !localProviderModels.has(modelId)) {
582
+ continue;
583
+ }
584
+ const localModel = localProviderModels.get(modelId);
585
+ models.push({
586
+ providerId,
587
+ providerName: provider.name ?? providerId,
588
+ modelId,
589
+ name: model.name ?? (localModel?.name ?? modelId),
590
+ reasoning: model.reasoning ?? Boolean(localModel?.reasoning),
591
+ contextWindow: resolveNumber(model.limit?.context, localModel?.contextWindow),
592
+ maxTokens: resolveNumber(model.limit?.output, localModel?.maxTokens),
593
+ });
594
+ }
595
+ }
596
+
597
+ if (models.length === 0) {
598
+ return buildLocalCatalog(modelRegistry);
599
+ }
600
+
601
+ return {
602
+ source: "models.dev",
603
+ models,
604
+ index: indexModels(models),
605
+ };
606
+ }
607
+
608
+ function indexModels(models) {
609
+ const map = new Map();
610
+ for (const model of models) {
611
+ if (!map.has(model.providerId)) {
612
+ map.set(model.providerId, new Map());
613
+ }
614
+ map.get(model.providerId).set(model.modelId, model);
615
+ }
616
+ return map;
617
+ }
618
+
619
+ function filterCatalog(models, { reasoningOnly, minContext }) {
620
+ return models.filter((model) => {
621
+ if (reasoningOnly && !model.reasoning) {
622
+ return false;
623
+ }
624
+ if (minContext > 0) {
625
+ return model.contextWindow >= minContext;
626
+ }
627
+ return true;
628
+ });
629
+ }
630
+
631
+ function buildProviderOptions(models) {
632
+ const counts = new Map();
633
+ const names = new Map();
634
+ for (const model of models) {
635
+ counts.set(model.providerId, (counts.get(model.providerId) ?? 0) + 1);
636
+ if (!names.has(model.providerId)) {
637
+ names.set(model.providerId, model.providerName || model.providerId);
638
+ }
639
+ }
640
+ return Array.from(counts.entries())
641
+ .map(([providerId, count]) => ({
642
+ value: providerId,
643
+ label: `${names.get(providerId)} (${providerId})`,
644
+ hint: `${count} models`,
645
+ }))
646
+ .sort((a, b) => a.label.localeCompare(b.label));
647
+ }
648
+
649
+ function buildModelOptions(models, providerId) {
650
+ const filtered = models.filter((model) => model.providerId === providerId);
651
+ return filtered
652
+ .map((model) => ({
653
+ value: model.modelId,
654
+ label: model.name,
655
+ hint: `id: ${model.modelId} | ctx ${formatContext(model.contextWindow)} | reasoning ${
656
+ model.reasoning ? "yes" : "no"
657
+ }`,
658
+ }))
659
+ .sort((a, b) => a.label.localeCompare(b.label));
660
+ }
661
+
662
+ function formatContext(value) {
663
+ if (!value || value <= 0) {
664
+ return "n/a";
665
+ }
666
+ if (value >= 1000000) {
667
+ return `${Math.round(value / 100000) / 10}M`;
668
+ }
669
+ if (value >= 1000) {
670
+ return `${Math.round(value / 100) / 10}k`;
671
+ }
672
+ return String(value);
673
+ }
674
+
675
+ function resolveNumber(value, fallback) {
676
+ if (typeof value === "number" && !Number.isNaN(value)) {
677
+ return value;
678
+ }
679
+ if (typeof fallback === "number" && !Number.isNaN(fallback)) {
680
+ return fallback;
681
+ }
682
+ return 0;
683
+ }
684
+
685
+ function gray(text) {
686
+ if (!process.stdout.isTTY) {
687
+ return text;
688
+ }
689
+ return `${DIM}${text}${RESET}`;
690
+ }
691
+
692
+ function createBashApprovalExtension() {
693
+ let allowAll = false;
694
+
695
+ return (pi) => {
696
+ pi.on("tool_call", async (event) => {
697
+ if (event.toolName !== "bash") {
698
+ return;
699
+ }
700
+
701
+ if (allowAll) {
702
+ return;
703
+ }
704
+
705
+ const command = typeof event.input?.command === "string" ? event.input.command : "";
706
+ if (!process.stdin.isTTY) {
707
+ return { block: true, reason: "Bash command blocked: no TTY for approval." };
708
+ }
709
+
710
+ suppressBashToolLine = true;
711
+ let decision;
712
+ try {
713
+ decision = await promptBashApproval(command);
714
+ } finally {
715
+ suppressBashToolLine = false;
716
+ }
717
+
718
+ if (decision === "accept_all") {
719
+ allowAll = true;
720
+ return;
721
+ }
722
+
723
+ if (decision === "accept") {
724
+ return;
725
+ }
726
+
727
+ return { block: true, reason: "Bash command rejected by user." };
728
+ });
729
+ };
730
+ }
731
+
732
+ async function promptBashApproval(command) {
733
+ return inquirerSelect({
734
+ message: gray(`! ${command || "(empty)"}`),
735
+ choices: [
736
+ { value: "accept", name: "Accept" },
737
+ { value: "reject", name: "Reject" },
738
+ { value: "accept_all", name: "Dangerous Accept All" },
739
+ ],
740
+ pageSize: 3,
741
+ loop: false,
742
+ theme: {
743
+ prefix: "",
744
+ icon: {
745
+ cursor: ">",
746
+ },
747
+ indexMode: "hidden",
748
+ style: {
749
+ keysHelpTip: () => undefined,
750
+ disabled: (text) => text,
751
+ description: (text) => text,
752
+ },
753
+ },
754
+ });
755
+ }
756
+
757
+ function fail(message) {
758
+ process.stderr.write(`${message}\n`);
759
+ process.exit(1);
760
+ }