From c79733899b3be85d243b896ca7ce9d52837afcc6 Mon Sep 17 00:00:00 2001 From: shamspias Date: Sun, 5 Jul 2026 16:01:10 +0600 Subject: [PATCH] feat(m7.5d): TypeScript reins CLI over a reflected SQLite database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero-code path for the TS port: point the reins CLI at a SQLite database and inspect/ask/chat over it — the mirror of the Go CLI. - cli/sqlite.ts: live schema reflection via the built-in node:sqlite (ZERO new deps). reflectSqlite reads PRAGMA table_info/index_list into the same ORM-neutral ModelInfo the Drizzle path uses, so it flows straight through the shared buildCapabilities (intent names, natural-key lookups, linting, trimming, schema index). SqliteBackend runs the six ops as parameterized SQL with validated + double-quoted identifiers (§2.6) and boolean/value coercion for SQLite binding. - cli/cli.ts: inspect / ask / chat as plain functions taking a line Writer and a reins.Model, so they test with a FakeModel and no network. ask/chat build a read_only agent (writes aren't generated); inspect --write reveals the gated create/update/delete. openDb accepts ./app.db, :memory:, sqlite:/// URLs, file: URIs. - cli/main.ts: the reins bin (package.json bin) — inspect/ask/chat/version, --model (provider:model) or env auto-detect, --write. node:sqlite is imported lazily so version/help don't emit its experimental-feature warning. - tests: cli.test.ts covers inspect (read-only vs --write), FakeModel-driven ask/chat, and openDb happy/hinted-error paths. Verified: make js-check green — format, typecheck, 143 tests (142 pass, 1 skip); a built bin smoke-tested against a real SQLite file (reflected a table, chose the unique non-pk column as the natural key, gated writes behind --write). --- README.md | 2 +- packages/js/package.json | 3 + packages/js/src/cli/cli.ts | 147 ++++++++++++++++++++ packages/js/src/cli/main.ts | 142 +++++++++++++++++++ packages/js/src/cli/sqlite.ts | 249 ++++++++++++++++++++++++++++++++++ packages/js/tests/cli.test.ts | 88 ++++++++++++ 6 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 packages/js/src/cli/cli.ts create mode 100644 packages/js/src/cli/main.ts create mode 100644 packages/js/src/cli/sqlite.ts create mode 100644 packages/js/tests/cli.test.ts diff --git a/README.md b/README.md index 1141459..291c092 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ before `1.0`. - [x] Instant web integration — `reins.endpoint`, ``, session-identity → RLS - [x] A flagship end-to-end example (FastAPI + SQLAlchemy, gated writes) — `make demo` - [x] **Go package** (`packages/go`) — same verbs, passes the shared conformance suite; capabilities + classification + linter, the `Ask`/`Run` loop, full safety (policy/approval/audit/RLS), progressive disclosure, an OpenAI-compatible adapter for all providers above, GORM `FromORM`, and a `reins inspect|ask|chat ` CLI. Remaining: a native Go code-mode sandbox. -- [x] **TypeScript package** (`packages/js`) — same verbs, **passes the shared conformance suite**; the core types + `Model`/`FakeModel`, capabilities + classification + linter, the async `ask`/`run` loop, full safety (policy/approval/audit/RLS), dual progressive disclosure, an OpenAI-compatible adapter for all providers above, and `fromDrizzle` (auto-expose Drizzle models via the optional `reins/orm` subpath). Remaining: a native TS code-mode sandbox, a CLI, and ``. +- [x] **TypeScript package** (`packages/js`) — same verbs, **passes the shared conformance suite**; the core types + `Model`/`FakeModel`, capabilities + classification + linter, the async `ask`/`run` loop, full safety (policy/approval/audit/RLS), dual progressive disclosure, an OpenAI-compatible adapter for all providers above, `fromDrizzle` (auto-expose Drizzle models via the optional `reins/orm` subpath), and a `reins inspect|ask|chat ` CLI (zero-dep SQLite reflection via `node:sqlite`). Remaining: a native TS code-mode sandbox and ``. ## Development diff --git a/packages/js/package.json b/packages/js/package.json index d946a9c..be7f461 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -6,6 +6,9 @@ "license": "MIT", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "reins": "./dist/cli/main.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/js/src/cli/cli.ts b/packages/js/src/cli/cli.ts new file mode 100644 index 0000000..cb3e17c --- /dev/null +++ b/packages/js/src/cli/cli.ts @@ -0,0 +1,147 @@ +import { DatabaseSync } from 'node:sqlite'; +import type { Model } from '../model.js'; +import type { BoundCapability } from '../capability.js'; +import { Agent } from '../loop.js'; +import { ReinsError } from '../errors.js'; +import { fromSqlite } from './sqlite.js'; + +/** + * The `reins` command-line tool: point it at a SQLite database URL and inspect / + * ask / chat over it with zero hand-written code. The commands are plain functions + * taking a line writer (and, for ask/chat, a reins.Model), so they test with a + * FakeModel and no network. The TypeScript mirror of Go's `cli` package. + */ + +/** A sink for one line of output. */ +export type Writer = (line: string) => void; + +/** + * Open a SQLite database from a URL: a bare path, `:memory:`, a `sqlite://` URL + * (scheme stripped), or a `file:` URI (passed through). + */ +export function openDb(dbUrl: string): DatabaseSync { + try { + return new DatabaseSync(sqlitePath(dbUrl)); + } catch (e) { + throw new ReinsError( + `cannot open database: ${message(e)}`, + 'pass a SQLite file path, :memory:, or a sqlite:/// URL', + ); + } +} + +function sqlitePath(dbUrl: string): string { + if (dbUrl.startsWith('file:')) { + return dbUrl; + } + for (const prefix of ['sqlite3://', 'sqlite://', 'sqlite3:', 'sqlite:']) { + if (dbUrl.startsWith(prefix)) { + return dbUrl.slice(prefix.length); + } + } + return dbUrl; +} + +/** Reflect the database and list the capabilities reins would expose. No model needed. */ +export function inspect(dbUrl: string, canWrite: boolean, out: Writer): void { + const db = openDb(dbUrl); + try { + const { capabilities, schema } = fromSqlite(db, canWrite, 0); + out(`Reflected ${schema.tables?.length ?? 0} table(s) from ${dbUrl}`); + for (const t of schema.tables ?? []) { + out(` • ${t.name} (${t.columns?.length ?? 0} columns)`); + } + out(''); + out(`${capabilities.length} capabilities:`); + for (const line of describe(capabilities)) { + out(line); + } + if (!canWrite) { + out(''); + out('(read-only — pass --write to also generate gated create/update/delete)'); + } + } finally { + db.close(); + } +} + +/** Answer one read-only question. The build is read-only (no writes are generated). */ +export async function ask( + dbUrl: string, + question: string, + model: Model, + out: Writer, +): Promise { + const { agent, db } = readOnlyAgent(dbUrl, model); + try { + const res = await agent.ask(question); + out((res.output.text ?? '').trim()); + } finally { + db.close(); + } +} + +/** + * Run an interactive read-only question loop. `prompt` reads one line (returning + * null at EOF); `out` writes replies. Stops on EOF or "exit"/"quit". + */ +export async function chat( + dbUrl: string, + model: Model, + prompt: (label: string) => Promise, + out: Writer, +): Promise { + const { agent, db } = readOnlyAgent(dbUrl, model); + out(`reins chat — read-only over ${dbUrl}. Ask a question, or type 'exit'.`); + try { + for (;;) { + const line = await prompt('\n> '); + if (line === null) { + break; + } + const q = line.trim(); + if (q === '') { + continue; + } + if (q === 'exit' || q === 'quit') { + break; + } + try { + const res = await agent.ask(q); + out((res.output.text ?? '').trim()); + } catch (e) { + out(String(e)); + } + } + } finally { + db.close(); + } +} + +function readOnlyAgent(dbUrl: string, model: Model): { agent: Agent; db: DatabaseSync } { + const db = openDb(dbUrl); + try { + const { capabilities, schema } = fromSqlite(db, false, 0); + const agent = new Agent(model, { capabilities, schema, autonomy: 'read_only' }); + return { agent, db }; + } catch (e) { + db.close(); + throw e; + } +} + +/** Render capabilities grouped read → write → destructive, then by name. */ +function describe(caps: BoundCapability[]): string[] { + const rank = (a: string): number => (a === 'read' ? 0 : a === 'write' ? 1 : 2); + return [...caps] + .sort( + (x, y) => + rank(x.spec.access) - rank(y.spec.access) || + (x.spec.name < y.spec.name ? -1 : x.spec.name > y.spec.name ? 1 : 0), + ) + .map((c) => ` [${c.spec.access.padEnd(11)}] ${c.spec.name} — ${c.spec.description}`); +} + +function message(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/packages/js/src/cli/main.ts b/packages/js/src/cli/main.ts new file mode 100644 index 0000000..9131142 --- /dev/null +++ b/packages/js/src/cli/main.ts @@ -0,0 +1,142 @@ +#!/usr/bin/env node +import { createInterface } from 'node:readline/promises'; +import { VERSION } from '../index.js'; +import { resolveModel, defaultModel } from '../openai.js'; +import type { Model } from '../model.js'; +import { ReinsError } from '../errors.js'; + +// The CLI commands live in ./cli.js, which imports node:sqlite. It is imported +// lazily (only for the commands that touch a database) so `version`/`help` don't +// trigger node:sqlite's experimental-feature warning. +type Writer = (line: string) => void; + +/** + * The `reins` CLI entry point. Point it at a SQLite database and inspect / ask / + * chat over it with no hand-written code: + * + * reins inspect ./app.db + * reins ask ./app.db "how many active customers?" + * reins chat ./app.db --model groq:llama-3.3-70b-versatile + * + * The model is resolved from --model or the environment (OPENAI_API_KEY, + * GEMINI_API_KEY, GROQ_API_KEY, ZAI_API_KEY, OLLAMA_BASE_URL, VLLM_BASE_URL). + */ + +const usage = `reins ${VERSION} — operate your app in plain language (TypeScript) + +usage: + reins inspect [--write] list the capabilities reins would expose + reins ask "" [--model ] + reins chat [--model ] + reins version + +db-url: ./app.db :memory: sqlite:///abs/path/app.db +--model: is "provider:model" (e.g. groq:llama-3.3-70b-versatile) or a bare + model name; omit to auto-detect from the environment. +`; + +interface Flags { + write: boolean; + model?: string; +} + +function parseArgs(args: string[]): { positional: string[]; flags: Flags } { + const positional: string[] = []; + const flags: Flags = { write: false }; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === '--write' || a === '-w') { + flags.write = true; + } else if (a === '--model' || a === '-m') { + if (i + 1 < args.length) { + flags.model = args[++i]; + } + } else { + positional.push(a); + } + } + return { positional, flags }; +} + +function requireDbUrl(positional: string[], cmd: string): string { + if (positional.length === 0) { + throw new ReinsError( + `${cmd} needs a database URL`, + `reins ${cmd} (e.g. ./app.db or :memory:)`, + ); + } + return positional[0]!; +} + +function resolve(spec: string | undefined): Model { + return spec ? resolveModel(spec) : defaultModel(); +} + +async function run(args: string[]): Promise { + if (args.length === 0) { + process.stdout.write(usage); + return; + } + const [cmd, ...rest] = args; + const out: Writer = (line) => process.stdout.write(line + '\n'); + + switch (cmd) { + case 'version': + case '--version': + case '-v': + out(`reins ${VERSION}`); + return; + case 'help': + case '-h': + case '--help': + process.stdout.write(usage); + return; + case 'inspect': { + const { positional, flags } = parseArgs(rest); + const cli = await import('./cli.js'); + cli.inspect(requireDbUrl(positional, 'inspect'), flags.write, out); + return; + } + case 'ask': { + const { positional, flags } = parseArgs(rest); + const dbUrl = requireDbUrl(positional, 'ask'); + if (positional.length < 2) { + throw new ReinsError('ask needs a question', 'reins ask "your question"'); + } + const question = positional.slice(1).join(' '); + const cli = await import('./cli.js'); + await cli.ask(dbUrl, question, resolve(flags.model), out); + return; + } + case 'chat': { + const { positional, flags } = parseArgs(rest); + const dbUrl = requireDbUrl(positional, 'chat'); + const model = resolve(flags.model); + const cli = await import('./cli.js'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const prompt = async (label: string): Promise => { + try { + return await rl.question(label); + } catch { + return null; // EOF / closed stdin + } + }; + try { + await cli.chat(dbUrl, model, prompt, out); + } finally { + rl.close(); + } + return; + } + default: + throw new ReinsError( + `unknown command: ${cmd}`, + 'run `reins help` to see the available commands', + ); + } +} + +run(process.argv.slice(2)).catch((e: unknown) => { + process.stderr.write(String(e) + '\n'); + process.exit(1); +}); diff --git a/packages/js/src/cli/sqlite.ts b/packages/js/src/cli/sqlite.ts new file mode 100644 index 0000000..7152947 --- /dev/null +++ b/packages/js/src/cli/sqlite.ts @@ -0,0 +1,249 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { SchemaIndex } from '../types.js'; +import type { BoundCapability } from '../capability.js'; +import { + type Backend, + type ColumnInfo, + type ModelInfo, + buildCapabilities, + missing, + singularize, + trimRow, +} from '../orm/build.js'; +import { ReinsError } from '../errors.js'; + +/** + * Zero-code CLI reflection: read a live SQLite schema through PRAGMA and hand the + * resulting ORM-neutral ModelInfo to the shared `buildCapabilities` engine — the + * TypeScript mirror of Go's `orm/sqlreflect.go`. Uses the built-in `node:sqlite` + * (no driver dependency); the SqliteBackend runs the six operations as parameterized + * SQL with validated + quoted identifiers (§2.6). + */ + +type SqlValue = string | number | bigint | null | Uint8Array; + +const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function isSafeIdent(name: string): boolean { + return IDENT.test(name); +} + +function quoteIdent(name: string): string { + if (!isSafeIdent(name)) { + throw new ReinsError( + `unsafe identifier: ${name}`, + 'reins only operates on plain identifier names', + ); + } + return `"${name}"`; +} + +/** SQLite has no boolean/object binding; coerce to a supported value. */ +function coerce(v: unknown): SqlValue { + if (typeof v === 'boolean') return v ? 1 : 0; + if ( + v === null || + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'bigint' || + v instanceof Uint8Array + ) { + return v; + } + return String(v); +} + +/** Introspect a live SQLite database into ORM-neutral ModelInfo, one per user table. */ +export function reflectSqlite(db: DatabaseSync): ModelInfo[] { + const tables = ( + db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .all() as Array<{ name: string }> + ).map((r) => r.name); + if (tables.length === 0) { + throw new ReinsError( + 'the database has no tables', + 'point reins at a database with tables, or create one first', + ); + } + const infos: ModelInfo[] = []; + for (const table of tables) { + const info = reflectTable(db, table); + if (info && info.columns.length > 0) { + infos.push(info); + } + } + if (infos.length === 0) { + throw new ReinsError('no usable tables found', 'reins needs tables with readable columns'); + } + return infos; +} + +function reflectTable(db: DatabaseSync, table: string): ModelInfo | null { + if (!isSafeIdent(table)) { + return null; // unusual name — skip rather than risk unsafe SQL + } + const rows = db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all() as Array<{ + name: string; + type: string; + notnull: number; + dflt_value: unknown; + pk: number; + }>; + const columns: ColumnInfo[] = []; + let primaryKey: ColumnInfo | undefined; + for (const r of rows) { + if (!isSafeIdent(r.name)) { + continue; + } + const col: ColumnInfo = { + name: r.name, + jsonType: sqliteJsonType(r.type), + nullable: r.notnull === 0 && r.pk === 0, + autoincrement: r.pk === 1 && /int/i.test(r.type), + hasDefault: r.dflt_value !== null, + unique: false, + }; + columns.push(col); + if (r.pk >= 1 && !primaryKey) { + primaryKey = { ...col }; + } + } + const unique = singleColumnUniques(db, table); + for (const c of columns) { + if (unique.has(c.name)) { + c.unique = true; + } + } + const info: ModelInfo = { singular: singularize(table), plural: table, columns }; + if (primaryKey) { + info.primaryKey = primaryKey; + } + return info; +} + +/** Columns backed by a single-column UNIQUE index — what makes a natural-key lookup safe. */ +function singleColumnUniques(db: DatabaseSync, table: string): Set { + const out = new Set(); + const indexes = db.prepare(`PRAGMA index_list(${quoteIdent(table)})`).all() as Array<{ + name: string; + unique: number; + partial?: number; + }>; + for (const idx of indexes) { + if (idx.unique !== 1 || (idx.partial ?? 0) !== 0 || !isSafeIdent(idx.name)) { + continue; + } + const cols = ( + db.prepare(`PRAGMA index_info(${quoteIdent(idx.name)})`).all() as Array<{ + name: string | null; + }> + ) + .map((c) => c.name) + .filter((n): n is string => n !== null); + if (cols.length === 1) { + out.add(cols[0]!); + } + } + return out; +} + +/** Map a declared SQLite type to a JSON Schema type by affinity. */ +function sqliteJsonType(declared: string): string { + const t = declared.toUpperCase(); + if (t.includes('INT')) return 'integer'; + if (t.includes('BOOL')) return 'boolean'; + if (/REAL|FLOA|DOUB|NUMERIC|DEC/.test(t)) return 'number'; + return 'string'; +} + +/** Reflect a live SQLite database and return capabilities plus a schema index. */ +export function fromSqlite( + db: DatabaseSync, + canWrite: boolean, + readLimit: number, +): { capabilities: BoundCapability[]; schema: SchemaIndex } { + return buildCapabilities(new SqliteBackend(db), reflectSqlite(db), canWrite, readLimit); +} + +function whereClause(filters: Record): { clause: string; params: SqlValue[] } { + const keys = Object.keys(filters).sort(); + if (keys.length === 0) { + return { clause: '', params: [] }; + } + const parts = keys.map((k) => `${quoteIdent(k)} = ?`); + return { clause: ' WHERE ' + parts.join(' AND '), params: keys.map((k) => coerce(filters[k])) }; +} + +/** Runs the six operations against a node:sqlite database using parameterized SQL. */ +class SqliteBackend implements Backend { + constructor(private readonly db: DatabaseSync) {} + + async find( + info: ModelInfo, + filters: Record, + limit: number, + ): Promise>> { + const { clause, params } = whereClause(filters); + const rows = this.db + .prepare(`SELECT * FROM ${quoteIdent(info.plural)}${clause} LIMIT ${limit}`) + .all(...params) as Array>; + return rows.map((r) => trimRow(info, { ...r })); + } + + async count(info: ModelInfo, filters: Record): Promise { + const { clause, params } = whereClause(filters); + const row = this.db + .prepare(`SELECT COUNT(*) AS n FROM ${quoteIdent(info.plural)}${clause}`) + .get(...params) as { n: number } | undefined; + return Number(row?.n ?? 0); + } + + async get(info: ModelInfo, key: string, value: unknown): Promise | null> { + const rows = await this.find(info, { [key]: value }, 1); + return rows[0] ?? null; + } + + async create(info: ModelInfo, values: Record): Promise> { + const keys = Object.keys(values).sort(); + if (keys.length === 0) { + throw new ReinsError('nothing to create', 'provide at least one column value'); + } + const cols = keys.map(quoteIdent).join(', '); + const placeholders = keys.map(() => '?').join(', '); + this.db + .prepare(`INSERT INTO ${quoteIdent(info.plural)} (${cols}) VALUES (${placeholders})`) + .run(...keys.map((k) => coerce(values[k]))); + return trimRow(info, values); + } + + async update( + info: ModelInfo, + key: string, + value: unknown, + changes: Record, + ): Promise | null> { + if (!(await this.get(info, key, value))) { + throw missing(info, key, value); + } + const keys = Object.keys(changes).sort(); + if (keys.length > 0) { + const sets = keys.map((k) => `${quoteIdent(k)} = ?`).join(', '); + this.db + .prepare(`UPDATE ${quoteIdent(info.plural)} SET ${sets} WHERE ${quoteIdent(key)} = ?`) + .run(...keys.map((k) => coerce(changes[k])), coerce(value)); + } + return this.get(info, key, value); + } + + async delete(info: ModelInfo, key: string, value: unknown): Promise { + if (!(await this.get(info, key, value))) { + throw missing(info, key, value); + } + this.db + .prepare(`DELETE FROM ${quoteIdent(info.plural)} WHERE ${quoteIdent(key)} = ?`) + .run(coerce(value)); + } +} diff --git a/packages/js/tests/cli.test.ts b/packages/js/tests/cli.test.ts new file mode 100644 index 0000000..4f6ea4c --- /dev/null +++ b/packages/js/tests/cli.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inspect, ask, chat, openDb } from '../src/cli/cli.js'; +import { FakeModel, callResponse, finalResponse, ReinsError } from '../src/index.js'; + +/** Create a real on-disk SQLite database seeded with a customers table + two rows. */ +function tempDb(): string { + const path = join(mkdtempSync(join(tmpdir(), 'reins-cli-')), 'app.db'); + const db = new DatabaseSync(path); + db.exec( + `CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT, active INTEGER DEFAULT 1)`, + ); + db.exec( + `INSERT INTO customers (email,name,active) VALUES ('ada@x.com','Ada',1),('bob@x.com','Bob',0)`, + ); + db.close(); + return path; +} + +function collector() { + const lines: string[] = []; + return { out: (s: string) => lines.push(s), text: () => lines.join('\n') }; +} + +describe('cli inspect', () => { + it('lists read capabilities read-only, and gated writes with --write', () => { + const path = tempDb(); + + const ro = collector(); + inspect(path, false, ro.out); + const s = ro.text(); + for (const want of [ + 'Reflected 1 table', + 'find_customers', + 'count_customers', + 'get_customer_by_email', + 'read-only', + ]) { + expect(s).toContain(want); + } + expect(s).not.toContain('create_customer'); + + const rw = collector(); + inspect(path, true, rw.out); + for (const want of ['create_customer', 'update_customer', 'delete_customer']) { + expect(rw.text()).toContain(want); + } + }); +}); + +describe('cli ask', () => { + it('answers a read question via a scripted model', async () => { + const path = tempDb(); + const model = new FakeModel( + callResponse('c1', 'get_customer_by_email', { email: 'ada@x.com' }), + finalResponse('Ada is active.'), + ); + const c = collector(); + await ask(path, 'is ada active?', model, c.out); + expect(c.text().trim()).toBe('Ada is active.'); + }); +}); + +describe('cli chat', () => { + it('answers then exits on EOF', async () => { + const path = tempDb(); + const model = new FakeModel(finalResponse('There are 2 customers.')); + const queued: Array = ['how many customers?', null]; + let i = 0; + const prompt = async (): Promise => queued[i++] ?? null; + + const c = collector(); + await chat(path, model, prompt, c.out); + expect(c.text()).toContain('There are 2 customers.'); + expect(c.text()).toContain('read-only over'); + }); +}); + +describe('openDb', () => { + it('opens :memory: and errors with a hint on an unopenable path', () => { + const db = openDb(':memory:'); + db.close(); + expect(() => openDb('/no-such-reins-dir-xyz/app.db')).toThrow(ReinsError); + }); +});