ai.ts429 lines · main
| 1 | import { writeFile } from 'node:fs/promises'; |
| 2 | |
| 3 | import { apiCall, ApiCallError } from '../api-client.js'; |
| 4 | import { readCredentials } from '../config.js'; |
| 5 | import { readProjectConfig } from '../project-config.js'; |
| 6 | import { 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 | |
| 27 | const SUBCOMMANDS = ['schema', 'function', 'explain'] as const; |
| 28 | type Subcommand = (typeof SUBCOMMANDS)[number]; |
| 29 | |
| 30 | export 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 | |
| 185 | interface 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 | |
| 196 | function 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 | */ |
| 237 | async 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 | |
| 315 | interface SseFrame { |
| 316 | event: string; |
| 317 | data: string; |
| 318 | } |
| 319 | |
| 320 | function 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. */ |
| 331 | function unescapeJsonChunk(s: string): string { |
| 332 | try { |
| 333 | return JSON.parse(`"${s}"`) as string; |
| 334 | } catch { |
| 335 | return s; |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | async 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 | |
| 349 | interface 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 | |
| 370 | async 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 | |
| 404 | function 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 | } |