@olahulleberg/infer 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +80 -23
  2. package/package.json +2 -2
  3. package/src/cli.js +257 -171
package/README.md CHANGED
@@ -12,7 +12,7 @@ A tiny, no‑TUI CLI for asking a quick agentic question from your terminal.
12
12
  bun add -g @olahulleberg/infer @mariozechner/pi-coding-agent
13
13
  ```
14
14
 
15
- To get the latest pi without reinstalling infer:
15
+ To update pi independently (new models, fixes) without touching infer:
16
16
 
17
17
  ```bash
18
18
  bun add -g @mariozechner/pi-coding-agent
@@ -29,36 +29,87 @@ bun link
29
29
 
30
30
  ```bash
31
31
  infer "Summarize this repo"
32
- infer -c "Continue from last session"
32
+ infer -c "What did we just change?"
33
33
  infer --provider openai --model gpt-4o "Explain this error"
34
- infer config
35
- infer config --source models.dev
36
34
  echo "What files changed?" | infer
37
35
  ```
38
36
 
39
- ## Why use it
37
+ ## Auth
40
38
 
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
39
+ **API key** set an environment variable or store it via `infer config`:
44
40
 
45
- ## How it behaves
41
+ ```bash
42
+ export ANTHROPIC_API_KEY=sk-...
43
+ export OPENAI_API_KEY=sk-...
44
+ ```
46
45
 
47
- **Sessions**
48
- - Default: starts fresh and clears previous sessions
49
- - Continue: `-c` or `-r`
50
- - Storage: `~/.infer/agent/sessions/last.jsonl`
46
+ **OAuth** — for providers that use browser login (Claude Pro/Max, ChatGPT Plus/Pro, GitHub Copilot, Gemini):
47
+
48
+ ```bash
49
+ infer login # pick a provider interactively
50
+ infer login openai-codex
51
+ infer login anthropic
52
+ infer login github-copilot
53
+ infer login google-gemini-cli
54
+ ```
55
+
56
+ ## Config
57
+
58
+ ```bash
59
+ infer config
60
+ ```
61
+
62
+ Interactive setup: pick a provider and model, set a default thinking level, optionally store an API key, and optionally configure a **classifier model**.
63
+
64
+ ## Bash approval
65
+
66
+ Every `bash` tool call requires approval before it runs:
51
67
 
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
68
+ ```
69
+ ! grep -r "TODO" src/
70
+ > Accept
71
+ Reject
72
+ Dangerous Accept All
73
+ ```
74
+
75
+ - **Accept** — run once
76
+ - **Reject** — block this command
77
+ - **Dangerous Accept All** — skip approval for all remaining commands in this session
57
78
 
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`
79
+ ## Classifier
80
+
81
+ When a classifier model is configured (via `infer config`), read-only commands are auto-approved silently. Only commands with side effects prompt for approval.
82
+
83
+ ```
84
+ ✓ Search TODO in src/
85
+ ```
86
+
87
+ vs.
88
+
89
+ ```
90
+ ! Delete build artifacts
91
+ > Accept Reject Dangerous Accept All
92
+ ```
93
+
94
+ The classifier uses the model you configure — no separate API key needed. If it fails for any reason, it falls back to the standard approval prompt.
95
+
96
+ To set it up: run `infer config` and answer yes to the classifier prompt at the end.
97
+
98
+ ## Sandbox
99
+
100
+ When a classifier is configured, auto-approved commands run inside a sandbox that makes the filesystem read-only. The command can read anything but cannot write, delete, or modify files.
101
+
102
+ **Linux:** requires [`bwrap`](https://github.com/containers/bubblewrap) (`sudo apt install bubblewrap` / `sudo pacman -S bubblewrap`)
103
+
104
+ **macOS:** uses `sandbox-exec`, which is built-in.
105
+
106
+ If the classifier is configured but no sandbox is detected, infer warns at startup and falls back to running auto-approved commands without isolation.
107
+
108
+ ## Sessions
109
+
110
+ - Default: fresh session, clears previous
111
+ - Continue: `-c`, `-r`, `--continue`, `--resume`
112
+ - Storage: `~/.infer/agent/sessions/last.jsonl`
62
113
 
63
114
  ## Flags
64
115
 
@@ -68,5 +119,11 @@ Every `bash` tool call asks for approval:
68
119
  | `-p`, `--provider <name>` | Model provider |
69
120
  | `-m`, `--model <id>` | Model id |
70
121
  | `--thinking <level>` | off \| minimal \| low \| medium \| high \| xhigh |
71
- | `--source <local\|models.dev>` | Model source for `infer config` |
72
122
  | `-h`, `--help` | Show help |
123
+ | `-v`, `--version` | Show version |
124
+
125
+ ## Config dir
126
+
127
+ `~/.infer/agent` — override with `INFER_AGENT_DIR`.
128
+
129
+ Contains `settings.json`, `auth.json`, `classifier.json`, and `sessions/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olahulleberg/infer",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Minimal Pi-powered CLI for one-shot prompts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@
14
14
  "@inquirer/select": "^5.0.4"
