| #!/usr/bin/env bun | ||
| import { getDb, getReadonlyDb, createSchema, dbExists, getDbPath, getDataDir } from '../lib/db.js'; | ||
| import { seedDatabase, incrementalSync } from '../lib/parser.js'; | ||
| import { runHook } from '../lib/hook.js'; | ||
| import { runStatusline } from '../lib/statusline.js'; | ||
| import { installHook, uninstallHook, installClaudeMd, uninstallClaudeMd, installSlashCommand, uninstallSlashCommand, installStatusline, uninstallStatusline } from '../lib/install.js'; | ||
| import { saveThread, removeThread, renameThread, listThreads, interactiveLaunch } from '../lib/launch.js'; | ||
| import { ingestDoc, listDocs, searchDocs, deleteDoc } from '../lib/docs.js'; | ||
| import { getConfig, getConfigValue, setConfig, unsetConfig, parseBool, isKnownKey, listKnownKeys, describeKey, getConfigPath, isBoolKey, isStringKey, STATUSLINE_COLORS } from '../lib/config.js'; | ||
| import { existsSync, rmSync, statSync } from 'fs'; | ||
| const args = process.argv.slice(2); | ||
| const command = args[0]; | ||
| switch (command) { | ||
| case 'init': | ||
| await cmdInit(); | ||
| break; | ||
| case 'import': | ||
| cmdImport(args[1]); | ||
| break; | ||
| case 'hook': | ||
| runHook(); | ||
| break; | ||
| case 'statusline': | ||
| runStatusline(); | ||
| break; | ||
| case 'config': | ||
| await cmdConfig(args.slice(1)); | ||
| break; | ||
| case 'sync': | ||
| case 'seed': | ||
| cmdSync(command === 'seed'); | ||
| break; | ||
| case 'query': | ||
| cmdQuery(args.slice(1).join(' ')); | ||
| break; | ||
| case 'sql': | ||
| cmdSql(args.slice(1).join(' ')); | ||
| break; | ||
| case 'status': | ||
| cmdStatus(); | ||
| break; | ||
| case 'reset': | ||
| await cmdReset(); | ||
| break; | ||
| case 'launch': | ||
| await cmdLaunch(args.slice(1)); | ||
| break; | ||
| case 'docs': | ||
| await cmdDocs(args.slice(1)); | ||
| break; | ||
| case 'help': | ||
| case '--help': | ||
| case '-h': | ||
| case undefined: | ||
| showHelp(); | ||
| break; | ||
| default: | ||
| console.error(`Unknown command: ${command}`); | ||
| showHelp(); | ||
| process.exit(1); | ||
| } | ||
| async function cmdInit() { | ||
| console.log(''); | ||
| console.log(' CloudCtx — Persistent memory for Claude Code'); | ||
| console.log(''); | ||
| if (dbExists()) { | ||
| console.log(` Database already exists at ${getDbPath()}`); | ||
| console.log(' Run "cloudctx seed" to sync new conversations.'); | ||
| console.log(' Run "cloudctx reset" to start fresh.'); | ||
| console.log(''); | ||
| return; | ||
| } | ||
| // Create database | ||
| console.log(' Creating database...'); | ||
| const db = getDb(); | ||
| createSchema(db); | ||
| // Seed from existing conversations | ||
| console.log(' Parsing Claude Code conversations...'); | ||
| console.log(''); | ||
| const stats = seedDatabase(db, (project, total) => { | ||
| process.stdout.write(`\r Processed ${total} files...`); | ||
| }); | ||
| db.close(); | ||
| console.log(''); | ||
| console.log(` ✓ Database created: ${getDbPath()}`); | ||
| console.log(` ${stats.messages.toLocaleString()} messages, ${stats.tools.toLocaleString()} tool uses, ${stats.history.toLocaleString()} history entries`); | ||
| const dbSize = statSync(getDbPath()).size / 1024 / 1024; | ||
| console.log(` Size: ${dbSize.toFixed(1)} MB`); | ||
| console.log(''); | ||
| // Install hooks | ||
| console.log(' Installing Claude Code hooks...'); | ||
| installHook(); | ||
| console.log(' ✓ Hook added to ~/.claude/settings.json'); | ||
| // Install CLAUDE.md | ||
| installClaudeMd(); | ||
| console.log(' ✓ Instructions added to ~/.claude/CLAUDE.md'); | ||
| // Install slash commands | ||
| installSlashCommand(); | ||
| console.log(' ✓ Slash commands /cloudctx-save and /cloudctx-rename added to ~/.claude/commands/'); | ||
| console.log(''); | ||
| console.log(' ✓ Memory is active. Open a new Claude Code session to use it.'); | ||
| console.log(''); | ||
| console.log(' Commands:'); | ||
| console.log(' cloudctx query "search terms" Search memory'); | ||
| console.log(' cloudctx sql "SELECT ..." Raw SQL'); | ||
| console.log(' cloudctx launch Resume saved threads'); | ||
| console.log(' cloudctx launch --save "name" Save current thread'); | ||
| console.log(' cloudctx docs ingest <url> [tags] Ingest reference docs'); | ||
| console.log(' cloudctx status Database stats'); | ||
| console.log(' cloudctx reset Remove everything'); | ||
| console.log(''); | ||
| } | ||
| function cmdSync(fullReseed = false) { | ||
| if (!dbExists()) { | ||
| console.error('No database found. Run: cloudctx init'); | ||
| process.exit(1); | ||
| } | ||
| const db = getDb(); | ||
| if (fullReseed) { | ||
| console.log('Re-seeding from all Claude Code conversations...'); | ||
| const stats = seedDatabase(db, (project, total) => { | ||
| process.stdout.write(`\r Processed ${total} files...`); | ||
| }); | ||
| console.log(''); | ||
| console.log(` ✓ ${stats.messages.toLocaleString()} new messages, ${stats.tools.toLocaleString()} tool uses`); | ||
| } else { | ||
| const stats = incrementalSync(db); | ||
| if (stats.messages > 0) { | ||
| console.log(`Synced ${stats.messages} new messages, ${stats.tools} tool uses`); | ||
| } | ||
| } | ||
| db.close(); | ||
| } | ||
| function cmdQuery(terms) { | ||
| if (!terms) { | ||
| console.error('Usage: cloudctx query "search terms"'); | ||
| process.exit(1); | ||
| } | ||
| if (!dbExists()) { | ||
| console.error('No database found. Run: cloudctx init'); | ||
| process.exit(1); | ||
| } | ||
| const db = getReadonlyDb(); | ||
| const rows = db.prepare(` | ||
| SELECT m.type, substr(m.content, 1, 300) as preview, m.timestamp | ||
| FROM messages_fts f | ||
| JOIN messages m ON f.rowid = m.id | ||
| WHERE messages_fts MATCH ? | ||
| ORDER BY rank | ||
| LIMIT 10 | ||
| `).all(terms); | ||
| db.close(); | ||
| if (!rows.length) { | ||
| console.log(`No results for: ${terms}`); | ||
| return; | ||
| } | ||
| for (const r of rows) { | ||
| const ts = r.timestamp ? r.timestamp.split('T')[0] : ''; | ||
| console.log(`[${r.type}] ${ts}`); | ||
| console.log(` ${r.preview}`); | ||
| console.log(''); | ||
| } | ||
| } | ||
| function cmdSql(query) { | ||
| if (!query) { | ||
| console.error('Usage: cloudctx sql "SELECT ..."'); | ||
| process.exit(1); | ||
| } | ||
| if (!dbExists()) { | ||
| console.error('No database found. Run: cloudctx init'); | ||
| process.exit(1); | ||
| } | ||
| // Safety: enforce read-only | ||
| const lower = query.toLowerCase().trim(); | ||
| if (lower.startsWith('insert') || lower.startsWith('update') || lower.startsWith('delete') || lower.startsWith('drop') || lower.startsWith('alter') || lower.startsWith('create')) { | ||
| console.error('Read-only: write operations are not allowed via cloudctx sql'); | ||
| process.exit(1); | ||
| } | ||
| const db = getReadonlyDb(); | ||
| try { | ||
| const rows = db.prepare(query).all(); | ||
| if (!rows.length) { | ||
| console.log('No results.'); | ||
| return; | ||
| } | ||
| // Print results | ||
| for (const row of rows) { | ||
| const values = Object.values(row); | ||
| console.log(values.join(' | ')); | ||
| } | ||
| } catch (e) { | ||
| console.error(`SQL error: ${e.message}`); | ||
| process.exit(1); | ||
| } finally { | ||
| db.close(); | ||
| } | ||
| } | ||
| function cmdStatus() { | ||
| if (!dbExists()) { | ||
| console.log('CloudCtx is not initialized. Run: cloudctx init'); | ||
| return; | ||
| } | ||
| const db = getReadonlyDb(); | ||
| const messages = db.prepare('SELECT COUNT(*) as count FROM messages').get().count; | ||
| const sessions = db.prepare('SELECT COUNT(*) as count FROM sessions').get().count; | ||
| const tools = db.prepare('SELECT COUNT(*) as count FROM tool_uses').get().count; | ||
| const docs = db.prepare('SELECT COUNT(*) as count FROM docs').get().count; | ||
| const threads = db.prepare('SELECT COUNT(*) as count FROM saved_threads').get().count; | ||
| const latest = db.prepare('SELECT MAX(timestamp) as ts FROM messages').get().ts || 'none'; | ||
| db.close(); | ||
| const dbSize = statSync(getDbPath()).size / 1024 / 1024; | ||
| console.log(''); | ||
| console.log(' CloudCtx Status'); | ||
| console.log(' ' + '─'.repeat(40)); | ||
| console.log(` Messages: ${messages.toLocaleString()}`); | ||
| console.log(` Sessions: ${sessions.toLocaleString()}`); | ||
| console.log(` Tool uses: ${tools.toLocaleString()}`); | ||
| console.log(` Docs: ${docs}`); | ||
| console.log(` Saved threads: ${threads}`); | ||
| console.log(` Latest: ${latest}`); | ||
| console.log(` DB size: ${dbSize.toFixed(1)} MB`); | ||
| console.log(` DB path: ${getDbPath()}`); | ||
| console.log(''); | ||
| } | ||
| async function cmdReset() { | ||
| const { createInterface } = await import('readline'); | ||
| const rl = createInterface({ input: process.stdin, output: process.stdout }); | ||
| console.log(''); | ||
| console.log(' This will:'); | ||
| console.log(' - Remove cloudctx hooks from ~/.claude/settings.json'); | ||
| console.log(' - Remove cloudctx block from ~/.claude/CLAUDE.md'); | ||
| console.log(` - Delete ${getDataDir()} (database + config)`); | ||
| console.log(''); | ||
| const answer = await new Promise(resolve => { | ||
| rl.question(' Proceed? (y/N) ', resolve); | ||
| }); | ||
| rl.close(); | ||
| if (answer.toLowerCase() !== 'y') { | ||
| console.log(' Cancelled.'); | ||
| return; | ||
| } | ||
| // Remove hooks | ||
| if (uninstallHook()) { | ||
| console.log(' ✓ Hooks removed'); | ||
| } | ||
| // Remove CLAUDE.md block | ||
| if (uninstallClaudeMd()) { | ||
| console.log(' ✓ CLAUDE.md block removed'); | ||
| } | ||
| // Remove slash command | ||
| if (uninstallSlashCommand()) { | ||
| console.log(' ✓ Slash command removed'); | ||
| } | ||
| // Remove statusline if it was installed | ||
| if (uninstallStatusline()) { | ||
| console.log(' ✓ Status line removed'); | ||
| } | ||
| // Delete data dir | ||
| if (existsSync(getDataDir())) { | ||
| rmSync(getDataDir(), { recursive: true }); | ||
| console.log(' ✓ Database deleted'); | ||
| } | ||
| console.log(''); | ||
| console.log(' Claude Code is back to default.'); | ||
| console.log(''); | ||
| } | ||
| async function cmdLaunch(subArgs) { | ||
| if (subArgs[0] === '--save') { | ||
| const name = subArgs[1]; | ||
| const sessionId = subArgs[2] || null; | ||
| if (!name) { | ||
| console.error('Usage: cloudctx launch --save "descriptive-thread-name"'); | ||
| process.exit(1); | ||
| } | ||
| saveThread(name, sessionId); | ||
| } else if (subArgs[0] === '--remove') { | ||
| const name = subArgs[1]; | ||
| if (!name) { | ||
| console.error('Usage: cloudctx launch --remove "thread-name"'); | ||
| process.exit(1); | ||
| } | ||
| removeThread(name); | ||
| } else if (subArgs[0] === '--rename') { | ||
| const oldName = subArgs[1]; | ||
| const newName = subArgs[2]; | ||
| if (!oldName || !newName) { | ||
| console.error('Usage: cloudctx launch --rename "old-name" "new-name"'); | ||
| process.exit(1); | ||
| } | ||
| renameThread(oldName, newName); | ||
| } else if (subArgs[0] === '--list') { | ||
| listThreads(); | ||
| } else { | ||
| // Interactive launcher | ||
| await interactiveLaunch(); | ||
| } | ||
| } | ||
| async function cmdDocs(subArgs) { | ||
| const subCmd = subArgs[0]; | ||
| switch (subCmd) { | ||
| case 'ingest': { | ||
| const source = subArgs[1]; | ||
| const tags = subArgs[2] || ''; | ||
| if (!source) { | ||
| console.error('Usage: cloudctx docs ingest <url_or_file> [tags]'); | ||
| process.exit(1); | ||
| } | ||
| await ingestDoc(source, tags); | ||
| break; | ||
| } | ||
| case 'list': | ||
| listDocs(); | ||
| break; | ||
| case 'search': { | ||
| const query = subArgs.slice(1).join(' '); | ||
| if (!query) { | ||
| console.error('Usage: cloudctx docs search "query"'); | ||
| process.exit(1); | ||
| } | ||
| searchDocs(query); | ||
| break; | ||
| } | ||
| case 'delete': { | ||
| const id = parseInt(subArgs[1], 10); | ||
| if (isNaN(id)) { | ||
| console.error('Usage: cloudctx docs delete <id>'); | ||
| process.exit(1); | ||
| } | ||
| deleteDoc(id); | ||
| break; | ||
| } | ||
| default: | ||
| console.error('Usage: cloudctx docs [ingest|list|search|delete]'); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| async function cmdConfig(subArgs) { | ||
| const sub = subArgs[0] || 'list'; | ||
| if (sub === 'list') { | ||
| const config = getConfig(); | ||
| console.log(''); | ||
| console.log(' CloudCtx Config'); | ||
| console.log(' ' + '─'.repeat(50)); | ||
| for (const key of listKnownKeys()) { | ||
| const val = config[key]; | ||
| const desc = describeKey(key); | ||
| console.log(` ${key.padEnd(18)} ${String(val).padEnd(6)} ${desc}`); | ||
| } | ||
| console.log(''); | ||
| console.log(` File: ${getConfigPath()}`); | ||
| console.log(''); | ||
| console.log(' cloudctx config set <key> <true|false>'); | ||
| console.log(' cloudctx config unset <key>'); | ||
| console.log(''); | ||
| return; | ||
| } | ||
| if (sub === 'get') { | ||
| const key = subArgs[1]; | ||
| if (!key) { console.error('Usage: cloudctx config get <key>'); process.exit(1); } | ||
| if (!isKnownKey(key)) { | ||
| console.error(`Unknown key: ${key}`); | ||
| console.error(`Known keys: ${listKnownKeys().join(', ')}`); | ||
| process.exit(1); | ||
| } | ||
| console.log(getConfigValue(key)); | ||
| return; | ||
| } | ||
| if (sub === 'color' || sub === 'colors') { | ||
| if (!process.stdin.isTTY) { | ||
| console.log(''); | ||
| console.log(' Valid statusline_color values:'); | ||
| for (const name of Object.keys(STATUSLINE_COLORS)) { | ||
| const code = STATUSLINE_COLORS[name]; | ||
| const preview = code ? `\x1b[1;${code}m📌 ${name}\x1b[0m` : `\x1b[1m📌 ${name}\x1b[0m`; | ||
| console.log(` ${preview}`); | ||
| } | ||
| console.log(''); | ||
| console.log(' cloudctx config set statusline_color <name>'); | ||
| console.log(''); | ||
| return; | ||
| } | ||
| const chosen = await pickColorInteractive(); | ||
| if (chosen) { | ||
| setConfig('statusline_color', chosen); | ||
| const code = STATUSLINE_COLORS[chosen]; | ||
| const preview = code ? `\x1b[1;${code}m📌 ${chosen}\x1b[0m` : `\x1b[1m📌 ${chosen}\x1b[0m`; | ||
| console.log(` ✓ statusline_color = ${preview}`); | ||
| } else { | ||
| console.log(' (cancelled)'); | ||
| } | ||
| return; | ||
| } | ||
| if (sub === 'set') { | ||
| const key = subArgs[1]; | ||
| const value = subArgs[2]; | ||
| if (!key || value === undefined) { | ||
| console.error('Usage: cloudctx config set <key> <value>'); | ||
| process.exit(1); | ||
| } | ||
| if (!isKnownKey(key)) { | ||
| console.error(`Unknown key: ${key}`); | ||
| console.error(`Known keys: ${listKnownKeys().join(', ')}`); | ||
| process.exit(1); | ||
| } | ||
| let storeValue; | ||
| if (isBoolKey(key)) { | ||
| const bool = parseBool(value); | ||
| if (bool === null) { | ||
| console.error(`Value must be true/false (or on/off, yes/no) — got: ${value}`); | ||
| process.exit(1); | ||
| } | ||
| storeValue = bool; | ||
| } else if (isStringKey(key)) { | ||
| if (key === 'statusline_color' && !(value in STATUSLINE_COLORS)) { | ||
| console.error(`Unknown color: ${value}`); | ||
| console.error(`Run: cloudctx config colors`); | ||
| process.exit(1); | ||
| } | ||
| if (key === 'launch_sort' && !['time', 'alpha'].includes(value)) { | ||
| console.error(`launch_sort must be 'time' or 'alpha' — got: ${value}`); | ||
| process.exit(1); | ||
| } | ||
| storeValue = value; | ||
| } else { | ||
| storeValue = value; | ||
| } | ||
| setConfig(key, storeValue); | ||
| const bool = storeValue; | ||
| if (key === 'statusline') { | ||
| if (bool) { | ||
| const result = installStatusline(); | ||
| console.log(` ✓ statusline = true — wired into ~/.claude/settings.json`); | ||
| if (result.wrapped) { | ||
| console.log(` Existing statusLine detected — wrapping it (you'll see both).`); | ||
| } | ||
| console.log(` Open a new Claude Code session to see it.`); | ||
| } else { | ||
| const result = uninstallStatusline(); | ||
| if (result.restored) { | ||
| console.log(` ✓ statusline = false — your original statusLine restored.`); | ||
| } else { | ||
| console.log(` ✓ statusline = false — removed from ~/.claude/settings.json`); | ||
| } | ||
| } | ||
| } else { | ||
| console.log(` ✓ ${key} = ${storeValue}`); | ||
| } | ||
| return; | ||
| } | ||
| if (sub === 'unset') { | ||
| const key = subArgs[1]; | ||
| if (!key) { console.error('Usage: cloudctx config unset <key>'); process.exit(1); } | ||
| unsetConfig(key); | ||
| if (key === 'statusline') uninstallStatusline(); | ||
| console.log(` ✓ unset ${key}`); | ||
| return; | ||
| } | ||
| console.error('Usage: cloudctx config [list|get|set|unset] ...'); | ||
| process.exit(1); | ||
| } | ||
| function registerRawModeExitGuard() { | ||
| process.once('exit', () => { | ||
| try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {} | ||
| }); | ||
| for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) { | ||
| process.once(sig, () => { | ||
| try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {} | ||
| process.exit(130); | ||
| }); | ||
| } | ||
| } | ||
| async function pickColorInteractive() { | ||
| registerRawModeExitGuard(); | ||
| const colors = Object.keys(STATUSLINE_COLORS); | ||
| const currentColor = getConfigValue('statusline_color') || 'cyan'; | ||
| let cursor = Math.max(0, colors.indexOf(currentColor)); | ||
| const render = () => { | ||
| process.stdout.write('\x1b[2J\x1b[H'); | ||
| console.log(''); | ||
| console.log(' \x1b[1mCloudCtx — Choose statusline color\x1b[0m'); | ||
| console.log(' \x1b[2m↑↓ navigate ⏎ save q cancel\x1b[0m'); | ||
| console.log(''); | ||
| for (let i = 0; i < colors.length; i++) { | ||
| const name = colors[i]; | ||
| const code = STATUSLINE_COLORS[name]; | ||
| const preview = code ? `\x1b[1;${code}m📌 ${name}\x1b[0m` : `\x1b[1m📌 ${name}\x1b[0m`; | ||
| const pointer = i === cursor ? ' \x1b[36m❯\x1b[0m ' : ' '; | ||
| const mark = name === currentColor ? ' \x1b[2m(current)\x1b[0m' : ''; | ||
| console.log(`${pointer}${preview}${mark}`); | ||
| } | ||
| console.log(''); | ||
| }; | ||
| process.stdin.setRawMode(true); | ||
| process.stdin.resume(); | ||
| process.stdin.setEncoding('utf-8'); | ||
| render(); | ||
| return new Promise((resolve) => { | ||
| const cleanup = () => { | ||
| process.stdout.write('\x1b[2J\x1b[H'); | ||
| process.stdin.setRawMode(false); | ||
| process.stdin.removeListener('data', onKey); | ||
| process.stdin.pause(); | ||
| }; | ||
| const onKey = (key) => { | ||
| if (key === '\x03' || key === 'q' || key === '\x1b') { | ||
| cleanup(); | ||
| resolve(null); | ||
| return; | ||
| } | ||
| if (key === '\x1b[A' || key === 'k') { | ||
| cursor = (cursor - 1 + colors.length) % colors.length; | ||
| render(); | ||
| return; | ||
| } | ||
| if (key === '\x1b[B' || key === 'j') { | ||
| cursor = (cursor + 1) % colors.length; | ||
| render(); | ||
| return; | ||
| } | ||
| if (key === '\r' || key === '\n') { | ||
| cleanup(); | ||
| resolve(colors[cursor]); | ||
| return; | ||
| } | ||
| }; | ||
| process.stdin.on('data', onKey); | ||
| }); | ||
| } | ||
| function cmdImport(dbPath) { | ||
| if (!dbPath) { | ||
| console.error('Usage: cloudctx import /path/to/existing.db'); | ||
| process.exit(1); | ||
| } | ||
| if (!existsSync(dbPath)) { | ||
| console.error(`Database not found: ${dbPath}`); | ||
| process.exit(1); | ||
| } | ||
| const db = getDb(); | ||
| createSchema(db); | ||
| console.log(` Importing from ${dbPath}...`); | ||
| // Attach the source database | ||
| db.exec(`ATTACH DATABASE '${dbPath}' AS source`); | ||
| // Import each table, skipping duplicates | ||
| const tables = [ | ||
| { name: 'sessions', key: 'session_id' }, | ||
| { name: 'messages', key: 'uuid' }, | ||
| { name: 'tool_uses', key: null }, | ||
| { name: 'summaries', key: null }, | ||
| { name: 'prompt_history', key: null }, | ||
| { name: 'docs', key: 'source' }, | ||
| ]; | ||
| for (const { name, key } of tables) { | ||
| try { | ||
| // Check if table exists in source | ||
| const exists = db.prepare(`SELECT name FROM source.sqlite_master WHERE type='table' AND name=?`).get(name); | ||
| if (!exists) continue; | ||
| const conflict = key ? `OR IGNORE` : ''; | ||
| const result = db.exec(`INSERT ${conflict} INTO main.${name} SELECT * FROM source.${name}`); | ||
| const count = db.prepare(`SELECT COUNT(*) as c FROM main.${name}`).get().c; | ||
| console.log(` ${name}: ${count.toLocaleString()} rows`); | ||
| } catch (e) { | ||
| console.log(` ${name}: skipped (${e.message})`); | ||
| } | ||
| } | ||
| // Rebuild FTS indexes | ||
| console.log(' Rebuilding search indexes...'); | ||
| try { | ||
| db.exec(`INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`); | ||
| db.exec(`INSERT INTO docs_fts(docs_fts) VALUES('rebuild')`); | ||
| } catch (e) { | ||
| console.log(` FTS rebuild: ${e.message}`); | ||
| } | ||
| db.exec('DETACH DATABASE source'); | ||
| db.close(); | ||
| const dbSize = statSync(getDbPath()).size / 1024 / 1024; | ||
| console.log(` ✓ Import complete. DB size: ${dbSize.toFixed(1)} MB`); | ||
| } | ||
| function showHelp() { | ||
| console.log(` | ||
| CloudCtx — Persistent memory for Claude Code | ||
| Usage: cloudctx <command> [options] | ||
| Commands: | ||
| init Set up database, hooks, and CLAUDE.md | ||
| import /path/to/db Import from existing SQLite database | ||
| query "search terms" FTS search across all conversations | ||
| sql "SELECT ..." Raw read-only SQL query | ||
| sync Incremental sync of new conversations | ||
| seed Re-import all conversations | ||
| status Database stats | ||
| reset Remove everything (database, hooks, CLAUDE.md) | ||
| launch Interactive thread picker | ||
| launch --save "name" [id] Save a thread for quick resume | ||
| launch --rename "old" "new" Rename a saved thread | ||
| launch --remove "name" Remove a saved thread | ||
| launch --list List saved threads | ||
| docs ingest <url|file> [tags] Ingest reference documentation | ||
| docs list List all docs | ||
| docs search "query" Search docs | ||
| docs delete <id> Delete a doc | ||
| config List all config values | ||
| config color Interactive color picker for statusline | ||
| config get <key> Get one value | ||
| config set <key> <value> Set a value (known keys: statusline, statusline_color) | ||
| config unset <key> Remove a value | ||
| hook (internal) UserPromptSubmit handler | ||
| statusline (internal) Claude Code statusLine handler | ||
| help Show this help | ||
| `); | ||
| } |
+42
-683
| #!/usr/bin/env node | ||
| // Node-compatible shim. Detects Bun, prints clear install instructions if | ||
| // missing, otherwise re-execs the real entry under Bun. The compiled binary | ||
| // (bun build --compile) bypasses this shim entirely. | ||
| import { getDb, getReadonlyDb, createSchema, dbExists, getDbPath, getDataDir } from '../lib/db.js'; | ||
| import { seedDatabase, incrementalSync } from '../lib/parser.js'; | ||
| import { runHook } from '../lib/hook.js'; | ||
| import { runStatusline } from '../lib/statusline.js'; | ||
| import { installHook, uninstallHook, installClaudeMd, uninstallClaudeMd, installSlashCommand, uninstallSlashCommand, installStatusline, uninstallStatusline } from '../lib/install.js'; | ||
| import { saveThread, removeThread, renameThread, listThreads, interactiveLaunch } from '../lib/launch.js'; | ||
| import { ingestDoc, listDocs, searchDocs, deleteDoc } from '../lib/docs.js'; | ||
| import { getConfig, getConfigValue, setConfig, unsetConfig, parseBool, isKnownKey, listKnownKeys, describeKey, getConfigPath, isBoolKey, isStringKey, STATUSLINE_COLORS } from '../lib/config.js'; | ||
| import { existsSync, rmSync, statSync } from 'fs'; | ||
| import { spawnSync } from 'child_process'; | ||
| import { fileURLToPath } from 'url'; | ||
| import { dirname, join } from 'path'; | ||
| const args = process.argv.slice(2); | ||
| const command = args[0]; | ||
| const BUN_MIN = '1.1.0'; | ||
| switch (command) { | ||
| case 'init': | ||
| await cmdInit(); | ||
| break; | ||
| case 'import': | ||
| cmdImport(args[1]); | ||
| break; | ||
| case 'hook': | ||
| runHook(); | ||
| break; | ||
| case 'statusline': | ||
| runStatusline(); | ||
| break; | ||
| case 'config': | ||
| await cmdConfig(args.slice(1)); | ||
| break; | ||
| case 'sync': | ||
| case 'seed': | ||
| cmdSync(command === 'seed'); | ||
| break; | ||
| case 'query': | ||
| cmdQuery(args.slice(1).join(' ')); | ||
| break; | ||
| case 'sql': | ||
| cmdSql(args.slice(1).join(' ')); | ||
| break; | ||
| case 'status': | ||
| cmdStatus(); | ||
| break; | ||
| case 'reset': | ||
| await cmdReset(); | ||
| break; | ||
| case 'launch': | ||
| await cmdLaunch(args.slice(1)); | ||
| break; | ||
| case 'docs': | ||
| await cmdDocs(args.slice(1)); | ||
| break; | ||
| case 'help': | ||
| case '--help': | ||
| case '-h': | ||
| case undefined: | ||
| showHelp(); | ||
| break; | ||
| default: | ||
| console.error(`Unknown command: ${command}`); | ||
| showHelp(); | ||
| process.exit(1); | ||
| } | ||
| async function cmdInit() { | ||
| console.log(''); | ||
| console.log(' CloudCtx — Persistent memory for Claude Code'); | ||
| console.log(''); | ||
| if (dbExists()) { | ||
| console.log(` Database already exists at ${getDbPath()}`); | ||
| console.log(' Run "cloudctx seed" to sync new conversations.'); | ||
| console.log(' Run "cloudctx reset" to start fresh.'); | ||
| console.log(''); | ||
| return; | ||
| function compareVersion(a, b) { | ||
| const pa = a.split('.').map(n => parseInt(n, 10) || 0); | ||
| const pb = b.split('.').map(n => parseInt(n, 10) || 0); | ||
| for (let i = 0; i < 3; i++) { | ||
| if (pa[i] !== pb[i]) return pa[i] - pb[i]; | ||
| } | ||
| // Create database | ||
| console.log(' Creating database...'); | ||
| const db = getDb(); | ||
| createSchema(db); | ||
| // Seed from existing conversations | ||
| console.log(' Parsing Claude Code conversations...'); | ||
| console.log(''); | ||
| const stats = seedDatabase(db, (project, total) => { | ||
| process.stdout.write(`\r Processed ${total} files...`); | ||
| }); | ||
| db.close(); | ||
| console.log(''); | ||
| console.log(` ✓ Database created: ${getDbPath()}`); | ||
| console.log(` ${stats.messages.toLocaleString()} messages, ${stats.tools.toLocaleString()} tool uses, ${stats.history.toLocaleString()} history entries`); | ||
| const dbSize = statSync(getDbPath()).size / 1024 / 1024; | ||
| console.log(` Size: ${dbSize.toFixed(1)} MB`); | ||
| console.log(''); | ||
| // Install hooks | ||
| console.log(' Installing Claude Code hooks...'); | ||
| installHook(); | ||
| console.log(' ✓ Hook added to ~/.claude/settings.json'); | ||
| // Install CLAUDE.md | ||
| installClaudeMd(); | ||
| console.log(' ✓ Instructions added to ~/.claude/CLAUDE.md'); | ||
| // Install slash commands | ||
| installSlashCommand(); | ||
| console.log(' ✓ Slash commands /cloudctx-save and /cloudctx-rename added to ~/.claude/commands/'); | ||
| console.log(''); | ||
| console.log(' ✓ Memory is active. Open a new Claude Code session to use it.'); | ||
| console.log(''); | ||
| console.log(' Commands:'); | ||
| console.log(' cloudctx query "search terms" Search memory'); | ||
| console.log(' cloudctx sql "SELECT ..." Raw SQL'); | ||
| console.log(' cloudctx launch Resume saved threads'); | ||
| console.log(' cloudctx launch --save "name" Save current thread'); | ||
| console.log(' cloudctx docs ingest <url> [tags] Ingest reference docs'); | ||
| console.log(' cloudctx status Database stats'); | ||
| console.log(' cloudctx reset Remove everything'); | ||
| console.log(''); | ||
| return 0; | ||
| } | ||
| function cmdSync(fullReseed = false) { | ||
| if (!dbExists()) { | ||
| console.error('No database found. Run: cloudctx init'); | ||
| process.exit(1); | ||
| } | ||
| const db = getDb(); | ||
| if (fullReseed) { | ||
| console.log('Re-seeding from all Claude Code conversations...'); | ||
| const stats = seedDatabase(db, (project, total) => { | ||
| process.stdout.write(`\r Processed ${total} files...`); | ||
| }); | ||
| console.log(''); | ||
| console.log(` ✓ ${stats.messages.toLocaleString()} new messages, ${stats.tools.toLocaleString()} tool uses`); | ||
| } else { | ||
| const stats = incrementalSync(db); | ||
| if (stats.messages > 0) { | ||
| console.log(`Synced ${stats.messages} new messages, ${stats.tools} tool uses`); | ||
| } | ||
| } | ||
| db.close(); | ||
| } | ||
| function cmdQuery(terms) { | ||
| if (!terms) { | ||
| console.error('Usage: cloudctx query "search terms"'); | ||
| process.exit(1); | ||
| } | ||
| if (!dbExists()) { | ||
| console.error('No database found. Run: cloudctx init'); | ||
| process.exit(1); | ||
| } | ||
| const db = getReadonlyDb(); | ||
| const rows = db.prepare(` | ||
| SELECT m.type, substr(m.content, 1, 300) as preview, m.timestamp | ||
| FROM messages_fts f | ||
| JOIN messages m ON f.rowid = m.id | ||
| WHERE messages_fts MATCH ? | ||
| ORDER BY rank | ||
| LIMIT 10 | ||
| `).all(terms); | ||
| db.close(); | ||
| if (!rows.length) { | ||
| console.log(`No results for: ${terms}`); | ||
| return; | ||
| } | ||
| for (const r of rows) { | ||
| const ts = r.timestamp ? r.timestamp.split('T')[0] : ''; | ||
| console.log(`[${r.type}] ${ts}`); | ||
| console.log(` ${r.preview}`); | ||
| console.log(''); | ||
| } | ||
| } | ||
| function cmdSql(query) { | ||
| if (!query) { | ||
| console.error('Usage: cloudctx sql "SELECT ..."'); | ||
| process.exit(1); | ||
| } | ||
| if (!dbExists()) { | ||
| console.error('No database found. Run: cloudctx init'); | ||
| process.exit(1); | ||
| } | ||
| // Safety: enforce read-only | ||
| const lower = query.toLowerCase().trim(); | ||
| if (lower.startsWith('insert') || lower.startsWith('update') || lower.startsWith('delete') || lower.startsWith('drop') || lower.startsWith('alter') || lower.startsWith('create')) { | ||
| console.error('Read-only: write operations are not allowed via cloudctx sql'); | ||
| process.exit(1); | ||
| } | ||
| const db = getReadonlyDb(); | ||
| function detectBun() { | ||
| try { | ||
| const rows = db.prepare(query).all(); | ||
| if (!rows.length) { | ||
| console.log('No results.'); | ||
| return; | ||
| } | ||
| // Print results | ||
| for (const row of rows) { | ||
| const values = Object.values(row); | ||
| console.log(values.join(' | ')); | ||
| } | ||
| } catch (e) { | ||
| console.error(`SQL error: ${e.message}`); | ||
| process.exit(1); | ||
| } finally { | ||
| db.close(); | ||
| } | ||
| const r = spawnSync('bun', ['--version'], { encoding: 'utf-8' }); | ||
| if (r.status === 0) return r.stdout.trim(); | ||
| } catch {} | ||
| return null; | ||
| } | ||
| function cmdStatus() { | ||
| if (!dbExists()) { | ||
| console.log('CloudCtx is not initialized. Run: cloudctx init'); | ||
| return; | ||
| } | ||
| const installed = detectBun(); | ||
| const db = getReadonlyDb(); | ||
| const messages = db.prepare('SELECT COUNT(*) as count FROM messages').get().count; | ||
| const sessions = db.prepare('SELECT COUNT(*) as count FROM sessions').get().count; | ||
| const tools = db.prepare('SELECT COUNT(*) as count FROM tool_uses').get().count; | ||
| const docs = db.prepare('SELECT COUNT(*) as count FROM docs').get().count; | ||
| const threads = db.prepare('SELECT COUNT(*) as count FROM saved_threads').get().count; | ||
| const latest = db.prepare('SELECT MAX(timestamp) as ts FROM messages').get().ts || 'none'; | ||
| db.close(); | ||
| if (!installed) { | ||
| process.stderr.write(` | ||
| cloudctx now requires Bun (>=${BUN_MIN}). | ||
| You're running it under Node, which no longer ships the SQLite layer | ||
| cloudctx uses. | ||
| const dbSize = statSync(getDbPath()).size / 1024 / 1024; | ||
| Install Bun (one-liner): | ||
| curl -fsSL https://bun.sh/install | bash | ||
| console.log(''); | ||
| console.log(' CloudCtx Status'); | ||
| console.log(' ' + '─'.repeat(40)); | ||
| console.log(` Messages: ${messages.toLocaleString()}`); | ||
| console.log(` Sessions: ${sessions.toLocaleString()}`); | ||
| console.log(` Tool uses: ${tools.toLocaleString()}`); | ||
| console.log(` Docs: ${docs}`); | ||
| console.log(` Saved threads: ${threads}`); | ||
| console.log(` Latest: ${latest}`); | ||
| console.log(` DB size: ${dbSize.toFixed(1)} MB`); | ||
| console.log(` DB path: ${getDbPath()}`); | ||
| console.log(''); | ||
| } | ||
| Then run cloudctx again. Your existing data in ~/.cloudctx is preserved. | ||
| async function cmdReset() { | ||
| const { createInterface } = await import('readline'); | ||
| const rl = createInterface({ input: process.stdin, output: process.stdout }); | ||
| Why the change: bun:sqlite is built into the Bun runtime — no native | ||
| compile, no node-gyp, no breakage when Node releases a new major version. | ||
| See: https://github.com/chadptk1238/cloudctx#bun | ||
| console.log(''); | ||
| console.log(' This will:'); | ||
| console.log(' - Remove cloudctx hooks from ~/.claude/settings.json'); | ||
| console.log(' - Remove cloudctx block from ~/.claude/CLAUDE.md'); | ||
| console.log(` - Delete ${getDataDir()} (database + config)`); | ||
| console.log(''); | ||
| const answer = await new Promise(resolve => { | ||
| rl.question(' Proceed? (y/N) ', resolve); | ||
| }); | ||
| rl.close(); | ||
| if (answer.toLowerCase() !== 'y') { | ||
| console.log(' Cancelled.'); | ||
| return; | ||
| } | ||
| // Remove hooks | ||
| if (uninstallHook()) { | ||
| console.log(' ✓ Hooks removed'); | ||
| } | ||
| // Remove CLAUDE.md block | ||
| if (uninstallClaudeMd()) { | ||
| console.log(' ✓ CLAUDE.md block removed'); | ||
| } | ||
| // Remove slash command | ||
| if (uninstallSlashCommand()) { | ||
| console.log(' ✓ Slash command removed'); | ||
| } | ||
| // Remove statusline if it was installed | ||
| if (uninstallStatusline()) { | ||
| console.log(' ✓ Status line removed'); | ||
| } | ||
| // Delete data dir | ||
| if (existsSync(getDataDir())) { | ||
| rmSync(getDataDir(), { recursive: true }); | ||
| console.log(' ✓ Database deleted'); | ||
| } | ||
| console.log(''); | ||
| console.log(' Claude Code is back to default.'); | ||
| console.log(''); | ||
| `); | ||
| process.exit(1); | ||
| } | ||
| async function cmdLaunch(subArgs) { | ||
| if (subArgs[0] === '--save') { | ||
| const name = subArgs[1]; | ||
| const sessionId = subArgs[2] || null; | ||
| if (!name) { | ||
| console.error('Usage: cloudctx launch --save "descriptive-thread-name"'); | ||
| process.exit(1); | ||
| } | ||
| saveThread(name, sessionId); | ||
| } else if (subArgs[0] === '--remove') { | ||
| const name = subArgs[1]; | ||
| if (!name) { | ||
| console.error('Usage: cloudctx launch --remove "thread-name"'); | ||
| process.exit(1); | ||
| } | ||
| removeThread(name); | ||
| } else if (subArgs[0] === '--rename') { | ||
| const oldName = subArgs[1]; | ||
| const newName = subArgs[2]; | ||
| if (!oldName || !newName) { | ||
| console.error('Usage: cloudctx launch --rename "old-name" "new-name"'); | ||
| process.exit(1); | ||
| } | ||
| renameThread(oldName, newName); | ||
| } else if (subArgs[0] === '--list') { | ||
| listThreads(); | ||
| } else { | ||
| // Interactive launcher | ||
| await interactiveLaunch(); | ||
| } | ||
| } | ||
| async function cmdDocs(subArgs) { | ||
| const subCmd = subArgs[0]; | ||
| switch (subCmd) { | ||
| case 'ingest': { | ||
| const source = subArgs[1]; | ||
| const tags = subArgs[2] || ''; | ||
| if (!source) { | ||
| console.error('Usage: cloudctx docs ingest <url_or_file> [tags]'); | ||
| process.exit(1); | ||
| } | ||
| await ingestDoc(source, tags); | ||
| break; | ||
| } | ||
| case 'list': | ||
| listDocs(); | ||
| break; | ||
| case 'search': { | ||
| const query = subArgs.slice(1).join(' '); | ||
| if (!query) { | ||
| console.error('Usage: cloudctx docs search "query"'); | ||
| process.exit(1); | ||
| } | ||
| searchDocs(query); | ||
| break; | ||
| } | ||
| case 'delete': { | ||
| const id = parseInt(subArgs[1], 10); | ||
| if (isNaN(id)) { | ||
| console.error('Usage: cloudctx docs delete <id>'); | ||
| process.exit(1); | ||
| } | ||
| deleteDoc(id); | ||
| break; | ||
| } | ||
| default: | ||
| console.error('Usage: cloudctx docs [ingest|list|search|delete]'); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| async function cmdConfig(subArgs) { | ||
| const sub = subArgs[0] || 'list'; | ||
| if (sub === 'list') { | ||
| const config = getConfig(); | ||
| console.log(''); | ||
| console.log(' CloudCtx Config'); | ||
| console.log(' ' + '─'.repeat(50)); | ||
| for (const key of listKnownKeys()) { | ||
| const val = config[key]; | ||
| const desc = describeKey(key); | ||
| console.log(` ${key.padEnd(18)} ${String(val).padEnd(6)} ${desc}`); | ||
| } | ||
| console.log(''); | ||
| console.log(` File: ${getConfigPath()}`); | ||
| console.log(''); | ||
| console.log(' cloudctx config set <key> <true|false>'); | ||
| console.log(' cloudctx config unset <key>'); | ||
| console.log(''); | ||
| return; | ||
| } | ||
| if (sub === 'get') { | ||
| const key = subArgs[1]; | ||
| if (!key) { console.error('Usage: cloudctx config get <key>'); process.exit(1); } | ||
| if (!isKnownKey(key)) { | ||
| console.error(`Unknown key: ${key}`); | ||
| console.error(`Known keys: ${listKnownKeys().join(', ')}`); | ||
| process.exit(1); | ||
| } | ||
| console.log(getConfigValue(key)); | ||
| return; | ||
| } | ||
| if (sub === 'color' || sub === 'colors') { | ||
| if (!process.stdin.isTTY) { | ||
| console.log(''); | ||
| console.log(' Valid statusline_color values:'); | ||
| for (const name of Object.keys(STATUSLINE_COLORS)) { | ||
| const code = STATUSLINE_COLORS[name]; | ||
| const preview = code ? `\x1b[1;${code}m📌 ${name}\x1b[0m` : `\x1b[1m📌 ${name}\x1b[0m`; | ||
| console.log(` ${preview}`); | ||
| } | ||
| console.log(''); | ||
| console.log(' cloudctx config set statusline_color <name>'); | ||
| console.log(''); | ||
| return; | ||
| } | ||
| const chosen = await pickColorInteractive(); | ||
| if (chosen) { | ||
| setConfig('statusline_color', chosen); | ||
| const code = STATUSLINE_COLORS[chosen]; | ||
| const preview = code ? `\x1b[1;${code}m📌 ${chosen}\x1b[0m` : `\x1b[1m📌 ${chosen}\x1b[0m`; | ||
| console.log(` ✓ statusline_color = ${preview}`); | ||
| } else { | ||
| console.log(' (cancelled)'); | ||
| } | ||
| return; | ||
| } | ||
| if (sub === 'set') { | ||
| const key = subArgs[1]; | ||
| const value = subArgs[2]; | ||
| if (!key || value === undefined) { | ||
| console.error('Usage: cloudctx config set <key> <value>'); | ||
| process.exit(1); | ||
| } | ||
| if (!isKnownKey(key)) { | ||
| console.error(`Unknown key: ${key}`); | ||
| console.error(`Known keys: ${listKnownKeys().join(', ')}`); | ||
| process.exit(1); | ||
| } | ||
| let storeValue; | ||
| if (isBoolKey(key)) { | ||
| const bool = parseBool(value); | ||
| if (bool === null) { | ||
| console.error(`Value must be true/false (or on/off, yes/no) — got: ${value}`); | ||
| process.exit(1); | ||
| } | ||
| storeValue = bool; | ||
| } else if (isStringKey(key)) { | ||
| if (key === 'statusline_color' && !(value in STATUSLINE_COLORS)) { | ||
| console.error(`Unknown color: ${value}`); | ||
| console.error(`Run: cloudctx config colors`); | ||
| process.exit(1); | ||
| } | ||
| if (key === 'launch_sort' && !['time', 'alpha'].includes(value)) { | ||
| console.error(`launch_sort must be 'time' or 'alpha' — got: ${value}`); | ||
| process.exit(1); | ||
| } | ||
| storeValue = value; | ||
| } else { | ||
| storeValue = value; | ||
| } | ||
| setConfig(key, storeValue); | ||
| const bool = storeValue; | ||
| if (key === 'statusline') { | ||
| if (bool) { | ||
| const result = installStatusline(); | ||
| console.log(` ✓ statusline = true — wired into ~/.claude/settings.json`); | ||
| if (result.wrapped) { | ||
| console.log(` Existing statusLine detected — wrapping it (you'll see both).`); | ||
| } | ||
| console.log(` Open a new Claude Code session to see it.`); | ||
| } else { | ||
| const result = uninstallStatusline(); | ||
| if (result.restored) { | ||
| console.log(` ✓ statusline = false — your original statusLine restored.`); | ||
| } else { | ||
| console.log(` ✓ statusline = false — removed from ~/.claude/settings.json`); | ||
| } | ||
| } | ||
| } else { | ||
| console.log(` ✓ ${key} = ${storeValue}`); | ||
| } | ||
| return; | ||
| } | ||
| if (sub === 'unset') { | ||
| const key = subArgs[1]; | ||
| if (!key) { console.error('Usage: cloudctx config unset <key>'); process.exit(1); } | ||
| unsetConfig(key); | ||
| if (key === 'statusline') uninstallStatusline(); | ||
| console.log(` ✓ unset ${key}`); | ||
| return; | ||
| } | ||
| console.error('Usage: cloudctx config [list|get|set|unset] ...'); | ||
| if (compareVersion(installed, BUN_MIN) < 0) { | ||
| process.stderr.write(`cloudctx requires Bun >=${BUN_MIN} (you have ${installed}). | ||
| Update: curl -fsSL https://bun.sh/install | bash | ||
| `); | ||
| process.exit(1); | ||
| } | ||
| function registerRawModeExitGuard() { | ||
| process.once('exit', () => { | ||
| try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {} | ||
| }); | ||
| for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) { | ||
| process.once(sig, () => { | ||
| try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {} | ||
| process.exit(130); | ||
| }); | ||
| } | ||
| } | ||
| async function pickColorInteractive() { | ||
| registerRawModeExitGuard(); | ||
| const colors = Object.keys(STATUSLINE_COLORS); | ||
| const currentColor = getConfigValue('statusline_color') || 'cyan'; | ||
| let cursor = Math.max(0, colors.indexOf(currentColor)); | ||
| const render = () => { | ||
| process.stdout.write('\x1b[2J\x1b[H'); | ||
| console.log(''); | ||
| console.log(' \x1b[1mCloudCtx — Choose statusline color\x1b[0m'); | ||
| console.log(' \x1b[2m↑↓ navigate ⏎ save q cancel\x1b[0m'); | ||
| console.log(''); | ||
| for (let i = 0; i < colors.length; i++) { | ||
| const name = colors[i]; | ||
| const code = STATUSLINE_COLORS[name]; | ||
| const preview = code ? `\x1b[1;${code}m📌 ${name}\x1b[0m` : `\x1b[1m📌 ${name}\x1b[0m`; | ||
| const pointer = i === cursor ? ' \x1b[36m❯\x1b[0m ' : ' '; | ||
| const mark = name === currentColor ? ' \x1b[2m(current)\x1b[0m' : ''; | ||
| console.log(`${pointer}${preview}${mark}`); | ||
| } | ||
| console.log(''); | ||
| }; | ||
| process.stdin.setRawMode(true); | ||
| process.stdin.resume(); | ||
| process.stdin.setEncoding('utf-8'); | ||
| render(); | ||
| return new Promise((resolve) => { | ||
| const cleanup = () => { | ||
| process.stdout.write('\x1b[2J\x1b[H'); | ||
| process.stdin.setRawMode(false); | ||
| process.stdin.removeListener('data', onKey); | ||
| process.stdin.pause(); | ||
| }; | ||
| const onKey = (key) => { | ||
| if (key === '\x03' || key === 'q' || key === '\x1b') { | ||
| cleanup(); | ||
| resolve(null); | ||
| return; | ||
| } | ||
| if (key === '\x1b[A' || key === 'k') { | ||
| cursor = (cursor - 1 + colors.length) % colors.length; | ||
| render(); | ||
| return; | ||
| } | ||
| if (key === '\x1b[B' || key === 'j') { | ||
| cursor = (cursor + 1) % colors.length; | ||
| render(); | ||
| return; | ||
| } | ||
| if (key === '\r' || key === '\n') { | ||
| cleanup(); | ||
| resolve(colors[cursor]); | ||
| return; | ||
| } | ||
| }; | ||
| process.stdin.on('data', onKey); | ||
| }); | ||
| } | ||
| function cmdImport(dbPath) { | ||
| if (!dbPath) { | ||
| console.error('Usage: cloudctx import /path/to/existing.db'); | ||
| process.exit(1); | ||
| } | ||
| if (!existsSync(dbPath)) { | ||
| console.error(`Database not found: ${dbPath}`); | ||
| process.exit(1); | ||
| } | ||
| const db = getDb(); | ||
| createSchema(db); | ||
| console.log(` Importing from ${dbPath}...`); | ||
| // Attach the source database | ||
| db.exec(`ATTACH DATABASE '${dbPath}' AS source`); | ||
| // Import each table, skipping duplicates | ||
| const tables = [ | ||
| { name: 'sessions', key: 'session_id' }, | ||
| { name: 'messages', key: 'uuid' }, | ||
| { name: 'tool_uses', key: null }, | ||
| { name: 'summaries', key: null }, | ||
| { name: 'prompt_history', key: null }, | ||
| { name: 'docs', key: 'source' }, | ||
| ]; | ||
| for (const { name, key } of tables) { | ||
| try { | ||
| // Check if table exists in source | ||
| const exists = db.prepare(`SELECT name FROM source.sqlite_master WHERE type='table' AND name=?`).get(name); | ||
| if (!exists) continue; | ||
| const conflict = key ? `OR IGNORE` : ''; | ||
| const result = db.exec(`INSERT ${conflict} INTO main.${name} SELECT * FROM source.${name}`); | ||
| const count = db.prepare(`SELECT COUNT(*) as c FROM main.${name}`).get().c; | ||
| console.log(` ${name}: ${count.toLocaleString()} rows`); | ||
| } catch (e) { | ||
| console.log(` ${name}: skipped (${e.message})`); | ||
| } | ||
| } | ||
| // Rebuild FTS indexes | ||
| console.log(' Rebuilding search indexes...'); | ||
| try { | ||
| db.exec(`INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`); | ||
| db.exec(`INSERT INTO docs_fts(docs_fts) VALUES('rebuild')`); | ||
| } catch (e) { | ||
| console.log(` FTS rebuild: ${e.message}`); | ||
| } | ||
| db.exec('DETACH DATABASE source'); | ||
| db.close(); | ||
| const dbSize = statSync(getDbPath()).size / 1024 / 1024; | ||
| console.log(` ✓ Import complete. DB size: ${dbSize.toFixed(1)} MB`); | ||
| } | ||
| function showHelp() { | ||
| console.log(` | ||
| CloudCtx — Persistent memory for Claude Code | ||
| Usage: cloudctx <command> [options] | ||
| Commands: | ||
| init Set up database, hooks, and CLAUDE.md | ||
| import /path/to/db Import from existing SQLite database | ||
| query "search terms" FTS search across all conversations | ||
| sql "SELECT ..." Raw read-only SQL query | ||
| sync Incremental sync of new conversations | ||
| seed Re-import all conversations | ||
| status Database stats | ||
| reset Remove everything (database, hooks, CLAUDE.md) | ||
| launch Interactive thread picker | ||
| launch --save "name" [id] Save a thread for quick resume | ||
| launch --rename "old" "new" Rename a saved thread | ||
| launch --remove "name" Remove a saved thread | ||
| launch --list List saved threads | ||
| docs ingest <url|file> [tags] Ingest reference documentation | ||
| docs list List all docs | ||
| docs search "query" Search docs | ||
| docs delete <id> Delete a doc | ||
| config List all config values | ||
| config color Interactive color picker for statusline | ||
| config get <key> Get one value | ||
| config set <key> <value> Set a value (known keys: statusline, statusline_color) | ||
| config unset <key> Remove a value | ||
| hook (internal) UserPromptSubmit handler | ||
| statusline (internal) Claude Code statusLine handler | ||
| help Show this help | ||
| `); | ||
| } | ||
| const main = join(dirname(fileURLToPath(import.meta.url)), 'cloudctx-main.js'); | ||
| const result = spawnSync('bun', ['run', main, ...process.argv.slice(2)], { | ||
| stdio: 'inherit', | ||
| env: process.env, | ||
| }); | ||
| process.exit(result.status ?? 1); |
+9
-5
@@ -1,2 +0,2 @@ | ||
| import Database from 'better-sqlite3'; | ||
| import { Database } from 'bun:sqlite'; | ||
| import { existsSync, mkdirSync } from 'fs'; | ||
@@ -25,6 +25,10 @@ import { join } from 'path'; | ||
| ensureDataDir(); | ||
| const db = new Database(DB_PATH, { readonly }); | ||
| db.pragma('journal_mode = WAL'); | ||
| db.pragma('busy_timeout = 5000'); | ||
| db.pragma('foreign_keys = OFF'); | ||
| // bun:sqlite requires explicit flags — { readonly: false } is rejected as | ||
| // ambiguous. Pass nothing for the default RW-create behavior. | ||
| const db = readonly | ||
| ? new Database(DB_PATH, { readonly: true }) | ||
| : new Database(DB_PATH); | ||
| db.exec('PRAGMA journal_mode = WAL'); | ||
| db.exec('PRAGMA busy_timeout = 5000'); | ||
| db.exec('PRAGMA foreign_keys = OFF'); | ||
| return db; | ||
@@ -31,0 +35,0 @@ } |
+48
-7
@@ -311,2 +311,14 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'; | ||
| // CC's hook stdout limit is ~10KB. We target 8KB to leave headroom for the | ||
| // framing lines and any safety margin. Tiered cap gives the most-recent | ||
| // messages real detail and progressively less to older ones; running total | ||
| // against TOTAL_BUDGET means short recent messages naturally free up room | ||
| // for older ones. | ||
| const COMPACTION_TOTAL_BUDGET = 8000; | ||
| const COMPACTION_TIERS = [ | ||
| { count: 5, cap: 1500 }, // most recent 5: rich detail | ||
| { count: 10, cap: 600 }, // next 10: medium | ||
| { count: 25, cap: 200 }, // older 25: just gist | ||
| ]; | ||
| function injectPostCompactionContext(sessionId) { | ||
@@ -339,2 +351,35 @@ try { | ||
| // filtered is newest-first (ORDER BY id DESC). Walk it applying the | ||
| // current tier's per-message cap, stopping when total budget is exhausted. | ||
| const picked = []; // newest-first | ||
| let used = 0; | ||
| let tierIdx = 0; | ||
| let inTier = 0; | ||
| for (const row of filtered) { | ||
| while (tierIdx < COMPACTION_TIERS.length && inTier >= COMPACTION_TIERS[tierIdx].count) { | ||
| tierIdx++; | ||
| inTier = 0; | ||
| } | ||
| if (tierIdx >= COMPACTION_TIERS.length) break; | ||
| const cap = COMPACTION_TIERS[tierIdx].cap; | ||
| const truncated = row.content.length > cap | ||
| ? row.content.slice(0, cap) + '...' | ||
| : row.content; | ||
| // Approximate per-line cost: "[role] content\n" → role + 3 + content + 1 | ||
| const cost = truncated.length + row.type.length + 4; | ||
| if (used + cost > COMPACTION_TOTAL_BUDGET) break; | ||
| picked.push({ role: row.type, content: truncated }); | ||
| used += cost; | ||
| inTier++; | ||
| } | ||
| if (!picked.length) { | ||
| console.log('COMPACTION DETECTED: Context was compressed but recent messages did not fit in the budget. Ask the user what they were working on.'); | ||
| return; | ||
| } | ||
| const lines = [ | ||
@@ -345,9 +390,5 @@ 'COMPACTION DETECTED — Message Injection (most recent messages):', | ||
| // Truncate each message to keep total output under 10KB limit | ||
| const maxPerMessage = 500; | ||
| for (const row of filtered.reverse()) { | ||
| const content = row.content.length > maxPerMessage | ||
| ? row.content.slice(0, maxPerMessage) + '...' | ||
| : row.content; | ||
| lines.push(`[${row.type}] ${content}`); | ||
| // Reverse to chronological order for output | ||
| for (const m of picked.reverse()) { | ||
| lines.push(`[${m.role}] ${m.content}`); | ||
| } | ||
@@ -354,0 +395,0 @@ |
+14
-0
@@ -34,5 +34,19 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'fs'; | ||
| function getCloudctxBinPath() { | ||
| // Prefer direct bun invocation on the main script — skips the Node shim's | ||
| // re-exec overhead. Hook runs on every prompt; latency matters. | ||
| // Quote paths so they survive shells that split on spaces (Windows usernames | ||
| // like "Charles Needham" → "/c/Users/Charles Needham/..." otherwise breaks). | ||
| try { | ||
| const bunPath = execSync('which bun', { encoding: 'utf-8' }).trim(); | ||
| if (bunPath) { | ||
| try { | ||
| const npmRoot = execSync('npm root -g', { encoding: 'utf-8' }).trim(); | ||
| const mainPath = join(npmRoot, 'cloudctx', 'bin', 'cloudctx-main.js'); | ||
| if (existsSync(mainPath)) return `"${bunPath}" "${mainPath}"`; | ||
| } catch {} | ||
| } | ||
| } catch {} | ||
| // Fall back to whatever `cloudctx` resolves to on PATH (Homebrew binary, | ||
| // npm shim, etc.). | ||
| try { | ||
| const result = execSync('which cloudctx', { encoding: 'utf-8' }).trim(); | ||
@@ -39,0 +53,0 @@ if (result) return `"${result}"`; |
+14
-7
| { | ||
| "name": "cloudctx", | ||
| "version": "0.1.8", | ||
| "version": "0.2.0", | ||
| "description": "Persistent memory for Claude Code. One command, full recall.", | ||
@@ -16,3 +16,10 @@ "type": "module", | ||
| "scripts": { | ||
| "test": "node bin/cloudctx.js status" | ||
| "test": "bun bin/cloudctx-main.js status", | ||
| "dev": "bun bin/cloudctx-main.js", | ||
| "build:macos-arm64": "bun build --compile --target=bun-darwin-arm64 bin/cloudctx-main.js --outfile dist/cloudctx-macos-arm64", | ||
| "build:macos-x64": "bun build --compile --target=bun-darwin-x64 bin/cloudctx-main.js --outfile dist/cloudctx-macos-x64", | ||
| "build:linux-x64": "bun build --compile --target=bun-linux-x64 bin/cloudctx-main.js --outfile dist/cloudctx-linux-x64", | ||
| "build:linux-arm64": "bun build --compile --target=bun-linux-arm64 bin/cloudctx-main.js --outfile dist/cloudctx-linux-arm64", | ||
| "build:windows-x64": "bun build --compile --target=bun-windows-x64 bin/cloudctx-main.js --outfile dist/cloudctx-windows-x64.exe", | ||
| "build:all": "bun run build:macos-arm64 && bun run build:macos-x64 && bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:windows-x64" | ||
| }, | ||
@@ -27,3 +34,4 @@ "keywords": [ | ||
| "fts5", | ||
| "conversation-history" | ||
| "conversation-history", | ||
| "bun" | ||
| ], | ||
@@ -39,9 +47,8 @@ "repository": { | ||
| "engines": { | ||
| "node": ">=18.0.0" | ||
| "node": ">=18.0.0", | ||
| "bun": ">=1.1.0" | ||
| }, | ||
| "author": "Chad Piatek", | ||
| "license": "MIT", | ||
| "dependencies": { | ||
| "better-sqlite3": "^11.10.0" | ||
| } | ||
| "dependencies": {} | ||
| } |
+21
-1
@@ -14,2 +14,5 @@ # CloudCtx | ||
| ```bash | ||
| # Prereq: Bun (https://bun.sh) | ||
| curl -fsSL https://bun.sh/install | bash | ||
| npm install -g cloudctx | ||
@@ -76,2 +79,6 @@ cloudctx init | ||
| ```bash | ||
| # 1. Install Bun (cloudctx runs on the Bun runtime) | ||
| curl -fsSL https://bun.sh/install | bash | ||
| # 2. Install cloudctx | ||
| npm install -g cloudctx | ||
@@ -89,5 +96,18 @@ cloudctx init | ||
| - Node.js 18+ | ||
| - [Bun](https://bun.sh) 1.1+ (the runtime cloudctx runs on) | ||
| - Node.js 18+ (only used as the entry shim for `npm install -g`) | ||
| - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed | ||
| <a id="bun"></a> | ||
| ### Why Bun | ||
| cloudctx 0.2.0 moved from Node + `better-sqlite3` to Bun + `bun:sqlite`. Reasons: | ||
| - **No native compile.** `better-sqlite3` required `node-gyp` + Python + a C++ toolchain, and broke on every new Node major release (Node 24 was the last straw). `bun:sqlite` is built into the Bun runtime — zero compile, zero breakage. | ||
| - **One install, every platform.** Same Bun binary across macOS, Linux, Windows. | ||
| - **Faster cold start.** Bun starts ~3x faster than Node for the small CLI commands the cloudctx hook runs on every prompt. | ||
| If `cloudctx` is invoked without Bun installed, you'll get a clear install message rather than a cryptic native-binary error. Existing data in `~/.cloudctx` is preserved across the upgrade — no migration needed. | ||
| --- | ||
@@ -94,0 +114,0 @@ |
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
90631
7.15%0
-100%13
8.33%2262
4.72%213
10.36%6
20%- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed