@olahulleberg/infer 0.1.0 → 0.2.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 +70 -23
  2. package/package.json +2 -2
  3. package/src/cli.js +202 -172
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,77 @@ 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:
67
+
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
78
+
79
+ ## Classifier
51
80
 
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
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.
57
82
 
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`
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
+ ## Sessions
99
+
100
+ - Default: fresh session, clears previous
101
+ - Continue: `-c`, `-r`, `--continue`, `--resume`
102
+ - Storage: `~/.infer/agent/sessions/last.jsonl`
62
103
 
63
104
  ## Flags
64
105
 
@@ -68,5 +109,11 @@ Every `bash` tool call asks for approval:
68
109
  | `-p`, `--provider <name>` | Model provider |
69
110
  | `-m`, `--model <id>` | Model id |
70
111
  | `--thinking <level>` | off \| minimal \| low \| medium \| high \| xhigh |
71
- | `--source <local\|models.dev>` | Model source for `infer config` |
72
112
  | `-h`, `--help` | Show help |
113
+ | `-v`, `--version` | Show version |
114
+
115
+ ## Config dir
116
+
117
+ `~/.infer/agent` — override with `INFER_AGENT_DIR`.
118
+
119
+ 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.0",
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 } 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();
@@ -156,7 +153,7 @@ const resourceLoader = new DefaultResourceLoader({
156
153
  cwd: process.cwd(),
157
154
  agentDir,
158
155
  settingsManager,
159
- extensionFactories: [createBashApprovalExtension()],
156
+ extensionFactories: [createBashApprovalExtension({ modelRegistry, classifierConfig: loadClassifierConfig(agentDir) })],
160
157
  });
161
158
 
162
159
  await resourceLoader.reload();
@@ -183,11 +180,10 @@ const { session } = await createAgentSession({
183
180
 
184
181
  let lastAssistantText = "";
185
182
  let printedToolLine = false;
186
- let suppressBashToolLine = false;
187
183
 
188
184
  session.subscribe((event) => {
189
185
  if (event.type === "tool_execution_start") {
190
- if (event.toolName === "bash" && suppressBashToolLine) {
186
+ if (event.toolName === "bash") {
191
187
  return;
192
188
  }
193
189
  const line = formatToolLine(event.toolName, event.args);
@@ -337,13 +333,13 @@ function printHelp() {
337
333
  process.stdout.write(
338
334
  `Usage: ${COMMAND_NAME} [options] <prompt>\n\n` +
339
335
  `Commands:\n` +
340
- ` config Interactive configuration\n\n` +
336
+ ` config Interactive configuration\n` +
337
+ ` login [provider] Login to an OAuth provider\n\n` +
341
338
  `Options:\n` +
342
339
  ` -c, --continue, -r, --resume Continue last session\n` +
343
340
  ` -p, --provider <name> Model provider\n` +
344
341
  ` -m, --model <id> Model id\n` +
345
342
  ` --thinking <level> off|minimal|low|medium|high|xhigh\n` +
346
- ` --source <local|models.dev> Model source for config\n` +
347
343
  ` -h, --help Show help\n` +
348
344
  ` -v, --version Show version\n`,
349
345
  );
@@ -361,83 +357,75 @@ function requireValue(flag, value) {
361
357
  return value;
362
358
  }
363
359
 
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
- }
360
+ function openBrowser(url) {
361
+ const cmd =
362
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
363
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
364
+ }
372
365
 
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
- }));
366
+ async function runLogin({ authStorage, providerId }) {
367
+ intro("infer login");
383
368
 
384
- if (isCancel(selectedSource)) {
385
- outro("Canceled.");
369
+ const providers = authStorage.getOAuthProviders();
370
+ if (providers.length === 0) {
371
+ outro("No OAuth providers available.");
386
372
  return;
387
373
  }
388
374
 
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;
375
+ let resolvedId = providerId;
376
+ if (!resolvedId) {
377
+ const choice = await select({
378
+ message: "Provider",
379
+ options: providers.map((p) => ({ value: p.id, label: p.name ?? p.id })),
380
+ });
381
+ if (isCancel(choice)) {
382
+ outro("Canceled.");
383
+ return;
401
384
  }
385
+ resolvedId = choice;
402
386
  }
403
387
 
404
- const reasoningOnly = await confirm({
405
- message: "Require reasoning support?",
406
- initialValue: false,
407
- });
408
- if (isCancel(reasoningOnly)) {
409
- outro("Canceled.");
410
- return;
411
- }
388
+ const spin = spinner();
389
+ spin.start(`Logging in to ${resolvedId}`);
412
390
 
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;
391
+ try {
392
+ await authStorage.login(resolvedId, {
393
+ onAuth: ({ url, instructions }) => {
394
+ spin.stop(instructions ? `${instructions}\n ${url}` : url);
395
+ openBrowser(url);
396
+ },
397
+ onPrompt: async ({ message }) => {
398
+ const input = await text({ message });
399
+ if (isCancel(input)) throw new Error("Canceled.");
400
+ return input;
401
+ },
402
+ onManualCodeInput: async () => {
403
+ const input = await text({ message: "Paste the authorization code or redirect URL:" });
404
+ if (isCancel(input)) throw new Error("Canceled.");
405
+ return input;
406
+ },
407
+ onProgress: (message) => {
408
+ spin.start(message);
409
+ },
410
+ });
411
+ spin.stop(`Logged in to ${resolvedId}`);
412
+ outro("Done.");
413
+ } catch (err) {
414
+ spin.stop("Login failed.");
415
+ fail(err instanceof Error ? err.message : String(err));
427
416
  }
417
+ }
428
418
 
429
- const filtered = filterCatalog(catalog.models, {
430
- reasoningOnly,
431
- minContext,
432
- });
419
+ async function runConfigurator({ agentDir, settingsManager, authStorage, modelRegistry }) {
420
+ intro("infer config");
433
421
 
434
- if (filtered.length === 0) {
435
- note("No models matched those filters.", "No matches");
436
- outro("Canceled.");
422
+ const catalog = buildLocalCatalog(modelRegistry);
423
+ if (catalog.models.length === 0) {
424
+ outro("No local models found. Check your installation.");
437
425
  return;
438
426
  }
439
427
 
440
- const providerOptions = buildProviderOptions(filtered);
428
+ const providerOptions = buildProviderOptions(catalog.models);
441
429
  const providerId = await autocomplete({
442
430
  message: "Provider",
443
431
  options: providerOptions,
@@ -448,7 +436,7 @@ async function runConfigurator({ agentDir, settingsManager, authStorage, modelRe
448
436
  return;
449
437
  }
450
438
 
451
- const modelOptions = buildModelOptions(filtered, providerId);
439
+ const modelOptions = buildModelOptions(catalog.models, providerId);
452
440
  if (modelOptions.length === 0) {
453
441
  note("No models found for that provider.", "No matches");
454
442
  outro("Canceled.");
@@ -525,6 +513,46 @@ async function runConfigurator({ agentDir, settingsManager, authStorage, modelRe
525
513
  )}.`,
