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.
- package/CLAUDE.md +51 -0
- package/README.md +397 -2
- package/config.example.json +9 -0
- package/package.json +21 -4
- package/scripts/sse-harness.ts +252 -0
- package/scripts/sub-wrapper.sh +11 -0
- package/src/adapters/anthropic.ts +311 -0
- package/src/adapters/openai.ts +227 -0
- package/src/approval.ts +21 -0
- package/src/chatviewport.ts +123 -0
- package/src/client.ts +398 -0
- package/src/clipboard.ts +62 -0
- package/src/commandpolicy.ts +272 -0
- package/src/commands.ts +18 -0
- package/src/config.ts +107 -0
- package/src/effort.ts +18 -0
- package/src/images.ts +77 -0
- package/src/index.ts +248 -0
- package/src/lineinput.ts +726 -0
- package/src/loop.ts +201 -0
- package/src/markdown.ts +244 -0
- package/src/repl.ts +700 -0
- package/src/sessions.ts +58 -0
- package/src/sse.ts +64 -0
- package/src/terminal.ts +228 -0
- package/src/token.ts +36 -0
- package/src/toolpreview.ts +54 -0
- package/src/tools.ts +790 -0
- package/src/types.ts +73 -0
- package/src/ui.ts +813 -0
- package/src/usage.ts +186 -0
- package/test/absolute-tools.test.ts +296 -0
- package/test/absolute-ui.test.ts +153 -0
- package/test/adapters.test.ts +205 -0
- package/test/anthropic.test.ts +246 -0
- package/test/auto-ui.test.ts +106 -0
- package/test/chatviewport.test.ts +49 -0
- package/test/client.test.ts +327 -0
- package/test/clipboard.test.ts +63 -0
- package/test/command-input.test.ts +183 -0
- package/test/command-tools.test.ts +141 -0
- package/test/commandpolicy.test.ts +252 -0
- package/test/disk-tools.test.ts +220 -0
- package/test/effort.test.ts +123 -0
- package/test/fixtures/openai-tools.sse +84 -0
- package/test/image-adapters.test.ts +78 -0
- package/test/image-ui.test.ts +151 -0
- package/test/images.test.ts +60 -0
- package/test/loop.test.ts +387 -0
- package/test/m5.test.ts +127 -0
- package/test/markdown.test.ts +201 -0
- package/test/repl-ui.test.ts +293 -0
- package/test/sse.test.ts +74 -0
- package/test/steering-ui.test.ts +182 -0
- package/test/steering.test.ts +68 -0
- package/test/terminal-ui.test.ts +144 -0
- package/test/terminal.test.ts +229 -0
- package/test/toolpreview.test.ts +51 -0
- package/test/tools.test.ts +227 -0
- package/test/ui.test.ts +635 -0
- package/test/usage-footer.test.ts +180 -0
- package/tsconfig.json +17 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sub — a personal agentic coding CLI for the subrouter AI router.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { RouterClient, errorMessage, dialectFor } from './client.ts';
|
|
8
|
+
import { resolveConfig, saveConfigFile, type ResolvedConfig } from './config.ts';
|
|
9
|
+
import { startRepl } from './repl.ts';
|
|
10
|
+
import { renderMarkdown } from './markdown.ts';
|
|
11
|
+
import { EFFORT_LEVELS, parseEffort, type EffortLevel } from './effort.ts';
|
|
12
|
+
import { loadImage, addImages, type ImageAttachment } from './images.ts';
|
|
13
|
+
import type { ChatMessage, ChatRequest } from './types.ts';
|
|
14
|
+
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
function version(): string {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')) as {
|
|
20
|
+
version?: string;
|
|
21
|
+
};
|
|
22
|
+
return pkg.version ?? '0.0.0';
|
|
23
|
+
} catch {
|
|
24
|
+
return '0.1.0';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function printHelp(): void {
|
|
29
|
+
console.log(`sub — agentic coding CLI for the subrouter AI router
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
sub start the REPL
|
|
33
|
+
sub -p "prompt" one-shot, non-interactive
|
|
34
|
+
sub --check connectivity + model catalog diagnostic
|
|
35
|
+
sub --model <id> set the session model
|
|
36
|
+
sub --effort <level> auto, low, medium, high, xhigh, max
|
|
37
|
+
sub --image <path> attach an image (repeat for more; requires a vision model)
|
|
38
|
+
sub --absolute entire drive + arbitrary commands; warning/typed acknowledgement
|
|
39
|
+
sub --auto guarded auto: files + recognized tasks; risky/unknown commands ask
|
|
40
|
+
sub --auto-approve allow project file changes only; all terminal commands still ask
|
|
41
|
+
sub --debug print raw SSE frames (credentials never shown)
|
|
42
|
+
sub --no-color plain output
|
|
43
|
+
sub --version print the version
|
|
44
|
+
|
|
45
|
+
API key: set SUBROUTER_API_KEY or create ~/.config/subrouter-cli/config.json
|
|
46
|
+
(see config.example.json in this repo).`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Flags {
|
|
50
|
+
check: boolean;
|
|
51
|
+
showVersion: boolean;
|
|
52
|
+
showHelp: boolean;
|
|
53
|
+
model?: string;
|
|
54
|
+
effort?: EffortLevel;
|
|
55
|
+
prompt?: string;
|
|
56
|
+
images: string[];
|
|
57
|
+
debug: boolean;
|
|
58
|
+
autoApprove?: boolean;
|
|
59
|
+
autoMode?: boolean;
|
|
60
|
+
absoluteMode?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv: string[]): Flags | null {
|
|
64
|
+
const flags: Flags = { check: false, showVersion: false, showHelp: false, images: [], debug: false };
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const arg = argv[i];
|
|
67
|
+
if (arg === '--check') flags.check = true;
|
|
68
|
+
else if (arg === '--version' || arg === '-v') flags.showVersion = true;
|
|
69
|
+
else if (arg === '--help' || arg === '-h') flags.showHelp = true;
|
|
70
|
+
else if (arg === '--debug') flags.debug = true;
|
|
71
|
+
else if (arg === '--absolute') flags.absoluteMode = true;
|
|
72
|
+
else if (arg === '--auto') flags.autoMode = true;
|
|
73
|
+
else if (arg === '--auto-approve') flags.autoApprove = true;
|
|
74
|
+
else if (arg === '--no-color') process.env.NO_COLOR = '1';
|
|
75
|
+
else if ((arg === '--model' || arg === '-m') && i + 1 < argv.length) flags.model = argv[++i];
|
|
76
|
+
else if (arg === '--effort') {
|
|
77
|
+
const effort = parseEffort(argv[++i]);
|
|
78
|
+
if (!effort) {
|
|
79
|
+
console.error(`--effort requires one of: ${EFFORT_LEVELS.join(', ')}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
flags.effort = effort;
|
|
83
|
+
} else if (arg === '--image') {
|
|
84
|
+
const file = argv[++i];
|
|
85
|
+
if (!file || file.startsWith('--')) {
|
|
86
|
+
console.error('--image requires a local image path');
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
flags.images.push(file);
|
|
90
|
+
} else if ((arg === '--prompt' || arg === '-p') && i + 1 < argv.length) flags.prompt = argv[++i];
|
|
91
|
+
else {
|
|
92
|
+
console.error(`Unknown argument: ${arg}`);
|
|
93
|
+
console.error('Run `sub --help` for usage.');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if ([flags.absoluteMode, flags.autoMode, flags.autoApprove].filter(Boolean).length > 1) {
|
|
98
|
+
console.error('Choose --absolute, --auto or --auto-approve, not both or multiple.');
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return flags;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function say(line: string): void {
|
|
105
|
+
process.stdout.write(line + '\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runCheck(client: RouterClient, config: ResolvedConfig): Promise<number> {
|
|
109
|
+
say(`base: ${config.baseUrl}`);
|
|
110
|
+
const ok = await client.healthz();
|
|
111
|
+
say(`healthz: ${ok ? 'ok' : 'FAILED'}`);
|
|
112
|
+
try {
|
|
113
|
+
const models = await client.listModels();
|
|
114
|
+
say(`models: ${models.length} in catalog`);
|
|
115
|
+
for (const m of models.slice(0, 5)) {
|
|
116
|
+
const ctx = m.contextLength ? ` (${m.contextLength.toLocaleString()} ctx)` : '';
|
|
117
|
+
say(` - ${m.id}${ctx}`);
|
|
118
|
+
}
|
|
119
|
+
if (models.length > 5) say(' …');
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const e = err as { status?: number };
|
|
122
|
+
say(`models: FAILED (${e.status ?? '?'}) ${errorMessage(err)}`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
return ok ? 0 : 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Session model: --model flag > config default > first-run bootstrap.
|
|
130
|
+
* The bootstrap prefers the model verified to work on this key, then the first catalog
|
|
131
|
+
* entry, and persists the choice — never a hardcoded guess.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveSessionModel(
|
|
134
|
+
flagsModel: string | undefined,
|
|
135
|
+
config: ResolvedConfig,
|
|
136
|
+
client: RouterClient,
|
|
137
|
+
): Promise<string> {
|
|
138
|
+
if (flagsModel) return flagsModel;
|
|
139
|
+
if (config.defaultModel) return config.defaultModel;
|
|
140
|
+
let catalog;
|
|
141
|
+
try {
|
|
142
|
+
catalog = await client.listModels();
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
'No default model configured and the catalog fetch failed — pass --model <id>. ' +
|
|
146
|
+
`(${errorMessage(err)})`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const preferred = ['opencode/deepseek-v4-pro', 'deepseek/deepseek-v4-pro'];
|
|
150
|
+
const pick = preferred.find((id) => catalog.some((m) => m.id === id)) ?? catalog[0]?.id;
|
|
151
|
+
if (!pick) throw new Error('The catalog is empty — pass --model <id>.');
|
|
152
|
+
saveConfigFile({ default_model: pick });
|
|
153
|
+
return pick;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function systemPrompt(): string {
|
|
157
|
+
return `You are sub, a coding agent running in the user's terminal. Be concise and direct. Today: ${new Date().toISOString().slice(0, 10)}.`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runOneShot(
|
|
161
|
+
client: RouterClient,
|
|
162
|
+
config: ResolvedConfig,
|
|
163
|
+
model: string,
|
|
164
|
+
prompt: string,
|
|
165
|
+
images: ImageAttachment[],
|
|
166
|
+
): Promise<number> {
|
|
167
|
+
const messages: ChatMessage[] = [{ role: 'user', content: prompt, ...(images.length ? { images } : {}) }];
|
|
168
|
+
const req: ChatRequest = {
|
|
169
|
+
model,
|
|
170
|
+
dialect: dialectFor(model),
|
|
171
|
+
messages,
|
|
172
|
+
system: systemPrompt(),
|
|
173
|
+
stream: false,
|
|
174
|
+
maxTokens: config.maxOutputTokens,
|
|
175
|
+
effort: config.reasoningEffort,
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
const turn = await client.complete(req);
|
|
179
|
+
const content = turn.message.content || '(no response)';
|
|
180
|
+
say(renderMarkdown(content).replace(/\n+$/, ''));
|
|
181
|
+
if (turn.stopReason === 'length') say('! response truncated — consider a shorter scope');
|
|
182
|
+
return 0;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
say('× ' + errorMessage(err));
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function main(): Promise<number> {
|
|
190
|
+
const flags = parseArgs(process.argv.slice(2));
|
|
191
|
+
if (!flags) return 2;
|
|
192
|
+
if (flags.showVersion) {
|
|
193
|
+
console.log(version());
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
if (flags.showHelp) {
|
|
197
|
+
printHelp();
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (flags.absoluteMode && (flags.prompt !== undefined || flags.check || !process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
202
|
+
console.error('--absolute requires an interactive REPL and typed warning acknowledgement; not -p or --check.');
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
if (flags.check && flags.images.length) {
|
|
206
|
+
console.error('--image is for prompts, not --check');
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
let images: ImageAttachment[] = [];
|
|
210
|
+
try {
|
|
211
|
+
for (const file of flags.images) images = addImages(images, [await loadImage(file, process.cwd())]);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error('× ' + errorMessage(err));
|
|
214
|
+
return 2;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { config, warnings } = resolveConfig();
|
|
218
|
+
if (flags.effort) config.reasoningEffort = flags.effort;
|
|
219
|
+
for (const w of warnings) console.error('! ' + w);
|
|
220
|
+
if (!config.apiKey) {
|
|
221
|
+
console.error('No API key found.');
|
|
222
|
+
console.error('Set the SUBROUTER_API_KEY environment variable, or create:');
|
|
223
|
+
console.error(' ~/.config/subrouter-cli/config.json (see config.example.json)');
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const client = new RouterClient({
|
|
228
|
+
baseUrl: config.baseUrl,
|
|
229
|
+
apiKey: config.apiKey,
|
|
230
|
+
debug: flags.debug ? (line) => console.error('[debug] ' + line) : undefined,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (flags.check) return await runCheck(client, config);
|
|
234
|
+
|
|
235
|
+
const model = await resolveSessionModel(flags.model, config, client);
|
|
236
|
+
if (flags.prompt !== undefined) {
|
|
237
|
+
return await runOneShot(client, config, model, flags.prompt, images);
|
|
238
|
+
}
|
|
239
|
+
startRepl({ client, config, model, images, autoApprove: flags.autoApprove, autoMode: flags.autoMode, absoluteMode: flags.absoluteMode });
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
process.exitCode = await main();
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error('× ' + errorMessage(err));
|
|
247
|
+
process.exitCode = 1;
|
|
248
|
+
}
|