Files
homeclaw/src/tools/sqlite.ts
T
kimandClaude Sonnet 4.6 2d3e33b6b3 Centralize all path management via src/config/paths.ts
- Add getPaths() with workspace, scripts, ppt skin/template paths
- Export getWorkspacePath(args?) helper — removes duplicate local functions
  and stale process.cwd()/workspace fallbacks from sqlite, pdf, excel,
  image, pubmed, email, audio-transcribe (7 files)
- weather.ts: replace __dirname-relative scripts path with getPaths().scripts
- Move ppt/ to .smallclaw/skills/presenter/ppt/; update server-v2.ts,
  pptx.ts, pptx_gen.py (argparse --skin-dir/--template-dir)
- Fix PPT skin thumbnail serving: res.sendFile → fs.createReadStream
  (bypasses send module dotfiles restriction for .smallclaw/ paths)
- Fix workspace default: .smallclaw/workspace (was project root workspace/)
- Fix subagent paths to use SMALLCLAW_DATA_DIR env var
- Fix Monaco marked module load failure: restore define.amd on
  DOMContentLoaded after deferred UMD libs (tone, magenta) have loaded
- Add AudioContext resume on first user click (Chrome autoplay policy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:55:14 +09:00

124 lines
4.9 KiB
TypeScript

import path from 'path';
import fs from 'fs';
import { getWorkspacePath } from '../config/paths.js';
import { ToolResult } from '../types.js';
function isPathInsideDir(base: string, target: string): boolean {
const resolvedBase = path.resolve(base);
const resolvedTarget = path.resolve(target);
if (resolvedBase === resolvedTarget) return true;
const rel = path.relative(resolvedBase, resolvedTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function formatTable(columns: string[], rows: any[][]): string {
if (!rows.length) return '(no rows)';
const widths = columns.map((c, i) =>
Math.min(40, Math.max(c.length, ...rows.map(r => String(r[i] ?? '').length)))
);
const pad = (s: string, w: number) => String(s ?? '').padEnd(w).slice(0, w);
const sep = widths.map(w => '-'.repeat(w)).join('-+-');
const header = columns.map((c, i) => pad(c, widths[i])).join(' | ');
return [header, sep, ...rows.map(r => r.map((cell, i) => pad(String(cell ?? ''), widths[i])).join(' | '))].join('\n');
}
const WRITE_RE = /^\s*(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|REPLACE|TRUNCATE|ATTACH|DETACH)\s/i;
export const sqliteTool = {
name: 'sqlite_query',
description: 'Execute SQL queries on a SQLite database file inside the workspace. SELECT queries are allowed by default. Set write=true to run INSERT/UPDATE/DELETE/CREATE/DROP.',
schema: {
db_path: 'Path to the .db file (relative to workspace, or absolute)',
query: 'SQL query to execute',
write: 'Allow write queries: INSERT/UPDATE/DELETE/CREATE/DROP (default: false)',
max_rows: 'Maximum rows to return for SELECT (default: 100)',
},
jsonSchema: {
type: 'object',
properties: {
db_path: { type: 'string', description: 'Path to the SQLite database file' },
query: { type: 'string', description: 'SQL query' },
write: { type: 'boolean', description: 'Allow write operations (default: false)' },
max_rows: { type: 'number', description: 'Max rows returned for SELECT (default: 100)' },
},
required: ['db_path', 'query'],
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const dbPath = String(args?.db_path || '').trim();
const query = String(args?.query || '').trim();
if (!dbPath) return { success: false, error: 'db_path is required' };
if (!query) return { success: false, error: 'query is required' };
const workspacePath = getWorkspacePath(args);
let resolved: string;
if (path.isAbsolute(dbPath)) {
resolved = dbPath;
} else {
resolved = path.resolve(workspacePath, dbPath);
if (!isPathInsideDir(workspacePath, resolved)) {
return { success: false, error: 'relative db_path must stay inside workspace (use absolute path for shared databases)' };
}
}
const allowWrite = Boolean(args?.write);
const isWrite = WRITE_RE.test(query);
if (isWrite && !allowWrite) {
return { success: false, error: 'Write query requires write=true. Set write=true to allow INSERT/UPDATE/DELETE/CREATE.' };
}
let Database: any;
try {
const mod = await import('better-sqlite3');
Database = mod.default ?? mod;
} catch (err: any) {
return { success: false, error: `better-sqlite3 unavailable: ${err.message}` };
}
let db: any;
try {
db = new Database(resolved, { readonly: !allowWrite });
} catch (err: any) {
return { success: false, error: `Cannot open database: ${err.message}` };
}
try {
const maxRows = Math.min(1_000, Math.max(1, Number(args?.max_rows ?? 100)));
const stmt = db.prepare(query);
if (isWrite || !query.trim().toUpperCase().startsWith('SELECT')) {
const info = stmt.run();
return {
success: true,
stdout: `OK\nRows affected: ${info.changes} | Last insert rowid: ${info.lastInsertRowid}`,
data: { changes: info.changes, lastInsertRowid: info.lastInsertRowid },
};
}
const rows: any[] = stmt.all().slice(0, maxRows);
if (!rows.length) return { success: true, stdout: '(no rows returned)', data: { rows: [] } };
const columns = Object.keys(rows[0]);
const table = formatTable(columns, rows.map((r: any) => columns.map(c => r[c])));
let total = rows.length;
try {
// Wrap query in subquery to get total row count
const countRow = db.prepare(`SELECT COUNT(*) AS _cnt FROM (${query})`).get();
total = Number(countRow?._cnt ?? rows.length);
} catch {}
const note = rows.length < total ? `\n\n[Showing ${rows.length} of ${total} rows. Use max_rows to increase limit.]` : '';
return {
success: true,
stdout: table + note,
data: { columns, row_count: rows.length, total, rows },
};
} catch (err: any) {
return { success: false, error: `Query error: ${err.message}` };
} finally {
try { db.close(); } catch {}
}
},
};