@adrive/cli 0.3.0 → 1.1.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/dist/index.js CHANGED
@@ -16,7 +16,10 @@ import { cmdBilling } from './commands/billing.js';
16
16
  import { cmdPlan } from './commands/plan.js';
17
17
  import { cmdPrivacy } from './commands/privacy.js';
18
18
  import { cmdWatch } from './commands/watch.js';
19
- import { cmdTranscriptsIngest } from './commands/transcripts.js';
19
+ import { cmdHook } from './commands/hook.js';
20
+ import { cmdSync } from './commands/sync.js';
21
+ import { cmdResume } from './commands/resume.js';
22
+ import { cmdTimeline } from './commands/timeline.js';
20
23
  // Read from the bundled package.json so we never drift again.
21
24
  import { readFileSync } from 'node:fs';
22
25
  import { fileURLToPath } from 'node:url';
@@ -103,7 +106,14 @@ program
103
106
  workspace: opts.workspace,
104
107
  })));
105
108
  program
106
- .command('context <task>')
109
+ .command('timeline')
110
+ .description('Cross-agent activity stream for your org')
111
+ .option('--today', 'Only events since midnight UTC')
112
+ .option('--since <iso>', 'Only events strictly after this ISO datetime')
113
+ .option('--limit <n>', 'Max events to show (default 200, max 1000)')
114
+ .action(wrap((opts) => cmdTimeline(opts)));
115
+ program
116
+ .command('context [task]')
107
117
  .description('Auto-pull relevant artifacts for a task')
108
118
  .option('--limit <n>', 'Max artifacts to return')
109
119
  .option('-w, --workspace <id>', 'Restrict to one workspace')
@@ -142,24 +152,29 @@ program
142
152
  noEncrypt: opts.encrypt === false,
143
153
  debounce: opts.debounce,
144
154
  })));
145
- const transcripts = program.command('transcripts').description('Ingest chat transcripts from local agent hosts');
146
- transcripts
147
- .command('ingest')
148
- .description('Read recent transcripts, redact secrets, upload to AgentDrive')
149
- .option('--platform <p>', 'Only ingest one platform (default: claude-code)', 'claude-code')
150
- .option('-w, --workspace <id>', 'Target workspace id')
151
- .option('--since <date>', 'Ignore transcripts older than this ISO date')
152
- .option('--limit <n>', 'Cap how many to ingest', (v) => Number(v))
153
- .option('--dry-run', 'Print what would be uploaded; never write')
154
- .option('--reingest', 'Re-upload sessions already in the manifest')
155
- .action(wrap((opts) => cmdTranscriptsIngest({
156
- platform: opts.platform,
157
- workspace: opts.workspace,
158
- since: opts.since,
159
- limit: opts.limit,
160
- dryRun: opts.dryRun,
161
- reingest: opts.reingest,
162
- })));
155
+ program
156
+ .command('hook <harness>')
157
+ .description('Lifecycle hook entry for claude-code | codex | opencode | gemini-cli | cursor (reads the event JSON on stdin)')
158
+ .action((harness) => cmdHook(harness));
159
+ program
160
+ .command('sync')
161
+ .description('Upload agent sessions and the untracked files they wrote to AgentDrive')
162
+ .option('--harness <h>', 'claude-code | codex | opencode | gemini-cli | cursor')
163
+ .option('--session <id>', 'Sync this harness session (with --harness)')
164
+ .option('--transcript <path>', 'Transcript file of that session')
165
+ .option('--cwd <dir>', 'Session working directory, used when the transcript has none')
166
+ .option('--force', 'Ignore the debounce and mark the named session finished')
167
+ .option('--start', 'Session-start sync: always sweep for recent sessions')
168
+ .option('--all', 'Sweep full history instead of changes since the last sweep')
169
+ .option('--quiet', 'Log to sync.log in the config dir instead of stdout')
170
+ .option('--dry-run', 'Parse and report; never upload')
171
+ .action(wrap((opts) => cmdSync(opts)));
172
+ program
173
+ .command('resume [id]')
174
+ .description('Print a handoff for a prior session of this project (latest if no id)')
175
+ .option('--list', 'List recent sessions for this project')
176
+ .option('--max-chars <n>', 'Handoff size budget in characters', (v) => Number(v))
177
+ .action(wrap((id, opts) => cmdResume(id, { list: opts.list, maxChars: opts.maxChars })));
163
178
  program
