@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.
- package/README.md +70 -23
- package/package.json +2 -2
- 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
|
|
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 "
|
|
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
|
-
##
|
|
37
|
+
## Auth
|
|
40
38
|
|
|
41
|
-
|
|
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
|
-
|
|
41
|
+
```bash
|
|
42
|
+
export ANTHROPIC_API_KEY=sk-...
|
|
43
|
+
export OPENAI_API_KEY=sk-...
|
|
44
|
+
```
|
|
46
45
|
|
|
47
|
-
**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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": "
|
|
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 =
|
|
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"
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
|
390
|
-
if (
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
405
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
minContext,
|
|
432
|
-
});
|
|
419
|
+
async function runConfigurator({ agentDir, settingsManager, authStorage, modelRegistry }) {
|
|
420
|
+
intro("infer config");
|
|
433
421
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
outro("
|
|
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(
|
|
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(
|
|
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
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
715
|
-
|
|
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
|
|
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" },
|