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
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
+ }