ai.ts429 lines · main
1import { writeFile } from 'node:fs/promises';
2
3import { apiCall, ApiCallError } from '../api-client.js';
4import { readCredentials } from '../config.js';
5import { readProjectConfig } from '../project-config.js';
6import { banner, blankLine, error as printError, step, success } from '../output.js';
7
8/**
9 * `briven ai <subcommand>` — schema | function | explain.
10 *
11 * Proxies to the same three api endpoints the dashboard hits. Same
12 * not_configured semantics: when the api host hasn't wired
13 * BRIVEN_OLLAMA_URL the cli prints a clear "AI assistant offline"
14 * message instead of an opaque http error.
15 *
16 * usage:
17 * briven ai schema "a blog with users, posts, comments"
18 * briven ai schema "a blog…" --out briven/schema.ts
19 * briven ai function "list posts from the last 24h" --with-schema
20 * briven ai explain --file briven/functions/listPosts.ts
21 *
22 * shared flags:
23 * --out <path> write the result to <path> instead of stdout
24 * --raw emit raw response (no banner / meta footer)
25 */
26
27const SUBCOMMANDS = ['schema', 'function', 'explain'] as const;
28type Subcommand = (typeof SUBCOMMANDS)[number];
29
30export async function runAi(argv: readonly string[]): Promise<number> {
31 const [sub, ...rest] = argv;
32
33 if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
34 printHelp();
35 return 0;
36 }
37
38 if (!SUBCOMMANDS.includes(sub as Subcommand)) {
39 printError(`unknown ai subcommand '${sub}'`);
40 printHelp();
41 return 1;
42 }
43
44 const subcommand = sub as Subcommand;
45 const flags = parseFlags(rest);
46 if (flags.help) {
47 printHelp(subcommand);
48 return 0;
49 }
50
51 // Resolve the project + api credentials. Same shape as `briven invoke`:
52 // briven.json gives the projectId, ~/.config/briven/credentials.json
53 // gives the api origin + per-project key.
54 const local = await readProjectConfig();
55 if (!local?.projectId) {
56 printError('not linked to a project. run `briven link` first.');
57 return 1;
58 }
59 const creds = await readCredentials();
60 const cred = creds.projects[local.projectId];
61 if (!cred) {
62 printError(
63 `no api key on this machine for ${local.projectId}. run \`briven login\` or paste a brk_ key.`,
64 );
65 return 1;
66 }
67
68 // Read the prompt. For schema/function the prompt is positional; for
69 // explain the input is a code file (--file <path>) or a positional
70 // path. --perspective is explain-only.
71 let body: Record<string, unknown> = {};
72 let routeSuffix: string;
73 if (subcommand === 'schema') {
74 if (!flags.positional) {
75 printError('briven ai schema requires a prompt argument');
76 return 1;
77 }
78 routeSuffix = 'generate-schema';
79 body = { prompt: flags.positional };
80 } else if (subcommand === 'function') {
81 if (!flags.positional) {
82 printError('briven ai function requires a prompt argument');
83 return 1;
84 }
85 routeSuffix = 'generate-function';
86 body = { prompt: flags.positional };
87 if (flags.withSchema) {
88 const schemaCtx = await fetchSchemaContext(cred.apiOrigin, cred.apiKey, local.projectId);
89 if (schemaCtx) body.schemaContext = schemaCtx;
90 }
91 } else {
92 // explain
93 const code = flags.file ? await readFileOrFail(flags.file) : flags.positional;
94 if (!code) {
95 printError('briven ai explain requires --file <path> or an inline snippet');
96 return 1;
97 }
98 routeSuffix = 'explain-code';
99 body = { code };
100 if (flags.perspective) body.perspective = flags.perspective;
101 }
102
103 if (!flags.raw) {
104 banner(`ai ${subcommand}`);
105 blankLine();
106 step(`forwarding to ${cred.apiOrigin}${flags.stream ? ' (streaming)' : ''}`);
107 }
108
109 // Streaming path — write tokens to stdout as they arrive. Caller can
110 // pipe this into a file with `> briven/schema.ts`. `--out` is still
111 // honoured: we accumulate the stream and write at the end so the
112 // file isn't half-written if the stream errors.
113 if (flags.stream) {
114 return runStreaming({
115 apiOrigin: cred.apiOrigin,
116 apiKey: cred.apiKey,
117 projectId: local.projectId,
118 routeSuffix,
119 body,
120 flags,
121 isRaw: flags.raw,
122 });
123 }
124
125 let response: unknown;
126 try {
127 response = await apiCall<unknown>(
128 `/v1/projects/${local.projectId}/ai/${routeSuffix}`,
129 {
130 apiOrigin: cred.apiOrigin,
131 apiKey: cred.apiKey,
132 method: 'POST',
133 body,
134 },
135 );
136 } catch (err) {
137 if (err instanceof ApiCallError) {
138 if (err.code === 'not_configured') {
139 printError(
140 `AI assistant offline on this deployment (operator: set BRIVEN_OLLAMA_URL on the api host).`,
141 );
142 return 2;
143 }
144 if (err.code === 'validation_failed') {
145 printError(`validation failed: ${err.message}`);
146 return 1;
147 }
148 printError(`api error (${err.status} ${err.code}): ${err.message}`);
149 return 1;
150 }
151 printError(err instanceof Error ? err.message : 'request failed');
152 return 1;
153 }
154
155 // Each subcommand returns a different content field — collapse them.
156 const result = response as {
157 schema?: string;
158 function?: string;
159 explanation?: string;
160 model?: string;
161 elapsedMs?: number;
162 };
163 const content =
164 result.schema ?? result.function ?? result.explanation ?? '';
165
166 if (flags.out) {
167 await writeFile(flags.out, content + '\n', 'utf8');
168 if (!flags.raw) {
169 success(`wrote ${content.length} chars to ${flags.out}`);
170 if (result.model) {
171 step(`generated by ${result.model} in ${result.elapsedMs ?? 0}ms`);
172 }
173 }
174 return 0;
175 }
176
177 process.stdout.write(content + '\n');
178 if (!flags.raw && result.model) {
179 blankLine();
180 step(`generated by ${result.model} in ${result.elapsedMs ?? 0}ms`);
181 }
182 return 0;
183}
184
185interface Flags {
186 positional: string | null;
187 out: string | null;
188 file: string | null;
189 perspective: string | null;
190 withSchema: boolean;
191 raw: boolean;
192 stream: boolean;
193 help: boolean;
194}
195
196function parseFlags(argv: readonly string[]): Flags {
197 const out: Flags = {
198 positional: null,
199 out: null,
200 file: null,
201 perspective: null,
202 withSchema: false,
203 raw: false,
204 stream: false,
205 help: false,
206 };
207 for (let i = 0; i < argv.length; i++) {
208 const arg = argv[i]!;
209 if (arg === '--out' && argv[i + 1]) {
210 out.out = argv[++i]!;
211 } else if (arg === '--file' && argv[i + 1]) {
212 out.file = argv[++i]!;
213 } else if (arg === '--perspective' && argv[i + 1]) {
214 out.perspective = argv[++i]!;
215 } else if (arg === '--with-schema') {
216 out.withSchema = true;
217 } else if (arg === '--raw') {
218 out.raw = true;
219 } else if (arg === '--stream') {
220 out.stream = true;
221 } else if (arg === '--help' || arg === '-h') {
222 out.help = true;
223 } else if (!arg.startsWith('--') && out.positional === null) {
224 out.positional = arg;
225 }
226 }
227 return out;
228}
229
230/**
231 * Stream-mode path. POSTs to the /stream variant of the route, reads
232 * the response body as a UTF-8 stream, parses SSE frames, writes each
233 * token chunk to stdout in real time. Honors --out by accumulating the
234 * stream and writing the file at the end (so a stream error doesn't
235 * leave a half-written file on disk).
236 */
237async function runStreaming(args: {
238 apiOrigin: string;
239 apiKey: string;
240 projectId: string;
241 routeSuffix: string;
242 body: Record<string, unknown>;
243 flags: Flags;
244 isRaw: boolean;
245}): Promise<number> {
246 const url = `${args.apiOrigin}/v1/projects/${args.projectId}/ai/${args.routeSuffix}/stream`;
247 let res: Response;
248 try {
249 res = await fetch(url, {
250 method: 'POST',
251 headers: {
252 'content-type': 'application/json',
253 authorization: `Bearer ${args.apiKey}`,
254 accept: 'text/event-stream',
255 },
256 body: JSON.stringify(args.body),
257 });
258 } catch (err) {
259 printError(err instanceof Error ? err.message : 'network error');
260 return 1;
261 }
262
263 if (res.status === 503) {
264 printError('AI assistant offline on this deployment (operator: set BRIVEN_OLLAMA_URL).');
265 return 2;
266 }
267 if (!res.ok || !res.body) {
268 const text = await res.text().catch(() => '');
269 printError(`api error (${res.status}): ${text}`);
270 return 1;
271 }
272
273 const reader = res.body.getReader();
274 const decoder = new TextDecoder();
275 let buffer = '';
276 let accumulated = '';
277
278 while (true) {
279 const { done, value } = await reader.read();
280 if (done) break;
281 buffer += decoder.decode(value, { stream: true });
282 let idx: number;
283 while ((idx = buffer.indexOf('\n\n')) >= 0) {
284 const frame = buffer.slice(0, idx);
285 buffer = buffer.slice(idx + 2);
286 const parsed = parseSseFrame(frame);
287 if (parsed.event === 'token' && parsed.data) {
288 const chunk = unescapeJsonChunk(parsed.data);
289 accumulated += chunk;
290 // Stream to stdout when there's no --out target so the user
291 // sees tokens land in their terminal as the model produces them.
292 if (!args.flags.out) process.stdout.write(chunk);
293 } else if (parsed.event === 'error') {
294 if (!args.flags.out) process.stdout.write('\n');
295 printError(parsed.data || 'stream error');
296 return 1;
297 }
298 // 'done' just ends the stream — the reader will see EOF next.
299 }
300 }
301
302 if (args.flags.out) {
303 await writeFile(args.flags.out, accumulated + '\n', 'utf8');
304 if (!args.isRaw) {
305 success(`wrote ${accumulated.length} chars to ${args.flags.out}`);
306 }
307 } else if (!args.isRaw) {
308 process.stdout.write('\n');
309 blankLine();
310 step(`streamed ${accumulated.length} chars`);
311 }
312 return 0;
313}
314
315interface SseFrame {
316 event: string;
317 data: string;
318}
319
320function parseSseFrame(frame: string): SseFrame {
321 let event = 'message';
322 const dataLines: string[] = [];
323 for (const line of frame.split('\n')) {
324 if (line.startsWith('event:')) event = line.slice('event:'.length).trim();
325 else if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trim());
326 }
327 return { event, data: dataLines.join('\n') };
328}
329
330/** Reverses JSON.stringify(s).slice(1,-1) from the api's SSE writer. */
331function unescapeJsonChunk(s: string): string {
332 try {
333 return JSON.parse(`"${s}"`) as string;
334 } catch {
335 return s;
336 }
337}
338
339async function readFileOrFail(path: string): Promise<string | null> {
340 try {
341 const { readFile } = await import('node:fs/promises');
342 return await readFile(path, 'utf8');
343 } catch {
344 printError(`could not read ${path}`);
345 return null;
346 }
347}
348
349interface SchemaCurrentResponse {
350 deploymentId: string | null;
351 snapshot: {
352 tables: Record<
353 string,
354 {
355 columns: Record<
356 string,
357 {
358 sqlType: string;
359 nullable: boolean;
360 primaryKey: boolean;
361 unique: boolean;
362 references?: { table: string; column: string };
363 }
364 >;
365 }
366 >;
367 } | null;
368}
369
370async function fetchSchemaContext(
371 apiOrigin: string,
372 apiKey: string,
373 projectId: string,
374): Promise<string | null> {
375 try {
376 const res = await apiCall<SchemaCurrentResponse>(
377 `/v1/projects/${projectId}/schema/current`,
378 { apiOrigin, apiKey },
379 );
380 if (!res.snapshot) return null;
381 const lines: string[] = [];
382 for (const [tableName, table] of Object.entries(res.snapshot.tables)) {
383 const cols = Object.entries(table.columns)
384 .map(([name, col]) => {
385 const parts = [`${name}: ${col.sqlType}`];
386 if (col.primaryKey) parts.push('PK');
387 if (col.unique) parts.push('UNIQUE');
388 if (!col.nullable) parts.push('NOT NULL');
389 if (col.references) parts.push(`-> ${col.references.table}.${col.references.column}`);
390 return ` ${parts.join(' ')}`;
391 })
392 .join('\n');
393 lines.push(`table ${tableName} {\n${cols}\n}`);
394 }
395 return lines.join('\n\n');
396 } catch {
397 // The api couldn't resolve a current schema (project never deployed,
398 // or fetch failed). Returning null tells the caller to skip context;
399 // the model still answers, just without table/column hints.
400 return null;
401 }
402}
403
404function printHelp(sub?: Subcommand): void {
405 banner('ai');
406 blankLine();
407 if (!sub || sub === 'schema') {
408 step('briven ai schema "<prompt>" generate a draft schema.ts');
409 step(' --out <path> write to file instead of stdout');
410 }
411 if (!sub || sub === 'function') {
412 step('briven ai function "<prompt>" generate a draft function file');
413 step(' --with-schema include current project schema as context');
414 step(' --out <path> write to file instead of stdout');
415 }
416 if (!sub || sub === 'explain') {
417 step('briven ai explain --file <path> explain a briven code file');
418 step('briven ai explain "<inline snippet>"');
419 step(' --perspective "<note>" shape the explanation (e.g. "i\'m new")');
420 step(' --out <path> write to file instead of stdout');
421 }
422 if (!sub) {
423 blankLine();
424 step('shared flags:');
425 step(' --raw skip banners + meta footer');
426 step(' --stream render tokens as they arrive (SSE)');
427 step('AI is gated by ollama on the api host. exit code 2 means not_configured.');
428 }
429}