@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.
- package/README.md +80 -23
- package/package.json +2 -2
- 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
|
|
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 "
|
|
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:
|
|
51
67
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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": "
|
|
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 =
|
|
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"
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
}));
|
|
395
|
+
async function runLogin({ authStorage, providerId }) {
|
|
396
|
+
intro("infer login");
|
|
383
397
|
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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;
|
|
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
|
|
405
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
minContext,
|
|
432
|
-
});
|
|
448
|
+
async function runConfigurator({ agentDir, settingsManager, authStorage, modelRegistry }) {
|
|
449
|
+
intro("infer config");
|
|
433
450
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
outro("
|
|
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(
|
|
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(
|
|
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
|
|
676
|
-
|
|
677
|
-
|
|
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 (
|
|
680
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
715
|
-
|
|
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
|
|
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" },
|