subrouter-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CLAUDE.md +51 -0
  2. package/README.md +397 -2
  3. package/config.example.json +9 -0
  4. package/package.json +21 -4
  5. package/scripts/sse-harness.ts +252 -0
  6. package/scripts/sub-wrapper.sh +11 -0
  7. package/src/adapters/anthropic.ts +311 -0
  8. package/src/adapters/openai.ts +227 -0
  9. package/src/approval.ts +21 -0
  10. package/src/chatviewport.ts +123 -0
  11. package/src/client.ts +398 -0
  12. package/src/clipboard.ts +62 -0
  13. package/src/commandpolicy.ts +272 -0
  14. package/src/commands.ts +18 -0
  15. package/src/config.ts +107 -0
  16. package/src/effort.ts +18 -0
  17. package/src/images.ts +77 -0
  18. package/src/index.ts +248 -0
  19. package/src/lineinput.ts +726 -0
  20. package/src/loop.ts +201 -0
  21. package/src/markdown.ts +244 -0
  22. package/src/repl.ts +700 -0
  23. package/src/sessions.ts +58 -0
  24. package/src/sse.ts +64 -0
  25. package/src/terminal.ts +228 -0
  26. package/src/token.ts +36 -0
  27. package/src/toolpreview.ts +54 -0
  28. package/src/tools.ts +790 -0
  29. package/src/types.ts +73 -0
  30. package/src/ui.ts +813 -0
  31. package/src/usage.ts +186 -0
  32. package/test/absolute-tools.test.ts +296 -0
  33. package/test/absolute-ui.test.ts +153 -0
  34. package/test/adapters.test.ts +205 -0
  35. package/test/anthropic.test.ts +246 -0
  36. package/test/auto-ui.test.ts +106 -0
  37. package/test/chatviewport.test.ts +49 -0
  38. package/test/client.test.ts +327 -0
  39. package/test/clipboard.test.ts +63 -0
  40. package/test/command-input.test.ts +183 -0
  41. package/test/command-tools.test.ts +141 -0
  42. package/test/commandpolicy.test.ts +252 -0
  43. package/test/disk-tools.test.ts +220 -0
  44. package/test/effort.test.ts +123 -0
  45. package/test/fixtures/openai-tools.sse +84 -0
  46. package/test/image-adapters.test.ts +78 -0
  47. package/test/image-ui.test.ts +151 -0
  48. package/test/images.test.ts +60 -0
  49. package/test/loop.test.ts +387 -0
  50. package/test/m5.test.ts +127 -0
  51. package/test/markdown.test.ts +201 -0
  52. package/test/repl-ui.test.ts +293 -0
  53. package/test/sse.test.ts +74 -0
  54. package/test/steering-ui.test.ts +182 -0
  55. package/test/steering.test.ts +68 -0
  56. package/test/terminal-ui.test.ts +144 -0
  57. package/test/terminal.test.ts +229 -0
  58. package/test/toolpreview.test.ts +51 -0
  59. package/test/tools.test.ts +227 -0
  60. package/test/ui.test.ts +635 -0
  61. package/test/usage-footer.test.ts +180 -0
  62. package/tsconfig.json +17 -0