526
514
  "Done",
527
515
  );
516
+
517
+ const configureClassifier = await confirm({
518
+ message: "Configure a classifier model for auto-approving safe bash commands?",
519
+ initialValue: false,
520
+ });
521
+ if (isCancel(configureClassifier)) {
522
+ outro("Configuration complete.");
523
+ return;
524
+ }
525
+
526
+ if (configureClassifier) {
527
+ const classifierProviderOptions = buildProviderOptions(catalog.models);
528
+ const classifierProviderId = await autocomplete({
529
+ message: "Classifier provider",
530
+ options: classifierProviderOptions,
531
+ maxItems: 12,
532
+ });
533
+ if (isCancel(classifierProviderId)) {
534
+ outro("Configuration complete.");
535
+ return;
536
+ }
537
+
538
+ const classifierModelOptions = buildModelOptions(catalog.models, classifierProviderId);
539
+ if (classifierModelOptions.length === 0) {
540
+ note("No models found for that provider.", "Skipping classifier");
541
+ } else {
542
+ const classifierModelId = await autocomplete({
543
+ message: "Classifier model",
544
+ options: classifierModelOptions,
545
+ maxItems: 12,
546
+ });
547
+ if (isCancel(classifierModelId)) {
548
+ outro("Configuration complete.");
549
+ return;
550
+ }
551
+ saveClassifierConfig(agentDir, { provider: classifierProviderId, model: classifierModelId });
552
+ note(`Classifier: ${classifierProviderId}/${classifierModelId}`, "Classifier saved");
553
+ }
554
+ }
555
+
528
556
  outro("Configuration complete.");
529
557
  }
530
558
 
@@ -539,67 +567,6 @@ function buildLocalCatalog(modelRegistry) {
539
567
  maxTokens: typeof model.maxTokens === "number" ? model.maxTokens : 0,
540
568
  }));
541
569
  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
570
  models,
604
571
  index: indexModels(models),
605
572
  };
@@ -616,18 +583,6 @@ function indexModels(models) {
616
583
  return map;
617
584
  }
618
585
 
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
586
  function buildProviderOptions(models) {
632
587
  const counts = new Map();
633
588
  const names = new Map();
@@ -672,14 +627,22 @@ function formatContext(value) {
672
627
  return String(value);
673
628
  }
674
629
 
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;
630
+ function loadClassifierConfig(agentDir) {
631
+ const file = join(agentDir, "classifier.json");
632
+ if (!existsSync(file)) return null;
633
+ try {
634
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
635
+ if (typeof parsed.provider === "string" && typeof parsed.model === "string") {
636
+ return { provider: parsed.provider, model: parsed.model };
637
+ }
638
+ return null;
639
+ } catch {
640
+ return null;
681
641
  }
682
- return 0;
642
+ }
643
+
644
+ function saveClassifierConfig(agentDir, config) {
645
+ writeFileSync(join(agentDir, "classifier.json"), JSON.stringify(config, null, 2), "utf-8");
683
646
  }
684
647
 
685
648
  function gray(text) {
@@ -689,7 +652,7 @@ function gray(text) {
689
652
  return `${DIM}${text}${RESET}`;
690
653
  }
691
654
 
692
- function createBashApprovalExtension() {
655
+ function createBashApprovalExtension({ modelRegistry, classifierConfig }) {
693
656
  let allowAll = false;
694
657
 
695
658
  return (pi) => {
@@ -699,20 +662,39 @@ function createBashApprovalExtension() {
699
662
  }
700
663
 
701
664
  if (allowAll) {
665
+ const command = typeof event.input?.command === "string" ? event.input.command : "";
666
+ process.stdout.write(gray(`! ${command}\n`));
702
667
  return;
703
668
  }
704
669
 
705
- const command = typeof event.input?.command === "string" ? event.input.command : "";
706
670
  if (!process.stdin.isTTY) {
707
671
  return { block: true, reason: "Bash command blocked: no TTY for approval." };
708
672
  }
709
673
 
710
- suppressBashToolLine = true;
674
+ const command = typeof event.input?.command === "string" ? event.input.command : "";
675
+
676
+ if (classifierConfig) {
677
+ const classification = await classifyCommand(command, { modelRegistry, classifierConfig });
678
+ if (classification.harmless) {
679
+ process.stdout.write(gray(`✓ ${classification.description}\n`));
680
+ return;
681
+ }
682
+ let decision;
683
+ try {
684
+ decision = await promptBashApproval(command, classification.description);
685
+ } catch (e) {
686
+ throw e;
687
+ }
688
+ if (decision === "accept_all") { allowAll = true; return; }
689
+ if (decision === "accept") return;
690
+ return { block: true, reason: "Bash command rejected by user." };
691
+ }
692
+
711
693
  let decision;
712
694
  try {
713
695
  decision = await promptBashApproval(command);
714
- } finally {
715
- suppressBashToolLine = false;
696
+ } catch (e) {
697
+ throw e;
716
698
  }
717
699
 
718
700
  if (decision === "accept_all") {
@@ -729,9 +711,57 @@ function createBashApprovalExtension() {
729
711
  };
730
712
  }
731
713
 
732
- async function promptBashApproval(command) {
714
+ async function classifyCommand(command, { modelRegistry, classifierConfig }) {
715
+ try {
716
+ const model = modelRegistry.find(classifierConfig.provider, classifierConfig.model);
717
+ if (!model) return { harmless: false, description: command };
718
+
719
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
720
+ if (!auth.ok || !auth.apiKey) return { harmless: false, description: command };
721
+
722
+ const result = await completeSimple(
723
+ model,
724
+ {
725
+ systemPrompt: `Classify a bash command. Respond ONLY with JSON: {"description":"<concise action, max 6 words>","harmless":<true|false>}
726
+
727
+ "harmless" is true ONLY if the command is purely read-only and non-destructive:
728
+ - Reading files, listing directories, searching content (cat, ls, find, grep, head, tail, wc, etc.)
729
+ - Fetching URLs for display only (curl/wget without -o or piping to shell)
730
+ - Checking system state (ps, env, which, pwd, uname, echo, etc.)
731
+
732
+ "harmless" is false if the command:
733
+ - Writes, creates, moves, copies, or deletes files
734
+ - Makes API calls with side effects
735
+ - Downloads and saves files
736
+ - Runs scripts (.sh, .py, etc.) without reading them first
737
+ - Uses sudo or elevated privileges
738
+ - Pipes into another shell or interpreter
739
+ - Has any side effects beyond reading`,
740
+ messages: [{ role: "user", content: command, timestamp: Date.now() }],
741
+ },
742
+ { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 500 }
743
+ );
744
+
745
+ const text = result.content
746
+ .filter((b) => b.type === "text")
747
+ .map((b) => b.text)
748
+ .join("")
749
+ .trim();
750
+ const match = text.match(/\{[\s\S]*\}/);
751
+ if (!match) return { harmless: false, description: command };
752
+ const parsed = JSON.parse(match[0]);
753
+ return {
754
+ description: typeof parsed.description === "string" ? parsed.description : command,
755
+ harmless: parsed.harmless === true,
756
+ };
757
+ } catch {
758
+ return { harmless: false, description: command };
759
+ }
760
+ }
761
+
762
+ async function promptBashApproval(command, description) {
733
763
  return inquirerSelect({
734
- message: gray(`! ${command || "(empty)"}`),
764
+ message: gray(`! ${description || command || "(empty)"}`),
735
765
  choices: [
736
766
  { value: "accept", name: "Accept" },
737
767
  { value: "reject", name: "Reject" },