diff --git a/src/tools.ts b/src/tools.ts index ecccb60c84..ba5ca42804 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -72,6 +72,8 @@ import { TaskCreateTool } from './tools/TaskCreateTool/TaskCreateTool.js' import { TaskGetTool } from './tools/TaskGetTool/TaskGetTool.js' import { TaskUpdateTool } from './tools/TaskUpdateTool/TaskUpdateTool.js' import { TaskListTool } from './tools/TaskListTool/TaskListTool.js' +import { PostgresQueryTool } from './tools/PostgresQueryTool/PostgresQueryTool.js' +import { SqliteQueryTool } from './tools/SqliteQueryTool/SqliteQueryTool.js' import uniqBy from 'lodash-es/uniqBy.js' import { isToolSearchEnabledOptimistic } from './utils/toolSearch.js' import { isTodoV2Enabled } from './utils/tasks.js' @@ -194,6 +196,8 @@ export function getAllBaseTools(): Tools { FileWriteTool, NotebookEditTool, WebFetchTool, + PostgresQueryTool, + SqliteQueryTool, TodoWriteTool, WebSearchTool, TaskStopTool, diff --git a/src/tools/PostgresQueryTool/PostgresQueryTool.test.ts b/src/tools/PostgresQueryTool/PostgresQueryTool.test.ts new file mode 100644 index 0000000000..7a249afd64 --- /dev/null +++ b/src/tools/PostgresQueryTool/PostgresQueryTool.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'bun:test' +import { POSTGRES_QUERY_TOOL_NAME } from './prompt.js' +import { PostgresQueryTool } from './PostgresQueryTool.js' + +describe('PostgresQueryTool', () => { + it('has the correct name', () => { expect(PostgresQueryTool.name).toBe(POSTGRES_QUERY_TOOL_NAME) }) + it('has a non-empty description', async () => { expect((await PostgresQueryTool.description()).length).toBeGreaterThan(0) }) + it('has isEnabled from buildTool', () => { expect(PostgresQueryTool.isEnabled()).toBe(true) }) + + it('marks SELECT as read-only', () => { expect(PostgresQueryTool.isReadOnly?.({ query: 'SELECT * FROM users' })).toBe(true) }) + it('marks WITH as not read-only', () => { expect(PostgresQueryTool.isReadOnly?.({ query: 'WITH d AS (DELETE FROM users RETURNING *) SELECT * FROM d' })).toBe(false) }) + it('marks INSERT as not read-only', () => { expect(PostgresQueryTool.isReadOnly?.({ query: 'INSERT INTO users (name) VALUES (\'t\')' })).toBe(false) }) + it('marks DROP as destructive', () => { expect(PostgresQueryTool.isDestructive?.({ query: 'DROP TABLE users' })).toBe(true) }) + it('marks SELECT as not destructive', () => { expect(PostgresQueryTool.isDestructive?.({ query: 'SELECT 1' })).toBe(false) }) + + it('asks permission for destructive queries', async () => { + const p = await PostgresQueryTool.checkPermissions!({ query: 'DROP TABLE users' }) + expect(p.behavior).toBe('ask') + }) + it('asks permission for SELECT too (database access)', async () => { + const p = await PostgresQueryTool.checkPermissions!({ query: 'SELECT * FROM users' }) + expect(p.behavior).toBe('ask') + }) + + it('validates empty query', async () => { expect((await PostgresQueryTool.validateInput({} as any)).result).toBe(false) }) + it('validates valid query', async () => { expect((await PostgresQueryTool.validateInput({ query: 'SELECT 1' })).result).toBe(true) }) + + it('has mapToolResultToToolResultBlockParam', () => { + const b = PostgresQueryTool.mapToolResultToToolResultBlockParam({ success: true, durationMs: 10, dbPath: '' }, 'tid') + expect(b.tool_use_id).toBe('tid'); expect(b.type).toBe('tool_result') + }) + + it('renders success with rows', () => { expect(PostgresQueryTool.renderToolResultMessage?.({ success: true, rows: [{ id: 1 }], rowCount: 1, durationMs: 5, truncated: false })).toContain('1 rows') }) + it('renders error', () => { expect(PostgresQueryTool.renderToolResultMessage?.({ success: false, durationMs: 3, error: 'connection refused' })).toContain('connection refused') }) + it('provides auto-classifier', () => { expect(PostgresQueryTool.toAutoClassifierInput?.({ query: 'SELECT 1' })).toBe('SELECT 1') }) + it('renders aligned output', () => { expect(PostgresQueryTool.renderToolResultMessage?.({ success: true, rows: [{ id: 1, name: 'alice' }], rowCount: 1, columns: ['id', 'name'], durationMs: 10 })).toContain('1 rows') }) + it('renders single-column output', () => { expect(PostgresQueryTool.renderToolResultMessage?.({ success: true, rows: [{ count: 42 }], rowCount: 1, columns: ['count'], durationMs: 5 })).toContain('1 rows') }) + it('renders CSV output', () => { expect(PostgresQueryTool.renderToolResultMessage?.({ success: true, rows: [{ id: 1, name: 'hello, world' }], rowCount: 1, columns: ['id', 'name'], durationMs: 5 })).toContain('1 rows') }) +}) diff --git a/src/tools/PostgresQueryTool/PostgresQueryTool.ts b/src/tools/PostgresQueryTool/PostgresQueryTool.ts new file mode 100644 index 0000000000..34a9a954e1 --- /dev/null +++ b/src/tools/PostgresQueryTool/PostgresQueryTool.ts @@ -0,0 +1,175 @@ +import { spawnSync } from 'child_process' +import { z } from 'zod/v4' +import { buildTool } from '../../Tool.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { DESCRIPTION, POSTGRES_QUERY_TOOL_NAME, PROMPT } from './prompt.js' + +const inputSchema = lazySchema(() => + z.strictObject({ + connection: z.string().optional().describe('PostgreSQL connection string'), + query: z.string().describe('SQL query'), + timeout: z.number().optional().default(30).describe('Query timeout in seconds'), + format: z.enum(['table', 'csv']).optional().default('table').describe('Output format'), + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + success: z.boolean(), + rows: z.array(z.record(z.unknown())).optional(), + rowCount: z.number().optional(), + columns: z.array(z.string()).optional(), + error: z.string().optional(), + durationMs: z.number(), + truncated: z.boolean().optional(), + }), +) +type OutputSchema = ReturnType +export type Output = z.infer + +const MAX_RESULT_ROWS = 500 +const MAX_RESULT_CHARS = 50_000 + +// Heuristic: only pure SELECT (no WITH/CTE) is safely read-only. +// WITH can contain data-modifying CTEs (DELETE/UPDATE/INSERT). +function isReadOnlyQuery(q: string): boolean { + const t = q.trim().toLowerCase() + if (t.startsWith('with')) return false // CTEs can contain DML + return t.startsWith('select') || false +} + +function isDestructiveQuery(q: string): boolean { + const t = q.trim().toLowerCase() + return t.startsWith('drop') || t.startsWith('truncate') || t.startsWith('delete') || (t.startsWith('alter') && t.includes('drop')) +} + +function parseAligned(stdout: string): { rows: Record[]; columns: string[] } { + const lines = stdout.trim().split('\n').filter(l => l.trim()) + if (lines.length < 3) return { rows: [], columns: [] } + const sep = lines[1] + if (!sep || (!sep.includes('+') && !sep.includes('-'))) return { rows: [], columns: [] } + const hasPipes = lines[0].includes('|') + if (!hasPipes) { + // Single-column output: no pipe separators + // header: " count", separator: "-------", data: " 42" + const header = lines[0].trim() + const dataLines = lines.slice(2).filter(l => { + const t = l.trim() + if (!t || t.includes('---')) return false + // Filter psql footers like "(1 row)" or "(2 rows)" + if (t.startsWith('(') && t.endsWith(')') && /\d+\s+rows?/.test(t)) return false + return true + }) + const rows = dataLines.map(line => { + const val = line.trim() + return { [header]: /^\d+(\.\d+)?$/.test(val) ? (val.includes('.') ? parseFloat(val) : parseInt(val, 10)) : val } + }) + return { rows, columns: [header] } + } + const headers = lines[0].split('|').map(h => h.trim()).filter(Boolean) + const data = lines.slice(2).filter(l => l.includes('|') && !l.includes('+') && !l.includes('---')) + const rows = data.map(line => { + const row: Record = {} + const vals = line.split('|').map(v => v.trim()) + headers.forEach((col, i) => { row[col] = /^\d+(\.\d+)?$/.test(vals[i] || '') ? (vals[i].includes('.') ? parseFloat(vals[i]) : parseInt(vals[i], 10)) : (vals[i] || '') }) + return row + }) + return { rows, columns: headers } +} + +function parseCsv(stdout: string): { rows: Record[]; columns: string[] } { + function splitCsvLine(line: string): string[] { + const vals: string[] = []; let cur = ''; let inQ = false + for (let i = 0; i < line.length; i++) { + const c = line[i] + if (c === '"') { if (inQ && i + 1 < line.length && line[i + 1] === '"') { cur += '"'; i++ } else inQ = !inQ } + else if (c === ',' && !inQ) { vals.push(cur.trim()); cur = '' } + else cur += c + } + vals.push(cur.trim()); return vals + } + + const lines = stdout.trim().split('\n').filter(l => l.trim()) + if (lines.length < 1) return { rows: [], columns: [] } + const columns = splitCsvLine(lines[0]) + const rows = lines.slice(1).map(line => { + const vals = splitCsvLine(line) + const row: Record = {} + columns.forEach((col, i) => { row[col] = vals[i] || null }) + return row + }) + return { rows, columns } +} + +export const PostgresQueryTool = buildTool({ + name: POSTGRES_QUERY_TOOL_NAME, + searchHint: 'execute SQL queries against PostgreSQL', + maxResultSizeChars: MAX_RESULT_CHARS, + strict: true, + get inputSchema(): InputSchema { return inputSchema() }, + get outputSchema(): OutputSchema { return outputSchema() }, + userFacingName: () => 'Postgres Query', + isReadOnly(input) { return input ? isReadOnlyQuery(input.query) : false }, + isDestructive(input) { return input ? isDestructiveQuery(input.query) : false }, + toAutoClassifierInput(input) { return input.query.slice(0, 100) }, + async description() { return DESCRIPTION }, + async prompt() { return PROMPT }, + async validateInput(input) { + if (!input.query) return { result: false, message: 'Missing required parameter: query', errorCode: 1 } + if (input.query.length > 10000) return { result: false, message: 'Query too long', errorCode: 1 } + return { result: true } + }, + async checkPermissions(input) { + if (isDestructiveQuery(input.query)) return { behavior: 'ask', message: `Execute destructive query on PostgreSQL? ${input.query.slice(0, 200)}`, updatedInput: input } + return { behavior: 'ask', message: `Execute PostgreSQL query? ${input.query.slice(0, 200)}`, updatedInput: input } + }, + mapToolResultToToolResultBlockParam(output, toolUseID) { + return { tool_use_id: toolUseID, type: 'tool_result', content: JSON.stringify(output) } + }, + renderToolUseMessage(input) { + return `Executing PostgreSQL query: ${input.query.slice(0, 200)}` + }, + renderToolResultMessage(output) { + if (!output.success) return `Query failed: ${output.error}` + if (output.rows?.length) { + const preview = output.truncated ? `Returned ${output.rowCount} rows (truncated, showing first ${output.rows.length})` : `Returned ${output.rowCount} rows` + return `${preview} in ${output.durationMs}ms` + } + if (output.rowCount !== undefined) return `${output.rowCount} rows affected in ${output.durationMs}ms` + return `Query executed in ${output.durationMs}ms` + }, + async call(input, _ctx, _canUseTool?, _parentMessage?, _onProgress?) { + const startTime = Date.now() + // Connection priority: explicit string > PGDATABASE_URL > individual PG* env vars (libpq default) + const connStr = input.connection || process.env.PGDATABASE_URL || '' + const hasPgEnv = !connStr && (process.env.PGHOST || process.env.PGPORT || process.env.PGUSER || process.env.PGPASSWORD || process.env.PGDATABASE) + if (!connStr && !hasPgEnv) return { data: { success: false, durationMs: Date.now() - startTime, error: 'No PostgreSQL connection configured. Provide a connection parameter or set PGDATABASE_URL / PGHOST / PGPORT / PGUSER / PGPASSWORD / PGDATABASE.' } } + + try { + const args: string[] = ['--no-psqlrc'] + if (input.format === 'csv') { args.push('--csv') } else { args.push('--aligned') } + if (connStr) args.push('-c', input.query, connStr) + else args.push('-c', input.query) + + // Preserve existing PGSSLMODE; only set PG* env vars that are user-configured + const spawnEnv: Record = { ...process.env as Record, PGCONNECT_TIMEOUT: String(input.timeout ?? 30) } + if (process.env.PGSSLMODE === undefined) delete spawnEnv.PGSSLMODE // don't force SSL + + const result = spawnSync('psql', args, { timeout: (input.timeout ?? 30) * 1000, maxBuffer: MAX_RESULT_CHARS, encoding: 'utf-8', env: spawnEnv }) + const stdout = (result.stdout ?? '').trim() + const stderr = (result.stderr ?? '').trim() + + if ((result.status ?? 1) !== 0) return { data: { success: false, durationMs: Date.now() - startTime, error: (stdout + '\n' + stderr).trim().slice(0, 2000) || `psql exited with code ${result.status}` } } + + const { rows, columns } = input.format === 'csv' ? parseCsv(stdout) : parseAligned(stdout) + const selectLike = /^\s*(select)\s/i.test(input.query.trim()) + const rowCount = selectLike ? rows.length : (() => { const m = stdout.match(/(INSERT \d+|UPDATE |DELETE )\s*(\d+)/); return m ? parseInt(m[2] || m[1].split(' ').pop() || '0', 10) : undefined })() + + const truncated = rows.length > MAX_RESULT_ROWS + return { data: { success: true, rows: truncated ? rows.slice(0, MAX_RESULT_ROWS) : rows, rowCount: rowCount ?? rows.length, columns: columns.length > 0 ? columns : undefined, durationMs: Date.now() - startTime, truncated } } + } catch (err) { + return { data: { success: false, durationMs: Date.now() - startTime, error: err instanceof Error ? err.message.split('\n')[0] : String(err) } } + } + }, +}) diff --git a/src/tools/PostgresQueryTool/prompt.ts b/src/tools/PostgresQueryTool/prompt.ts new file mode 100644 index 0000000000..ea77cf5d92 --- /dev/null +++ b/src/tools/PostgresQueryTool/prompt.ts @@ -0,0 +1,25 @@ +export const POSTGRES_QUERY_TOOL_NAME = 'PostgresQuery' + +export const DESCRIPTION = + 'Execute SQL queries against a PostgreSQL database. Supports SELECT, INSERT, UPDATE, DELETE, and DDL statements. Uses the psql CLI or a direct connection string.' + +export const PROMPT = `Execute SQL queries against a PostgreSQL database. + +## Usage +- Provide a connection string or use environment variable PGDATABASE_URL +- Queries run with a configurable timeout (default: 30s) +- SELECT queries return rows as formatted text +- INSERT/UPDATE/DELETE return affected row count +- DDL statements return success/failure status + +## Connection Priority +1. Explicit connection parameter +2. PGDATABASE_URL environment variable +3. Individual PG* environment variables (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE) + +## Safety +- Queries are validated before execution +- Long-running queries are terminated after timeout +- Result size is limited to prevent memory issues +- Write operations prompt for confirmation +` diff --git a/src/tools/SqliteQueryTool/SqliteQueryTool.test.ts b/src/tools/SqliteQueryTool/SqliteQueryTool.test.ts new file mode 100644 index 0000000000..5391d4b46a --- /dev/null +++ b/src/tools/SqliteQueryTool/SqliteQueryTool.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'bun:test' +import { SQLITE_QUERY_TOOL_NAME } from './prompt.js' +import { SqliteQueryTool } from './SqliteQueryTool.js' + +describe('SqliteQueryTool', () => { + it('has the correct name', () => { expect(SqliteQueryTool.name).toBe(SQLITE_QUERY_TOOL_NAME) }) + it('has a non-empty description', async () => { expect((await SqliteQueryTool.description()).length).toBeGreaterThan(0) }) + it('has isEnabled from buildTool', () => { expect(SqliteQueryTool.isEnabled()).toBe(true) }) + + it('marks read mode as read-only', () => { expect(SqliteQueryTool.isReadOnly?.({ mode: 'read', query: 'SELECT 1' })).toBe(true) }) + it('marks write mode as not read-only', () => { expect(SqliteQueryTool.isReadOnly?.({ mode: 'write', query: 'SELECT 1' })).toBe(false) }) + it('marks INSERT as not read-only', () => { expect(SqliteQueryTool.isReadOnly?.({ mode: 'read', query: 'INSERT INTO t VALUES(1)' })).toBe(false) }) + it('marks DROP as destructive', () => { expect(SqliteQueryTool.isDestructive?.({ query: 'DROP TABLE users' })).toBe(true) }) + it('marks SELECT as not destructive', () => { expect(SqliteQueryTool.isDestructive?.({ query: 'SELECT 1' })).toBe(false) }) + + it('has checkPermissions defined', () => { expect(typeof SqliteQueryTool.checkPermissions).toBe('function') }) + it('has getPath defined', () => { expect(typeof SqliteQueryTool.getPath).toBe('function') }) + + it('validates empty path', async () => { expect((await SqliteQueryTool.validateInput({ path: '', query: 'SELECT 1' })).result).toBe(false) }) + it('validates empty query', async () => { expect((await SqliteQueryTool.validateInput({ path: 'test.db', query: '' })).result).toBe(false) }) + it('rejects non-db extension', async () => { expect((await SqliteQueryTool.validateInput({ path: 'data.txt', query: 'SELECT 1' })).result).toBe(false) }) + it('accepts .db extension', async () => { expect((await SqliteQueryTool.validateInput({ path: 'test.db', query: 'SELECT 1' })).result).toBe(true) }) + + it('has mapToolResultToToolResultBlockParam', () => { + const b = SqliteQueryTool.mapToolResultToToolResultBlockParam({ success: true, durationMs: 5, dbPath: '/tmp/test.db' }, 'tid') + expect(b.tool_use_id).toBe('tid'); expect(b.type).toBe('tool_result') + }) + + it('renders success with rows', () => { expect(SqliteQueryTool.renderToolResultMessage?.({ success: true, rows: [{ id: 1 }], rowCount: 1, durationMs: 2, truncated: false, dbPath: '/tmp/test.db' })).toContain('1 rows') }) + it('renders error', () => { expect(SqliteQueryTool.renderToolResultMessage?.({ success: false, durationMs: 1, error: 'no such table', dbPath: '/tmp/test.db' })).toContain('no such table') }) + it('provides auto-classifier', () => { expect(SqliteQueryTool.toAutoClassifierInput?.({ path: 'test.db', query: 'SELECT 1' })).toContain('SELECT 1') }) +}) diff --git a/src/tools/SqliteQueryTool/SqliteQueryTool.ts b/src/tools/SqliteQueryTool/SqliteQueryTool.ts new file mode 100644 index 0000000000..6551653a37 --- /dev/null +++ b/src/tools/SqliteQueryTool/SqliteQueryTool.ts @@ -0,0 +1,159 @@ +import { spawnSync } from 'child_process' +import { existsSync } from 'fs' +import { resolve, normalize } from 'path' +import { z } from 'zod/v4' +import { buildTool } from '../../Tool.js' +import { lazySchema } from '../../utils/lazySchema.js' +import { expandPath } from '../../utils/path.js' +import { checkReadPermissionForTool, checkWritePermissionForTool } from '../../utils/permissions/filesystem.js' +import { DESCRIPTION, SQLITE_QUERY_TOOL_NAME, PROMPT } from './prompt.js' + +const inputSchema = lazySchema(() => + z.strictObject({ + path: z.string().describe('Path to SQLite database file (.db, .sqlite, .sqlite3)'), + query: z.string().describe('SQL query'), + mode: z.enum(['read', 'write']).optional().default('read').describe('Access mode'), + }), +) +type InputSchema = ReturnType + +const outputSchema = lazySchema(() => + z.object({ + success: z.boolean(), + rows: z.array(z.record(z.unknown())).optional(), + rowCount: z.number().optional(), + columns: z.array(z.string()).optional(), + error: z.string().optional(), + durationMs: z.number(), + truncated: z.boolean().optional(), + dbPath: z.string(), + }), +) +type OutputSchema = ReturnType +export type Output = z.infer + +const MAX_RESULT_ROWS = 1000 +const MAX_RESULT_CHARS = 100_000 + +function parseOutput(stdout: string): { rows: Record[]; columns: string[] } { + try { + const parsed = JSON.parse(stdout) as Record[] + if (Array.isArray(parsed)) { + const columns = parsed.length > 0 ? Object.keys(parsed[0]) : [] + return { rows: parsed, columns } + } + } catch { /* fall through to pipe parsing for older sqlite */ } + // Fallback for sqlite3 without -json support: parse pipe-separated output + const lines = stdout.trim().split('\n').filter(l => l.trim()) + if (lines.length < 2) return { rows: [], columns: [] } + const columns = lines[0].split('|').map(c => c.trim()) + const rows = lines.slice(1).map(line => { + const vals = line.split('|').map(v => v.trim()) + const row: Record = {} + columns.forEach((col, i) => { + let v: unknown = vals[i] ?? null + if (v === 'NULL') v = null + else { const n = Number(v); if (!isNaN(n) && v !== '') v = n } + row[col] = v + }) + return row + }) + return { rows, columns } +} + +function isWriteQuery(q: string): boolean { + const t = q.trim().toLowerCase() + return !t.startsWith('select') && !t.startsWith('with') && !t.startsWith('pragma') && !t.startsWith('explain') +} + +export const SqliteQueryTool = buildTool({ + name: SQLITE_QUERY_TOOL_NAME, + searchHint: 'query a local SQLite database', + maxResultSizeChars: MAX_RESULT_CHARS, + strict: true, + get inputSchema(): InputSchema { return inputSchema() }, + get outputSchema(): OutputSchema { return outputSchema() }, + userFacingName: () => 'SQLite Query', + isReadOnly(input) { + if (input?.mode === 'write') return false + if (input?.query && isWriteQuery(input.query)) return false + return true + }, + isDestructive(input) { + if (input?.query) { const q = input.query.trim().toLowerCase(); return q.startsWith('drop') || q.startsWith('truncate') || q.startsWith('delete') || (q.startsWith('alter') && q.includes('drop')) } + return false + }, + toAutoClassifierInput(input) { return `${input.path}: ${input.query.slice(0, 100)}` }, + getPath({ path }): string { return path || '' }, + async description() { return DESCRIPTION }, + async prompt() { return PROMPT }, + async validateInput(input) { + if (!input.path) return { result: false, message: 'Missing path', errorCode: 1 } + if (!input.query) return { result: false, message: 'Missing query', errorCode: 1 } + if (input.query.length > 10000) return { result: false, message: 'Query too long', errorCode: 1 } + const f = resolve(expandPath(input.path)) + if (!f.endsWith('.db') && !f.endsWith('.sqlite') && !f.endsWith('.sqlite3')) return { result: false, message: 'File must have .db, .sqlite, or .sqlite3 extension', errorCode: 1 } + return { result: true } + }, + async checkPermissions(input, context) { + const appState = context.getAppState() + if (input.mode === 'write') { + return checkWritePermissionForTool(SqliteQueryTool, input, appState.toolPermissionContext) + } + return checkReadPermissionForTool(SqliteQueryTool, input, appState.toolPermissionContext) + }, + mapToolResultToToolResultBlockParam(output, toolUseID) { + return { tool_use_id: toolUseID, type: 'tool_result', content: JSON.stringify(output) } + }, + renderToolUseMessage(input) { + return `Querying SQLite ${input.path}: ${input.query.slice(0, 150)}` + }, + renderToolResultMessage(output) { + if (!output.success) return `SQLite query failed on ${output.dbPath}: ${output.error}` + if (output.rows?.length) return `Returned ${output.rowCount} rows from ${output.dbPath} in ${output.durationMs}ms${output.truncated ? ' (truncated)' : ''}` + if (output.rowCount !== undefined) return `${output.rowCount} rows affected on ${output.dbPath} in ${output.durationMs}ms` + return `Query executed on ${output.dbPath} in ${output.durationMs}ms` + }, + async call(input, _ctx, _canUseTool?, _parentMessage?, _onProgress?) { + const startTime = Date.now() + const resolvedPath = normalize(resolve(expandPath(input.path))) + if (!existsSync(resolvedPath)) return { data: { success: false, durationMs: Date.now() - startTime, error: `Database file not found: ${resolvedPath}`, dbPath: resolvedPath } } + + const readOnly = input.mode === 'read' + if (readOnly && isWriteQuery(input.query)) return { data: { success: false, durationMs: Date.now() - startTime, error: `Write queries not allowed in read mode. Set mode to 'write' to execute: ${input.query.slice(0, 100)}`, dbPath: resolvedPath } } + + try { + const args: string[] = ['-json'] + if (readOnly) args.push('-readonly') + args.push(resolvedPath, input.query) + + const result = spawnSync('sqlite3', args, { timeout: 30000, maxBuffer: MAX_RESULT_CHARS, encoding: 'utf-8' }) + if (result.error) return { data: { success: false, durationMs: Date.now() - startTime, error: `Binary not found: sqlite3. Install it and try again.`, dbPath: resolvedPath } } + const stdout = (result.stdout ?? '').trim() + const stderr = (result.stderr ?? '').trim() + + if ((result.status ?? 1) !== 0) return { data: { success: false, durationMs: Date.now() - startTime, error: (stdout + '\n' + stderr).trim().slice(0, 2000) || `sqlite3 exited with code ${result.status}`, dbPath: resolvedPath } } + + // Parse JSON output (handles all text values correctly, no pipe-separator corruption) + const { rows, columns } = parseOutput(stdout) + + // For write queries, get affected row count from a separate invocation + let rowCount: number | undefined + if (isWriteQuery(input.query)) { + try { + const cr = spawnSync('sqlite3', ['-json', resolvedPath, 'SELECT changes()'], { timeout: 5000, encoding: 'utf-8' }) + if (!cr.error && (cr.status ?? 0) === 0) { + const crParsed = JSON.parse(cr.stdout ?? '[]') as Array> + rowCount = parseInt(String(crParsed[0]?.['changes()'] ?? -1), 10) + if (isNaN(rowCount) || rowCount < 0) rowCount = undefined + } + } catch { rowCount = rows.length } + } else rowCount = rows.length + + const truncated = rows.length > MAX_RESULT_ROWS + return { data: { success: true, rows: truncated ? rows.slice(0, MAX_RESULT_ROWS) : rows, rowCount: rowCount ?? rows.length, columns, durationMs: Date.now() - startTime, truncated, dbPath: resolvedPath } } + } catch (err) { + return { data: { success: false, durationMs: Date.now() - startTime, error: err instanceof Error ? err.message : String(err), dbPath: resolvedPath } } + } + }, +}) diff --git a/src/tools/SqliteQueryTool/prompt.ts b/src/tools/SqliteQueryTool/prompt.ts new file mode 100644 index 0000000000..767afd31e7 --- /dev/null +++ b/src/tools/SqliteQueryTool/prompt.ts @@ -0,0 +1,20 @@ +export const SQLITE_QUERY_TOOL_NAME = 'SqliteQuery' + +export const DESCRIPTION = + 'Execute SQL queries against a local SQLite database file. Supports all SQL statements including SELECT, INSERT, UPDATE, DELETE, and DDL.' + +export const PROMPT = `Execute SQL queries against a local SQLite database file. + +## Usage +- Provide the path to a SQLite database file +- Queries run synchronously with a configurable timeout +- SELECT queries return rows as formatted results +- INSERT/UPDATE/DELETE return affected row count +- DDL statements return success/failure status + +## Safety +- Write operations are tracked and flagged +- Result size is limited to prevent memory issues +- Database file must be within the project directory +- Large results are automatically truncated +`