@adrive/cli 1.0.0 → 1.1.1

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,194 @@
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
+ // This repo is private; agentdrive-mcp carries a public copy of .claude-plugin/ and plugins/.
174
+ export const CLAUDE_MARKETPLACE = 'Rakesh1002/agentdrive-mcp';
175
+ export const CLAUDE_MANUAL_STEPS = [
176
+ `/plugin marketplace add ${CLAUDE_MARKETPLACE}`,
177
+ '/plugin install agentdrive@agentdrive',
178
+ ];
179
+ export function installClaudePlugin() {
180
+ const steps = [
181
+ ['plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE, '--scope', 'user'],
182
+ ['plugin', 'install', 'agentdrive@agentdrive', '-s', 'user', '-y'],
183
+ ];
184
+ for (const args of steps) {
185
+ const run = spawnSync('claude', args, { stdio: 'inherit' });
186
+ if (run.error?.code === 'ENOENT')
187
+ return { status: 'no-claude' };
188
+ if (run.error)
189
+ return { status: 'failed', detail: run.error.message };
190
+ if (run.status !== 0)
191
+ return { status: 'failed', detail: `\`claude ${args.join(' ')}\` exited ${run.status}` };
192
+ }
193
+ return { status: 'installed' };
194
+ }
@@ -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
+ }
package/dist/lib/skill.js CHANGED
@@ -1,117 +1,58 @@
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.
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.
7
4
  export const SKILL_MARKDOWN = `---
8
5
  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.
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.
14
7
  ---
15
8
 
16
- # AgentDrive — shared artifact workspace for multi-agent workflows
9
+ # AgentDrive
17
10
 
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.
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.
20
14
 
21
- ## When to use this skill
15
+ ## Sessions
22
16
 
23
- Trigger on any of these patterns:
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\`).
24
23
 
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
24
+ Both default to the current project.
33
25
 
34
- ## Commands
26
+ ## Context and search
35
27
 
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
- \`\`\`
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
45
36
 
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.
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.
49
40
 
50
- ## Output handling
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
+ \`\`\`
51
50
 
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.
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.
57
53
 
58
54
  ## Privacy
59
55
 
60
- Respect \`adrive privacy\` settings. If the user has "Private workspace" on,
61
- written content is encrypted client-side; server stores only ciphertext.
56
+ Respect \`adrive privacy\`. With "Private workspace" on, content is encrypted
57
+ on the device and the server stores only ciphertext.
62
58
  `;
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
- }