@adrive/cli 0.3.0 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @adrive/cli
2
2
 
3
- The `adrive` binary — a thin CLI for [AgentDrive](https://agentdrive.sh), the persistent workspace your agents share across Claude Code, Codex, Cursor, and LangGraph.
3
+ The `adrive` binary — a thin CLI for [AgentDrive](https://agentdrive.sh), the cross-agent artifact workspace with provenance and portable storage across Claude Code, Codex, Cursor, and LangGraph.
4
4
 
5
5
  ## Install
6
6
 
@@ -1,12 +1,22 @@
1
1
  import kleur from 'kleur';
2
2
  import { CliApiClient } from '../lib/api-client.js';
3
3
  import { requireLogin } from '../lib/config.js';
4
+ import { installClaudeSkill, describeSkillAction } from '../lib/skill.js';
4
5
  function truncate(s, n) {
5
6
  if (!s)
6
7
  return '';
7
8
  return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
8
9
  }
9
10
  export async function cmdContext(task, opts = {}) {
11
+ if (opts.installSkill) {
12
+ const result = await installClaudeSkill({ force: opts.force });
13
+ process.stdout.write(`Claude skill ${describeSkillAction(result.action)}: ${kleur.dim(result.path)}\n`);
14
+ return;
15
+ }
16
+ if (!task) {
17
+ process.stderr.write('error: missing required argument <task>\n');
18
+ process.exit(1);
19
+ }
10
20
  const cfg = await requireLogin();
11
21
  const client = new CliApiClient({
12
22
  baseUrl: cfg.api_url,
@@ -25,8 +35,15 @@ export async function cmdContext(task, opts = {}) {
25
35
  return;
26
36
  }
27
37
  for (const a of res.artifacts) {
28
- const score = a.score.toFixed(2);
38
+ const score = a.score === null ? 'keyword' : a.score.toFixed(2);
29
39
  process.stdout.write(`${kleur.cyan(a.path)} · score=${score} · by=${a.agent_id}@${a.agent_platform}\n`);
40
+ if (a.attribution?.reasons?.length) {
41
+ process.stdout.write(` ${kleur.dim('why: ' + a.attribution.reasons.join(' · '))}\n`);
42
+ }
43
+ if (a.last_run) {
44
+ const lr = a.last_run;
45
+ process.stdout.write(` ${kleur.dim('last run: ' + truncate(lr.task, 80) + ` [${lr.status}, ${lr.agent_platform}]`)}\n`);
46
+ }
30
47
  if (a.snippet)
31
48
  process.stdout.write(` ${kleur.dim(truncate(a.snippet, 500))}\n`);
32
49
  }
@@ -5,6 +5,7 @@ import { DEFAULT_API_URL, DEFAULT_PREFERENCES, readConfig, writeConfig, } from '
5
5
  import { pollAuth } from '../lib/poll-auth.js';
6
6
  import { detectInstalledAgents, wireAgent } from '../lib/detect-agents.js';
7
7
  import { writeAllInstructions } from '../lib/auto-context.js';
8
+ import { installClaudeSkill } from '../lib/skill.js';
8
9
  async function runPrivacyOptIn(existing, yes) {
9
10
  const start = { ...DEFAULT_PREFERENCES, ...(existing ?? {}) };
10
11
  process.stdout.write(`\n${kleur.bold('Your AI memory')}\n`);
@@ -144,6 +145,13 @@ export async function cmdInit(opts = {}) {
144
145
  process.stdout.write(' No new instructions to write.\n');
145
146
  }
146
147
  }
148
+ // Step 4b — install the Claude Code skill file. Skips silently when ~/.claude
149
+ // is missing OR when the auto-context preflight was just written above
150
+ // (would double-fire on the same triggers).
151
+ const skillResult = await installClaudeSkill();
152
+ if (skillResult.action === 'created' || skillResult.action === 'updated') {
153
+ process.stdout.write(` ${kleur.green('+')} ${skillResult.path}\n`);
154
+ }
147
155
  await writeConfig({
148
156
  api_url: apiUrl,
149
157
  api_key: apiKey,
@@ -0,0 +1,65 @@
1
+ import kleur from 'kleur';
2
+ import { CliApiClient } from '../lib/api-client.js';
3
+ import { requireLogin } from '../lib/config.js';
4
+ // Platform tints inspired by D4 (variant 2A "tinted pill with letter glyph").
5
+ // Falls through to no-color on unknown / undefined platforms.
6
+ const PLATFORM_GLYPH = {
7
+ 'claude-code': { glyph: 'CC', tint: (s) => kleur.magenta().bold(s) },
8
+ codex: { glyph: 'CX', tint: (s) => kleur.cyan().bold(s) },
9
+ cursor: { glyph: 'CR', tint: (s) => kleur.blue().bold(s) },
10
+ langgraph: { glyph: 'LG', tint: (s) => kleur.yellow().bold(s) },
11
+ crewai: { glyph: 'CA', tint: (s) => kleur.green().bold(s) },
12
+ autogen: { glyph: 'AG', tint: (s) => kleur.green().bold(s) },
13
+ hermes: { glyph: 'HM', tint: (s) => kleur.red().bold(s) },
14
+ openclaw: { glyph: 'OC', tint: (s) => kleur.red().bold(s) },
15
+ };
16
+ function badge(platform) {
17
+ const key = platform ?? 'unknown';
18
+ const entry = PLATFORM_GLYPH[key];
19
+ if (!entry)
20
+ return kleur.dim('— ');
21
+ return entry.tint(entry.glyph);
22
+ }
23
+ function actorLabel(ev) {
24
+ if (ev.actor_type === 'human')
25
+ return ev.actor_id;
26
+ return ev.actor_id.length > 14 ? `${ev.actor_id.slice(0, 14)}…` : ev.actor_id;
27
+ }
28
+ function formatTimestamp(iso) {
29
+ const d = new Date(iso);
30
+ const yyyy = d.getUTCFullYear();
31
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
32
+ const dd = String(d.getUTCDate()).padStart(2, '0');
33
+ const hh = String(d.getUTCHours()).padStart(2, '0');
34
+ const min = String(d.getUTCMinutes()).padStart(2, '0');
35
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}Z`;
36
+ }
37
+ function startOfTodayIso() {
38
+ const now = new Date();
39
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString();
40
+ }
41
+ export async function cmdTimeline(opts = {}) {
42
+ const cfg = await requireLogin();
43
+ const client = new CliApiClient({
44
+ baseUrl: cfg.api_url,
45
+ apiKey: cfg.api_key,
46
+ agentPlatform: cfg.agent_platform ?? 'cli',
47
+ agentRole: cfg.agent_role,
48
+ });
49
+ const limit = opts.limit ? Number.parseInt(opts.limit, 10) : undefined;
50
+ const since = opts.today ? startOfTodayIso() : opts.since;
51
+ const { items, truncated } = await client.timeline({ since, limit });
52
+ if (items.length === 0) {
53
+ process.stdout.write('No events in window.\n');
54
+ return;
55
+ }
56
+ for (const ev of items) {
57
+ const action = kleur.cyan(ev.action.padEnd(16));
58
+ const target = ev.target_path ?? ev.target_id;
59
+ process.stdout.write(`${kleur.dim(formatTimestamp(ev.created_at))} ${badge(ev.agent_platform)} ` +
60
+ `${action} ${kleur.dim(actorLabel(ev))} ${kleur.dim('→')} ${target}\n`);
61
+ }
62
+ if (truncated) {
63
+ process.stdout.write(kleur.dim(`\n${items.length} shown; capped by --limit. Re-run with a tighter --since to page back.\n`));
64
+ }
65
+ }
@@ -1,111 +1,26 @@
1
- import { promises as fs } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join, basename } from 'node:path';
4
1
  import kleur from 'kleur';
5
- import { CliApiClient } from '../lib/api-client.js';
6
- import { requireLogin, configDir } from '../lib/config.js';
7
- import { encryptContentAsync } from '../lib/crypto.js';
8
- import { redact, summarizeHits } from '../lib/redactor.js';
9
- function manifestPath() {
10
- return join(configDir(), 'transcripts.manifest.json');
11
- }
12
- async function readManifest() {
13
- try {
14
- const raw = await fs.readFile(manifestPath(), 'utf8');
15
- return JSON.parse(raw);
16
- }
17
- catch {
18
- return { ingested: {} };
19
- }
20
- }
21
- async function writeManifest(m) {
22
- await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
23
- await fs.writeFile(manifestPath(), JSON.stringify(m, null, 2), { encoding: 'utf8', mode: 0o600 });
24
- }
25
- async function findClaudeTranscripts() {
26
- const projectsDir = join(homedir(), '.claude', 'projects');
27
- let projects;
28
- try {
29
- projects = await fs.readdir(projectsDir);
30
- }
31
- catch {
32
- return [];
33
- }
34
- const out = [];
35
- for (const p of projects) {
36
- const pdir = join(projectsDir, p);
37
- let files;
38
- try {
39
- files = await fs.readdir(pdir);
40
- }
41
- catch {
42
- continue;
43
- }
44
- for (const f of files) {
45
- if (!f.endsWith('.jsonl'))
46
- continue;
47
- const full = join(pdir, f);
48
- let stat;
49
- try {
50
- stat = await fs.stat(full);
51
- }
52
- catch {
53
- continue;
54
- }
55
- out.push({
56
- sessionId: f.replace(/\.jsonl$/, ''),
57
- path: full,
58
- mtime: stat.mtime,
59
- projectDir: p,
60
- });
61
- }
62
- }
63
- return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
64
- }
65
- function extractTurn(line) {
66
- let obj;
67
- try {
68
- obj = JSON.parse(line);
69
- }
70
- catch {
71
- return null;
72
- }
73
- const type = obj.type ?? '';
74
- const ts = obj.timestamp ?? undefined;
75
- if (type === 'user' || type === 'assistant') {
76
- const msg = obj.message;
77
- if (!msg)
78
- return null;
79
- const role = msg.role ?? type;
80
- const content = msg.content;
81
- let text = '';
82
- if (typeof content === 'string') {
83
- text = content;
84
- }
85
- else if (Array.isArray(content)) {
86
- for (const part of content) {
87
- if (typeof part === 'string')
88
- text += part;
89
- else if (part && typeof part === 'object' && 'text' in part) {
90
- text += String(part.text);
91
- }
92
- }
93
- }
94
- if (!text.trim())
95
- return null;
96
- return { role, text, ts };
97
- }
98
- return null;
99
- }
100
- function formatTranscript(turns) {
101
- const lines = [];
102
- for (const t of turns) {
103
- lines.push(`## ${t.role}${t.ts ? ` _(${t.ts})_` : ''}`);
104
- lines.push('');
105
- lines.push(t.text);
106
- lines.push('');
107
- }
108
- return lines.join('\n');
2
+ import { requireLogin } from '../lib/config.js';
3
+ import { claudeSource } from '../lib/transcripts/claude.js';
4
+ import { codexSource } from '../lib/transcripts/codex.js';
5
+ import { cursorSource } from '../lib/transcripts/cursor.js';
6
+ import { ingestSource } from '../lib/transcripts/ingest.js';
7
+ const SOURCES = {
8
+ 'claude-code': claudeSource,
9
+ codex: codexSource,
10
+ cursor: cursorSource,
11
+ };
12
+ function selectSources(platform) {
13
+ if (!platform || platform === 'all') {
14
+ // Cursor is intentionally excluded from --all until Phase 2.5 because its
15
+ // enumerate() throws. Users that want it must opt in explicitly with
16
+ // --platform cursor and get the "not yet" error.
17
+ return [claudeSource, codexSource];
18
+ }
19
+ const source = SOURCES[platform];
20
+ if (!source) {
21
+ throw new Error(`Unknown platform: ${platform}. Supported: claude-code, codex, cursor, all.`);
22
+ }
23
+ return [source];
109
24
  }
110
25
  export async function cmdTranscriptsIngest(opts = {}) {
111
26
  const cfg = await requireLogin();
@@ -115,96 +30,27 @@ export async function cmdTranscriptsIngest(opts = {}) {
115
30
  ` or run a one-off dry-run with \`adrive transcripts ingest --dry-run\`.\n`);
116
31
  process.exit(2);
117
32
  }
118
- const platform = opts.platform ?? 'claude-code';
119
- if (platform !== 'claude-code') {
120
- throw new Error(`Transcript ingestion for ${platform} is not implemented yet.`);
121
- }
122
- const all = await findClaudeTranscripts();
123
- let entries = all;
124
- if (opts.since) {
125
- const since = new Date(opts.since);
126
- if (Number.isNaN(since.getTime()))
127
- throw new Error('Invalid --since date.');
128
- entries = entries.filter((e) => e.mtime > since);
129
- }
130
- if (typeof opts.limit === 'number')
131
- entries = entries.slice(0, opts.limit);
132
- if (entries.length === 0) {
133
- process.stdout.write('No transcripts found.\n');
134
- return;
135
- }
136
- const manifest = await readManifest();
137
- const client = new CliApiClient({
138
- baseUrl: cfg.api_url,
139
- apiKey: cfg.api_key,
140
- agentPlatform: cfg.agent_platform ?? 'cli',
141
- agentRole: cfg.agent_role,
142
- });
143
- let workspaceId = opts.workspace ?? cfg.default_workspace;
144
- if (!workspaceId && !opts.dryRun) {
145
- const list = await client.listWorkspaces();
146
- if (list.length === 1)
147
- workspaceId = list[0].id;
148
- else if (list.length === 0)
149
- throw new Error('No workspaces. Create one first.');
150
- else
151
- throw new Error('Multiple workspaces — specify --workspace <id>.');
152
- }
153
- const encrypt = !!cfg.preferences?.private_workspace;
154
- process.stdout.write(`${kleur.bold(`Found ${entries.length} ${platform} transcripts.`)}\n`);
155
- let ingested = 0;
156
- let skipped = 0;
157
- for (const e of entries) {
158
- if (!opts.reingest && manifest.ingested[e.sessionId]) {
159
- skipped++;
160
- continue;
161
- }
162
- let raw;
33
+ const sources = selectSources(opts.platform);
34
+ let totalIngested = 0;
35
+ let totalSkipped = 0;
36
+ for (const source of sources) {
163
37
  try {
164
- raw = await fs.readFile(e.path, 'utf8');
165
- }
166
- catch {
167
- continue;
168
- }
169
- const turns = [];
170
- for (const line of raw.split(/\r?\n/)) {
171
- if (!line.trim())
172
- continue;
173
- const turn = extractTurn(line);
174
- if (turn)
175
- turns.push(turn);
176
- }
177
- if (turns.length === 0) {
178
- skipped++;
179
- continue;
180
- }
181
- const md = `# Transcript — ${platform} / ${e.sessionId}\n\n` +
182
- `Project: ${e.projectDir}\nLast modified: ${e.mtime.toISOString()}\nTurns: ${turns.length}\n\n` +
183
- formatTranscript(turns);
184
- const { redacted, hits } = redact(md);
185
- const remotePath = `transcripts/${platform}/${e.sessionId}.md`;
186
- const tag = summarizeHits(hits);
187
- if (opts.dryRun) {
188
- process.stdout.write(`${kleur.dim('[dry]')} ${remotePath} ${kleur.dim(`(${turns.length} turns, redactor: ${tag})`)}\n`);
189
- ingested++;
190
- continue;
191
- }
192
- try {
193
- const body = encrypt ? await encryptContentAsync(cfg.api_key, redacted) : redacted;
194
- await client.writeFile(workspaceId, remotePath, body, `transcript ingest (${tag})`);
195
- manifest.ingested[e.sessionId] = new Date().toISOString();
196
- ingested++;
197
- process.stdout.write(`${kleur.green('[ok]')} ${remotePath}` +
198
- (encrypt ? kleur.cyan(' (encrypted)') : '') +
199
- (hits.length ? kleur.yellow(` (redacted: ${tag})`) : '') +
200
- '\n');
38
+ const result = await ingestSource(source, cfg, {
39
+ workspace: opts.workspace,
40
+ since: opts.since,
41
+ dryRun: opts.dryRun,
42
+ limit: opts.limit,
43
+ reingest: opts.reingest,
44
+ });
45
+ totalIngested += result.ingested;
46
+ totalSkipped += result.skipped;
201
47
  }
202
48
  catch (err) {
203
49
  const msg = err instanceof Error ? err.message : String(err);
204
- process.stderr.write(`${kleur.red('[err]')} ${basename(e.path)}: ${msg}\n`);
50
+ process.stderr.write(`${kleur.red('[err]')} ${source.platform}: ${msg}\n`);
205
51
  }
206
52
  }
207
- if (!opts.dryRun)
208
- await writeManifest(manifest);
209
- process.stdout.write(`\nIngested ${ingested}, skipped ${skipped}.\n`);
53
+ if (sources.length > 1) {
54
+ process.stdout.write(`\n${kleur.bold('Total')}: ${totalIngested} ingested, ${totalSkipped} skipped.\n`);
55
+ }
210
56
  }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { cmdPlan } from './commands/plan.js';
17
17
  import { cmdPrivacy } from './commands/privacy.js';
18
18
  import { cmdWatch } from './commands/watch.js';
19
19
  import { cmdTranscriptsIngest } from './commands/transcripts.js';
20
+ import { cmdTimeline } from './commands/timeline.js';
20
21
  // Read from the bundled package.json so we never drift again.
21
22
  import { readFileSync } from 'node:fs';
22
23
  import { fileURLToPath } from 'node:url';
@@ -103,10 +104,19 @@ program
103
104
  workspace: opts.workspace,
104
105
  })));
105
106
  program
106
- .command('context <task>')
107
+ .command('timeline')
108
+ .description('Cross-agent activity stream for your org')
109
+ .option('--today', 'Only events since midnight UTC')
110
+ .option('--since <iso>', 'Only events strictly after this ISO datetime')
111
+ .option('--limit <n>', 'Max events to show (default 200, max 1000)')
112
+ .action(wrap((opts) => cmdTimeline(opts)));
113
+ program
114
+ .command('context [task]')
107
115
  .description('Auto-pull relevant artifacts for a task')
108
116
  .option('--limit <n>', 'Max artifacts to return')
109
117
  .option('-w, --workspace <id>', 'Restrict to one workspace')
118
+ .option('--install-skill', 'Install the Claude Code SKILL.md (~/.claude/skills/agentdrive/)')
119
+ .option('--force', 'With --install-skill, write even if the auto-context preflight is already in CLAUDE.md')
110
120
  .action(wrap((task, opts) => cmdContext(task, opts)));
111
121
  program
112
122
  .command('run-log <task>')
@@ -146,7 +156,7 @@ const transcripts = program.command('transcripts').description('Ingest chat tran
146
156
  transcripts
147
157
  .command('ingest')
148
158
  .description('Read recent transcripts, redact secrets, upload to AgentDrive')
149
- .option('--platform <p>', 'Only ingest one platform (default: claude-code)', 'claude-code')
159
+ .option('--platform <p>', 'claude-code | codex | cursor | all (default: all)', 'all')
150
160
  .option('-w, --workspace <id>', 'Target workspace id')
151
161
  .option('--since <date>', 'Ignore transcripts older than this ISO date')
152
162
  .option('--limit <n>', 'Cap how many to ingest', (v) => Number(v))
@@ -119,4 +119,19 @@ export class CliApiClient {
119
119
  async billingPlan() {
120
120
  return this.request('GET', '/billing/plan');
121
121
  }
122
+ async timeline(opts = {}) {
123
+ const params = new URLSearchParams();
124
+ if (opts.since)
125
+ params.set('since', opts.since);
126
+ if (typeof opts.limit === 'number')
127
+ params.set('limit', String(opts.limit));
128
+ const query = params.toString() ? `?${params.toString()}` : '';
129
+ const url = `${this.baseUrl}/v1/org/timeline${query}`;
130
+ const res = await fetch(url, { method: 'GET', headers: this.headers(true) });
131
+ const json = (await res.json());
132
+ if (!res.ok || !json.success) {
133
+ throw new Error(json.error ?? `API request failed (${res.status})`);
134
+ }
135
+ return { items: (json.data ?? []), truncated: !!json.truncated };
136
+ }
122
137
  }
@@ -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 };
@@ -0,0 +1,115 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import kleur from 'kleur';
5
+ import { CliApiClient } from '../api-client.js';
6
+ import { configDir } from '../config.js';
7
+ import { encryptContentAsync } from '../crypto.js';
8
+ import { redact, summarizeHits } from '../redactor.js';
9
+ function manifestPath() {
10
+ return join(configDir(), 'transcripts.manifest.json');
11
+ }
12
+ async function readManifest() {
13
+ try {
14
+ const raw = await fs.readFile(manifestPath(), 'utf8');
15
+ return JSON.parse(raw);
16
+ }
17
+ catch {
18
+ return { ingested: {} };
19
+ }
20
+ }
21
+ async function writeManifest(m) {
22
+ await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
23
+ await fs.writeFile(manifestPath(), JSON.stringify(m, null, 2), { encoding: 'utf8', mode: 0o600 });
24
+ }
25
+ function formatTranscript(turns) {
26
+ const lines = [];
27
+ for (const t of turns) {
28
+ lines.push(`## ${t.role}${t.ts ? ` _(${t.ts})_` : ''}`);
29
+ lines.push('');
30
+ lines.push(t.text);
31
+ lines.push('');
32
+ }
33
+ return lines.join('\n');
34
+ }
35
+ function buildMarkdown(platform, doc) {
36
+ return (`# Transcript — ${platform} / ${doc.entry.sessionId}\n\n` +
37
+ `Project: ${doc.entry.label}\nLast modified: ${doc.entry.mtime.toISOString()}\n` +
38
+ `Turns: ${doc.turns.length}\n\n` +
39
+ formatTranscript(doc.turns));
40
+ }
41
+ export async function ingestSource(source, cfg, opts = {}) {
42
+ const home = opts.home ?? homedir();
43
+ const since = opts.since ? new Date(opts.since) : undefined;
44
+ if (since && Number.isNaN(since.getTime())) {
45
+ throw new Error('Invalid --since date.');
46
+ }
47
+ let entries = await source.enumerate({ home, since });
48
+ if (typeof opts.limit === 'number')
49
+ entries = entries.slice(0, opts.limit);
50
+ if (entries.length === 0) {
51
+ process.stdout.write(`No ${source.platform} transcripts found.\n`);
52
+ return { platform: source.platform, ingested: 0, skipped: 0 };
53
+ }
54
+ const manifest = await readManifest();
55
+ const client = new CliApiClient({
56
+ baseUrl: cfg.api_url,
57
+ apiKey: cfg.api_key,
58
+ agentPlatform: cfg.agent_platform ?? 'cli',
59
+ agentRole: cfg.agent_role,
60
+ });
61
+ let workspaceId = opts.workspace ?? cfg.default_workspace;
62
+ if (!workspaceId && !opts.dryRun) {
63
+ const list = await client.listWorkspaces();
64
+ if (list.length === 1)
65
+ workspaceId = list[0].id;
66
+ else if (list.length === 0)
67
+ throw new Error('No workspaces. Create one first.');
68
+ else
69
+ throw new Error('Multiple workspaces — specify --workspace <id>.');
70
+ }
71
+ const encrypt = !!cfg.preferences?.private_workspace;
72
+ process.stdout.write(`${kleur.bold(`Found ${entries.length} ${source.platform} transcripts.`)}\n`);
73
+ let ingested = 0;
74
+ let skipped = 0;
75
+ for (const entry of entries) {
76
+ if (!opts.reingest && manifest.ingested[entry.manifestKey]) {
77
+ skipped++;
78
+ continue;
79
+ }
80
+ const doc = await source.parseSession(entry);
81
+ if (!doc) {
82
+ skipped++;
83
+ continue;
84
+ }
85
+ const md = buildMarkdown(source.platform, doc);
86
+ const { redacted, hits } = redact(md);
87
+ const tag = summarizeHits(hits);
88
+ const remotePath = `transcripts/${source.platform}/${entry.sessionId}.md`;
89
+ if (opts.dryRun) {
90
+ process.stdout.write(`${kleur.dim('[dry]')} ${remotePath} ${kleur.dim(`(${doc.turns.length} turns, redactor: ${tag})`)}\n`);
91
+ ingested++;
92
+ continue;
93
+ }
94
+ try {
95
+ const body = encrypt ? await encryptContentAsync(cfg.api_key, redacted) : redacted;
96
+ await client.writeFile(workspaceId, remotePath, body, `transcript ingest (${tag})`);
97
+ manifest.ingested[entry.manifestKey] = new Date().toISOString();
98
+ ingested++;
99
+ process.stdout.write(`${kleur.green('[ok]')} ${remotePath}` +
100
+ (encrypt ? kleur.cyan(' (encrypted)') : '') +
101
+ (hits.length ? kleur.yellow(` (redacted: ${tag})`) : '') +
102
+ '\n');
103
+ }
104
+ catch (err) {
105
+ const msg = err instanceof Error ? err.message : String(err);
106
+ const base = entry.path.split('/').pop() ?? entry.path;
107
+ process.stderr.write(`${kleur.red('[err]')} ${base}: ${msg}\n`);
108
+ }
109
+ }
110
+ if (!opts.dryRun)
111
+ await writeManifest(manifest);
112
+ process.stdout.write(`\nIngested ${ingested}, skipped ${skipped}.\n`);
113
+ return { platform: source.platform, ingested, skipped };
114
+ }
115
+ export { buildMarkdown, formatTranscript };
@@ -0,0 +1,10 @@
1
+ // Functional TranscriptSource interface — per eng-review decision E4.
2
+ //
3
+ // A source knows two things:
4
+ // 1. `enumerate(home, opts)` — find candidate session files on disk
5
+ // 2. `parseSession(entry)` — turn one session file into a TranscriptDoc
6
+ //
7
+ // Everything else (redact, encrypt, manifest, upload) is centralized in
8
+ // lib/transcripts/ingest.ts so each source stays small and the cross-cutting
9
+ // concerns can't drift between platforms.
10
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adrive/cli",
3
- "version": "0.3.0",
4
- "description": "AgentDrive CLI — the persistent workspace your agents share across Claude Code, Codex, Cursor, and LangGraph.",
3
+ "version": "1.0.0",
4
+ "description": "AgentDrive CLI — cross-agent artifact workspace with provenance and portable storage across Claude Code, Codex, Cursor, and LangGraph.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {