@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/README.md +11 -12
- package/dist/commands/auth-login.js +9 -4
- package/dist/commands/context.js +12 -1
- package/dist/commands/hook.js +152 -0
- package/dist/commands/init.js +106 -74
- package/dist/commands/resume.js +41 -0
- package/dist/commands/sync.js +24 -0
- package/dist/commands/timeline.js +65 -0
- package/dist/index.js +35 -20
- package/dist/lib/api-client.js +32 -1
- package/dist/lib/auto-context.js +36 -53
- package/dist/lib/config.js +12 -0
- package/dist/lib/consent.js +60 -0
- package/dist/lib/detect-agents.js +73 -153
- package/dist/lib/harness-commands.js +63 -0
- package/dist/lib/harness-wiring.js +193 -0
- package/dist/lib/ignore.js +24 -4
- package/dist/lib/session-cwd.js +33 -0
- package/dist/lib/skill.js +58 -0
- package/dist/lib/sync.js +433 -0
- package/dist/lib/transcripts/claude.js +86 -0
- package/dist/lib/transcripts/codex.js +130 -0
- package/dist/lib/transcripts/cursor.js +73 -0
- package/dist/lib/transcripts/gemini.js +116 -0
- package/dist/lib/transcripts/opencode.js +95 -0
- package/dist/lib/transcripts/types.js +10 -0
- package/dist/lib/transcripts/util.js +102 -0
- package/dist/lib/write-file.js +55 -0
- package/package.json +12 -11
- package/dist/commands/transcripts.js +0 -210
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DocBuilder, asObj, asStr, jsonLines, mtimeOf, patchPaths, readText, toolSummary } from './util.js';
|
|
4
|
+
// Cursor agent: ~/.cursor/projects/<path-slug>/agent-transcripts/<conv>/<conv>.jsonl
|
|
5
|
+
// (or flat <conv>.jsonl). Lines are `{ role, message: { content: [...] } }`
|
|
6
|
+
// with no timestamps and no cwd; sync falls back to the cwd the hook recorded
|
|
7
|
+
// (see session-cwd.ts).
|
|
8
|
+
const PATH_TOOLS = new Set(['Write', 'StrReplace', 'Delete']);
|
|
9
|
+
const USER_QUERY_RE = /^\s*<user_query>\s*([\s\S]*?)\s*<\/user_query>\s*$/;
|
|
10
|
+
async function readdirSafe(dir) {
|
|
11
|
+
try {
|
|
12
|
+
return await fs.readdir(dir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const cursorSource = {
|
|
19
|
+
platform: 'cursor',
|
|
20
|
+
async enumerate({ home, since }) {
|
|
21
|
+
const root = join(home, '.cursor', 'projects');
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const slug of await readdirSafe(root)) {
|
|
24
|
+
const dir = join(root, slug, 'agent-transcripts');
|
|
25
|
+
for (const name of await readdirSafe(dir)) {
|
|
26
|
+
const sessionId = name.replace(/\.jsonl$/, '');
|
|
27
|
+
const path = name.endsWith('.jsonl') ? join(dir, name) : join(dir, name, `${name}.jsonl`);
|
|
28
|
+
const mtime = await mtimeOf(path);
|
|
29
|
+
if (!mtime || (since && mtime <= since))
|
|
30
|
+
continue;
|
|
31
|
+
out.push({ sessionId, path, mtime });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
35
|
+
},
|
|
36
|
+
async parseSession(entry) {
|
|
37
|
+
const raw = await readText(entry.path);
|
|
38
|
+
if (raw === null)
|
|
39
|
+
return null;
|
|
40
|
+
const doc = new DocBuilder();
|
|
41
|
+
for (const obj of jsonLines(raw)) {
|
|
42
|
+
const role = asStr(obj.role);
|
|
43
|
+
if (role !== 'user' && role !== 'assistant')
|
|
44
|
+
continue;
|
|
45
|
+
const content = asObj(obj.message)?.content;
|
|
46
|
+
let text = typeof content === 'string' ? content : '';
|
|
47
|
+
const tools = [];
|
|
48
|
+
for (const part of Array.isArray(content) ? content : []) {
|
|
49
|
+
const p = asObj(part);
|
|
50
|
+
if (p?.type === 'text') {
|
|
51
|
+
text += asStr(p.text) ?? '';
|
|
52
|
+
}
|
|
53
|
+
else if (p?.type === 'tool_use') {
|
|
54
|
+
const name = asStr(p.name) ?? 'tool';
|
|
55
|
+
if (name === 'ApplyPatch') {
|
|
56
|
+
const input = typeof p.input === 'string' ? p.input : Object.values(asObj(p.input) ?? {}).filter((v) => typeof v === 'string').join('\n');
|
|
57
|
+
const paths = patchPaths(input);
|
|
58
|
+
for (const path of paths)
|
|
59
|
+
doc.touch(path);
|
|
60
|
+
tools.push(...(paths.length > 0 ? paths.map((path) => ({ name, path })) : [{ name }]));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const path = PATH_TOOLS.has(name) ? asStr(asObj(p.input)?.path) : undefined;
|
|
64
|
+
doc.touch(path);
|
|
65
|
+
tools.push(path ? { name, path } : { name, summary: toolSummary(p.input) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
doc.addTurn(role, text.replace(USER_QUERY_RE, '$1'), undefined, tools);
|
|
70
|
+
}
|
|
71
|
+
return doc.build(entry);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readSessionCwds } from '../session-cwd.js';
|
|
5
|
+
import { DocBuilder, asObj, asStr, jsonLines, mtimeOf, readText, toolSummary } from './util.js';
|
|
6
|
+
// Gemini CLI: ~/.gemini/tmp/<project_hash>/chats/session-*.jsonl, one message
|
|
7
|
+
// record per line plus `$set`/`$patch`/`$rewindTo` meta lines; legacy chats
|
|
8
|
+
// are a single .json `{ sessionId, messages }`. The project hash is sha256 of
|
|
9
|
+
// the project root, so cwd is recovered by hashing cwds the hook recorded.
|
|
10
|
+
const CHAT_RE = /^session-.+\.jsonl?$/;
|
|
11
|
+
// Both formats put the session id first, so the head is enough to name a chat
|
|
12
|
+
// without parsing it; enumerate, parse and the hook must agree on this id.
|
|
13
|
+
const HEAD_BYTES = 16 * 1024;
|
|
14
|
+
const SESSION_ID_RE = /"sessionId"\s*:\s*"([^"\\]+)"/;
|
|
15
|
+
function sessionIdOf(head, path) {
|
|
16
|
+
return SESSION_ID_RE.exec(head)?.[1] ?? (path.split(/[\\/]/).pop() ?? path).replace(/^session-/, '').replace(/\.jsonl?$/, '');
|
|
17
|
+
}
|
|
18
|
+
async function readHead(path) {
|
|
19
|
+
const handle = await fs.open(path, 'r');
|
|
20
|
+
try {
|
|
21
|
+
const buf = Buffer.alloc(HEAD_BYTES);
|
|
22
|
+
const { bytesRead } = await handle.read(buf, 0, HEAD_BYTES, 0);
|
|
23
|
+
return buf.subarray(0, bytesRead).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await handle.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const FILE_TOOLS = new Set(['write_file', 'replace']);
|
|
30
|
+
function messageText(content) {
|
|
31
|
+
if (typeof content === 'string')
|
|
32
|
+
return content;
|
|
33
|
+
if (!Array.isArray(content))
|
|
34
|
+
return '';
|
|
35
|
+
return content.map((p) => (typeof p === 'string' ? p : asStr(asObj(p)?.text) ?? '')).join('');
|
|
36
|
+
}
|
|
37
|
+
function addMessage(doc, msg) {
|
|
38
|
+
if (msg.type !== 'user' && msg.type !== 'gemini')
|
|
39
|
+
return;
|
|
40
|
+
const tools = [];
|
|
41
|
+
for (const raw of Array.isArray(msg.toolCalls) ? msg.toolCalls : []) {
|
|
42
|
+
const call = asObj(raw);
|
|
43
|
+
const name = asStr(call?.name) ?? 'tool';
|
|
44
|
+
const args = asObj(call?.args);
|
|
45
|
+
const path = FILE_TOOLS.has(name) ? asStr(args?.file_path) : undefined;
|
|
46
|
+
doc.touch(path);
|
|
47
|
+
tools.push(path ? { name, path } : { name, summary: toolSummary(args) });
|
|
48
|
+
}
|
|
49
|
+
doc.addTurn(msg.type === 'user' ? 'user' : 'assistant', messageText(msg.content), asStr(msg.timestamp), tools);
|
|
50
|
+
}
|
|
51
|
+
export const geminiSource = {
|
|
52
|
+
platform: 'gemini-cli',
|
|
53
|
+
async enumerate({ home, since, configDir }) {
|
|
54
|
+
const tmp = join(home, '.gemini', 'tmp');
|
|
55
|
+
let hashes;
|
|
56
|
+
try {
|
|
57
|
+
hashes = await fs.readdir(tmp);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const cwdByHash = new Map();
|
|
63
|
+
for (const cwd of Object.values(configDir ? await readSessionCwds(configDir) : {})) {
|
|
64
|
+
cwdByHash.set(createHash('sha256').update(cwd).digest('hex'), cwd);
|
|
65
|
+
}
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const h of hashes) {
|
|
68
|
+
let files;
|
|
69
|
+
try {
|
|
70
|
+
files = await fs.readdir(join(tmp, h, 'chats'));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const f of files) {
|
|
76
|
+
if (!CHAT_RE.test(f))
|
|
77
|
+
continue;
|
|
78
|
+
const path = join(tmp, h, 'chats', f);
|
|
79
|
+
const mtime = await mtimeOf(path);
|
|
80
|
+
if (!mtime || (since && mtime <= since))
|
|
81
|
+
continue;
|
|
82
|
+
const head = await readHead(path).catch(() => '');
|
|
83
|
+
out.push({ sessionId: sessionIdOf(head, path), path, mtime, cwd: cwdByHash.get(h) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
87
|
+
},
|
|
88
|
+
async parseSession(entry) {
|
|
89
|
+
const raw = await readText(entry.path);
|
|
90
|
+
if (raw === null)
|
|
91
|
+
return null;
|
|
92
|
+
const doc = new DocBuilder();
|
|
93
|
+
if (entry.path.endsWith('.json')) {
|
|
94
|
+
let legacy;
|
|
95
|
+
try {
|
|
96
|
+
legacy = asObj(JSON.parse(raw));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
for (const m of Array.isArray(legacy?.messages) ? legacy.messages : []) {
|
|
102
|
+
const msg = asObj(m);
|
|
103
|
+
if (msg)
|
|
104
|
+
addMessage(doc, msg);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
for (const line of jsonLines(raw)) {
|
|
109
|
+
if ('$set' in line || '$patch' in line || '$rewindTo' in line)
|
|
110
|
+
continue;
|
|
111
|
+
addMessage(doc, line);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return doc.build({ ...entry, sessionId: sessionIdOf(Buffer.from(raw).subarray(0, HEAD_BYTES).toString('utf8'), entry.path) });
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { DocBuilder, asObj, asStr, patchPaths, toolSummary } from './util.js';
|
|
3
|
+
const EXEC_TIMEOUT_MS = 15_000;
|
|
4
|
+
const defaultExec = (args, cwd) => new Promise((resolve, reject) => {
|
|
5
|
+
execFile('opencode', [...args, '--pure'], { cwd, timeout: EXEC_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8' }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
|
|
6
|
+
});
|
|
7
|
+
function parseJson(out) {
|
|
8
|
+
const start = out.search(/[[{]/);
|
|
9
|
+
return start < 0 ? null : JSON.parse(out.slice(start));
|
|
10
|
+
}
|
|
11
|
+
function isoFromMs(ms) {
|
|
12
|
+
return typeof ms === 'number' ? new Date(ms).toISOString() : undefined;
|
|
13
|
+
}
|
|
14
|
+
function toolEntry(doc, name, input) {
|
|
15
|
+
if (name === 'write' || name === 'edit') {
|
|
16
|
+
const path = asStr(input?.filePath);
|
|
17
|
+
doc.touch(path);
|
|
18
|
+
return [path ? { name, path } : { name }];
|
|
19
|
+
}
|
|
20
|
+
if (name === 'patch' || name === 'apply_patch') {
|
|
21
|
+
const patch = Object.values(input ?? {}).filter((v) => typeof v === 'string').join('\n');
|
|
22
|
+
const paths = patchPaths(patch);
|
|
23
|
+
for (const p of paths)
|
|
24
|
+
doc.touch(p);
|
|
25
|
+
return paths.length > 0 ? paths.map((path) => ({ name, path })) : [{ name }];
|
|
26
|
+
}
|
|
27
|
+
return [{ name, summary: toolSummary(input) }];
|
|
28
|
+
}
|
|
29
|
+
export function createOpencodeSource(exec = defaultExec) {
|
|
30
|
+
return {
|
|
31
|
+
platform: 'opencode',
|
|
32
|
+
// shortcut: `opencode session list` only lists the project of `cwd`, so a
|
|
33
|
+
// sweep sees the sessions of the directory the hook fired in; reading
|
|
34
|
+
// OpenCode's storage directly would cover all projects.
|
|
35
|
+
async enumerate({ since, cwd }) {
|
|
36
|
+
let list;
|
|
37
|
+
try {
|
|
38
|
+
list = parseJson(await exec(['session', 'list', '--format', 'json'], cwd));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(list))
|
|
44
|
+
return [];
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const raw of list) {
|
|
47
|
+
const s = asObj(raw);
|
|
48
|
+
const id = asStr(s?.id);
|
|
49
|
+
const updated = s?.updated;
|
|
50
|
+
if (!id || typeof updated !== 'number')
|
|
51
|
+
continue;
|
|
52
|
+
const mtime = new Date(updated);
|
|
53
|
+
if (since && mtime <= since)
|
|
54
|
+
continue;
|
|
55
|
+
out.push({ sessionId: id, path: id, mtime, cwd: asStr(s?.directory) });
|
|
56
|
+
}
|
|
57
|
+
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
58
|
+
},
|
|
59
|
+
async parseSession(entry) {
|
|
60
|
+
let data;
|
|
61
|
+
try {
|
|
62
|
+
data = asObj(parseJson(await exec(['export', entry.sessionId], entry.cwd)));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!data)
|
|
68
|
+
return null;
|
|
69
|
+
const info = asObj(data.info);
|
|
70
|
+
const doc = new DocBuilder();
|
|
71
|
+
for (const rawMsg of Array.isArray(data.messages) ? data.messages : []) {
|
|
72
|
+
const msg = asObj(rawMsg);
|
|
73
|
+
const mInfo = asObj(msg?.info);
|
|
74
|
+
const role = asStr(mInfo?.role) ?? 'assistant';
|
|
75
|
+
const ts = isoFromMs(asObj(mInfo?.time)?.created);
|
|
76
|
+
let text = '';
|
|
77
|
+
const tools = [];
|
|
78
|
+
for (const rawPart of Array.isArray(msg?.parts) ? msg.parts : []) {
|
|
79
|
+
const part = asObj(rawPart);
|
|
80
|
+
if (!part || part.synthetic)
|
|
81
|
+
continue;
|
|
82
|
+
if (part.type === 'text')
|
|
83
|
+
text += asStr(part.text) ?? '';
|
|
84
|
+
else if (part.type === 'tool')
|
|
85
|
+
tools.push(...toolEntry(doc, asStr(part.tool) ?? 'tool', asObj(asObj(part.state)?.input)));
|
|
86
|
+
}
|
|
87
|
+
doc.addTurn(role, text, ts, tools);
|
|
88
|
+
}
|
|
89
|
+
const updated = isoFromMs(asObj(info?.time)?.updated);
|
|
90
|
+
const entryOut = updated ? { ...entry, mtime: new Date(updated) } : entry;
|
|
91
|
+
return doc.build(entryOut, asStr(info?.directory), asStr(info?.title));
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export const opencodeSource = createOpencodeSource();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Functional TranscriptSource interface — per eng-review decision E4.
|
|
2
|
+
//
|
|
3
|
+
// A source knows two things:
|
|
4
|
+
// 1. `enumerate(opts)` — find candidate sessions on disk
|
|
5
|
+
// 2. `parseSession(entry)` — turn one session into a TranscriptDoc
|
|
6
|
+
//
|
|
7
|
+
// Everything else (redact, encrypt, manifest, upload, run upsert) is
|
|
8
|
+
// centralized in lib/sync.ts so the cross-cutting concerns can't drift
|
|
9
|
+
// between harnesses.
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
3
|
+
const SUMMARY_CHARS = 160;
|
|
4
|
+
const TITLE_CHARS = 120;
|
|
5
|
+
// Only these input keys feed tool summaries, so file bodies (`content`,
|
|
6
|
+
// `new_string`, ...) never reach a tool line.
|
|
7
|
+
const SUMMARY_KEYS = ['command', 'cmd', 'file_path', 'filePath', 'notebook_path', 'path', 'pattern', 'query', 'url', 'description'];
|
|
8
|
+
export function asObj(value) {
|
|
9
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function asStr(value) {
|
|
12
|
+
return typeof value === 'string' ? value : undefined;
|
|
13
|
+
}
|
|
14
|
+
export async function readText(path) {
|
|
15
|
+
try {
|
|
16
|
+
const buf = await fs.readFile(path);
|
|
17
|
+
return (path.endsWith('.zst') ? zstdDecompressSync(buf) : buf).toString('utf8');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function jsonLines(raw) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
26
|
+
if (!line.trim())
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = asObj(JSON.parse(line));
|
|
30
|
+
if (parsed)
|
|
31
|
+
out.push(parsed);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Permissive: a torn last line or unknown record never fails a parse.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export function truncate(value, max) {
|
|
40
|
+
const flat = value.replace(/\s+/g, ' ').trim();
|
|
41
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 3)}...`;
|
|
42
|
+
}
|
|
43
|
+
export function toolSummary(input) {
|
|
44
|
+
const obj = asObj(input);
|
|
45
|
+
if (!obj)
|
|
46
|
+
return typeof input === 'string' && input.trim() ? truncate(input.split('\n')[0], SUMMARY_CHARS) : undefined;
|
|
47
|
+
for (const key of SUMMARY_KEYS) {
|
|
48
|
+
const v = obj[key];
|
|
49
|
+
const s = Array.isArray(v) ? v.filter((x) => typeof x === 'string').join(' ') : asStr(v);
|
|
50
|
+
if (s && s.trim())
|
|
51
|
+
return truncate(s, SUMMARY_CHARS);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
export function patchPaths(patch) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const m of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm))
|
|
58
|
+
out.push(m[1].trim());
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
export async function mtimeOf(path) {
|
|
62
|
+
try {
|
|
63
|
+
return (await fs.stat(path)).mtime;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export class DocBuilder {
|
|
70
|
+
turns = [];
|
|
71
|
+
files = new Set();
|
|
72
|
+
addTurn(role, text, ts, tools = []) {
|
|
73
|
+
const last = this.turns[this.turns.length - 1];
|
|
74
|
+
if (!text.trim()) {
|
|
75
|
+
if (tools.length === 0)
|
|
76
|
+
return;
|
|
77
|
+
// Harnesses log tool calls as separate records; fold them into the
|
|
78
|
+
// turn they belong to instead of emitting empty turns.
|
|
79
|
+
if (last && last.role === role) {
|
|
80
|
+
last.tools = [...(last.tools ?? []), ...tools];
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.turns.push(tools.length > 0 ? { role, text, ts, tools } : { role, text, ts });
|
|
85
|
+
}
|
|
86
|
+
touch(path) {
|
|
87
|
+
if (path && path.trim())
|
|
88
|
+
this.files.add(path.trim());
|
|
89
|
+
}
|
|
90
|
+
build(entry, cwd, title) {
|
|
91
|
+
if (this.turns.length === 0)
|
|
92
|
+
return null;
|
|
93
|
+
const firstUser = this.turns.find((t) => t.role === 'user' && t.text.trim());
|
|
94
|
+
return {
|
|
95
|
+
entry,
|
|
96
|
+
turns: this.turns,
|
|
97
|
+
cwd: cwd ?? entry.cwd,
|
|
98
|
+
title: title?.trim() ? truncate(title, TITLE_CHARS) : firstUser ? truncate(firstUser.text, TITLE_CHARS) : undefined,
|
|
99
|
+
filesTouched: [...this.files],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export async function readTextIfExists(path) {
|
|
4
|
+
try {
|
|
5
|
+
return await fs.readFile(path, 'utf8');
|
|
6
|
+
}
|
|
7
|
+
catch (err) {
|
|
8
|
+
if (err.code === 'ENOENT')
|
|
9
|
+
return null;
|
|
10
|
+
throw err;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// Throws on unparseable content so a merge never replaces a file it could not
|
|
14
|
+
// read (JSONC with comments, a half-written file) with only our keys.
|
|
15
|
+
export async function readJsonObject(path) {
|
|
16
|
+
const raw = await readTextIfExists(path);
|
|
17
|
+
if (raw === null)
|
|
18
|
+
return null;
|
|
19
|
+
if (!raw.trim())
|
|
20
|
+
return {};
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(`${path} is not plain JSON; left untouched`);
|
|
27
|
+
}
|
|
28
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
29
|
+
throw new Error(`${path} is not a JSON object; left untouched`);
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
export async function writeFileIfChanged(path, content, opts = {}) {
|
|
34
|
+
const current = await readTextIfExists(path);
|
|
35
|
+
if (current === content)
|
|
36
|
+
return 'unchanged';
|
|
37
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
38
|
+
if (current !== null && opts.backup) {
|
|
39
|
+
// The first .bak is the user's pre-AgentDrive original; later runs must not overwrite it.
|
|
40
|
+
// 0600 because an original Codex config.toml can hold the API key init just removed.
|
|
41
|
+
try {
|
|
42
|
+
await fs.copyFile(path, `${path}.bak`, constants.COPYFILE_EXCL);
|
|
43
|
+
await fs.chmod(`${path}.bak`, 0o600);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err.code !== 'EEXIST')
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await fs.writeFile(path, content, 'utf8');
|
|
51
|
+
return current === null ? 'created' : 'updated';
|
|
52
|
+
}
|
|
53
|
+
export async function writeJsonIfChanged(path, value) {
|
|
54
|
+
return writeFileIfChanged(path, `${JSON.stringify(value, null, 2)}\n`, { backup: true });
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrive/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "AgentDrive CLI —
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "AgentDrive CLI — cross-agent artifact workspace with provenance and portable storage across Claude Code, Codex, Cursor, and LangGraph.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -37,18 +37,13 @@
|
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"build": "tsc && node -e \"import('node:fs').then(fs => fs.chmodSync('dist/index.js', 0o755))\"",
|
|
42
|
-
"dev": "tsc --watch",
|
|
43
|
-
"typecheck": "tsc --noEmit",
|
|
44
|
-
"test": "vitest run"
|
|
45
|
-
},
|
|
46
40
|
"dependencies": {
|
|
47
41
|
"chokidar": "^5.0.0",
|
|
48
42
|
"commander": "^12.1.0",
|
|
49
43
|
"kleur": "^4.1.5",
|
|
50
44
|
"open": "^10.1.0",
|
|
51
|
-
"prompts": "^2.4.2"
|
|
45
|
+
"prompts": "^2.4.2",
|
|
46
|
+
"@adrive/mcp-server": "^1.1.0"
|
|
52
47
|
},
|
|
53
48
|
"devDependencies": {
|
|
54
49
|
"@types/node": "^22.0.0",
|
|
@@ -57,6 +52,12 @@
|
|
|
57
52
|
"vitest": "^2.1.0"
|
|
58
53
|
},
|
|
59
54
|
"engines": {
|
|
60
|
-
"node": ">=
|
|
55
|
+
"node": ">=22.15"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsc && node -e \"import('node:fs').then(fs => fs.chmodSync('dist/index.js', 0o755))\"",
|
|
59
|
+
"dev": "tsc --watch",
|
|
60
|
+
"typecheck": "tsc --noEmit",
|
|
61
|
+
"test": "vitest run"
|
|
61
62
|
}
|
|
62
|
-
}
|
|
63
|
+
}
|