@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.
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,
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
2
2
  import kleur from 'kleur';
3
3
  import { CliApiClient } from '../lib/api-client.js';
4
4
  import { requireLogin } from '../lib/config.js';
5
- import { decryptContent, isEncrypted } from '../lib/crypto.js';
5
+ import { decryptContentAsync, isEncrypted } from '../lib/crypto.js';
6
6
  export async function cmdPull(spec, opts = {}) {
7
7
  const cfg = await requireLogin();
8
8
  const client = new CliApiClient({
@@ -19,7 +19,7 @@ export async function cmdPull(spec, opts = {}) {
19
19
  const remotePath = spec.slice(slash + 1);
20
20
  const file = await client.readFileByPath(workspaceId, remotePath);
21
21
  const content = isEncrypted(file.content)
22
- ? decryptContent(cfg.api_key, file.content)
22
+ ? await decryptContentAsync(cfg.api_key, file.content)
23
23
  : file.content;
24
24
  if (opts.out) {
25
25
  await fs.writeFile(opts.out, content, 'utf8');
@@ -3,7 +3,7 @@ import { basename } from 'node:path';
3
3
  import kleur from 'kleur';
4
4
  import { CliApiClient } from '../lib/api-client.js';
5
5
  import { requireLogin } from '../lib/config.js';
6
- import { encryptContent } from '../lib/crypto.js';
6
+ import { encryptContentAsync } from '../lib/crypto.js';
7
7
  export async function cmdPush(localPath, opts = {}) {
8
8
  const cfg = await requireLogin();
9
9
  const client = new CliApiClient({
@@ -23,7 +23,7 @@ export async function cmdPush(localPath, opts = {}) {
23
23
  encrypt = false;
24
24
  else
25
25
  encrypt = cfg.preferences?.private_workspace ?? false;
26
- const content = encrypt ? encryptContent(cfg.api_key, raw) : raw;
26
+ const content = encrypt ? await encryptContentAsync(cfg.api_key, raw) : raw;
27
27
  let workspaceId = opts.workspace ?? cfg.default_workspace;
28
28
  if (!workspaceId) {
29
29
  const list = await client.listWorkspaces();
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ import kleur from 'kleur';
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];
24
+ }
25
+ export async function cmdTranscriptsIngest(opts = {}) {
26
+ const cfg = await requireLogin();
27
+ if (!cfg.preferences?.capture_transcripts && !opts.dryRun) {
28
+ process.stderr.write(`${kleur.yellow('[warn]')} Remember conversations is off in your preferences.\n` +
29
+ ` Enable with \`adrive privacy --capture-transcripts on\`,\n` +
30
+ ` or run a one-off dry-run with \`adrive transcripts ingest --dry-run\`.\n`);
31
+ process.exit(2);
32
+ }
33
+ const sources = selectSources(opts.platform);
34
+ let totalIngested = 0;
35
+ let totalSkipped = 0;
36
+ for (const source of sources) {
37
+ try {
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;
47
+ }
48
+ catch (err) {
49
+ const msg = err instanceof Error ? err.message : String(err);
50
+ process.stderr.write(`${kleur.red('[err]')} ${source.platform}: ${msg}\n`);
51
+ }
52
+ }
53
+ if (sources.length > 1) {
54
+ process.stdout.write(`\n${kleur.bold('Total')}: ${totalIngested} ingested, ${totalSkipped} skipped.\n`);
55
+ }
56
+ }
@@ -0,0 +1,110 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { resolve, relative, sep, posix } from 'node:path';
3
+ import { watch as chokidarWatch } from 'chokidar';
4
+ import kleur from 'kleur';
5
+ import { CliApiClient } from '../lib/api-client.js';
6
+ import { requireLogin } from '../lib/config.js';
7
+ import { encryptContentAsync } from '../lib/crypto.js';
8
+ import { redact, summarizeHits } from '../lib/redactor.js';
9
+ import { loadRules, shouldIgnore } from '../lib/ignore.js';
10
+ function toPosix(p) {
11
+ return p.split(sep).join(posix.sep);
12
+ }
13
+ export async function cmdWatch(rawRoot, opts = {}) {
14
+ const root = resolve(rawRoot ?? process.cwd());
15
+ const cfg = await requireLogin();
16
+ if (!cfg.preferences?.capture_artifacts && !opts.dryRun) {
17
+ process.stderr.write(`${kleur.yellow('[warn]')} Capture artifacts is off in your preferences.\n` +
18
+ ` Either enable it with \`adrive privacy --capture-artifacts on\`\n` +
19
+ ` or run a one-off dry-run with \`adrive watch --dry-run\`.\n`);
20
+ process.exit(2);
21
+ }
22
+ const dryRun = !!opts.dryRun;
23
+ const encrypt = !opts.noEncrypt && !!cfg.preferences?.private_workspace;
24
+ const debounceMs = opts.debounce ?? 750;
25
+ const rules = await loadRules(root);
26
+ const client = new CliApiClient({
27
+ baseUrl: cfg.api_url,
28
+ apiKey: cfg.api_key,
29
+ agentPlatform: cfg.agent_platform ?? 'cli',
30
+ agentRole: cfg.agent_role,
31
+ });
32
+ let workspaceId = opts.workspace ?? cfg.default_workspace;
33
+ if (!workspaceId && !dryRun) {
34
+ const list = await client.listWorkspaces();
35
+ if (list.length === 1)
36
+ workspaceId = list[0].id;
37
+ else if (list.length === 0)
38
+ throw new Error('No workspaces. Create one first.');
39
+ else
40
+ throw new Error(`Multiple workspaces — specify --workspace <id>.`);
41
+ }
42
+ process.stdout.write(`${kleur.bold('AgentDrive watch')}\n`);
43
+ process.stdout.write(` root: ${root}\n`);
44
+ process.stdout.write(` workspace: ${workspaceId ?? kleur.dim('(dry-run, none)')}\n`);
45
+ process.stdout.write(` mode: ${dryRun ? kleur.yellow('dry-run — nothing uploads') : kleur.green('live')}\n`);
46
+ process.stdout.write(` encryption: ${encrypt ? kleur.cyan('end-to-end on your device') : 'off'}\n`);
47
+ process.stdout.write(` redactor: on (secrets are scrubbed before anything leaves)\n\n`);
48
+ const queue = new Map();
49
+ let timer;
50
+ async function flush() {
51
+ const pending = [...queue.values()];
52
+ queue.clear();
53
+ for (const item of pending) {
54
+ try {
55
+ const raw = await fs.readFile(item.abs, 'utf8');
56
+ const { redacted, hits } = redact(raw);
57
+ const remotePath = toPosix(item.rel);
58
+ const tag = summarizeHits(hits);
59
+ if (dryRun) {
60
+ process.stdout.write(`${kleur.dim('[dry]')} ${item.reason.padEnd(6)} ${remotePath} ${kleur.dim(`(${raw.length}B, redactor: ${tag})`)}\n`);
61
+ continue;
62
+ }
63
+ const body = encrypt ? await encryptContentAsync(cfg.api_key, redacted) : redacted;
64
+ const message = `adrive watch: ${item.reason}${hits.length ? ` (redacted ${tag})` : ''}`;
65
+ const result = await client.writeFile(workspaceId, remotePath, body, message);
66
+ process.stdout.write(`${kleur.green('[ok]')} ${item.reason.padEnd(6)} ${remotePath} v${result.version}` +
67
+ (encrypt ? kleur.cyan(' (encrypted)') : '') +
68
+ (hits.length ? kleur.yellow(` (redacted: ${tag})`) : '') +
69
+ '\n');
70
+ }
71
+ catch (err) {
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ process.stderr.write(`${kleur.red('[err]')} ${item.rel}: ${msg}\n`);
74
+ }
75
+ }
76
+ }
77
+ function enqueue(absPath, reason) {
78
+ if (shouldIgnore(root, absPath, rules))
79
+ return;
80
+ const rel = relative(root, absPath);
81
+ queue.set(absPath, { abs: absPath, rel, reason });
82
+ if (timer)
83
+ clearTimeout(timer);
84
+ timer = setTimeout(() => {
85
+ void flush();
86
+ }, debounceMs);
87
+ }
88
+ const watcher = chokidarWatch(root, {
89
+ ignoreInitial: false,
90
+ persistent: true,
91
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
92
+ ignored: (p) => shouldIgnore(root, resolve(p), rules),
93
+ });
94
+ watcher.on('add', (p) => enqueue(resolve(p), 'add'));
95
+ watcher.on('change', (p) => enqueue(resolve(p), 'change'));
96
+ watcher.on('error', (err) => {
97
+ process.stderr.write(`${kleur.red('[err]')} watcher: ${String(err)}\n`);
98
+ });
99
+ const shutdown = async () => {
100
+ if (timer)
101
+ clearTimeout(timer);
102
+ await flush();
103
+ await watcher.close();
104
+ process.exit(0);
105
+ };
106
+ process.on('SIGINT', () => void shutdown());
107
+ process.on('SIGTERM', () => void shutdown());
108
+ // Resolve only when the user kills the process.
109
+ await new Promise(() => { });
110
+ }
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ import { cmdRunLog } from './commands/run-log.js';
15
15
  import { cmdBilling } from './commands/billing.js';
16
16
  import { cmdPlan } from './commands/plan.js';
17
17
  import { cmdPrivacy } from './commands/privacy.js';
18
+ import { cmdWatch } from './commands/watch.js';
19
+ import { cmdTranscriptsIngest } from './commands/transcripts.js';
20
+ import { cmdTimeline } from './commands/timeline.js';
18
21
  // Read from the bundled package.json so we never drift again.
19
22
  import { readFileSync } from 'node:fs';
20
23
  import { fileURLToPath } from 'node:url';
@@ -101,10 +104,19 @@ program
101
104
  workspace: opts.workspace,
102
105
  })));
103
106
  program
104
- .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]')
105
115
  .description('Auto-pull relevant artifacts for a task')
106
116
  .option('--limit <n>', 'Max artifacts to return')
107
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')
108
120
  .action(wrap((task, opts) => cmdContext(task, opts)));
109
121
  program
110
122
  .command('run-log <task>')
@@ -127,6 +139,37 @@ program
127
139
  .command('plan')
128
140
  .description('Show current plan, usage, and quotas')
129
141
  .action(wrap(() => cmdPlan()));
142
+ program
143
+ .command('watch [path]')
144
+ .description('Capture files your agents write under the given path (default: cwd)')
145
+ .option('-w, --workspace <id>', 'Workspace id (default: only workspace)')
146
+ .option('--dry-run', 'Print what would be uploaded; never write')
147
+ .option('--no-encrypt', 'Skip end-to-end encryption even when private workspace is on')
148
+ .option('--debounce <ms>', 'Coalesce changes within this window', (v) => Number(v))
149
+ .action(wrap((path, opts) => cmdWatch(path, {
150
+ workspace: opts.workspace,
151
+ dryRun: opts.dryRun,
152
+ noEncrypt: opts.encrypt === false,
153
+ debounce: opts.debounce,
154
+ })));
155
+ const transcripts = program.command('transcripts').description('Ingest chat transcripts from local agent hosts');
156
+ transcripts
157
+ .command('ingest')
158
+ .description('Read recent transcripts, redact secrets, upload to AgentDrive')
159
+ .option('--platform <p>', 'claude-code | codex | cursor | all (default: all)', 'all')
160
+ .option('-w, --workspace <id>', 'Target workspace id')
161
+ .option('--since <date>', 'Ignore transcripts older than this ISO date')
162
+ .option('--limit <n>', 'Cap how many to ingest', (v) => Number(v))
163
+ .option('--dry-run', 'Print what would be uploaded; never write')
164
+ .option('--reingest', 'Re-upload sessions already in the manifest')
165
+ .action(wrap((opts) => cmdTranscriptsIngest({
166
+ platform: opts.platform,
167
+ workspace: opts.workspace,
168
+ since: opts.since,
169
+ limit: opts.limit,
170
+ dryRun: opts.dryRun,
171
+ reingest: opts.reingest,
172
+ })));
130
173
  program
131
174
  .command('privacy')
132
175
  .description('Show or change your privacy + memory preferences')
@@ -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
  }
@@ -1,26 +1,26 @@
1
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync, } from 'node:crypto';
2
- // AgentDrive private-workspace encryption.
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
2
+ import { loadDek } from './keychain.js';
3
+ // AgentDrive private-workspace encryption (v1).
3
4
  //
4
- // Goal: a file uploaded with privacy on is opaque to the server. Even if our
5
- // storage is breached, the bytes are unreadable without the local key.
5
+ // Sync API (`encryptContent` / `decryptContent`) keeps the legacy v0
6
+ // derivation: scrypt(apiKey, fixedSalt). Used by tests + any caller that
7
+ // can't await. The async API (`encryptContentAsync` / `decryptContentAsync`)
8
+ // uses the keychain-stored DEK from lib/keychain.ts — that's the path the
9
+ // real `adrive push` / `adrive pull` take.
6
10
  //
7
- // v0 derives the workspace data key from the API key with a scrypt KDF and a
8
- // fixed salt. That gives a single client-side key per principal — enough to
9
- // protect content from the server, not enough to protect content from a
10
- // compromised API key. v1 moves the master key into the OS keychain and uses
11
- // per-workspace data keys wrapped by it.
12
- const SALT = Buffer.from('agentdrive.privacy.v0', 'utf8');
13
- const KEY_LENGTH = 32; // AES-256
14
- const IV_LENGTH = 12; // GCM standard
15
- const TAG_LENGTH = 16; // GCM standard
11
+ // Why two paths: a published v0.2.x ciphertext on disk should still decrypt
12
+ // after the upgrade. The async path is opt-in and used for new writes.
13
+ const LEGACY_SALT = Buffer.from('agentdrive.privacy.v0', 'utf8');
14
+ const KEY_LENGTH = 32;
15
+ const IV_LENGTH = 12;
16
+ const TAG_LENGTH = 16;
16
17
  const SCRYPT_COST = 1 << 14;
17
18
  const SCRYPT_MAXMEM = 64 * 1024 * 1024;
18
19
  const ENCRYPTED_PREFIX = 'agentdrive-encrypted:v0:';
19
20
  export function deriveWorkspaceKey(apiKey) {
20
- return scryptSync(apiKey, SALT, KEY_LENGTH, { N: SCRYPT_COST, maxmem: SCRYPT_MAXMEM });
21
+ return scryptSync(apiKey, LEGACY_SALT, KEY_LENGTH, { N: SCRYPT_COST, maxmem: SCRYPT_MAXMEM });
21
22
  }
22
- export function encryptContent(apiKey, plaintext) {
23
- const key = deriveWorkspaceKey(apiKey);
23
+ function encryptWithKey(key, plaintext) {
24
24
  const iv = randomBytes(IV_LENGTH);
25
25
  const cipher = createCipheriv('aes-256-gcm', key, iv);
26
26
  const buf = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
@@ -33,10 +33,7 @@ export function encryptContent(apiKey, plaintext) {
33
33
  };
34
34
  return ENCRYPTED_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
35
35
  }
36
- export function isEncrypted(content) {
37
- return typeof content === 'string' && content.startsWith(ENCRYPTED_PREFIX);
38
- }
39
- export function decryptContent(apiKey, encoded) {
36
+ function decryptWithKey(key, encoded) {
40
37
  if (!isEncrypted(encoded)) {
41
38
  throw new Error('Content is not AgentDrive-encrypted.');
42
39
  }
@@ -47,9 +44,39 @@ export function decryptContent(apiKey, encoded) {
47
44
  if (iv.length !== IV_LENGTH || tag.length !== TAG_LENGTH) {
48
45
  throw new Error('Malformed encrypted payload.');
49
46
  }
50
- const key = deriveWorkspaceKey(apiKey);
51
47
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
52
48
  decipher.setAuthTag(tag);
53
49
  const ciphertext = Buffer.from(payload.ciphertext, 'base64');
54
50
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
55
51
  }
52
+ export function encryptContent(apiKey, plaintext) {
53
+ return encryptWithKey(deriveWorkspaceKey(apiKey), plaintext);
54
+ }
55
+ export function decryptContent(apiKey, encoded) {
56
+ if (!isEncrypted(encoded)) {
57
+ throw new Error('Content is not AgentDrive-encrypted.');
58
+ }
59
+ return decryptWithKey(deriveWorkspaceKey(apiKey), encoded);
60
+ }
61
+ export async function encryptContentAsync(apiKey, plaintext) {
62
+ const dek = await loadDek(apiKey);
63
+ return encryptWithKey(dek, plaintext);
64
+ }
65
+ export async function decryptContentAsync(apiKey, encoded) {
66
+ const dek = await loadDek(apiKey);
67
+ try {
68
+ return decryptWithKey(dek, encoded);
69
+ }
70
+ catch (errKeychain) {
71
+ // Fallback for ciphertext written by v0 builds (pre-keychain).
72
+ try {
73
+ return decryptWithKey(deriveWorkspaceKey(apiKey), encoded);
74
+ }
75
+ catch {
76
+ throw errKeychain;
77
+ }
78
+ }
79
+ }
80
+ export function isEncrypted(content) {
81
+ return typeof content === 'string' && content.startsWith(ENCRYPTED_PREFIX);
82
+ }
@@ -0,0 +1,137 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join, relative, sep, posix } from 'node:path';
3
+ // .adriveignore parser. Subset of gitignore semantics: blank lines and lines
4
+ // starting with '#' are skipped; '!' negates a previous pattern; '/' anchors
5
+ // to the project root; '**' matches across directories; '*' matches within a
6
+ // segment.
7
+ //
8
+ // In addition we always layer a default-deny set on top so first-time users
9
+ // don't accidentally upload .env / .ssh / .aws / node_modules even before
10
+ // they edit the file.
11
+ export const DEFAULT_DENY = [
12
+ '.git/',
13
+ 'node_modules/',
14
+ '.next/',
15
+ '.open-next/',
16
+ '.turbo/',
17
+ 'dist/',
18
+ 'build/',
19
+ 'coverage/',
20
+ '.cache/',
21
+ '.DS_Store',
22
+ '.env',
23
+ '.env.*',
24
+ '*.pem',
25
+ '*.key',
26
+ '*.p12',
27
+ '*.pfx',
28
+ '*.crt',
29
+ '.ssh/',
30
+ '.aws/',
31
+ '.gcp/',
32
+ '.kube/',
33
+ '.docker/config.json',
34
+ '.npmrc',
35
+ '.pypirc',
36
+ '.netrc',
37
+ 'id_rsa',
38
+ 'id_ed25519',
39
+ 'credentials.json',
40
+ 'serviceAccount.json',
41
+ '*.sqlite',
42
+ '*.sqlite3',
43
+ '*.db',
44
+ '__pycache__/',
45
+ ];
46
+ function patternToRegex(raw) {
47
+ let pat = raw.trim();
48
+ const negate = pat.startsWith('!');
49
+ if (negate)
50
+ pat = pat.slice(1);
51
+ // Trailing slash means directory-only; treat it as match-anywhere-inside.
52
+ const dirOnly = pat.endsWith('/');
53
+ if (dirOnly)
54
+ pat = pat.slice(0, -1);
55
+ // Anchor to root if leading '/'.
56
+ const anchored = pat.startsWith('/');
57
+ if (anchored)
58
+ pat = pat.slice(1);
59
+ let re = '';
60
+ let i = 0;
61
+ while (i < pat.length) {
62
+ const c = pat[i];
63
+ if (c === '*') {
64
+ // '**' matches anything including slashes; single '*' matches within
65
+ // a path segment only.
66
+ if (pat[i + 1] === '*') {
67
+ re += '.*';
68
+ i += 2;
69
+ if (pat[i] === '/')
70
+ i++;
71
+ }
72
+ else {
73
+ re += '[^/]*';
74
+ i++;
75
+ }
76
+ }
77
+ else if (c === '?') {
78
+ re += '[^/]';
79
+ i++;
80
+ }
81
+ else if ('.+^$()[]{}|\\'.includes(c)) {
82
+ re += '\\' + c;
83
+ i++;
84
+ }
85
+ else {
86
+ re += c;
87
+ i++;
88
+ }
89
+ }
90
+ // dirOnly: match the dir itself or anything under it.
91
+ if (dirOnly)
92
+ re += '(?:/.*)?';
93
+ // anchored = at root only; unanchored = at any depth.
94
+ const final = anchored ? `^${re}$` : `^(?:.*/)?${re}$`;
95
+ return { re: new RegExp(final), negate };
96
+ }
97
+ export function compileRules(lines) {
98
+ const rules = [];
99
+ for (const raw of lines) {
100
+ const line = raw.replace(/^\s+|\s+$/g, '');
101
+ if (!line || line.startsWith('#'))
102
+ continue;
103
+ const { re, negate } = patternToRegex(line);
104
+ rules.push({ pattern: line, negate, re });
105
+ }
106
+ return rules;
107
+ }
108
+ export async function loadRules(root) {
109
+ const candidates = [join(root, '.adriveignore'), join(root, '.gitignore')];
110
+ const lines = [...DEFAULT_DENY];
111
+ for (const c of candidates) {
112
+ try {
113
+ const raw = await fs.readFile(c, 'utf8');
114
+ lines.push(...raw.split(/\r?\n/));
115
+ }
116
+ catch {
117
+ // ignore missing
118
+ }
119
+ }
120
+ return compileRules(lines);
121
+ }
122
+ function normalizeRelative(root, path) {
123
+ const rel = relative(root, path);
124
+ return rel.split(sep).join(posix.sep);
125
+ }
126
+ export function shouldIgnore(root, path, rules) {
127
+ const rel = normalizeRelative(root, path);
128
+ if (!rel || rel.startsWith('..'))
129
+ return true;
130
+ let ignored = false;
131
+ for (const r of rules) {
132
+ if (r.re.test(rel)) {
133
+ ignored = !r.negate;
134
+ }
135
+ }
136
+ return ignored;
137
+ }