#!/usr/bin/env node /** * Minimal pi-loom operator CLI. * * This is intentionally a thin shell over LoomStore and buildLoomContext. The * MCP server keeps the existing `pi-loom` bin; this CLI is published as * `pi-loom-cli` to avoid breaking current MCP configurations. */ import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { buildLoomContext } from "./context.js"; import { getDbDir, getDbPath, LoomStore, openDb, type VisibilityFilter } from "./store.js"; const VERSION = "0.5.0"; const VALID_VISIBILITY = new Set(["private", "project", "shared"]); interface CliIO { stdout: (text: string) => void; stderr: (text: string) => void; } interface ParsedArgs { positional: string[]; flags: Record; } export async function runCli(argv = process.argv.slice(2), io: CliIO = defaultIO): Promise { const parsed = parseArgs(argv); const command = parsed.positional[0] ?? "help"; try { if (command === "help" || command === "--help" || command === "-h") return help(io); if (command === "version" || command === "--version" || command === "-v") return version(io); if (command === "paths") return paths(io); if (command === "doctor") return withStore((store) => doctor(store, io)); if (command === "status") return withStore((store) => status(store, io, isJson(parsed))); if (command === "context") return withStore((store) => context(store, parsed, io)); if (command === "search") return withStore((store) => search(store, parsed, io)); if (command === "store") return withStore((store) => storeMemory(store, parsed, io)); if (command === "install") return installGuide(parsed, io); io.stderr(`Unknown command: ${command}`); help(io); return 1; } catch (error) { io.stderr(error instanceof Error ? error.message : String(error)); return 1; } } function help(io: CliIO): number { io.stdout(`pi-loom-cli v${VERSION} Usage: pi-loom-cli help pi-loom-cli version pi-loom-cli doctor pi-loom-cli status [--json] pi-loom-cli paths pi-loom-cli context [--max-total-chars 1800] [--visibility project] pi-loom-cli search [--limit 10] [--entity ] [--entity-filter ] [--json] pi-loom-cli store [--importance 0.7] [--kind memory] [--entity ] [--tags a,b] pi-loom-cli install [codex|claude] Existing bins: pi-loom MCP stdio server pi-loom-service HTTP/HTTPS service pi-loom-codex-hook Codex native hook pi-loom-claude-hook Claude Code native hook`); return 0; } function version(io: CliIO): number { io.stdout(VERSION); return 0; } function paths(io: CliIO): number { const root = packageRoot(); io.stdout([ `package_root: ${root}`, `data_dir: ${getDbDir()}`, `db_path: ${getDbPath()}`, `plugin_dir: ${resolve(root, "plugin")}`, ].join("\n")); return 0; } function doctor(store: LoomStore, io: CliIO): number { const root = packageRoot(); const checks = [ ["database", existsSync(getDbPath()), getDbPath()], ["codex_manifest", existsSync(resolve(root, "plugin/.codex-plugin/plugin.json")), "plugin/.codex-plugin/plugin.json"], ["claude_manifest", existsSync(resolve(root, "plugin/.claude-plugin/plugin.json")), "plugin/.claude-plugin/plugin.json"], ["mcp_manifest", existsSync(resolve(root, "plugin/.mcp.json")), "plugin/.mcp.json"], ["codex_hook", existsSync(resolve(root, "plugin/hooks/codex-hooks.json")), "plugin/hooks/codex-hooks.json"], ["claude_hook", existsSync(resolve(root, "plugin/hooks/hooks.json")), "plugin/hooks/hooks.json"], ] as const; const health = store.healthCheck(); const lines = [`pi-loom-cli v${VERSION}`, `db_path: ${getDbPath()}`, `active_memories: ${health.activeMemories}`]; for (const [name, ok, detail] of checks) lines.push(`${ok ? "ok" : "missing"} ${name}: ${detail}`); io.stdout(lines.join("\n")); return checks.every(([, ok]) => ok) ? 0 : 1; } function status(store: LoomStore, io: CliIO, json: boolean): number { const stats = store.stats(); const health = store.healthCheck(); const payload = { version: VERSION, dbPath: getDbPath(), dataDir: getDbDir(), stats, health }; if (json) { io.stdout(JSON.stringify(payload, null, 2)); return 0; } io.stdout([ `pi-loom ${VERSION}`, `db_path: ${payload.dbPath}`, `active: ${stats.active}`, `archived: ${stats.archived}`, `expired: ${stats.expired}`, `raw_events: ${health.rawEvents}`, `entity_edges: ${health.entityEdges}`, `fts_rows: ${health.ftsRows}`, `embedding_rows: ${health.embeddingRows}`, ].join("\n")); return 0; } function context(store: LoomStore, parsed: ParsedArgs, io: CliIO): number { const maxTotalChars = readPositiveInt(parsed.flags["max-total-chars"], 1800); const maxTokens = readPositiveInt(parsed.flags["max-tokens"], 350); const visibility = readVisibility(parsed.flags.visibility); io.stdout(buildLoomContext(store, { maxTotalChars, maxTokens, visibility })); return 0; } function search(store: LoomStore, parsed: ParsedArgs, io: CliIO): number { const query = parsed.positional.slice(1).join(" ").trim(); if (!query) throw new Error("search requires a query"); const limit = readPositiveInt(parsed.flags.limit, 10); const entity_id = readString(parsed.flags.entity); const entity_filter = readString(parsed.flags["entity-filter"]); const results = store.searchHybrid({ query, limit, entity_id, entity_filter }); if (isJson(parsed)) { io.stdout(JSON.stringify(results, null, 2)); return 0; } if (results.length === 0) { io.stdout("No memories found."); return 0; } io.stdout(results.map(formatMemoryLine).join("\n")); return 0; } function storeMemory(store: LoomStore, parsed: ParsedArgs, io: CliIO): number { const content = parsed.positional.slice(1).join(" ").trim(); if (!content) throw new Error("store requires memory content"); const mem = store.store({ content, importance: readNumber(parsed.flags.importance, 0.5), kind: readString(parsed.flags.kind) as any, entity_id: readString(parsed.flags.entity), tags: readTags(parsed.flags.tags), visibility: readVisibility(parsed.flags.visibility), }); io.stdout(`stored ${mem.id}`); return 0; } function installGuide(parsed: ParsedArgs, io: CliIO): number { const target = parsed.positional[1] ?? "all"; const root = packageRoot(); const lines: string[] = []; if (target === "all" || target === "codex") { lines.push("Codex:"); lines.push(` codex plugin marketplace add "${root}"`); lines.push(" Then install pi-loom from /plugins and restart Codex."); } if (target === "all" || target === "claude") { lines.push("Claude Code:"); lines.push(` Plugin files are bundled at: ${resolve(root, "plugin")}`); lines.push(" Use Claude Code's plugin flow to enable the bundled .claude-plugin manifest."); lines.push(" Loom does not modify ~/.claude/settings.json automatically."); } if (lines.length === 0) throw new Error("install target must be codex, claude, or omitted"); io.stdout(lines.join("\n")); return 0; } function withStore(fn: (store: LoomStore) => number): number { const db = openDb(); try { return fn(new LoomStore(db)); } finally { db.close(); } } function parseArgs(argv: string[]): ParsedArgs { const positional: string[] = []; const flags: Record = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (!arg.startsWith("--")) { positional.push(arg); continue; } const eq = arg.indexOf("="); if (eq > 2) { flags[arg.slice(2, eq)] = arg.slice(eq + 1); continue; } const key = arg.slice(2); const next = argv[i + 1]; if (next && !next.startsWith("--")) { flags[key] = next; i++; } else { flags[key] = true; } } return { positional, flags }; } function formatMemoryLine(mem: { id: string; content: string; entity_id: string | null; importance: number; kind?: string | null }): string { const entity = mem.entity_id ? ` entity=${mem.entity_id}` : ""; const kind = mem.kind ? ` kind=${mem.kind}` : ""; return `${mem.id} [${mem.importance.toFixed(2)}${kind}${entity}] ${oneLine(mem.content, 180)}`; } function oneLine(text: string, max: number): string { const compact = text.replace(/\s+/g, " ").trim(); return compact.length > max ? `${compact.slice(0, max - 1)}...` : compact; } function readString(value: string | boolean | undefined): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function readPositiveInt(value: string | boolean | undefined, fallback: number): number { const parsed = typeof value === "string" ? Number(value) : NaN; return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function readNumber(value: string | boolean | undefined, fallback: number): number { const parsed = typeof value === "string" ? Number(value) : NaN; return Number.isFinite(parsed) ? parsed : fallback; } function readTags(value: string | boolean | undefined): string[] | undefined { if (typeof value !== "string" || value.trim().length === 0) return undefined; return value.split(",").map((tag) => tag.trim()).filter(Boolean); } function readVisibility(value: string | boolean | undefined): VisibilityFilter | undefined { if (typeof value !== "string") return undefined; if (!VALID_VISIBILITY.has(value)) throw new Error("visibility must be private, project, or shared"); return value as VisibilityFilter; } function isJson(parsed: ParsedArgs): boolean { return parsed.flags.json === true; } function packageRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), ".."); } const defaultIO: CliIO = { stdout: (text) => console.log(text), stderr: (text) => console.error(text), }; if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { runCli().then((code) => { process.exitCode = code; }); }