Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

cloudctx

Package Overview
Dependencies
Maintainers
1
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cloudctx - npm Package Compare versions

Comparing version
0.1.8
to
0.2.0
+703
bin/cloudctx-main.js
#!/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);

@@ -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 @@ }

@@ -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 @@

@@ -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}"`;

{
"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": {}
}

@@ -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 @@