Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ before `1.0`.
- [x] Instant web integration — `reins.endpoint`, `<reins-chat>`, 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 <db-url>` 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 `<reins-chat>`.
- [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 <db-url>` CLI (zero-dep SQLite reflection via `node:sqlite`). Remaining: a native TS code-mode sandbox and `<reins-chat>`.

## Development

Expand Down
3 changes: 3 additions & 0 deletions packages/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
147 changes: 147 additions & 0 deletions packages/js/src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string | null>,
out: Writer,
): Promise<void> {
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);
}
142 changes: 142 additions & 0 deletions packages/js/src/cli/main.ts
Original file line number Diff line number Diff line change
@@ -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 <db-url> [--write] list the capabilities reins would expose
reins ask <db-url> "<question>" [--model <spec>]
reins chat <db-url> [--model <spec>]
reins version

db-url: ./app.db :memory: sqlite:///abs/path/app.db
--model: <spec> 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} <db-url> (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<void> {
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 <db-url> "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<string | null> => {
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);
});
Loading
Loading