From 0d7458c6f371c13e9041b2b551466d20f8a4d76a Mon Sep 17 00:00:00 2001 From: NanoClaw bot user Date: Wed, 6 May 2026 19:38:33 +0200 Subject: [PATCH] fix(skills): replace sqlite3 CLI with in-tree better-sqlite3 wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup deliberately avoids the sqlite3 CLI (`setup/verify.ts:5` calls this out: "Uses better-sqlite3 directly (no sqlite3 CLI)") and never installs or probes for the binary. Despite that, 13 skills shelled out to `sqlite3 ...` directly, breaking on hosts where the CLI isn't preinstalled — the same root cause as #2191 but spread across the skill surface. Add `scripts/q.ts`, a ~30-LOC wrapper over the `better-sqlite3` dep that setup already installs and verifies. Default output matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically — only the binary changes. SELECT/WITH queries go through `db.prepare().all()`; everything else (INSERT/UPDATE/DELETE, including compound statements) goes through `db.exec()`. Migrate every in-tree caller: - 17 hardcoded invocations across 8 SKILL.md files (init-first-agent, add-deltachat, add-signal, add-emacs, add-whatsapp, add-ollama-provider, debug, add-parallel) plus add-deltachat/VERIFY.md. - `manage-channels/SKILL.md` shows canonical SQL but never prescribed a tool, so the assistant defaulted to `sqlite3` and silently failed. Add a one-line wrapper hint above the SQL block. - `migrate-v2.sh` schema/count probes (was the original #2191 case). Replace `.tables` with `SELECT name FROM sqlite_master`. - Document the wrapper convention in root `CLAUDE.md` under "Central DB". Add `scripts/q.test.ts` with 6 vitest cases covering both modes, NULL rendering, empty-result, compound mutations, and arg validation. Wire `scripts/**/*.test.ts` into `vitest.config.ts`. Out of scope (flagged for follow-up): - `debug` and `add-parallel` still reference the v1-only path `store/messages.db`. Routing through the wrapper now produces a cleaner "no such file" error, but the surrounding sections are v1-era throughout — a v1-content cleanup is its own PR. - `cleanup-sessions.sh` is being addressed in #1889 (different style, hard-fail rather than wrap); left untouched here to avoid stepping on that author's work. Closes #2191. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-deltachat/SKILL.md | 6 +- .claude/skills/add-deltachat/VERIFY.md | 4 +- .claude/skills/add-emacs/SKILL.md | 4 +- .claude/skills/add-ollama-provider/SKILL.md | 4 +- .claude/skills/add-parallel/SKILL.md | 2 +- .claude/skills/add-signal/SKILL.md | 8 +- .claude/skills/add-whatsapp/SKILL.md | 4 +- .claude/skills/debug/SKILL.md | 2 +- .claude/skills/init-first-agent/SKILL.md | 4 +- .claude/skills/manage-channels/SKILL.md | 8 +- CLAUDE.md | 2 + migrate-v2.sh | 12 ++- scripts/q.test.ts | 95 +++++++++++++++++++++ scripts/q.ts | 46 ++++++++++ vitest.config.ts | 2 +- 15 files changed, 178 insertions(+), 25 deletions(-) create mode 100644 scripts/q.test.ts create mode 100644 scripts/q.ts diff --git a/.claude/skills/add-deltachat/SKILL.md b/.claude/skills/add-deltachat/SKILL.md index 45aa41690..3dd5df6be 100644 --- a/.claude/skills/add-deltachat/SKILL.md +++ b/.claude/skills/add-deltachat/SKILL.md @@ -140,7 +140,7 @@ After accepting, DeltaChat exchanges keys and creates the chat automatically. Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID: ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5" ``` @@ -226,7 +226,7 @@ Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart. 1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log` 2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log` 3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat -4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"` +4. Verify the messaging group is wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"` ### Stale lock file after crash @@ -248,7 +248,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -20 The messaging group exists but may not be wired to an agent group. Run: ```bash -sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'" ``` If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`. diff --git a/.claude/skills/add-deltachat/VERIFY.md b/.claude/skills/add-deltachat/VERIFY.md index 839fa8597..ae25c5841 100644 --- a/.claude/skills/add-deltachat/VERIFY.md +++ b/.claude/skills/add-deltachat/VERIFY.md @@ -37,7 +37,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -10 ## 4. Check messaging group was created ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5" ``` @@ -48,7 +48,7 @@ If a row appears, the inbound routing is working. If not, the adapter isn't rece If the message arrived but the agent didn't respond, the sender may not have access: ```bash -sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'" ``` Grant access as shown in the SKILL.md "Grant user access" section. diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md index 82a5098a3..4a24eca52 100644 --- a/.claude/skills/add-emacs/SKILL.md +++ b/.claude/skills/add-emacs/SKILL.md @@ -241,7 +241,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo " ### No response from agent 1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) -2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` +2. Messaging group wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"` 3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20` If no messaging group row exists, run the `register` command above. @@ -292,5 +292,5 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Remove the NanoClaw block from your Emacs config # Optionally clean up the messaging group: -sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" +pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';" ``` diff --git a/.claude/skills/add-ollama-provider/SKILL.md b/.claude/skills/add-ollama-provider/SKILL.md index 83f7e5ae6..fe42249a9 100644 --- a/.claude/skills/add-ollama-provider/SKILL.md +++ b/.claude/skills/add-ollama-provider/SKILL.md @@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh` Ask the user (plain text, not AskUserQuestion): -1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"` +1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT folder, name FROM agent_groups;"` 2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'` 3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts. @@ -111,7 +111,7 @@ Read the agent group's shared Claude settings: ```bash # Find the agent group ID -AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='';") +AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='';") SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json ``` diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index a9dff8f2a..c391f5309 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -275,7 +275,7 @@ Look for: `Parallel AI MCP servers configured` - Check agent-runner logs for "Parallel AI MCP servers configured" message **Task polling not working:** -- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"` +- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"` - Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"` - Ensure task prompt includes proper Parallel MCP tool names diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 7dcc8ad2c..449571588 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -200,7 +200,7 @@ systemctl --user restart nanoclaw After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then: ```bash -sqlite3 data/v2.db \ +pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" ``` @@ -212,7 +212,7 @@ Add the Signal number to a group from your phone, send any message, then wire th ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " +pnpm exec tsx scripts/q.ts data/v2.db " INSERT OR IGNORE INTO messaging_group_agents (id, messaging_group_id, agent_group_id, session_mode, priority, created_at) VALUES @@ -226,7 +226,7 @@ New Signal users (including the owner's Signal identity) are silently dropped wi ```bash NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") -sqlite3 data/v2.db " +pnpm exec tsx scripts/q.ts data/v2.db " INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW'); INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) @@ -282,7 +282,7 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA ### Bot not responding 1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` -2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` 3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) ### Lost connection mid-session diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 232725f32..edec47989 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -200,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group. - **type**: `whatsapp` - **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members. -- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. +- **how-to-find-id**: DMs use `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`. - **supports-threads**: no - **typical-use**: Interactive chat — direct messages or small groups - **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups. @@ -256,7 +256,7 @@ systemctl --user start nanoclaw 1. Auth exists: `test -f store/auth/creds.json` 2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1` -3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` +3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"` 4. Service running: `systemctl --user status nanoclaw` ### "conflict" disconnection diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 128b8c380..1fa459fa0 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -279,7 +279,7 @@ rm -rf data/sessions/ rm -rf data/sessions/{groupFolder}/.claude/ # Also clear the session ID from NanoClaw's tracking (stored in SQLite) -sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'" +pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'" ``` To verify session resumption is working, check the logs for the same session ID across messages: diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md index 6b110d37f..67ab80b12 100644 --- a/.claude/skills/init-first-agent/SKILL.md +++ b/.claude/skills/init-first-agent/SKILL.md @@ -54,7 +54,7 @@ Tell the user: Wait for the user's confirmation. Then look up the most recent DM messaging groups: ```bash -sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5" +pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5" ``` Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues. @@ -103,7 +103,7 @@ Wait for the user's reply. If they confirm receipt, the skill is done. If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't): -- `sqlite3 data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. +- `pnpm exec tsx scripts/q.ts data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. - `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes. - `ls data/v2-sessions//sessions/*/outbound.db` — confirm the session exists. diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index 0b348d1f7..21b3e19d9 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -11,7 +11,13 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user ## Assess Current State -Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`): +Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`). + +Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI: + +```bash +pnpm exec tsx scripts/q.ts data/v2.db "" +``` ```sql SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups; diff --git a/CLAUDE.md b/CLAUDE.md index c17001bb0..f33dca75b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,8 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f `data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`. +For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts ""`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically. + ## Key Files | File | Purpose | diff --git a/migrate-v2.sh b/migrate-v2.sh index ef3bda8e3..46a66707c 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -242,8 +242,12 @@ fi V1_DB="$V1_PATH/store/messages.db" -# Quick schema check — make sure the tables we need exist -TABLES=$(sqlite3 "$V1_DB" ".tables" 2>/dev/null || true) +# Quick schema check — make sure the tables we need exist. +# Uses the in-tree wrapper instead of the sqlite3 CLI: setup.sh (run via +# phase 0a above) installs Node + better-sqlite3 but NOT the sqlite3 CLI, +# and #2191 documented how a missing CLI here used to surface as a +# misleading "registered_groups missing" abort. +TABLES=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT name FROM sqlite_master WHERE type='table'" 2>/dev/null || true) if echo "$TABLES" | grep -q "registered_groups"; then step_ok "v1 database has registered_groups" @@ -253,8 +257,8 @@ else fi # Show what we found -GROUP_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0) -TASK_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0) +GROUP_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0) +TASK_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0) ENV_KEYS=0 if [ -f "$V1_PATH/.env" ]; then ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0) diff --git a/scripts/q.test.ts b/scripts/q.test.ts new file mode 100644 index 000000000..4685e2be9 --- /dev/null +++ b/scripts/q.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; + +import Database from 'better-sqlite3'; + +/** + * Smoke tests for the q.ts sqlite-CLI replacement wrapper. + * + * Verifies the two modes (SELECT prints rows in sqlite3 default "list" + * format; mutation runs via db.exec) and a few edge cases that real + * skill invocations rely on. + */ + +const Q = path.resolve(__dirname, 'q.ts'); + +describe('scripts/q.ts', () => { + let tempDir: string; + let dbPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-test-')); + dbPath = path.join(tempDir, 'test.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE t (id INTEGER, name TEXT, note TEXT); + INSERT INTO t (id, name, note) VALUES (1, 'alice', 'hi'), (2, 'bob', NULL); + `); + db.close(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function run(sql: string): { stdout: string; stderr: string; status: number } { + const r = spawnSync('pnpm', ['exec', 'tsx', Q, dbPath, sql], { + encoding: 'utf-8', + cwd: path.resolve(__dirname, '..'), + }); + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; + } + + it('SELECT prints pipe-separated rows in default order', () => { + const r = run('SELECT id, name FROM t ORDER BY id'); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe('1|alice\n2|bob'); + }); + + it('SELECT renders NULL as empty string (matches sqlite3 default mode)', () => { + const r = run('SELECT id, note FROM t ORDER BY id'); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe('1|hi\n2|'); + }); + + it('SELECT with no rows prints nothing', () => { + const r = run("SELECT id FROM t WHERE name = 'nobody'"); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + }); + + it('INSERT runs via db.exec and persists', () => { + const r = run("INSERT INTO t (id, name) VALUES (3, 'carol')"); + expect(r.status).toBe(0); + expect(r.stdout).toBe(''); + + const db = new Database(dbPath, { readonly: true }); + const row = db.prepare('SELECT name FROM t WHERE id = 3').get() as { name: string }; + db.close(); + expect(row.name).toBe('carol'); + }); + + it('compound mutation statements execute together', () => { + const r = run("DELETE FROM t WHERE id = 1; INSERT INTO t (id, name) VALUES (9, 'zed');"); + expect(r.status).toBe(0); + + const db = new Database(dbPath, { readonly: true }); + const ids = (db.prepare('SELECT id FROM t ORDER BY id').all() as { id: number }[]).map( + (r) => r.id, + ); + db.close(); + expect(ids).toEqual([2, 9]); + }); + + it('exits 2 with usage when args are missing', () => { + const r = spawnSync('pnpm', ['exec', 'tsx', Q], { + encoding: 'utf-8', + cwd: path.resolve(__dirname, '..'), + }); + expect(r.status).toBe(2); + expect(r.stderr).toMatch(/Usage/); + }); +}); diff --git a/scripts/q.ts b/scripts/q.ts new file mode 100644 index 000000000..71a467603 --- /dev/null +++ b/scripts/q.ts @@ -0,0 +1,46 @@ +/** + * scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations. + * + * Usage: + * pnpm exec tsx scripts/q.ts "" + * + * Detects SELECT vs mutation on the first keyword. SELECT/WITH queries + * print rows in sqlite3 CLI default ("list") format — pipe-separated, + * no header — so existing skill text reads identically. Anything else + * runs through db.exec() and prints nothing on success. + * + * Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids + * depending on the sqlite3 CLI binary; setup never installs or probes + * for it. Skills that shell out to `sqlite3` therefore fail on hosts + * where it isn't preinstalled (common on fresh Ubuntu — see #2191). + * This wrapper preserves the skill-text shape (path then SQL string) + * while routing through the better-sqlite3 dep that setup already + * installs and verifies. + */ +import Database from 'better-sqlite3'; + +const [, , dbPath, sql] = process.argv; + +if (!dbPath || sql === undefined) { + console.error('Usage: pnpm exec tsx scripts/q.ts ""'); + process.exit(2); +} + +const db = new Database(dbPath); +try { + const firstKeyword = sql.trim().split(/\s+/)[0]?.toUpperCase() ?? ''; + if (firstKeyword === 'SELECT' || firstKeyword === 'WITH') { + const rows = db.prepare(sql).all() as Record[]; + for (const row of rows) { + console.log( + Object.values(row) + .map((v) => (v === null ? '' : String(v))) + .join('|'), + ); + } + } else { + db.exec(sql); + } +} finally { + db.close(); +} diff --git a/vitest.config.ts b/vitest.config.ts index d961d1bcd..71afb7811 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { // container/agent-runner tests run under Bun (they depend on bun:sqlite). // See container/agent-runner/package.json "test" script. - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'scripts/**/*.test.ts'], }, });