@adrive/cli 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
3
+ import { hostname, userInfo } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { configDir } from './config.js';
6
+ // AgentDrive lightweight keychain (v1, software-only).
7
+ //
8
+ // Goal: a random 256-bit data-encryption key (DEK), persisted across runs,
9
+ // wrapped with a key-encrypting key (KEK) derived from
10
+ // scrypt(host || user || api_key, salt).
11
+ //
12
+ // This is stronger than "derive DEK directly from API key" because:
13
+ // - Rotating the API key doesn't change the DEK, so old ciphertext is
14
+ // still decryptable after rotation (with the same KEK derivation).
15
+ // - The DEK is random, not derivable from the API key alone.
16
+ // - host+user binding means a stolen API key cannot decrypt files
17
+ // without also having access to the user's home + machine identity.
18
+ //
19
+ // v2 will replace the KEK derivation with the OS keychain
20
+ // (Keychain.app / libsecret / DPAPI). The interface stays the same.
21
+ const KEY_LENGTH = 32;
22
+ const IV_LENGTH = 12;
23
+ const SALT_LENGTH = 16;
24
+ const TAG_LENGTH = 16;
25
+ const SCRYPT_N = 1 << 14;
26
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024;
27
+ const FILE_VERSION = 1;
28
+ export function keychainPath(env = process.env) {
29
+ return join(configDir(env), 'keychain.bin');
30
+ }
31
+ function deriveKek(apiKey, salt) {
32
+ // Bind to the machine + user. A stolen API key alone won't unwrap the DEK.
33
+ const ident = `${hostname()}|${userInfo().username}|${apiKey}`;
34
+ return scryptSync(ident, salt, KEY_LENGTH, { N: SCRYPT_N, maxmem: SCRYPT_MAXMEM });
35
+ }
36
+ async function readBlob(path) {
37
+ try {
38
+ const raw = await fs.readFile(path, 'utf8');
39
+ const parsed = JSON.parse(raw);
40
+ if (parsed.v !== FILE_VERSION)
41
+ return null;
42
+ return parsed;
43
+ }
44
+ catch (err) {
45
+ const e = err;
46
+ if (e.code === 'ENOENT')
47
+ return null;
48
+ return null;
49
+ }
50
+ }
51
+ async function writeBlob(path, blob) {
52
+ await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
53
+ await fs.writeFile(path, JSON.stringify(blob), { encoding: 'utf8', mode: 0o600 });
54
+ }
55
+ export async function loadDek(apiKey, path = keychainPath()) {
56
+ const existing = await readBlob(path);
57
+ if (existing) {
58
+ const salt = Buffer.from(existing.salt, 'base64');
59
+ const iv = Buffer.from(existing.iv, 'base64');
60
+ const tag = Buffer.from(existing.tag, 'base64');
61
+ const ct = Buffer.from(existing.ct, 'base64');
62
+ const kek = deriveKek(apiKey, salt);
63
+ const dec = createDecipheriv('aes-256-gcm', kek, iv);
64
+ dec.setAuthTag(tag);
65
+ return Buffer.concat([dec.update(ct), dec.final()]);
66
+ }
67
+ // First use: create a fresh DEK and persist wrapped.
68
+ const dek = randomBytes(KEY_LENGTH);
69
+ const salt = randomBytes(SALT_LENGTH);
70
+ const iv = randomBytes(IV_LENGTH);
71
+ const kek = deriveKek(apiKey, salt);
72
+ const enc = createCipheriv('aes-256-gcm', kek, iv);
73
+ const ct = Buffer.concat([enc.update(dek), enc.final()]);
74
+ const tag = enc.getAuthTag();
75
+ if (tag.length !== TAG_LENGTH)
76
+ throw new Error('Unexpected GCM tag length');
77
+ await writeBlob(path, {
78
+ v: FILE_VERSION,
79
+ salt: salt.toString('base64'),
80
+ iv: iv.toString('base64'),
81
+ tag: tag.toString('base64'),
82
+ ct: ct.toString('base64'),
83
+ });
84
+ return dek;
85
+ }
86
+ export async function purgeKeychain(path = keychainPath()) {
87
+ try {
88
+ await fs.unlink(path);
89
+ }
90
+ catch {
91
+ // ignore
92
+ }
93
+ }
@@ -0,0 +1,69 @@
1
+ // Secret redactor. Runs on every payload before capture features upload
2
+ // content. Patterns are intentionally conservative — false positives are
3
+ // tagged and replaced with a safe marker; false negatives are the risk we
4
+ // optimize against.
5
+ //
6
+ // IMPORTANT: redaction is best-effort, not a security boundary. It is one
7
+ // layer in a defense-in-depth stack: .adriveignore for path-level deny,
8
+ // redactor for content-level scrub, optional client-side encryption for
9
+ // confidentiality.
10
+ // Order matters: more specific patterns first so they win over the generic
11
+ // high-entropy fallback.
12
+ const PATTERNS = [
13
+ // Provider-specific keys
14
+ { kind: 'aws-access-key-id', re: /\bAKIA[0-9A-Z]{16}\b/g, marker: () => '[redacted:aws-access-key-id]' },
15
+ { kind: 'aws-secret', re: /\b(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[\s:=]+['"]?[A-Za-z0-9/+=]{40}['"]?/g, marker: () => '[redacted:aws-secret]' },
16
+ { kind: 'gcp-service-account', re: /"private_key":\s*"-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----[^"]*"/g, marker: () => '"private_key":"[redacted:gcp-service-account]"' },
17
+ { kind: 'stripe-secret', re: /\bsk_(?:live|test)_[A-Za-z0-9]{16,99}\b/g, marker: () => '[redacted:stripe-secret]' },
18
+ { kind: 'stripe-restricted', re: /\brk_(?:live|test)_[A-Za-z0-9]{16,99}\b/g, marker: () => '[redacted:stripe-restricted]' },
19
+ { kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g, marker: () => '[redacted:github-token]' },
20
+ { kind: 'github-fine-grained', re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g, marker: () => '[redacted:github-fine-grained]' },
21
+ { kind: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, marker: () => '[redacted:anthropic-key]' },
22
+ { kind: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, marker: () => '[redacted:openai-key]' },
23
+ { kind: 'agentdrive-key', re: /\bagentdrive_live_[A-Za-z0-9]{16,}/g, marker: () => '[redacted:agentdrive-key]' },
24
+ { kind: 'slack-bot-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, marker: () => '[redacted:slack-token]' },
25
+ { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, marker: () => '[redacted:jwt]' },
26
+ // Private keys (PEM-style blocks). Match the whole block.
27
+ { kind: 'private-key-pem', re: /-----BEGIN (?:RSA |OPENSSH |EC |DSA |ENCRYPTED |PRIVATE)?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |OPENSSH |EC |DSA |ENCRYPTED |PRIVATE)?PRIVATE KEY-----/g, marker: () => '[redacted:private-key]' },
28
+ // Generic high-entropy assignment forms: VAR=secret-looking-string
29
+ { kind: 'env-secret', re: /\b(?:[A-Z][A-Z0-9_]*_(?:KEY|SECRET|TOKEN|PASS(?:WORD)?|PWD|CREDENTIAL))\s*[:=]\s*['"]?([A-Za-z0-9+/_=-]{16,})['"]?/g, marker: (m) => m.replace(/(.*?[:=]\s*['"]?)([A-Za-z0-9+/_=-]{16,})(['"]?)/, '$1[redacted:env-secret]$3') },
30
+ ];
31
+ function offsetToLineCol(text, offset) {
32
+ let line = 1;
33
+ let lastNewline = -1;
34
+ for (let i = 0; i < offset; i++) {
35
+ if (text.charCodeAt(i) === 10) {
36
+ line++;
37
+ lastNewline = i;
38
+ }
39
+ }
40
+ return { line, column: offset - lastNewline };
41
+ }
42
+ export function redact(input) {
43
+ let out = input;
44
+ const hits = [];
45
+ for (const p of PATTERNS) {
46
+ out = out.replace(p.re, (match, ..._args) => {
47
+ // Locate this match in the *original* input for line/col reporting.
48
+ // Using the original gives stable coordinates the user can verify.
49
+ const idx = input.indexOf(match);
50
+ if (idx >= 0) {
51
+ const { line, column } = offsetToLineCol(input, idx);
52
+ hits.push({ kind: p.kind, line, column, length: match.length });
53
+ }
54
+ return p.marker(match);
55
+ });
56
+ }
57
+ return { redacted: out, hits };
58
+ }
59
+ export function summarizeHits(hits) {
60
+ if (hits.length === 0)
61
+ return 'no secrets detected';
62
+ const counts = {};
63
+ for (const h of hits)
64
+ counts[h.kind] = (counts[h.kind] ?? 0) + 1;
65
+ return Object.entries(counts)
66
+ .sort(([, a], [, b]) => b - a)
67
+ .map(([k, n]) => `${k}×${n}`)
68
+ .join(', ');
69
+ }
@@ -0,0 +1,117 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ // Source-of-truth for the Claude Code skill file. The shell installer
5
+ // (apps/installer/install.sh) inlines this content too — keep them in sync
6
+ // when SKILL_MARKDOWN changes.
7
+ export const SKILL_MARKDOWN = `---
8
+ name: agentdrive
9
+ description: |
10
+ Persistent cross-platform workspace for AI agents. Use this skill when the
11
+ user wants to share files between agent sessions, retrieve prior context,
12
+ log a run, or work with the AgentDrive MCP server but those tools are not
13
+ available. Calls the local \`adrive\` CLI as a fallback.
14
+ ---
15
+
16
+ # AgentDrive — shared artifact workspace for multi-agent workflows
17
+
18
+ If the \`drive_*\` MCP tools are wired, prefer them. Use the \`adrive\` CLI when
19
+ the MCP server is not available or for shell-level workflows.
20
+
21
+ ## When to use this skill
22
+
23
+ Trigger on any of these patterns:
24
+
25
+ - "Share this with Codex / Cursor / my other agent"
26
+ - "What did the last session produce?"
27
+ - "What did Codex / Cursor / Claude work on?"
28
+ - "Pull context for this task"
29
+ - "Save this artifact so the next agent can find it"
30
+ - "Log this run" / "remember what I just did"
31
+ - "Search my workspace for X"
32
+ - The user mentions a workspace, share link, or cross-agent handoff
33
+
34
+ ## Commands
35
+
36
+ \`\`\`
37
+ adrive context "<task>" [--limit N] # auto-pull relevant artifacts
38
+ adrive search "<query>" [--top-k N] # semantic search
39
+ adrive push <file> [--workspace ws] [--remote-path] # write artifact
40
+ adrive pull <ws-id>/<path> [-o out] # read artifact
41
+ adrive run-log "<task>" --status success # log a run
42
+ adrive ls # list workspaces
43
+ adrive whoami # show principal
44
+ \`\`\`
45
+
46
+ \`adrive\` reads its config from \`~/.config/agentdrive/config.json\` so the user
47
+ is already authenticated. If the user is NOT authenticated, prompt them to
48
+ run \`adrive init\` first instead of guessing.
49
+
50
+ ## Output handling
51
+
52
+ \`adrive context\` returns each artifact with an \`attribution\` block. When
53
+ you cite the artifact to the user, mention *why* it surfaced
54
+ (\`reasons: ["high semantic match", "still fresh"]\`). When the artifact has a
55
+ \`last_run\` block, mention the prior run's task so the user sees the
56
+ narrative thread.
57
+
58
+ ## Privacy
59
+
60
+ Respect \`adrive privacy\` settings. If the user has "Private workspace" on,
61
+ written content is encrypted client-side; server stores only ciphertext.
62
+ `;
63
+ // Marker that auto-context.ts writes into CLAUDE.md. If present, the user
64
+ // already has the MCP preflight wired and the skill would double-fire.
65
+ const AUTO_CONTEXT_MARKER = '<!-- agentdrive:auto-context:begin -->';
66
+ async function fileExists(p) {
67
+ try {
68
+ await fs.access(p);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ export async function installClaudeSkill(opts = {}) {
76
+ const home = opts.home ?? homedir();
77
+ const claudeDir = join(home, '.claude');
78
+ const skillPath = join(claudeDir, 'skills', 'agentdrive', 'SKILL.md');
79
+ if (!(await fileExists(claudeDir))) {
80
+ return { path: skillPath, action: 'skipped-no-claude' };
81
+ }
82
+ if (!opts.force) {
83
+ const claudeMdPath = join(claudeDir, 'CLAUDE.md');
84
+ if (await fileExists(claudeMdPath)) {
85
+ const claudeMd = await fs.readFile(claudeMdPath, 'utf8');
86
+ if (claudeMd.includes(AUTO_CONTEXT_MARKER)) {
87
+ return { path: skillPath, action: 'skipped-preflight-installed' };
88
+ }
89
+ }
90
+ }
91
+ const existed = await fileExists(skillPath);
92
+ if (existed) {
93
+ const current = await fs.readFile(skillPath, 'utf8');
94
+ if (current === SKILL_MARKDOWN) {
95
+ return { path: skillPath, action: 'unchanged' };
96
+ }
97
+ await fs.writeFile(skillPath, SKILL_MARKDOWN, 'utf8');
98
+ return { path: skillPath, action: 'updated' };
99
+ }
100
+ await fs.mkdir(dirname(skillPath), { recursive: true });
101
+ await fs.writeFile(skillPath, SKILL_MARKDOWN, 'utf8');
102
+ return { path: skillPath, action: 'created' };
103
+ }
104
+ export function describeSkillAction(action) {
105
+ switch (action) {
106
+ case 'created':
107
+ return 'created';
108
+ case 'updated':
109
+ return 'updated';
110
+ case 'unchanged':
111
+ return 'already up to date';
112
+ case 'skipped-no-claude':
113
+ return 'skipped (no ~/.claude directory; Claude Code not installed)';
114
+ case 'skipped-preflight-installed':
115
+ return 'skipped (auto-context preflight is already in CLAUDE.md — use --force to override)';
116
+ }
117
+ }
@@ -0,0 +1,112 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // Claude Code session format:
4
+ // ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
5
+ //
6
+ // Each line is a JSON object. We care about lines where
7
+ // type === 'user' or 'assistant'
8
+ // with a nested `message: { role, content }`. Content is a string OR
9
+ // an array of message parts. Tool calls and intermediate states are
10
+ // skipped — agents already see the natural-language turns.
11
+ export const claudeSource = {
12
+ platform: 'claude-code',
13
+ async enumerate({ home, since }) {
14
+ const projectsDir = join(home, '.claude', 'projects');
15
+ let projects;
16
+ try {
17
+ projects = await fs.readdir(projectsDir);
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ const out = [];
23
+ for (const p of projects) {
24
+ const pdir = join(projectsDir, p);
25
+ let files;
26
+ try {
27
+ files = await fs.readdir(pdir);
28
+ }
29
+ catch {
30
+ continue;
31
+ }
32
+ for (const f of files) {
33
+ if (!f.endsWith('.jsonl'))
34
+ continue;
35
+ const full = join(pdir, f);
36
+ let stat;
37
+ try {
38
+ stat = await fs.stat(full);
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ if (since && stat.mtime <= since)
44
+ continue;
45
+ const sessionId = f.replace(/\.jsonl$/, '');
46
+ out.push({
47
+ manifestKey: `claude-code:${sessionId}`,
48
+ sessionId,
49
+ path: full,
50
+ mtime: stat.mtime,
51
+ label: p,
52
+ });
53
+ }
54
+ }
55
+ return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
56
+ },
57
+ async parseSession(entry) {
58
+ let raw;
59
+ try {
60
+ raw = await fs.readFile(entry.path, 'utf8');
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ const turns = [];
66
+ for (const line of raw.split(/\r?\n/)) {
67
+ if (!line.trim())
68
+ continue;
69
+ const turn = extractTurn(line);
70
+ if (turn)
71
+ turns.push(turn);
72
+ }
73
+ if (turns.length === 0)
74
+ return null;
75
+ return { entry, turns };
76
+ },
77
+ };
78
+ export function extractTurn(line) {
79
+ let obj;
80
+ try {
81
+ obj = JSON.parse(line);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ const type = obj.type ?? '';
87
+ const ts = obj.timestamp ?? undefined;
88
+ if (type === 'user' || type === 'assistant') {
89
+ const msg = obj.message;
90
+ if (!msg)
91
+ return null;
92
+ const role = msg.role ?? type;
93
+ const content = msg.content;
94
+ let text = '';
95
+ if (typeof content === 'string') {
96
+ text = content;
97
+ }
98
+ else if (Array.isArray(content)) {
99
+ for (const part of content) {
100
+ if (typeof part === 'string')
101
+ text += part;
102
+ else if (part && typeof part === 'object' && 'text' in part) {
103
+ text += String(part.text);
104
+ }
105
+ }
106
+ }
107
+ if (!text.trim())
108
+ return null;
109
+ return { role, text, ts };
110
+ }
111
+ return null;
112
+ }
@@ -0,0 +1,168 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // OpenAI Codex CLI session format:
4
+ // ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ISO-date>-<session-id>.jsonl
5
+ //
6
+ // The first line is a `session_meta` record carrying the session id, cli
7
+ // version, cwd, and base instructions. Subsequent lines carry the turn
8
+ // stream as `{ timestamp, type, payload }`. We extract user + assistant
9
+ // text from `type: 'turn'` records (with `payload.role` + `payload.content`)
10
+ // and from `type: 'response_item'` records (with `payload.type: 'message'`
11
+ // and `payload.content[*].text`). Older Codex versions vary slightly; we
12
+ // only require that text fields exist where we expect them and drop the
13
+ // rest. Tool calls / reasoning / system events are skipped — agents only
14
+ // need the natural-language turns.
15
+ //
16
+ // Per Codex finding #11, the on-disk format may shift between CLI versions.
17
+ // The parser stays permissive: unknown record types are silently ignored
18
+ // rather than crashing the ingest.
19
+ const SESSION_ID_RE = /^rollout-.+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
20
+ async function walkRolloutFiles(root) {
21
+ // Walks the YYYY/MM/DD tree depth-first. Tolerant of missing dirs.
22
+ const acc = [];
23
+ let years;
24
+ try {
25
+ years = await fs.readdir(root);
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ for (const y of years) {
31
+ const ydir = join(root, y);
32
+ let months;
33
+ try {
34
+ months = await fs.readdir(ydir);
35
+ }
36
+ catch {
37
+ continue;
38
+ }
39
+ for (const m of months) {
40
+ const mdir = join(ydir, m);
41
+ let days;
42
+ try {
43
+ days = await fs.readdir(mdir);
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ for (const d of days) {
49
+ const ddir = join(mdir, d);
50
+ let files;
51
+ try {
52
+ files = await fs.readdir(ddir);
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ for (const f of files) {
58
+ if (f.startsWith('rollout-') && f.endsWith('.jsonl')) {
59
+ acc.push(join(ddir, f));
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return acc;
66
+ }
67
+ export const codexSource = {
68
+ platform: 'codex',
69
+ async enumerate({ home, since }) {
70
+ const root = join(home, '.codex', 'sessions');
71
+ const files = await walkRolloutFiles(root);
72
+ const out = [];
73
+ for (const full of files) {
74
+ let stat;
75
+ try {
76
+ stat = await fs.stat(full);
77
+ }
78
+ catch {
79
+ continue;
80
+ }
81
+ if (since && stat.mtime <= since)
82
+ continue;
83
+ const base = full.split('/').pop() ?? full;
84
+ const idMatch = base.match(SESSION_ID_RE);
85
+ const sessionId = idMatch ? idMatch[1] : base.replace(/\.jsonl$/, '');
86
+ // `label` is best-effort — fill from the cwd in session_meta on parse.
87
+ out.push({
88
+ manifestKey: `codex:${sessionId}`,
89
+ sessionId,
90
+ path: full,
91
+ mtime: stat.mtime,
92
+ label: 'codex session',
93
+ });
94
+ }
95
+ return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
96
+ },
97
+ async parseSession(entry) {
98
+ let raw;
99
+ try {
100
+ raw = await fs.readFile(entry.path, 'utf8');
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ const turns = [];
106
+ let cwd;
107
+ for (const line of raw.split(/\r?\n/)) {
108
+ if (!line.trim())
109
+ continue;
110
+ let obj;
111
+ try {
112
+ obj = JSON.parse(line);
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ const type = obj.type ?? '';
118
+ const ts = obj.timestamp ?? undefined;
119
+ const payload = (obj.payload ?? {});
120
+ if (type === 'session_meta') {
121
+ const c = payload.cwd;
122
+ if (typeof c === 'string')
123
+ cwd = c;
124
+ continue;
125
+ }
126
+ // Two known shapes for natural-language turns:
127
+ // 1. payload.role + payload.content (string or array)
128
+ // 2. payload.type === 'message' with payload.content[*].text
129
+ let role;
130
+ let text = '';
131
+ if (typeof payload.role === 'string' && payload.content !== undefined) {
132
+ role = payload.role;
133
+ const c = payload.content;
134
+ if (typeof c === 'string')
135
+ text = c;
136
+ else if (Array.isArray(c)) {
137
+ for (const part of c) {
138
+ if (typeof part === 'string')
139
+ text += part;
140
+ else if (part && typeof part === 'object' && 'text' in part) {
141
+ text += String(part.text);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ else if (payload.type === 'message') {
147
+ const innerRole = payload.role;
148
+ if (typeof innerRole === 'string')
149
+ role = innerRole;
150
+ const c = payload.content;
151
+ if (Array.isArray(c)) {
152
+ for (const part of c) {
153
+ if (part && typeof part === 'object' && 'text' in part) {
154
+ text += String(part.text);
155
+ }
156
+ }
157
+ }
158
+ }
159
+ if (role && text.trim()) {
160
+ turns.push({ role, text, ts });
161
+ }
162
+ }
163
+ if (turns.length === 0)
164
+ return null;
165
+ const labeled = cwd ? { ...entry, label: cwd } : entry;
166
+ return { entry: labeled, turns };
167
+ },
168
+ };
@@ -0,0 +1,23 @@
1
+ // Cursor stores chat history in a per-workspace SQLite database at
2
+ // ~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/state.vscdb
3
+ // (macOS) or the analogous path on Linux. Reading it requires `node:sqlite`
4
+ // (Node 22.5+) or `better-sqlite3`. The CLI's `engines.node` is currently
5
+ // `>=20` so neither is available out of the box.
6
+ //
7
+ // Per CEO plan D3 + Codex tension T3 + finding #11, this surface is brittle
8
+ // reverse-engineering and intentionally kept narrow. The full implementation
9
+ // lands in Phase 2.5 (tracked in TODOS.md). For now this source exposes a
10
+ // clear error so users hit explicit "not yet" rather than silent zero hits.
11
+ const CURSOR_NOT_IMPLEMENTED_MESSAGE = 'Cursor transcript ingestion is not yet available. Reading Cursor SQLite ' +
12
+ "history requires Node 22.5+ (node:sqlite) or a native dependency. " +
13
+ 'Tracking under Phase 2.5 of the v1 plan.';
14
+ export const cursorSource = {
15
+ platform: 'cursor',
16
+ async enumerate(_opts) {
17
+ throw new Error(CURSOR_NOT_IMPLEMENTED_MESSAGE);
18
+ },
19
+ async parseSession(_entry) {
20
+ throw new Error(CURSOR_NOT_IMPLEMENTED_MESSAGE);
21
+ },
22
+ };
23
+ export { CURSOR_NOT_IMPLEMENTED_MESSAGE };