- 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>
124 lines
4.9 KiB
TypeScript
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 {}
|
|
}
|
|
},
|
|
};
|