164
179
  .command('privacy')
165
180
  .description('Show or change your privacy + memory preferences')
@@ -47,12 +47,13 @@ export class CliApiClient {
47
47
  }
48
48
  return json.data;
49
49
  }
50
- async requestPage(method, path, body) {
50
+ async requestPage(method, path, body, signal) {
51
51
  const url = `${this.baseUrl}/v1${path}`;
52
52
  const res = await fetch(url, {
53
53
  method,
54
54
  headers: this.headers(true),
55
55
  body: body ? JSON.stringify(body) : undefined,
56
+ signal,
56
57
  });
57
58
  const json = await res.json();
58
59
  if (!res.ok || !json.success) {
@@ -94,6 +95,9 @@ export class CliApiClient {
94
95
  }
95
96
  return all;
96
97
  }
98
+ async createWorkspace(name) {
99
+ return this.request('POST', '/workspaces', { name });
100
+ }
97
101
  async writeFile(workspaceId, path, content, message) {
98
102
  return this.request('POST', `/workspaces/${workspaceId}/files`, { path, content, message });
99
103
  }
@@ -113,10 +117,37 @@ export class CliApiClient {
113
117
  async logRun(body) {
114
118
  return this.request('POST', '/runs', body);
115
119
  }
120
+ async listRuns(query) {
121
+ const params = new URLSearchParams({ project: query.project });
122
+ if (query.platform)
123
+ params.set('platform', query.platform);
124
+ if (query.limit)
125
+ params.set('limit', String(query.limit));
126
+ const signal = query.timeoutMs ? AbortSignal.timeout(query.timeoutMs) : undefined;
127
+ return (await this.requestPage('GET', `/runs?${params.toString()}`, undefined, signal)).items;
128
+ }
129
+ async getRun(runId) {
130
+ return this.request('GET', `/runs/${encodeURIComponent(runId)}`);
131
+ }
116
132
  async billingPortal() {
117
133
  return this.request('POST', '/billing/portal', {});
118
134
  }
119
135
  async billingPlan() {
120
136
  return this.request('GET', '/billing/plan');
121
137
  }
138
+ async timeline(opts = {}) {
139
+ const params = new URLSearchParams();
140
+ if (opts.since)
141
+ params.set('since', opts.since);
142
+ if (typeof opts.limit === 'number')
143
+ params.set('limit', String(opts.limit));
144
+ const query = params.toString() ? `?${params.toString()}` : '';
145
+ const url = `${this.baseUrl}/v1/org/timeline${query}`;
146
+ const res = await fetch(url, { method: 'GET', headers: this.headers(true) });
147
+ const json = (await res.json());
148
+ if (!res.ok || !json.success) {
149
+ throw new Error(json.error ?? `API request failed (${res.status})`);
150
+ }
151
+ return { items: (json.data ?? []), truncated: !!json.truncated };
152
+ }
122
153
  }
@@ -1,8 +1,9 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
3
+ import { join } from 'node:path';
4
+ import { readTextIfExists, writeFileIfChanged } from './write-file.js';
4
5
  // Small instructions block written into each agent host's persistent
5
- // preamble (CLAUDE.md, AGENTS.md, cursor rules). Tells the agent when to
6
+ // preamble (AGENTS.md, GEMINI.md, cursor rules). Tells the agent when to
6
7
  // reach for the AgentDrive MCP tools without the user having to ask each
7
8
  // time. Idempotent: bracketed with markers so re-running adrive init
8
9
  // updates the same region instead of stacking copies.
@@ -11,31 +12,30 @@ const END = '<!-- agentdrive:auto-context:end -->';
11
12
  const INSTRUCTIONS = `${BEGIN}
12
13
  ## AgentDrive — your cross-agent memory
13
14
 
14
- You have AgentDrive tools available via MCP. Use them like this without
15
- waiting to be asked:
15
+ AgentDrive records the user's agent sessions across Claude Code, Codex,
16
+ OpenCode, Gemini CLI, and Cursor. Use its MCP tools without waiting to be
17
+ asked:
16
18
 
17
- - **Session start** — call \`drive_context\` with a short summary of the
18
- task. It returns the most relevant artifacts and run history from your
19
- own and other agents' prior work in the same workspace.
20
- - **Saving work** — after producing any file or artifact worth keeping,
21
- call \`drive_write\` so the user's other agents can see it. The user
22
- has opted into automatic capture if they want everything saved.
23
- - **Recall** — when the user asks "what did the last session do?",
24
- "show me what Codex wrote", or anything cross-session, call
25
- \`drive_search\` (semantic search) or \`drive_recall\` (extracted
26
- facts).
27
- - **Run logging** — at the end of a meaningful session, call
28
- \`drive_log_run\` so the next agent in the chain can see what you did.
19
+ - **Session start** — if the user wants to continue earlier work, call
20
+ \`drive_resume\` (or \`drive_sessions\` to pick a session) and check the
21
+ files it mentions on disk before continuing. For a new task, call
22
+ \`drive_context\` with a short summary of the task.
23
+ - **Recall** — for "what did the last session do?" or "show me what Codex
24
+ wrote", call \`drive_sessions\`, \`drive_search\`, or \`drive_recall\`.
25
+ - **Saving work** — after producing a file worth sharing with the user's
26
+ other agents, call \`drive_write\`.
29
27
 
30
- Privacy: the user controls capture and encryption. If they have
31
- "Private workspace" on, written content is end-to-end encrypted on
32
- their device; the AgentDrive server stores only ciphertext.
28
+ Privacy: the user controls capture and encryption with \`adrive privacy\`.
29
+ With "Private workspace" on, content is end-to-end encrypted on their
30
+ device; the AgentDrive server stores only ciphertext.
33
31
  ${END}`;
34
32
  export function getInstructionsTargets(home = homedir()) {
33
+ const opencode = join(home, '.config', 'opencode');
35
34
  return [
36
- { path: join(home, '.claude', 'CLAUDE.md'), format: 'markdown' },
37
- { path: join(home, '.codex', 'AGENTS.md'), format: 'markdown' },
38
- { path: join(home, '.cursor', 'rules', 'agentdrive.mdc'), format: 'cursor-mdc' },
35
+ { root: join(home, '.codex'), path: join(home, '.codex', 'AGENTS.md'), format: 'markdown' },
36
+ { root: opencode, path: join(opencode, 'AGENTS.md'), format: 'markdown' },
37
+ { root: join(home, '.gemini'), path: join(home, '.gemini', 'GEMINI.md'), format: 'markdown' },
38
+ { root: join(home, '.cursor'), path: join(home, '.cursor', 'rules', 'agentdrive.mdc'), format: 'cursor-mdc' },
39
39
  ];
40
40
  }
41
41
  function wrap(format, body) {
@@ -45,50 +45,33 @@ function wrap(format, body) {
45
45
  }
46
46
  return `\n${body}\n`;
47
47
  }
48
- async function fileExists(p) {
48
+ async function dirExists(p) {
49
49
  try {
50
- await fs.access(p);
51
- return true;
50
+ return (await fs.stat(p)).isDirectory();
52
51
  }
53
52
  catch {
54
53
  return false;
55
54
  }
56
55
  }
57
- async function ensureDir(p) {
58
- await fs.mkdir(dirname(p), { recursive: true });
59
- }
60
56
  export async function writeInstructions(target) {
61
- const block = wrap(target.format, INSTRUCTIONS);
62
- const existed = await fileExists(target.path);
63
- if (!existed) {
64
- await ensureDir(target.path);
65
- await fs.writeFile(target.path, block, 'utf8');
66
- return { path: target.path, action: 'created' };
57
+ const current = await readTextIfExists(target.path);
58
+ let next;
59
+ if (current === null) {
60
+ next = wrap(target.format, INSTRUCTIONS);
61
+ }
62
+ else if (current.includes(BEGIN) && current.includes(END)) {
63
+ next = current.slice(0, current.indexOf(BEGIN)) + INSTRUCTIONS + current.slice(current.indexOf(END) + END.length);
67
64
  }
68
- const current = await fs.readFile(target.path, 'utf8');
69
- if (current.includes(BEGIN) && current.includes(END)) {
70
- const beginIdx = current.indexOf(BEGIN);
71
- const endIdx = current.indexOf(END) + END.length;
72
- const next = current.slice(0, beginIdx) + INSTRUCTIONS + current.slice(endIdx);
73
- if (next === current)
74
- return { path: target.path, action: 'unchanged' };
75
- await fs.writeFile(target.path, next, 'utf8');
76
- return { path: target.path, action: 'updated' };
65
+ else {
66
+ next = current.trimEnd() + '\n' + wrap(target.format, INSTRUCTIONS);
77
67
  }
78
- await fs.writeFile(target.path, current.trimEnd() + '\n' + block, 'utf8');
79
- return { path: target.path, action: 'updated' };
68
+ return { path: target.path, action: await writeFileIfChanged(target.path, next, { backup: true }) };
80
69
  }
81
70
  export async function writeAllInstructions(home = homedir()) {
82
- const targets = getInstructionsTargets(home);
83
71
  const out = [];
84
- for (const t of targets) {
85
- try {
72
+ for (const t of getInstructionsTargets(home)) {
73
+ if (await dirExists(t.root))
86
74
  out.push(await writeInstructions(t));
87
- }
88
- catch {
89
- // Best-effort across hosts. A missing parent dir for an
90
- // agent the user hasn't installed is fine to skip silently.
91
- }
92
75
  }
93
76
  return out;
94
77
  }
@@ -31,6 +31,7 @@ export async function readConfig(path = configPath()) {
31
31
  agent_role: parsed.agent_role,
32
32
  default_workspace: parsed.default_workspace,
33
33
  preferences: { ...DEFAULT_PREFERENCES, ...(parsed.preferences ?? {}) },
34
+ capture_notice_at: parsed.capture_notice_at,
34
35
  };
35
36
  }
36
37
  catch (err) {
@@ -40,6 +41,17 @@ export async function readConfig(path = configPath()) {
40
41
  throw err;
41
42
  }
42
43
  }
44
+ // readConfig fills in default preferences, so this reads the raw file to tell
45
+ // a fresh install apart from one where the user already chose.
46
+ export async function hasSavedPreferences(path = configPath()) {
47
+ try {
48
+ const parsed = JSON.parse(await fs.readFile(path, 'utf8'));
49
+ return !!parsed?.preferences && typeof parsed.preferences === 'object';
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
43
55
  export async function writeConfig(config, path = configPath()) {
44
56
  await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
45
57
  const payload = JSON.stringify(config, null, 2);
@@ -0,0 +1,60 @@
1
+ import prompts from 'prompts';
2
+ import kleur from 'kleur';
3
+ import { DEFAULT_PREFERENCES } from './config.js';
4
+ function notice(on) {
5
+ return `
6
+ ${kleur.bold(`AgentDrive capture is ${on ? 'on' : 'off'}`)}
7
+ - ${on ? 'Captured' : 'When on, captures'}: your agent session transcripts, and files your agents write that git doesn't track.
8
+ - Secrets are redacted on this device before anything is uploaded.
9
+ - Turn on "Private workspace" to encrypt everything end-to-end on your device.
10
+ - ${on ? 'Change or turn it off anytime' : 'Turn it on'}: ${kleur.bold('adrive privacy')}
11
+ `;
12
+ }
13
+ // `preferencesSaved` is whether the on-disk config already had a preferences
14
+ // block: only a fresh install gets capture switched on, so a choice made before
15
+ // the notice existed is never overridden.
16
+ export async function ensureCaptureConsent(config, client, opts) {
17
+ const next = { ...config };
18
+ if (!next.capture_notice_at) {
19
+ if (!opts.preferencesSaved) {
20
+ next.preferences = { ...DEFAULT_PREFERENCES, ...(next.preferences ?? {}), capture_transcripts: true, capture_artifacts: true };
21
+ }
22
+ const prefs = next.preferences;
23
+ process.stdout.write(notice(!!prefs?.capture_transcripts || !!prefs?.capture_artifacts));
24
+ next.capture_notice_at = (opts.now ?? (() => new Date()))().toISOString();
25
+ }
26
+ if (!next.default_workspace) {
27
+ try {
28
+ next.default_workspace = await pickDefaultWorkspace(client, opts.interactive);
29
+ }
30
+ catch (err) {
31
+ // Login already succeeded; a workspace can still be chosen later with --workspace.
32
+ const msg = err instanceof Error ? err.message : String(err);
33
+ process.stdout.write(`${kleur.yellow('[warn]')} could not set a default workspace: ${msg}\n`);
34
+ }
35
+ }
36
+ return next;
37
+ }
38
+ async function pickDefaultWorkspace(client, interactive) {
39
+ const workspaces = await client.listWorkspaces();
40
+ if (workspaces.length === 0) {
41
+ const ws = await client.createWorkspace('Personal');
42
+ process.stdout.write(`Created workspace ${kleur.bold(ws.name)} (${ws.id}) as your default.\n`);
43
+ return ws.id;
44
+ }
45
+ if (workspaces.length === 1)
46
+ return workspaces[0].id;
47
+ if (interactive) {
48
+ const res = (await prompts({
49
+ type: 'select',
50
+ name: 'ws',
51
+ message: 'Default workspace for captured sessions',
52
+ choices: workspaces.map((w) => ({ title: `${w.name} (${w.id})`, value: w.id })),
53
+ }));
54
+ if (res.ws)
55
+ return res.ws;
56
+ }
57
+ const first = workspaces[0];
58
+ process.stdout.write(`Using ${kleur.bold(first.name)} (${first.id}) as your default workspace. Change it in ~/.config/agentdrive/config.json.\n`);
59
+ return first.id;
60
+ }
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { homedir, platform } from 'node:os';
3
- import { dirname, join } from 'node:path';
3
+ import { join } from 'node:path';
4
+ import { readJsonObject, writeJsonIfChanged } from './write-file.js';
4
5
  export function getAgentTargets(opts = {}) {
5
6
  const env = opts.env ?? process.env;
6
7
  const home = opts.home ?? homedir();
@@ -11,43 +12,23 @@ export function getAgentTargets(opts = {}) {
11
12
  ? join(env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json')
12
13
  : join(home, '.config', 'Claude', 'claude_desktop_config.json');
13
14
  return [
14
- {
15
- name: 'claude-code',
16
- configPath: join(home, '.claude', 'mcp-servers.json'),
17
- format: 'mcp-json',
18
- defaultPlatform: 'claude-code',
19
- },
20
15
  {
21
16
  name: 'claude-desktop',
22
17
  configPath: claudeDesktop,
23
18
  format: 'mcp-json',
24
- defaultPlatform: 'claude-code',
25
- },
26
- {
27
- name: 'codex',
28
- configPath: join(home, '.codex', 'config.toml'),
29
- format: 'codex-toml',
30
- defaultPlatform: 'codex',
31
- },
32
- {
33
- name: 'cursor',
34
- configPath: join(home, '.cursor', 'mcp.json'),
35
- format: 'cursor-json',
36
- defaultPlatform: 'cursor',
19
+ platform: 'claude-code',
37
20
  },
38
21
  {
39
22
  name: 'continue',
40
23
  configPath: join(home, '.continue', 'config.json'),
41
24
  format: 'continue-json',
42
- defaultPlatform: 'continue',
25
+ platform: 'continue',
43
26
  },
44
27
  {
45
28
  name: 'zed',
46
- configPath: os === 'darwin'
47
- ? join(home, '.config', 'zed', 'settings.json')
48
- : join(home, '.config', 'zed', 'settings.json'),
29
+ configPath: join(home, '.config', 'zed', 'settings.json'),
49
30
  format: 'zed-json',
50
- defaultPlatform: 'zed',
31
+ platform: 'zed',
51
32
  },
52
33
  {
53
34
  name: 'cline',
@@ -55,7 +36,7 @@ export function getAgentTargets(opts = {}) {
55
36
  ? join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json')
56
37
  : join(home, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'),
57
38
  format: 'mcp-json',
58
- defaultPlatform: 'cline',
39
+ platform: 'cline',
59
40
  },
60
41
  ];
61
42
  }
@@ -68,156 +49,95 @@ async function fileExists(path) {
68
49
  return false;
69
50
  }
70
51
  }
71
- async function readJsonSafe(path) {
72
- try {
73
- const raw = await fs.readFile(path, 'utf8');
74
- if (!raw.trim())
75
- return {};
76
- return JSON.parse(raw);
77
- }
78
- catch (err) {
79
- const e = err;
80
- if (e.code === 'ENOENT')
81
- return null;
82
- return null;
83
- }
84
- }
85
- function mcpEntry(env, platformOverride) {
86
- const envBlock = {
87
- AGENTDRIVE_API_KEY: env.apiKey,
88
- };
89
- if (env.apiUrl)
90
- envBlock.AGENTDRIVE_API_URL = env.apiUrl;
91
- const plat = env.agentPlatform ?? platformOverride;
92
- if (plat)
93
- envBlock.AGENTDRIVE_AGENT_PLATFORM = plat;
52
+ // No API key here: the MCP server reads it from ~/.config/agentdrive/config.json,
53
+ // so harness configs never hold a secret.
54
+ export function mcpEntry(agentPlatform) {
94
55
  return {
95
56
  command: 'npx',
96
57
  args: ['-y', '@adrive/mcp-server'],
97
- env: envBlock,
58
+ env: { AGENTDRIVE_AGENT_PLATFORM: agentPlatform },
98
59
  };
99
60
  }
100
- export function mergeMcpJson(existing, env, platformOverride) {
101
- const base = existing && typeof existing === 'object' ? { ...existing } : {};
102
- const serversRaw = base.mcpServers;
103
- const servers = serversRaw && typeof serversRaw === 'object' && !Array.isArray(serversRaw)
104
- ? { ...serversRaw }
105
- : {};
106
- servers.agentdrive = mcpEntry(env, platformOverride);
61
+ function asObject(v) {
62
+ return v && typeof v === 'object' && !Array.isArray(v) ? { ...v } : {};
63
+ }
64
+ export function mergeMcpJson(existing, agentPlatform) {
65
+ const base = asObject(existing);
66
+ const servers = asObject(base.mcpServers);
67
+ servers.agentdrive = mcpEntry(agentPlatform);
107
68
  base.mcpServers = servers;
108
69
  return base;
109
70
  }
110
- export function mergeCursorJson(existing, env) {
111
- return mergeMcpJson(existing, env, 'cursor');
112
- }
113
- export function mergeContinueJson(existing, env) {
114
- const base = existing && typeof existing === 'object' ? { ...existing } : {};
115
- const rawList = base.mcpServers;
116
- const list = Array.isArray(rawList) ? [...rawList] : [];
117
- const filtered = list.filter((s) => {
118
- if (!s || typeof s !== 'object')
119
- return true;
120
- return s.name !== 'agentdrive';
121
- });
122
- filtered.push({
123
- name: 'agentdrive',
124
- ...mcpEntry(env, 'continue'),
125
- });
71
+ export function mergeContinueJson(existing) {
72
+ const base = asObject(existing);
73
+ const list = Array.isArray(base.mcpServers) ? base.mcpServers : [];
74
+ const filtered = list.filter((s) => asObject(s).name !== 'agentdrive');
75
+ filtered.push({ name: 'agentdrive', ...mcpEntry('continue') });
126
76
  base.mcpServers = filtered;
127
77
  return base;
128
78
  }
129
- export function mergeZedJson(existing, env) {
130
- const base = existing && typeof existing === 'object' ? { ...existing } : {};
131
- const ctxRaw = base.context_servers;
132
- const ctx = ctxRaw && typeof ctxRaw === 'object' && !Array.isArray(ctxRaw)
133
- ? { ...ctxRaw }
134
- : {};
135
- ctx.agentdrive = mcpEntry(env, 'zed');
79
+ export function mergeZedJson(existing) {
80
+ const base = asObject(existing);
81
+ const ctx = asObject(base.context_servers);
82
+ ctx.agentdrive = mcpEntry('zed');
136
83
  base.context_servers = ctx;
137
84
  return base;
138
85
  }
139
- export function mergeCodexToml(existing, env) {
140
- const blockHeader = '[[mcp_servers]]';
141
- const target = 'name = "agentdrive"';
142
- const apiKey = env.apiKey.replace(/"/g, '\\"');
143
- const apiUrlLine = env.apiUrl ? `, AGENTDRIVE_API_URL = "${env.apiUrl}"` : '';
144
- const newBlock = `${blockHeader}\nname = "agentdrive"\ncommand = "npx"\nargs = ["-y", "@adrive/mcp-server"]\n` +
145
- `env = { AGENTDRIVE_API_KEY = "${apiKey}", AGENTDRIVE_AGENT_PLATFORM = "codex"${apiUrlLine} }\n`;
146
- const trimmed = existing.trimEnd();
147
- if (!trimmed)
148
- return newBlock;
149
- if (!trimmed.includes(target)) {
150
- return `${trimmed}\n\n${newBlock}`;
86
+ export function mergeOpencodeJson(existing) {
87
+ const base = asObject(existing);
88
+ const mcp = asObject(base.mcp);
89
+ mcp.agentdrive = {
90
+ type: 'local',
91
+ command: ['npx', '-y', '@adrive/mcp-server'],
92
+ environment: { AGENTDRIVE_AGENT_PLATFORM: 'opencode' },
93
+ enabled: true,
94
+ };
95
+ base.mcp = mcp;
96
+ return base;
97
+ }
98
+ const CODEX_BLOCK = '[mcp_servers.agentdrive]\ncommand = "npx"\nargs = ["-y", "@adrive/mcp-server"]\n\n' +
99
+ '[mcp_servers.agentdrive.env]\nAGENTDRIVE_AGENT_PLATFORM = "codex"\n';
100
+ // Line-based so every table we don't own keeps its exact text. Drops our
101
+ // [mcp_servers.agentdrive*] tables plus the [[mcp_servers]] name="agentdrive"
102
+ // array entry older CLIs wrote, which would make the file invalid TOML next
103
+ // to a [mcp_servers.*] table.
104
+ export function mergeCodexToml(existing) {
105
+ const sections = [];
106
+ for (const line of existing.split(/(?<=\n)/)) {
107
+ if (sections.length === 0 || /^\s*\[/.test(line))
108
+ sections.push(line);
109
+ else
110
+ sections[sections.length - 1] += line;
151
111
  }
152
- // Replace the existing agentdrive block in place: scan blocks, swap the matching one.
153
- const blocks = trimmed.split(/(?=^\[\[mcp_servers\]\]$)/m);
154
- const updated = blocks.map((block) => {
155
- if (block.includes(target))
156
- return newBlock.trimEnd();
157
- return block.trimEnd();
112
+ const kept = sections.filter((section, i) => {
113
+ const header = section.split('\n', 1)[0].trim();
114
+ if (i === 0 && !header.startsWith('['))
115
+ return true;
116
+ if (/^\[mcp_servers\.agentdrive(\.[^\]]+)?\]$/.test(header))
117
+ return false;
118
+ if (header === '[[mcp_servers]]' && /^\s*name\s*=\s*"agentdrive"\s*$/m.test(section))
119
+ return false;
120
+ return true;
158
121
  });
159
- return `${updated.join('\n\n')}\n`;
122
+ const rest = kept.join('').trimEnd();
123
+ return rest ? `${rest}\n\n${CODEX_BLOCK}` : CODEX_BLOCK;
160
124
  }
161
- export async function wireAgent(target, env, opts = {}) {
162
- const exists = await fileExists(target.configPath);
163
- if (!exists && !opts.writeIfMissing) {
125
+ export async function wireAgent(target) {
126
+ const existing = await readJsonObject(target.configPath);
127
+ if (existing === null)
164
128
  throw new Error(`Config not found: ${target.configPath}`);
165
- }
166
- await fs.mkdir(dirname(target.configPath), { recursive: true });
167
- if (target.format === 'codex-toml') {
168
- const existing = exists ? await fs.readFile(target.configPath, 'utf8') : '';
169
- const merged = mergeCodexToml(existing, env);
170
- await fs.writeFile(target.configPath, merged, 'utf8');
171
- return;
172
- }
173
- const existing = exists ? await readJsonSafe(target.configPath) : {};
174
- let merged;
175
- switch (target.format) {
176
- case 'mcp-json':
177
- merged = mergeMcpJson(existing, env, target.defaultPlatform);
178
- break;
179
- case 'cursor-json':
180
- merged = mergeCursorJson(existing, env);
181
- break;
182
- case 'continue-json':
183
- merged = mergeContinueJson(existing, env);
184
- break;
185
- case 'zed-json':
186
- merged = mergeZedJson(existing, env);
187
- break;
188
- default:
189
- throw new Error(`Unknown format: ${String(target.format)}`);
190
- }
191
- await fs.writeFile(target.configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
129
+ const merged = target.format === 'continue-json'
130
+ ? mergeContinueJson(existing)
131
+ : target.format === 'zed-json'
132
+ ? mergeZedJson(existing)
133
+ : mergeMcpJson(existing, target.platform);
134
+ return writeJsonIfChanged(target.configPath, merged);
192
135
  }
193
136
  export async function detectInstalledAgents(opts = {}) {
194
- const targets = getAgentTargets(opts);
195
137
  const present = [];
196
- for (const t of targets) {
138
+ for (const t of getAgentTargets(opts)) {
197
139
  if (await fileExists(t.configPath))
198
140
  present.push(t);
199
141
  }
200
142
  return present;
201
143
  }
202
- export async function wireAllInstalled(env, opts = {}) {
203
- const all = getAgentTargets(opts);
204
- const wired = [];
205
- const skipped = [];
206
- for (const t of all) {
207
- const exists = await fileExists(t.configPath);
208
- const forceWrite = opts.includeMissing?.includes(t.name) ?? false;
209
- if (!exists && !forceWrite) {
210
- skipped.push({ name: t.name, reason: 'config not found' });
211
- continue;
212
- }
213
- try {
214
- await wireAgent(t, env, { writeIfMissing: forceWrite });
215
- wired.push(t.name);
216
- }
217
- catch (err) {
218
- const msg = err instanceof Error ? err.message : String(err);
219
- skipped.push({ name: t.name, reason: msg });
220
- }
221
- }
222
- return { wired, skipped };
223
- }