@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.
@@ -0,0 +1,63 @@
1
+ const ARGS = '{{ARGS}}';
2
+ const TEMPLATES = {
3
+ resume: {
4
+ description: 'Resume an AgentDrive session from any agent harness',
5
+ argumentHint: '[session-id]',
6
+ body: `Resume earlier agent work from AgentDrive.
7
+
8
+ 1. Call the \`drive_resume\` MCP tool. If a session id was given (${ARGS}), pass it as \`session_id\`; otherwise call it with no arguments to get the most recent session for this project.
9
+ 2. Summarize the handoff for the user in 3-5 bullets: the goal, what is done, what is left, and any open questions.
10
+ 3. Before relying on the handoff, check the current state on disk of the files it mentions; the work may have changed since that session ended.
11
+ 4. Continue the work from where it stopped.
12
+
13
+ If the \`drive_resume\` tool is not available, run \`adrive resume\` in the shell (add the session id if one was given) and use its output as the handoff.
14
+ `,
15
+ },
16
+ sessions: {
17
+ description: 'List recent AgentDrive sessions across agent harnesses',
18
+ argumentHint: '[platform or project]',
19
+ body: `List recent agent sessions captured by AgentDrive.
20
+
21
+ Call the \`drive_sessions\` MCP tool. If a filter was given (${ARGS}), pass a harness name (claude-code, codex, opencode, gemini-cli, cursor) as \`platform\` and anything else as \`project\`.
22
+
23
+ Show the result as a compact list: session id, harness, project, when it was last active, and a one-line summary. Then tell the user they can pick one up with the AgentDrive resume command and its session id.
24
+
25
+ If the \`drive_sessions\` tool is not available, run \`adrive resume --list\` in the shell instead.
26
+ `,
27
+ },
28
+ login: {
29
+ description: 'Log in to AgentDrive and connect your other agent harnesses',
30
+ body: `Log the user in to AgentDrive and connect their other agent tools.
31
+
32
+ 1. Run \`npx -y @adrive/cli auth login\` in the shell. It opens a browser for sign-in and waits until it completes; tell the user to finish signing in there.
33
+ 2. When it succeeds, run \`npx -y @adrive/cli init --yes\` to wire AgentDrive into Codex, OpenCode, Gemini CLI, and Cursor where they are installed.
34
+ 3. Relay init's summary to the user, including any one-time step it asks for (such as approving the hooks in Codex via /hooks).
35
+ `,
36
+ },
37
+ };
38
+ // Login is plugin-only: init itself is what wires the other harnesses, so it
39
+ // has already logged in by the time their commands exist.
40
+ export const HARNESS_COMMANDS = ['resume', 'sessions'];
41
+ // Codex prompts, OpenCode, and Cursor commands share a flat namespace with
42
+ // built-ins such as /resume and /sessions, so they get a prefix.
43
+ export function commandFileName(harness, name) {
44
+ if (harness === 'gemini-cli')
45
+ return `${name}.toml`;
46
+ if (harness === 'claude-code')
47
+ return `${name}.md`;
48
+ return `agentdrive-${name}.md`;
49
+ }
50
+ export function renderCommand(harness, name) {
51
+ const t = TEMPLATES[name];
52
+ if (harness === 'gemini-cli') {
53
+ return `description = ${JSON.stringify(t.description)}\nprompt = """\n${t.body.replace(ARGS, '{{args}}')}"""\n`;
54
+ }
55
+ if (harness === 'cursor') {
56
+ return t.body.replace(ARGS, 'the text after the command, if any');
57
+ }
58
+ const body = t.body.replace(ARGS, '$ARGUMENTS');
59
+ const front = [`description: ${t.description}`];
60
+ if (harness !== 'opencode' && t.argumentHint)
61
+ front.push(`argument-hint: "${t.argumentHint}"`);
62
+ return `---\n${front.join('\n')}\n---\n\n${body}`;
63
+ }
@@ -0,0 +1,193 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { mergeCodexToml, mergeMcpJson, mergeOpencodeJson } from './detect-agents.js';
5
+ import { HARNESS_COMMANDS, commandFileName, renderCommand } from './harness-commands.js';
6
+ import { SKILL_MARKDOWN } from './skill.js';
7
+ import { readJsonObject, readTextIfExists, writeFileIfChanged, writeJsonIfChanged, } from './write-file.js';
8
+ export const HOOK_HARNESSES = ['codex', 'opencode', 'gemini-cli', 'cursor'];
9
+ export const HARNESS_LABELS = {
10
+ codex: 'Codex',
11
+ opencode: 'OpenCode',
12
+ 'gemini-cli': 'Gemini CLI',
13
+ cursor: 'Cursor',
14
+ };
15
+ export const CODEX_TRUST_NOTE = 'Codex: open Codex and approve the AgentDrive hooks via /hooks.';
16
+ // Same form as plugins/agentdrive/hooks/hooks.json: GUI-launched harnesses can
17
+ // miss the global npm bin on PATH, so fall back to npx rather than fail.
18
+ export function hookCommand(harness) {
19
+ return `command -v adrive >/dev/null 2>&1 && adrive hook ${harness} || npx -y @adrive/cli hook ${harness}`;
20
+ }
21
+ function asObject(v) {
22
+ return v && typeof v === 'object' && !Array.isArray(v) ? { ...v } : {};
23
+ }
24
+ function isOurCommand(command) {
25
+ return typeof command === 'string' && command.includes('adrive hook');
26
+ }
27
+ // Replaces our entry where it already sits (dropping duplicates) or appends
28
+ // it, so foreign hooks keep their order and a re-run is byte-identical.
29
+ function upsert(list, desired, isOurs) {
30
+ const entries = Array.isArray(list) ? list : [];
31
+ const first = entries.findIndex((e) => isOurs(asObject(e)));
32
+ if (first === -1)
33
+ return [...entries, desired];
34
+ return entries.flatMap((e, i) => (i === first ? [desired] : isOurs(asObject(e)) ? [] : [e]));
35
+ }
36
+ function groupIsOurs(group) {
37
+ return Array.isArray(group.hooks) && group.hooks.some((h) => isOurCommand(asObject(h).command));
38
+ }
39
+ // Codex and Gemini CLI use Claude Code's {event: [{matcher?, hooks: [handler]}]} shape.
40
+ export function mergeGroupedHooks(existing, groups) {
41
+ const hooks = asObject(existing.hooks);
42
+ for (const [event, group] of Object.entries(groups)) {
43
+ hooks[event] = upsert(hooks[event], group, groupIsOurs);
44
+ }
45
+ return { ...existing, hooks };
46
+ }
47
+ export function codexHookGroups() {
48
+ const group = { hooks: [{ type: 'command', command: hookCommand('codex'), timeout: 10 }] };
49
+ return { SessionStart: group, Stop: group, SessionEnd: group, PreCompact: group };
50
+ }
51
+ export function geminiHookGroups() {
52
+ // Gemini CLI timeouts are milliseconds.
53
+ const group = {
54
+ matcher: '*',
55
+ hooks: [{ type: 'command', command: hookCommand('gemini-cli'), timeout: 10000, name: 'agentdrive' }],
56
+ };
57
+ return { SessionStart: group, AfterAgent: group, SessionEnd: group, PreCompress: group };
58
+ }
59
+ export function mergeCursorHooks(existing) {
60
+ const hooks = asObject(existing.hooks);
61
+ const entry = { command: hookCommand('cursor'), timeout: 10 };
62
+ for (const event of ['sessionStart', 'stop', 'sessionEnd', 'preCompact']) {
63
+ hooks[event] = upsert(hooks[event], entry, (e) => isOurCommand(e.command));
64
+ }
65
+ return { version: 1, ...existing, hooks };
66
+ }
67
+ // Loaded by OpenCode's Bun runtime from ~/.config/opencode/plugins/. It hands
68
+ // the session to `adrive hook opencode` and never waits on it or surfaces errors,
69
+ // so a missing or slow adrive cannot stall OpenCode.
70
+ export const OPENCODE_PLUGIN = `// Generated by \`adrive init\`. Re-run init to update; edits are overwritten.
71
+ import { spawn } from 'node:child_process';
72
+
73
+ function send(payload: Record<string, unknown>): void {
74
+ try {
75
+ const child = spawn('adrive', ['hook', 'opencode'], { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
76
+ child.on('error', () => {});
77
+ child.stdin?.on('error', () => {});
78
+ child.stdin?.end(JSON.stringify(payload));
79
+ child.unref();
80
+ } catch {}
81
+ }
82
+
83
+ interface OpencodeEvent {
84
+ type: string;
85
+ properties?: { info?: { id?: string; parentID?: string; directory?: string }; sessionID?: string };
86
+ }
87
+
88
+ export const AgentDrive = async ({ directory }: { directory: string }) => ({
89
+ event: async ({ event }: { event: OpencodeEvent }) => {
90
+ if (event.type === 'session.created') {
91
+ const info = event.properties?.info;
92
+ if (!info?.id || info.parentID) return;
93
+ send({ hook_event_name: 'session.created', session_id: info.id, cwd: info.directory ?? directory });
94
+ } else if (event.type === 'session.idle' && event.properties?.sessionID) {
95
+ send({ hook_event_name: 'session.idle', session_id: event.properties.sessionID, cwd: directory });
96
+ }
97
+ },
98
+ });
99
+
100
+ export default { id: 'agentdrive', server: AgentDrive };
101
+ `;
102
+ export function harnessRoot(harness, home) {
103
+ switch (harness) {
104
+ case 'codex':
105
+ return join(home, '.codex');
106
+ case 'opencode':
107
+ return join(home, '.config', 'opencode');
108
+ case 'gemini-cli':
109
+ return join(home, '.gemini');
110
+ case 'cursor':
111
+ return join(home, '.cursor');
112
+ }
113
+ }
114
+ const COMMAND_DIRS = {
115
+ codex: 'prompts',
116
+ opencode: 'commands',
117
+ 'gemini-cli': join('commands', 'agentdrive'),
118
+ cursor: 'commands',
119
+ };
120
+ export async function harnessInstalled(harness, home) {
121
+ try {
122
+ return (await fs.stat(harnessRoot(harness, home))).isDirectory();
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ export async function wireHarness(harness, home) {
129
+ const root = harnessRoot(harness, home);
130
+ const result = { changed: [], warnings: [] };
131
+ const track = async (path, write) => {
132
+ if ((await write) !== 'unchanged')
133
+ result.changed.push(path);
134
+ };
135
+ const mergeJson = async (path, merge) => track(path, writeJsonIfChanged(path, merge((await readJsonObject(path)) ?? {})));
136
+ switch (harness) {
137
+ case 'codex': {
138
+ await mergeJson(join(root, 'hooks.json'), (e) => mergeGroupedHooks(e, codexHookGroups()));
139
+ const toml = join(root, 'config.toml');
140
+ await track(toml, writeFileIfChanged(toml, mergeCodexToml((await readTextIfExists(toml)) ?? ''), { backup: true }));
141
+ break;
142
+ }
143
+ case 'opencode': {
144
+ const json = join(root, 'opencode.json');
145
+ if ((await readTextIfExists(json)) === null && (await readTextIfExists(join(root, 'opencode.jsonc'))) !== null) {
146
+ result.warnings.push('opencode.jsonc found; add the agentdrive MCP entry to it by hand (comments would be lost if merged)');
147
+ }
148
+ else {
149
+ await mergeJson(json, mergeOpencodeJson);
150
+ }
151
+ const plugin = join(root, 'plugins', 'agentdrive.ts');
152
+ await track(plugin, writeFileIfChanged(plugin, OPENCODE_PLUGIN));
153
+ break;
154
+ }
155
+ case 'gemini-cli':
156
+ await mergeJson(join(root, 'settings.json'), (e) => mergeGroupedHooks(mergeMcpJson(e, 'gemini-cli'), geminiHookGroups()));
157
+ break;
158
+ case 'cursor':
159
+ await mergeJson(join(root, 'hooks.json'), mergeCursorHooks);
160
+ await mergeJson(join(root, 'mcp.json'), (e) => mergeMcpJson(e, 'cursor'));
161
+ break;
162
+ }
163
+ for (const name of HARNESS_COMMANDS) {
164
+ const path = join(root, COMMAND_DIRS[harness], commandFileName(harness, name));
165
+ await track(path, writeFileIfChanged(path, renderCommand(harness, name)));
166
+ }
167
+ if (harness !== 'gemini-cli') {
168
+ const skill = join(root, 'skills', 'agentdrive', 'SKILL.md');
169
+ await track(skill, writeFileIfChanged(skill, SKILL_MARKDOWN));
170
+ }
171
+ return result;
172
+ }
173
+ export const CLAUDE_MARKETPLACE = 'Rakesh1002/agentdrive';
174
+ export const CLAUDE_MANUAL_STEPS = [
175
+ `/plugin marketplace add ${CLAUDE_MARKETPLACE}`,
176
+ '/plugin install agentdrive@agentdrive',
177
+ ];
178
+ export function installClaudePlugin() {
179
+ const steps = [
180
+ ['plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE, '--scope', 'user'],
181
+ ['plugin', 'install', 'agentdrive@agentdrive', '-s', 'user', '-y'],
182
+ ];
183
+ for (const args of steps) {
184
+ const run = spawnSync('claude', args, { stdio: 'inherit' });
185
+ if (run.error?.code === 'ENOENT')
186
+ return { status: 'no-claude' };
187
+ if (run.error)
188
+ return { status: 'failed', detail: run.error.message };
189
+ if (run.status !== 0)
190
+ return { status: 'failed', detail: `\`claude ${args.join(' ')}\` exited ${run.status}` };
191
+ }
192
+ return { status: 'installed' };
193
+ }
@@ -21,6 +21,15 @@ export const DEFAULT_DENY = [
21
21
  '.DS_Store',
22
22
  '.env',
23
23
  '.env.*',
24
+ '.envrc',
25
+ '.dev.vars',
26
+ '*.tfvars',
27
+ '*.tfstate',
28
+ '*.tfstate.*',
29
+ 'secrets/',
30
+ '*.keystore',
31
+ 'credentials',
32
+ 'service-account*.json',
24
33
  '*.pem',
25
34
  '*.key',
26
35
  '*.p12',
@@ -36,6 +45,18 @@ export const DEFAULT_DENY = [
36
45
  '.netrc',
37
46
  'id_rsa',
38
47
  'id_ed25519',
48
+ 'id_ecdsa',
49
+ 'id_dsa',
50
+ '.gnupg/',
51
+ '.password-store/',
52
+ '.git-credentials',
53
+ '.config/gh/',
54
+ '.config/agentdrive/',
55
+ '.claude.json',
56
+ '.credentials.json',
57
+ 'auth.json',
58
+ 'oauth_creds.json',
59
+ '*_history',
39
60
  'credentials.json',
40
61
  'serviceAccount.json',
41
62
  '*.sqlite',
@@ -105,12 +126,11 @@ export function compileRules(lines) {
105
126
  }
106
127
  return rules;
107
128
  }
108
- export async function loadRules(root) {
109
- const candidates = [join(root, '.adriveignore'), join(root, '.gitignore')];
129
+ export async function loadRules(root, files = ['.adriveignore', '.gitignore']) {
110
130
  const lines = [...DEFAULT_DENY];
111
- for (const c of candidates) {
131
+ for (const f of files) {
112
132
  try {
113
- const raw = await fs.readFile(c, 'utf8');
133
+ const raw = await fs.readFile(join(root, f), 'utf8');
114
134
  lines.push(...raw.split(/\r?\n/));
115
135
  }
116
136
  catch {
@@ -0,0 +1,33 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // Hooks see each session's cwd, but some transcripts (Cursor, Gemini) never
4
+ // record it, so `adrive hook` remembers `<harness>:<session id> -> cwd` here
5
+ // for sync to fall back on.
6
+ const MAX_ENTRIES = 500;
7
+ export function sessionCwdPath(configDir) {
8
+ return join(configDir, 'session-cwd.json');
9
+ }
10
+ export async function readSessionCwds(configDir) {
11
+ try {
12
+ const parsed = JSON.parse(await fs.readFile(sessionCwdPath(configDir), 'utf8'));
13
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ export async function recordSessionCwd(configDir, key, cwd) {
20
+ const map = await readSessionCwds(configDir);
21
+ if (map[key] === cwd)
22
+ return;
23
+ delete map[key];
24
+ map[key] = cwd;
25
+ const keys = Object.keys(map);
26
+ for (const old of keys.slice(0, Math.max(0, keys.length - MAX_ENTRIES)))
27
+ delete map[old];
28
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
29
+ // Concurrent hooks may race; rename keeps the file whole even if one update is lost.
30
+ const tmp = `${sessionCwdPath(configDir)}.${process.pid}.tmp`;
31
+ await fs.writeFile(tmp, JSON.stringify(map), { encoding: 'utf8', mode: 0o600 });
32
+ await fs.rename(tmp, sessionCwdPath(configDir));
33
+ }
@@ -0,0 +1,58 @@
1
+ // Must stay byte-identical to plugins/agentdrive/skills/agentdrive/SKILL.md
2
+ // (the Claude Code plugin copy); test/skill.test.ts enforces it. adrive init
3
+ // writes this into the Codex, OpenCode, and Cursor skill directories.
4
+ export const SKILL_MARKDOWN = `---
5
+ name: agentdrive
6
+ description: Cross-agent session memory and artifact workspace. Use when the user wants to continue work another agent session started (Claude Code, Codex, OpenCode, Gemini CLI, Cursor), asks what an earlier session did, needs prior context for a task, or wants to save an artifact for their other agents.
7
+ ---
8
+
9
+ # AgentDrive
10
+
11
+ AgentDrive captures the user's agent sessions in the background and makes any
12
+ of them resumable from any harness. Prefer the \`drive_*\` MCP tools; fall back
13
+ to the \`adrive\` CLI when they are not available.
14
+
15
+ ## Sessions
16
+
17
+ - "Continue where I left off", "pick up the Codex session", "what was I
18
+ doing?": call \`drive_resume\` (optionally with \`session_id\` or
19
+ \`platform\`). Summarize the handoff in a few bullets, check the files it
20
+ mentions on disk, then continue the work.
21
+ - "What sessions do I have?", "what did Cursor do today?": call
22
+ \`drive_sessions\` (filters: \`project\`, \`platform\`, \`limit\`).
23
+
24
+ Both default to the current project.
25
+
26
+ ## Context and search
27
+
28
+ - Starting a non-trivial task: call \`drive_context\` with a one-line summary
29
+ of the task to get relevant artifacts and runs from earlier work.
30
+ - Looking for something specific: call \`drive_search\`.
31
+
32
+ When citing a result, say why it surfaced (\`attribution.reasons\`) and mention
33
+ the \`last_run\` task when present.
34
+
35
+ ## Saving work
36
+
37
+ After producing a file worth sharing with the user's other agents, call
38
+ \`drive_write\`. Transcripts and files git doesn't track are already captured
39
+ automatically when the user has capture on, so don't re-upload them.
40
+
41
+ ## CLI fallback
42
+
43
+ \`\`\`
44
+ adrive resume [session-id] # print a session handoff
45
+ adrive resume --list # list recent sessions
46
+ adrive context "<task>" # relevant artifacts for a task
47
+ adrive search "<query>" # semantic search
48
+ adrive push <file> # save an artifact
49
+ \`\`\`
50
+
51
+ If \`adrive\` says the user is not logged in, ask them to run \`adrive init\`
52
+ (or \`/agentdrive:login\` in Claude Code). Never guess credentials.
53
+
54
+ ## Privacy
55
+
56
+ Respect \`adrive privacy\`. With "Private workspace" on, content is encrypted
57
+ on the device and the server stores only ciphertext.
58
+ `;