@@ -0,0 +1,272 @@
1
+ import { constants } from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export interface CommandPolicyResult { auto: boolean; reason: string }
6
+
7
+ const MAX_COMMAND = 8192;
8
+ const MAX_MANIFEST = 64 * 1024;
9
+ const MAX_DEPTH = 8;
10
+ const MAX_COUNT = 64;
11
+ const MAX_TEXT = 32 * 1024;
12
+ const ASK: CommandPolicyResult = { auto: false, reason: 'This command requires explicit approval.' };
13
+ const TRUSTED: CommandPolicyResult = {
14
+ auto: true,
15
+ reason: 'Recognized project check or build; it runs trusted project code and is not sandboxed.',
16
+ };
17
+ const INSPECT: CommandPolicyResult = {
18
+ auto: true,
19
+ reason: 'Recognized inspection command; trusted tool configuration still applies and this is not a sandbox.',
20
+ };
21
+ interface Context { root: string; count: number; text: number; active: Set<string> }
22
+
23
+ // Deliberately not a shell parser: only whole literal/quoted tokens and &&.
24
+ // Reject even quoted metacharacters rather than try to model shell expansions.
25
+ function lex(command: string): string[][] | undefined {
26
+ if (!command || command.length > MAX_COMMAND || /[^\x20-\x7e]|[$`\\;|<>#~!*?()[\]{}^]/.test(command)) return;
27
+ const segments: string[][] = [[]];
28
+ let i = 0;
29
+ while (i < command.length) {
30
+ if (command[i] === ' ') { i++; continue; }
31
+ if (command.slice(i, i + 2) === '&&') {
32
+ if (!segments.at(-1)!.length || segments.length >= 16) return;
33
+ segments.push([]); i += 2; continue;
34
+ }
35
+ let token = '';
36
+ const quote = command[i] === '"' || command[i] === "'" ? command[i++] : undefined;
37
+ if (quote) {
38
+ const start = i;
39
+ while (i < command.length && command[i] !== quote) i++;
40
+ if (i === command.length) return;
41
+ token = command.slice(start, i++);
42
+ if (!token || !/^[A-Za-z0-9_./:@%+,= -]+$/.test(token)) return;
43
+ } else {
44
+ const start = i;
45
+ while (i < command.length && command[i] !== ' ' && command[i] !== '&') i++;
46
+ token = command.slice(start, i);
47
+ if (!token || !/^[A-Za-z0-9_./:@%+,=-]+$/.test(token)) return;
48
+ }
49
+ if (i < command.length && command[i] !== ' ' && command.slice(i, i + 2) !== '&&') return;
50
+ const segment = segments.at(-1)!;
51
+ if (segment.length >= 128) return;
52
+ segment.push(token);
53
+ }
54
+ return segments.at(-1)!.length ? segments : undefined;
55
+ }
56
+
57
+ // No absolute paths, parent components, option-like paths, or Git pathspec magic.
58
+ // Existing paths must also resolve inside cwd (including through symlinks).
59
+ async function localPath(value: string, ctx: Context, kind: 'any' | 'script' | 'json' = 'any'): Promise<boolean> {
60
+ if (!value || value.startsWith('-') || path.isAbsolute(value) || !/^[A-Za-z0-9_./@+ -]+$/.test(value)) return false;
61
+ if (value.split('/').some((part) => part === '..' || part.startsWith('-'))) return false;
62
+ if (kind === 'script' && !/\.[cm]?[jt]s$/.test(value)) return false;
63
+ if (kind === 'json' && !value.endsWith('.json')) return false;
64
+ try {
65
+ const real = await fs.realpath(path.resolve(ctx.root, value));
66
+ const relative = path.relative(ctx.root, real);
67
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
68
+ const stat = await fs.stat(real);
69
+ return kind === 'any' ? stat.isFile() || stat.isDirectory() : stat.isFile();
70
+ } catch { return false; }
71
+ }
72
+
73
+ async function readScripts(root: string): Promise<Record<string, unknown> | undefined> {
74
+ // O_NONBLOCK prevents a FIFO from hanging before fstat; O_NOFOLLOW rejects a
75
+ // symlinked manifest. Read one bounded buffer, not readFile (files can grow).
76
+ if (typeof constants.O_NOFOLLOW !== 'number' || typeof constants.O_NONBLOCK !== 'number') return;
77
+ let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
78
+ try {
79
+ handle = await fs.open(path.join(root, 'package.json'), constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
80
+ const stat = await handle.stat();
81
+ if (!stat.isFile() || stat.size > MAX_MANIFEST) return;
82
+ const buffer = Buffer.alloc(MAX_MANIFEST + 1);
83
+ let length = 0;
84
+ while (length < buffer.length) {
85
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
86
+ if (!bytesRead) break;
87
+ length += bytesRead;
88
+ }
89
+ if (length > MAX_MANIFEST) return;
90
+ const manifest: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, length)));
91
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return;
92
+ const scripts = (manifest as Record<string, unknown>).scripts;
93
+ if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) return;
94
+ return scripts as Record<string, unknown>;
95
+ } catch { return; }
96
+ finally { await handle?.close().catch(() => {}); }
97
+ }
98
+
99
+ async function npmPolicy(args: string[], ctx: Context, depth: number): Promise<CommandPolicyResult> {
100
+ const script = args.length === 1 && args[0] === 'test' ? 'test'
101
+ : args.length === 2 && args[0] === 'run' && ['build', 'test', 'lint', 'typecheck', 'check'].includes(args[1]) ? args[1] : undefined;
102
+ if (!script || depth >= MAX_DEPTH || ctx.active.has(script)) return ASK;
103
+ const scripts = await readScripts(ctx.root);
104
+ if (!scripts || !Object.hasOwn(scripts, script)) return ASK;
105
+ ctx.active.add(script);
106
+ try {
107
+ for (const name of [`pre${script}`, script, `post${script}`]) {
108
+ if (!Object.hasOwn(scripts, name)) continue;
109
+ const text = scripts[name];
110
+ if (typeof text !== 'string' || !(await inspect(text, ctx, depth + 1)).auto) return ASK;
111
+ }
112
+ return TRUSTED;
113
+ } finally { ctx.active.delete(script); }
114
+ }
115
+
116
+ function positive(value: string, max: number): boolean {
117
+ return /^[1-9][0-9]*$/.test(value) && Number(value) <= max;
118
+ }
119
+
120
+ async function nodePolicy(args: string[], ctx: Context): Promise<boolean> {
121
+ if (args.shift() !== '--test') return false;
122
+ const seen = new Set<string>();
123
+ let paths = false;
124
+ for (let i = 0; i < args.length; i++) {
125
+ const arg = args[i];
126
+ if (!paths && arg === '--') { paths = true; continue; }
127
+ if (!paths && arg.startsWith('-')) {
128
+ const eq = arg.indexOf('=');
129
+ const flag = eq < 0 ? arg : arg.slice(0, eq);
130
+ if (seen.has(flag)) return false;
131
+ seen.add(flag);
132
+ if (flag === '--test-only' && eq < 0) continue;
133
+ if (!['--test-concurrency', '--test-reporter', '--test-timeout', '--test-name-pattern', '--test-skip-pattern'].includes(flag)) return false;
134
+ const value = eq < 0 ? args[++i] : arg.slice(eq + 1);
135
+ if (!value) return false;
136
+ if (flag === '--test-reporter' && !['dot', 'spec', 'tap'].includes(value)) return false;
137
+ if (flag === '--test-concurrency' && !positive(value, 256)) return false;
138
+ if (flag === '--test-timeout' && !positive(value, 600000)) return false;
139
+ if (flag.endsWith('-pattern') && !/^[A-Za-z0-9_][A-Za-z0-9_ .-]*$/.test(value)) return false;
140
+ } else if (!(await localPath(arg, ctx, 'script'))) return false;
141
+ }
142
+ return true;
143
+ }
144
+
145
+ async function tscPolicy(args: string[], ctx: Context): Promise<boolean> {
146
+ const seen = new Set<string>();
147
+ let build = false;
148
+ for (let i = 0; i < args.length; i++) {
149
+ const arg = args[i];
150
+ if (arg === '--build' || arg === '-b') {
151
+ if (i !== 0 || seen.has('project')) return false;
152
+ build = true; continue;
153
+ }
154
+ if (arg === '--project' || arg === '-p') {
155
+ if (build || seen.has('project') || !args[i + 1] || !(await localPath(args[++i], ctx, 'json'))) return false;
156
+ seen.add('project'); continue;
157
+ }
158
+ if (build && !arg.startsWith('-')) {
159
+ if (!(await localPath(arg, ctx, 'json'))) return false;
160
+ continue;
161
+ }
162
+ if (!['--noEmit', '--pretty', '--strict', '--skipLibCheck', '--incremental', '--composite'].includes(arg) || seen.has(arg)) return false;
163
+ seen.add(arg);
164
+ if (args[i + 1] === 'true' || args[i + 1] === 'false') {
165
+ if (arg === '--noEmit' && args[i + 1] === 'false') return false;
166
+ i++;
167
+ }
168
+ }
169
+ return true;
170
+ }
171
+
172
+ async function eslintPolicy(args: string[], ctx: Context): Promise<boolean> {
173
+ let paths = 0;
174
+ let positional = false;
175
+ const seen = new Set<string>();
176
+ for (let i = 0; i < args.length; i++) {
177
+ const arg = args[i];
178
+ if (!positional && arg === '--') { positional = true; continue; }
179
+ if (!positional && arg.startsWith('-')) {
180
+ const eq = arg.indexOf('=');
181
+ const flag = eq < 0 ? arg : arg.slice(0, eq);
182
+ if (seen.has(flag)) return false;
183
+ seen.add(flag);
184
+ if (['--quiet', '--no-fix', '--no-warn-ignored'].includes(flag) && eq < 0) continue;
185
+ if (!['--max-warnings', '--ext', '--format', '-f'].includes(flag)) return false;
186
+ const value = eq < 0 ? args[++i] : arg.slice(eq + 1);
187
+ if (!value) return false;
188
+ if (flag === '--max-warnings' && !/^(0|[1-9][0-9]{0,3})$/.test(value)) return false;
189
+ if (flag === '--ext' && !/^\.[cm]?[jt]sx?(,\.[cm]?[jt]sx?)*$/.test(value)) return false;
190
+ if ((flag === '--format' || flag === '-f') && !['stylish', 'json'].includes(value)) return false;
191
+ } else {
192
+ if (!(await localPath(arg, ctx))) return false;
193
+ paths++;
194
+ }
195
+ }
196
+ return paths > 0;
197
+ }
198
+
199
+ async function gitPolicy(args: string[], ctx: Context): Promise<boolean> {
200
+ // Do not silently rewrite commands. Require callback-disabling flags in the
201
+ // actual command: pager for all forms, external diff/textconv for patch tools.
202
+ if (args.shift() !== '--no-pager') return false;
203
+ const sub = args.shift();
204
+ if (!sub || !['status', 'diff', 'log', 'show', 'ls-files'].includes(sub)) return false;
205
+ if ((sub === 'diff' || sub === 'show') && (!args.includes('--no-ext-diff') || !args.includes('--no-textconv'))) return false;
206
+ const flags: Record<string, string[]> = {
207
+ status: ['--short', '-s', '--branch', '-b', '--porcelain', '--porcelain=v1', '--porcelain=v2', '--untracked-files=no', '--untracked-files=normal', '--untracked-files=all'],
208
+ diff: ['--no-ext-diff', '--no-textconv', '--no-color', '--cached', '--staged', '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--check'],
209
+ log: ['--oneline', '--no-color', '--no-decorate', '--decorate=no'],
210
+ show: ['--no-ext-diff', '--no-textconv', '--no-color', '--stat', '--name-only', '--name-status'],
211
+ 'ls-files': ['--cached', '-c', '--modified', '-m', '--deleted', '-d', '--others', '-o', '--exclude-standard', '--stage', '-s'],
212
+ };
213
+ const seen = new Set<string>();
214
+ let positional = false;
215
+ let revisions = 0;
216
+ for (let i = 0; i < args.length; i++) {
217
+ const arg = args[i];
218
+ if (!positional && arg === '--') { positional = true; continue; }
219
+ if (!positional && flags[sub].includes(arg)) {
220
+ if (seen.has(arg)) return false;
221
+ seen.add(arg); continue;
222
+ }
223
+ if (!positional && sub === 'log' && (arg === '-n' || arg === '--max-count' || arg.startsWith('--max-count='))) {
224
+ if (seen.has('count')) return false;
225
+ seen.add('count');
226
+ const value = arg.startsWith('--max-count=') ? arg.slice(12) : args[++i];
227
+ if (!value || !positive(value, 1000)) return false;
228
+ continue;
229
+ }
230
+ if (positional) {
231
+ if (!(await localPath(arg, ctx))) return false;
232
+ } else if ((sub === 'log' || sub === 'show') && revisions++ === 0 && /^[A-Za-z0-9][A-Za-z0-9_./-]*$/.test(arg) && !arg.includes('..')) {
233
+ continue;
234
+ } else return false;
235
+ }
236
+ return true;
237
+ }
238
+
239
+ async function inspect(command: string, ctx: Context, depth: number): Promise<CommandPolicyResult> {
240
+ ctx.text += command.length;
241
+ if (ctx.text > MAX_TEXT || depth > MAX_DEPTH) return ASK;
242
+ const segments = lex(command);
243
+ if (!segments) return ASK;
244
+ let project = false;
245
+ for (const [executable, ...args] of segments) {
246
+ if (++ctx.count > MAX_COUNT) return ASK;
247
+ let result: CommandPolicyResult = ASK;
248
+ if (executable === 'pwd' && args.length === 0) result = INSPECT;
249
+ else if (executable === 'git' && await gitPolicy(args, ctx)) result = INSPECT;
250
+ else if (executable === 'node' && await nodePolicy(args, ctx)) result = TRUSTED;
251
+ else if (executable === 'tsc' && await tscPolicy(args, ctx)) result = TRUSTED;
252
+ else if (executable === 'vite' && args.length === 1 && args[0] === 'build') result = TRUSTED;
253
+ else if (executable === 'eslint' && await eslintPolicy(args, ctx)) result = TRUSTED;
254
+ else if (executable === 'npm') result = await npmPolicy(args, ctx, depth);
255
+ if (!result.auto) return ASK;
256
+ project ||= result === TRUSTED;
257
+ }
258
+ return project ? TRUSTED : INSPECT;
259
+ }
260
+
261
+ /** A conservative approval heuristic, not an OS sandbox or executable verifier.
262
+ * Tests/builds and tool configuration are trusted; executors must recheck at
263
+ * approval/execution time because package scripts can change. Nothing is cached.
264
+ */
265
+ export async function commandPolicy(command: string, cwd: string): Promise<{ auto: boolean; reason: string }> {
266
+ try {
267
+ if (typeof command !== 'string' || typeof cwd !== 'string') return { ...ASK };
268
+ const root = await fs.realpath(cwd);
269
+ if (!(await fs.stat(root)).isDirectory()) return { ...ASK };
270
+ return { ...await inspect(command, { root, count: 0, text: 0, active: new Set() }, 0) };
271
+ } catch { return { ...ASK }; }
272
+ }
@@ -0,0 +1,18 @@
1
+ // One catalog for the command palette; no command text is sent to the agent.
2
+ export const COMMANDS = [
3
+ { id: '/model', description: 'Choose a model' },
4
+ { id: '/models', description: 'Browse the model catalog' },
5
+ { id: '/usage', description: 'Live provider usage and account limits' },
6
+ { id: '/effort', description: 'Choose reasoning effort' },
7
+ { id: '/mode', description: 'Permissions: ask, files, auto, or absolute (warning required)' },
8
+ { id: '/clear', description: 'Start a fresh conversation' },
9
+ { id: '/exit', description: 'Exit sub' },
10
+ ];
11
+
12
+ export function matchingCommands(text: string): typeof COMMANDS {
13
+ if (!text.startsWith('/') || /\s/.test(text)) return [];
14
+ const matches = COMMANDS.filter((command) => command.id.startsWith(text.toLowerCase()));
15
+ // /mode is also a prefix of /model: a fully typed command must win.
16
+ const exact = matches.find((command) => command.id === text.toLowerCase());
17
+ return exact ? [exact, ...matches.filter((command) => command !== exact)] : matches;
18
+ }
package/src/config.ts ADDED
@@ -0,0 +1,107 @@
1
+ // Config loading. Key precedence: SUBROUTER_API_KEY env > ~/.config/subrouter-cli/config.json.
2
+ // The real key is never logged or printed — not even in masked form (see CLAUDE.md).
3
+
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { parseEffort, type EffortLevel } from './effort.ts';
8
+
9
+ export interface SubConfig {
10
+ base_url: string;
11
+ api_key: string | null;
12
+ default_model: string | null;
13
+ reasoning_effort: EffortLevel;
14
+ max_output_tokens: number;
15
+ max_tool_rounds: number;
16
+ auto_approve: boolean;
17
+ }
18
+
19
+ export interface ResolvedConfig {
20
+ baseUrl: string; // OpenAI-compatible root (with /v1)
21
+ apiKey: string;
22
+ defaultModel: string | null;
23
+ reasoningEffort: EffortLevel;
24
+ maxOutputTokens: number;
25
+ maxToolRounds: number;
26
+ autoApprove: boolean;
27
+ configPath: string | null; // where the config file lives (null when only env was used)
28
+ }
29
+
30
+ const DEFAULTS: SubConfig = {
31
+ base_url: 'https://router.eva.pink/v1',
32
+ api_key: null,
33
+ default_model: null,
34
+ reasoning_effort: 'auto',
35
+ max_output_tokens: 8192,
36
+ max_tool_rounds: 25,
37
+ auto_approve: false,
38
+ };
39
+
40
+ export function configDir(): string {
41
+ return path.join(os.homedir(), '.config', 'subrouter-cli');
42
+ }
43
+
44
+ export function configPath(): string {
45
+ return path.join(configDir(), 'config.json');
46
+ }
47
+
48
+ function loadConfigFile(): SubConfig | null {
49
+ let raw: string;
50
+ try {
51
+ raw = fs.readFileSync(configPath(), 'utf8');
52
+ } catch {
53
+ return null; // no file yet — normal on first run
54
+ }
55
+ try {
56
+ const parsed: unknown = JSON.parse(raw);
57
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
58
+ return { ...DEFAULTS, ...(parsed as Record<string, unknown>) };
59
+ } catch {
60
+ return null; // corrupt file — caller warns
61
+ }
62
+ }
63
+
64
+ /** Merge a patch into the config file, creating it (chmod 600) if needed. */
65
+ export function saveConfigFile(patch: Partial<SubConfig>): void {
66
+ const dir = configDir();
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ const current = loadConfigFile() ?? DEFAULTS;
69
+ const next = { ...current, ...patch };
70
+ const file = configPath();
71
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
72
+ fs.chmodSync(file, 0o600);
73
+ }
74
+
75
+ export function resolveConfig(): { config: ResolvedConfig; warnings: string[] } {
76
+ const warnings: string[] = [];
77
+ const file = loadConfigFile();
78
+ if (file === null && fs.existsSync(configPath())) {
79
+ warnings.push('config file is unreadable or invalid — using defaults');
80
+ }
81
+ const reasoningEffort = parseEffort(file?.reasoning_effort) ?? DEFAULTS.reasoning_effort;
82
+ if (file && parseEffort(file.reasoning_effort) === undefined)
83
+ warnings.push('invalid reasoning_effort in config — using auto');
84
+ const envKey = process.env.SUBROUTER_API_KEY?.trim() || null;
85
+ const fileKey = (file?.api_key || '').trim() || null;
86
+ if (envKey && fileKey && envKey !== fileKey) {
87
+ warnings.push('SUBROUTER_API_KEY env overrides the key in the config file');
88
+ }
89
+ const baseUrl = (
90
+ process.env.SUBROUTER_BASE_URL?.trim() ||
91
+ file?.base_url ||
92
+ DEFAULTS.base_url
93
+ ).replace(/\/+$/, '');
94
+ return {
95
+ config: {
96
+ baseUrl,
97
+ apiKey: envKey ?? fileKey ?? '',
98
+ defaultModel: (file?.default_model || '').trim() || null,
99
+ reasoningEffort,
100
+ maxOutputTokens: file?.max_output_tokens ?? DEFAULTS.max_output_tokens,
101
+ maxToolRounds: file?.max_tool_rounds ?? DEFAULTS.max_tool_rounds,
102
+ autoApprove: file?.auto_approve ?? DEFAULTS.auto_approve,
103
+ configPath: file ? configPath() : null,
104
+ },
105
+ warnings,
106
+ };
107
+ }
package/src/effort.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Local preferences, not a promise that every routed model supports every level.
2
+ export const EFFORT_LEVELS = ['auto', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
3
+ export type EffortLevel = typeof EFFORT_LEVELS[number];
4
+
5
+ export const EFFORT_DESCRIPTIONS: Record<EffortLevel, string> = {
6
+ auto: 'Use the provider default',
7
+ low: 'Fast, lightweight reasoning',
8
+ medium: 'Balance reasoning and latency',
9
+ high: 'More thorough reasoning',
10
+ xhigh: 'Extra-high reasoning · model support varies',
11
+ max: 'Maximum reasoning · model support varies',
12
+ };
13
+
14
+ export function parseEffort(value: unknown): EffortLevel | undefined {
15
+ if (typeof value !== 'string') return undefined;
16
+ const level = value.trim().toLowerCase();
17
+ return EFFORT_LEVELS.find((effort) => effort === level);
18
+ }
package/src/images.ts ADDED
@@ -0,0 +1,77 @@
1
+ // Explicit user-selected local attachments, separate from the agent's confined file tools.
2
+ import fs from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { stripVTControlCharacters } from 'node:util';
7
+
8
+ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';
9
+ export interface ImageAttachment {
10
+ name: string;
11
+ mediaType: ImageMediaType;
12
+ data: string; // base64; never rendered in the terminal
13
+ }
14
+
15
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
16
+ export const MAX_ATTACHED_IMAGES = 4;
17
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
18
+
19
+ function mediaType(bytes: Buffer): ImageMediaType | undefined {
20
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png';
21
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg';
22
+ if (['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'))) return 'image/gif';
23
+ if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
24
+ return undefined;
25
+ }
26
+
27
+ export function attachmentPath(value: string, cwd: string): string {
28
+ let file = value.trim();
29
+ if (file.length >= 2 && (file[0] === '"' && file.at(-1) === '"' || file[0] === "'" && file.at(-1) === "'"))
30
+ file = file.slice(1, -1);
31
+ else file = file.replace(/\\([\\ '"()])/g, '$1'); // macOS drag-and-drop shell escaping
32
+ if (!file || file.includes('\0')) throw new Error('provide a local image path');
33
+ if (file === '~' || file.startsWith('~/')) file = path.join(os.homedir(), file.slice(1));
34
+ return path.resolve(cwd, file);
35
+ }
36
+
37
+ export function imageFromBytes(bytes: Buffer, name: string): ImageAttachment {
38
+ if (bytes.length > MAX_IMAGE_BYTES) throw new Error('image exceeds the 10 MiB attachment limit');
39
+ const type = mediaType(bytes);
40
+ if (!type) throw new Error('unsupported image — use PNG, JPEG, WebP, or GIF (HEIC is not supported)');
41
+ return { name: stripVTControlCharacters(path.basename(name)).replace(/[\x00-\x1f\x7f]/g, ' '), mediaType: type, data: bytes.toString('base64') };
42
+ }
43
+
44
+ export async function loadImage(value: string, cwd: string): Promise<ImageAttachment> {
45
+ const file = attachmentPath(value, cwd);
46
+ let handle: Awaited<ReturnType<typeof fs.open>>;
47
+ try { handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK); }
48
+ catch { throw new Error('could not open that image — check the path and file permissions'); }
49
+ try {
50
+ const stat = await handle.stat();
51
+ if (!stat.isFile()) throw new Error('attachments must be regular image files');
52
+ if (stat.size > MAX_IMAGE_BYTES) throw new Error('image exceeds the 10 MiB attachment limit');
53
+ const chunks: Buffer[] = [];
54
+ let size = 0;
55
+ while (true) {
56
+ const buffer = Buffer.alloc(Math.min(64 * 1024, MAX_IMAGE_BYTES + 1 - size));
57
+ const { bytesRead } = await handle.read(buffer);
58
+ if (!bytesRead) break;
59
+ size += bytesRead;
60
+ if (size > MAX_IMAGE_BYTES) throw new Error('image exceeds the 10 MiB attachment limit');
61
+ chunks.push(buffer.subarray(0, bytesRead));
62
+ }
63
+ return imageFromBytes(Buffer.concat(chunks, size), file);
64
+ } finally { await handle.close(); }
65
+ }
66
+
67
+ export function imageBytes(image: ImageAttachment): number {
68
+ return Buffer.byteLength(image.data, 'base64');
69
+ }
70
+
71
+ export function addImages(current: ImageAttachment[], added: ImageAttachment[]): ImageAttachment[] {
72
+ const images = [...current, ...added];
73
+ if (images.length > MAX_ATTACHED_IMAGES) throw new Error('attach at most 4 images per prompt — Ctrl+X clears pending images');
74
+ if (images.reduce((total, image) => total + imageBytes(image), 0) > MAX_ATTACHMENT_BYTES)
75
+ throw new Error('attached images exceed the 20 MiB total limit — Ctrl+X clears pending images');
76
+ return images;
77
+ }