15
15
  },
16
16
  "peerDependencies": {
17
- "@mariozechner/pi-coding-agent": ">=0.52.7"
17
+ "@mariozechner/pi-coding-agent": "^0.63.2"
18
18
  },
19
19
  "files": [
20
20
  "src/"
package/src/cli.js CHANGED
@@ -17,11 +17,14 @@ import {
17
17
  password,
18
18
  select,
19
19
  spinner,
20
+ text,
20
21
  } from "@clack/prompts";
21
22
  import inquirerSelect from "@inquirer/select";
23
+ import { completeSimple } from "@mariozechner/pi-ai";
22
24
  import { homedir } from "os";
23
25
  import { join, resolve } from "path";
24
- import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "fs";
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "fs";
27
+ import { spawn, execFileSync } from "child_process";
25
28
 
26
29
  const VALID_THINKING_LEVELS = new Set([
27
30
  "off",
@@ -42,7 +45,6 @@ const options = {
42
45
  provider: undefined,
43
46
  model: undefined,
44
47
  thinking: undefined,
45
- source: undefined,
46
48
  help: false,
47
49
  version: false,
48
50
  };
@@ -73,15 +75,14 @@ for (let i = 0; i < args.length; i += 1) {
73
75
  i += 1;
74
76
  continue;
75
77
  }
76
- if (arg === "--source") {
77
- options.source = requireValue(arg, args[i + 1]);
78
- i += 1;
79
- continue;
80
- }
81
78
  if (arg === "config") {
82
79
  options.command = "config";
83
80
  continue;
84
81
  }
82
+ if (arg === "login") {
83
+ options.command = "login";
84
+ continue;
85
+ }
85
86
  if (arg === "-h" || arg === "--help") {
86
87
  options.help = true;
87
88
  continue;
@@ -110,14 +111,6 @@ if (options.thinking && !VALID_THINKING_LEVELS.has(options.thinking)) {
110
111
  fail(`Invalid --thinking value: ${options.thinking}`);
111
112
  }
112
113
 
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
114
  const agentDir = resolveAgentDir();
122
115
  const sessionDir = join(agentDir, "sessions");
123
116
  const sessionFile = join(sessionDir, "last.jsonl");
@@ -126,7 +119,7 @@ ensureDir(agentDir);
126
119
  ensureDir(sessionDir);
127
120
 
128
121
  const settingsManager = SettingsManager.create(process.cwd(), agentDir);
129
- const authStorage = new AuthStorage(join(agentDir, "auth.json"));
122
+ const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
130
123
  const modelRegistry = new ModelRegistry(authStorage, join(agentDir, "models.json"));
131
124
 
132
125
  if (options.command === "config") {
@@ -138,11 +131,15 @@ if (options.command === "config") {
138
131
  settingsManager,
139
132
  authStorage,
140
133
  modelRegistry,
141
- source: options.source,
142
134
  });
143
135
  process.exit(0);
144
136
  }
145
137
 
138
+ if (options.command === "login") {
139
+ await runLogin({ authStorage, providerId: promptParts[0] });
140
+ process.exit(0);
141
+ }
142
+
146
143
  const prompt = await resolvePrompt(promptParts);
147
144
  if (!prompt) {
148
145
  printHelp();
@@ -152,11 +149,26 @@ if (!prompt) {
152
149
  if (!options.continue) {
153
150
  clearSessions(sessionDir);
154
151
  }
152
+
153
+ const classifierConfig = loadClassifierConfig(agentDir);
154
+ const sandboxBin = detectSandbox();
155
+
156
+ if (classifierConfig && !sandboxBin) {
157
+ process.stderr.write(
158
+ gray(
159
+ "Warning: classifier is active but no sandbox detected. Auto-approved commands run without isolation.\n" +
160
+ " Linux: install bubblewrap (bwrap) | macOS: sandbox-exec should be built-in\n",
161
+ ),
162
+ );
163
+ }
164
+
165
+ const sandboxState = { next: false };
166
+
155
167
  const resourceLoader = new DefaultResourceLoader({
156
168
  cwd: process.cwd(),
157
169
  agentDir,
158
170
  settingsManager,
159
- extensionFactories: [createBashApprovalExtension()],
171
+ extensionFactories: [createBashApprovalExtension({ modelRegistry, classifierConfig, sandboxBin, sandboxState })],
160
172
  });
161
173
 
162
174
  await resourceLoader.reload();
@@ -181,13 +193,26 @@ const { session } = await createAgentSession({
181
193
  thinkingLevel: options.thinking,
182
194
  });
183
195
 
196
+ if (sandboxBin && classifierConfig) {
197
+ const bt = session.agent.state.tools.find((t) => t.name === "bash");
198
+ if (bt) {
199
+ const orig = bt.execute;
200
+ bt.execute = async (id, args, signal, progress) => {
201
+ if (sandboxState.next) {
202
+ sandboxState.next = false;
203
+ args = { ...args, command: wrapWithSandbox(args.command, sandboxBin) };
204
+ }
205
+ return orig(id, args, signal, progress);
206
+ };
207
+ }
208
+ }
209
+
184
210
  let lastAssistantText = "";
185
211
  let printedToolLine = false;
186
- let suppressBashToolLine = false;
187
212
 
188
213
  session.subscribe((event) => {
189
214
  if (event.type === "tool_execution_start") {
190
- if (event.toolName === "bash" && suppressBashToolLine) {
215
+ if (event.toolName === "bash") {
191
216
  return;
192
217
  }
193
218
  const line = formatToolLine(event.toolName, event.args);
@@ -337,13 +362,13 @@ function printHelp() {
337
362
  process.stdout.write(
338
363
  `Usage: ${COMMAND_NAME} [options] <prompt>\n\n` +
339
364
  `Commands:\n` +
340
- ` config Interactive configuration\n\n` +
365
+ ` config Interactive configuration\n` +
366
+ ` login [provider] Login to an OAuth provider\n\n` +
341
367
  `Options:\n` +
342
368
  ` -c, --continue, -r, --resume Continue last session\n` +
343
369
  ` -p, --provider <name> Model provider\n` +
344
370
  ` -m, --model <id> Model id\n` +
345
371
  ` --thinking <level> off|minimal|low|medium|high|xhigh\n` +
346
- ` --source <local|models.dev> Model source for config\n` +
347
372
  ` -h, --help Show help\n` +
348
373
  ` -v, --version Show version\n`,
349
374
  );
@@ -361,83 +386,75 @@ function requireValue(flag, value) {
361
386
  return value;
362
387
  }
363
388
 
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
- }
389
+ function openBrowser(url) {
390
+ const cmd =
391
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
392
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
393
+ }
372
394
 
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
- }));
395
+ async function runLogin({ authStorage, providerId }) {
396
+ intro("infer login");
383
397
 
384
- if (isCancel(selectedSource)) {
385
- outro("Canceled.");
398
+ const providers = authStorage.getOAuthProviders();
399
+ if (providers.length === 0) {
400
+ outro("No OAuth providers available.");
386
401
  return;
387
402
  }
388
403
 
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;
404
+ let resolvedId = providerId;
405
+ if (!resolvedId) {
406
+ const choice = await select({
407
+ message: "Provider",
408
+ options: providers.map((p) => ({ value: p.id, label: p.name ?? p.id })),
409
+ });
410
+ if (isCancel(choice)) {
411
+ outro("Canceled.");
412
+ return;
401
413
  }
414
+ resolvedId = choice;
402
415
  }
403
416
 
404
- const reasoningOnly = await confirm({
405
- message: "Require reasoning support?",
406
- initialValue: false,
407
- });
408
- if (isCancel(reasoningOnly)) {
409
- outro("Canceled.");
410
- return;
411
- }
417
+ const spin = spinner();
418
+ spin.start(`Logging in to ${resolvedId}`);
412
419
 
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;
420
+ try {
421
+ await authStorage.login(resolvedId, {
422
+ onAuth: ({ url, instructions }) => {
423
+ spin.stop(instructions ? `${instructions}\n ${url}` : url);
424
+ openBrowser(url);
425
+ },
426
+ onPrompt: async ({ message }) => {
427
+ const input = await text({ message });
428
+ if (isCancel(input)) throw new Error("Canceled.");
429
+ return input;
430
+ },
431
+ onManualCodeInput: async () => {
432
+ const input = await text({ message: "Paste the authorization code or redirect URL:" });
433
+ if (isCancel(input)) throw new Error("Canceled.");
434
+ return input;
435
+ },
436
+ onProgress: (message) => {
437
+ spin.start(message);
438
+ },
439
+ });
440
+ spin.stop(`Logged in to ${resolvedId}`);
441
+ outro("Done.");
442
+ } catch (err) {
443
+ spin.stop("Login failed.");
444
+ fail(err instanceof Error ? err.message : String(err));
427
445
  }
446
+ }
428
447
 
429
- const filtered = filterCatalog(catalog.models, {
430
- reasoningOnly,
431
- minContext,
432
- });
448
+ async function runConfigurator({ agentDir, settingsManager, authStorage, modelRegistry }) {
449
+ intro("infer config");
433
450
 
434
- if (filtered.length === 0) {
435
- note("No models matched those filters.", "No matches");
436
- outro("Canceled.");
451
+ const catalog = buildLocalCatalog(modelRegistry);
452
+ if (catalog.models.length === 0) {
453
+ outro("No local models found. Check your installation.");
437
454
  return;
438
455
  }
439
456
 
440
- const providerOptions = buildProviderOptions(filtered);
457
+ const providerOptions = buildProviderOptions(catalog.models);
441
458
  const providerId = await autocomplete({
442
459
  message: "Provider",
443
460
  options: providerOptions,
@@ -448,7 +465,7 @@ async function runConfigurator({ agentDir, settingsManager, authStorage, modelRe
448
465
  return;
449
466
  }
450
467
 
451
- const modelOptions = buildModelOptions(filtered, providerId);
468
+ const modelOptions = buildModelOptions(catalog.models, providerId);
452
469
  if (modelOptions.length === 0) {
453
470
  note("No models found for that provider.", "No matches");
454
471
  outro("Canceled.");
@@ -525,6 +542,46 @@ async function runConfigurator({ agentDir, settingsManager, authStorage, modelRe
525
542
  )}.`,
526
543
  "Done",
527
544
  );
545
+
546
+ const configureClassifier = await confirm({
547
+ message: "Configure a classifier model for auto-approving safe bash commands?",
548
+ initialValue: false,
549
+ });
550
+ if (isCancel(configureClassifier)) {
551
+ outro("Configuration complete.");
552
+ return;
553
+ }
554
+
555
+ if (configureClassifier) {
556
+ const classifierProviderOptions = buildProviderOptions(catalog.models);
557
+ const classifierProviderId = await autocomplete({
558
+ message: "Classifier provider",
559
+ options: classifierProviderOptions,
560
+ maxItems: 12,
561
+ });
562
+ if (isCancel(classifierProviderId)) {
563
+ outro("Configuration complete.");
564
+ return;
565
+ }
566
+
567
+ const classifierModelOptions = buildModelOptions(catalog.models, classifierProviderId);
568
+ if (classifierModelOptions.length === 0) {
569
+ note("No models found for that provider.", "Skipping classifier");
570
+ } else {
571
+ const classifierModelId = await autocomplete({
572
+ message: "Classifier model",
573
+ options: classifierModelOptions,
574
+ maxItems: 12,
575
+ });
576
+ if (isCancel(classifierModelId)) {
577
+ outro("Configuration complete.");
578
+ return;
579
+ }
580
+ saveClassifierConfig(agentDir, { provider: classifierProviderId, model: classifierModelId });
581
+ note(`Classifier: ${classifierProviderId}/${classifierModelId}`, "Classifier saved");
582
+ }
583
+ }
584
+
528
585
  outro("Configuration complete.");
529
586
  }
530
587
 
@@ -539,67 +596,6 @@ function buildLocalCatalog(modelRegistry) {
539
596
  maxTokens: typeof model.maxTokens === "number" ? model.maxTokens : 0,
540
597
  }));
541
598
  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
599
  models,
604
600
  index: indexModels(models),
605
601
  };
@@ -616,18 +612,6 @@ function indexModels(models) {
616
612
  return map;
617
613
  }
618
614
 
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
615
  function buildProviderOptions(models) {
632
616
  const counts = new Map();
633
617
  const names = new Map();
@@ -672,14 +656,48 @@ function formatContext(value) {
672
656
  return String(value);
673
657
  }
674
658
 
675
- function resolveNumber(value, fallback) {
676
- if (typeof value === "number" && !Number.isNaN(value)) {
677
- return value;
659
+ function detectSandbox() {
660
+ try {
661
+ if (process.platform === "linux") {
662
+ execFileSync("which", ["bwrap"], { stdio: "ignore" });
663
+ return "bwrap";
664
+ }
665
+ if (process.platform === "darwin") {
666
+ execFileSync("which", ["sandbox-exec"], { stdio: "ignore" });
667
+ return "sandbox-exec";
668
+ }
669
+ } catch {}
670
+ return null;
671
+ }
672
+
673
+ function wrapWithSandbox(command, sandboxBin) {
674
+ const q = "'" + command.replace(/'/g, "'\\''") + "'";
675
+ if (sandboxBin === "bwrap") {
676
+ return `bwrap --ro-bind / / --dev-bind /dev /dev --proc /proc --tmpfs /tmp -- sh -c ${q}`;
678
677
  }
679
- if (typeof fallback === "number" && !Number.isNaN(fallback)) {
680
- return fallback;
678
+ if (sandboxBin === "sandbox-exec") {
679
+ const profile = "(version 1)(deny default)(allow file-read* file-map-executable process-exec process-fork signal sysctl-read mach-lookup)";
680
+ return `sandbox-exec -p '${profile}' sh -c ${q}`;
681
681
  }
682
- return 0;
682
+ return command;
683
+ }
684
+
685
+ function loadClassifierConfig(agentDir) {
686
+ const file = join(agentDir, "classifier.json");
687
+ if (!existsSync(file)) return null;
688
+ try {
689
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
690
+ if (typeof parsed.provider === "string" && typeof parsed.model === "string") {
691
+ return { provider: parsed.provider, model: parsed.model };
692
+ }
693
+ return null;
694
+ } catch {
695
+ return null;
696
+ }
697
+ }
698
+
699
+ function saveClassifierConfig(agentDir, config) {
700
+ writeFileSync(join(agentDir, "classifier.json"), JSON.stringify(config, null, 2), "utf-8");
683
701
  }
684
702
 
685
703
  function gray(text) {
@@ -689,7 +707,7 @@ function gray(text) {
689
707
  return `${DIM}${text}${RESET}`;
690
708
  }
691
709
 
692
- function createBashApprovalExtension() {
710
+ function createBashApprovalExtension({ modelRegistry, classifierConfig, sandboxBin, sandboxState }) {
693
711
  let allowAll = false;
694
712
 
695
713
  return (pi) => {
@@ -699,20 +717,40 @@ function createBashApprovalExtension() {
699
717
  }
700
718
 
701
719
  if (allowAll) {
720
+ const command = typeof event.input?.command === "string" ? event.input.command : "";
721
+ process.stdout.write(gray(`! ${command}\n`));
702
722
  return;
703
723
  }
704
724
 
705
- const command = typeof event.input?.command === "string" ? event.input.command : "";
706
725
  if (!process.stdin.isTTY) {
707
726
  return { block: true, reason: "Bash command blocked: no TTY for approval." };
708
727
  }
709
728
 
710
- suppressBashToolLine = true;
729
+ const command = typeof event.input?.command === "string" ? event.input.command : "";
730
+
731
+ if (classifierConfig) {
732
+ const classification = await classifyCommand(command, { modelRegistry, classifierConfig });
733
+ if (classification.harmless) {
734
+ process.stdout.write(gray(`✓ ${classification.description}\n`));
735
+ if (sandboxBin) sandboxState.next = true;
736
+ return;
737
+ }
738
+ let decision;
739
+ try {
740
+ decision = await promptBashApproval(command, classification.description);
741
+ } catch (e) {
742
+ throw e;
743
+ }
744
+ if (decision === "accept_all") { allowAll = true; return; }
745
+ if (decision === "accept") return;
746
+ return { block: true, reason: "Bash command rejected by user." };
747
+ }
748
+
711
749
  let decision;
712
750
  try {
713
751
  decision = await promptBashApproval(command);
714
- } finally {
715
- suppressBashToolLine = false;
752
+ } catch (e) {
753
+ throw e;
716
754
  }
717
755
 
718
756
  if (decision === "accept_all") {
@@ -729,9 +767,57 @@ function createBashApprovalExtension() {
729
767
  };
730
768
  }
731
769
 
732
- async function promptBashApproval(command) {
770
+ async function classifyCommand(command, { modelRegistry, classifierConfig }) {
771
+ try {
772
+ const model = modelRegistry.find(classifierConfig.provider, classifierConfig.model);
773
+ if (!model) return { harmless: false, description: command };
774
+
775
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
776
+ if (!auth.ok || !auth.apiKey) return { harmless: false, description: command };
777
+
778
+ const result = await completeSimple(
779
+ model,
780
+ {
781
+ systemPrompt: `Classify a bash command. Respond ONLY with JSON: {"description":"<concise action, max 6 words>","harmless":<true|false>}
782
+
783
+ "harmless" is true ONLY if the command is purely read-only and non-destructive:
784
+ - Reading files, listing directories, searching content (cat, ls, find, grep, head, tail, wc, etc.)
785
+ - Fetching URLs for display only (curl/wget without -o or piping to shell)
786
+ - Checking system state (ps, env, which, pwd, uname, echo, etc.)
787
+
788
+ "harmless" is false if the command:
789
+ - Writes, creates, moves, copies, or deletes files
790
+ - Makes API calls with side effects
791
+ - Downloads and saves files
792
+ - Runs scripts (.sh, .py, etc.) without reading them first
793
+ - Uses sudo or elevated privileges
794
+ - Pipes into another shell or interpreter
795
+ - Has any side effects beyond reading`,
796
+ messages: [{ role: "user", content: command, timestamp: Date.now() }],
797
+ },
798
+ { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 500 }
799
+ );
800
+
801
+ const text = result.content
802
+ .filter((b) => b.type === "text")
803
+ .map((b) => b.text)
804
+ .join("")
805
+ .trim();
806
+ const match = text.match(/\{[\s\S]*\}/);
807
+ if (!match) return { harmless: false, description: command };
808
+ const parsed = JSON.parse(match[0]);
809
+ return {
810
+ description: typeof parsed.description === "string" ? parsed.description : command,
811
+ harmless: parsed.harmless === true,
812
+ };
813
+ } catch {
814
+ return { harmless: false, description: command };
815
+ }
816
+ }
817
+
818
+ async function promptBashApproval(command, description) {
733
819
  return inquirerSelect({
734
- message: gray(`! ${command || "(empty)"}`),
820
+ message: gray(`! ${description || command || "(empty)"}`),
735
821
  choices: [
736
822
  { value: "accept", name: "Accept" },
737
823
  { value: "reject", name: "Reject" },