residoo 0.1.0 → 0.2.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 +225 -46
- package/SECURITY.md +29 -22
- package/package.json +1 -1
- package/src/cli.js +82 -16
- package/src/integrity.js +669 -0
- package/src/patterns.js +78 -5
- package/src/report.js +74 -7
- package/src/sources/agent-configs.js +308 -0
- package/src/sources/aider.js +361 -0
- package/src/sources/amazon-q.js +199 -0
- package/src/sources/antigravity-cli.js +155 -0
- package/src/sources/cline.js +208 -0
- package/src/sources/codebuff.js +295 -0
- package/src/sources/codex-cli.js +258 -0
- package/src/sources/cody.js +325 -0
- package/src/sources/continue.js +408 -0
- package/src/sources/copilot-chat.js +272 -0
- package/src/sources/copilot-cli.js +300 -0
- package/src/sources/crush.js +364 -0
- package/src/sources/cursor.js +374 -0
- package/src/sources/devin-cli.js +241 -0
- package/src/sources/factory-droid.js +153 -0
- package/src/sources/fx.js +136 -0
- package/src/sources/gemini-cli.js +242 -0
- package/src/sources/goose.js +366 -0
- package/src/sources/grok-cli.js +267 -0
- package/src/sources/hermes.js +282 -0
- package/src/sources/index.js +172 -8
- package/src/sources/jetbrains-ai-assistant.js +343 -0
- package/src/sources/jetbrains-junie.js +292 -0
- package/src/sources/kilo-code.js +430 -0
- package/src/sources/kimi-code.js +147 -0
- package/src/sources/kiro-cli.js +393 -0
- package/src/sources/kiro-ide.js +230 -0
- package/src/sources/llm.js +328 -0
- package/src/sources/mentat.js +143 -0
- package/src/sources/open-interpreter.js +224 -0
- package/src/sources/openclaw.js +218 -0
- package/src/sources/opencode.js +379 -0
- package/src/sources/openhands.js +181 -0
- package/src/sources/pearai.js +151 -0
- package/src/sources/pi-agent.js +130 -0
- package/src/sources/qodo-gen.js +189 -0
- package/src/sources/qwen-code.js +244 -0
- package/src/sources/roo-code.js +239 -0
- package/src/sources/trae.js +294 -0
- package/src/sources/void.js +273 -0
- package/src/sources/warp.js +395 -0
- package/src/sources/windsurf.js +256 -0
- package/src/sources/zed.js +374 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const { createInterface } = require("readline/promises");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Google Antigravity (antigravity.google) — the agentic development platform
|
|
10
|
+
* Google launched at I/O 2026, whose CLI (built in Go, replacing Gemini CLI
|
|
11
|
+
* for agentic use) and desktop editor both write local session state under
|
|
12
|
+
* `~/.gemini/antigravity*`.
|
|
13
|
+
*
|
|
14
|
+
* VERIFICATION STATUS: corroborated by one detailed, credible source — NOT
|
|
15
|
+
* by two independent sources of the exact schema, and NOT checked against a
|
|
16
|
+
* real install on the machine this source was built on (no `~/.gemini`
|
|
17
|
+
* directory exists there; see CONTRIBUTING.md). Ship this with that
|
|
18
|
+
* explicitly weaker standing in mind relative to this project's other new
|
|
19
|
+
* sources.
|
|
20
|
+
*
|
|
21
|
+
* The source: jazzyalex/agent-sessions (github.com/jazzyalex/agent-sessions,
|
|
22
|
+
* 800+ stars) — a real, actively maintained macOS app built specifically to
|
|
23
|
+
* parse local AI-coding-agent session history for browsing/search — ships a
|
|
24
|
+
* dedicated guide page, "Antigravity CLI local history: transcripts and
|
|
25
|
+
* brain artifacts under `~/.gemini`," documenting (as this tool's own
|
|
26
|
+
* behavior, i.e. code the maintainer wrote and presumably ran against a real
|
|
27
|
+
* Antigravity install, not a secondhand description):
|
|
28
|
+
* - CLI transcripts: `~/.gemini/antigravity-cli/brain/<conversation-id>/
|
|
29
|
+
* .system_generated/logs/transcript.jsonl` (one step per line: fields
|
|
30
|
+
* including step_index, source, type, status, created_at, content,
|
|
31
|
+
* tool_calls, thinking, truncated_fields) plus a sibling
|
|
32
|
+
* `transcript_full.jsonl` restoring content the primary file truncates.
|
|
33
|
+
* - Editor artifacts: `~/.gemini/antigravity/brain/<conversation-id>/`
|
|
34
|
+
* holding per-artifact Markdown (`task.md`, `implementation_plan.md`,
|
|
35
|
+
* `walkthrough.md`, `proposal.md`) each paired with a `.metadata.json`.
|
|
36
|
+
* - The same guide notes upgraded installs may also carry
|
|
37
|
+
* `~/.gemini/antigravity-ide/brain` and `~/.gemini/antigravity-backup/
|
|
38
|
+
* brain`, but says its own tool only scans the first two — this source
|
|
39
|
+
* follows that same, narrower choice rather than guessing at the other
|
|
40
|
+
* two directory names' internal shape.
|
|
41
|
+
*
|
|
42
|
+
* Real-world significance: Antigravity is a first-party Google product
|
|
43
|
+
* (Google I/O 2026 launch, "the only tool in this group not built on VS
|
|
44
|
+
* Code," free public preview with Gemini 3 Pro access as of that launch),
|
|
45
|
+
* so — schema-verification caveat above notwithstanding — this is exactly
|
|
46
|
+
* the kind of tool this project's coverage would be conspicuously incomplete
|
|
47
|
+
* without.
|
|
48
|
+
*
|
|
49
|
+
* This source walks both brain roots recursively for `.jsonl`, `.json`, and
|
|
50
|
+
* `.md` files (skipping the `.system_generated/logs` vs top-level distinction
|
|
51
|
+
* rather than hard-coding it) — the same "match by extension, don't pin the
|
|
52
|
+
* exact depth" tolerance claude-code.js applies to its own project-slug
|
|
53
|
+
* directories, since a screenshot or other binary asset the CLI writes
|
|
54
|
+
* alongside these would not match any of those three extensions anyway.
|
|
55
|
+
*/
|
|
56
|
+
const HOME = os.homedir();
|
|
57
|
+
const GEMINI_DIR = path.join(HOME, ".gemini");
|
|
58
|
+
const CLI_BRAIN_ROOT = path.join(GEMINI_DIR, "antigravity-cli", "brain");
|
|
59
|
+
const EDITOR_BRAIN_ROOT = path.join(GEMINI_DIR, "antigravity", "brain");
|
|
60
|
+
|
|
61
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — same backstop as claude-code.js.
|
|
62
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
63
|
+
const MAX_WALK_DEPTH = 8;
|
|
64
|
+
|
|
65
|
+
function id() { return "antigravity"; }
|
|
66
|
+
function label() { return "Google Antigravity"; }
|
|
67
|
+
|
|
68
|
+
function available() {
|
|
69
|
+
for (const root of [CLI_BRAIN_ROOT, EDITOR_BRAIN_ROOT]) {
|
|
70
|
+
try { if (fs.statSync(root).isDirectory()) return true; } catch { /* try the next root */ }
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Same defensive symlink-following helpers as claude-code.js — see that
|
|
77
|
+
* file's docstring. Duplicated rather than imported, per this project's
|
|
78
|
+
* self-contained-source-file convention (see cursor.js's docstring).
|
|
79
|
+
*/
|
|
80
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
81
|
+
if (checkFn(dirent)) return true;
|
|
82
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
83
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
84
|
+
}
|
|
85
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
86
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
87
|
+
|
|
88
|
+
const TEXT_EXTENSIONS = new Set([".jsonl", ".json", ".md"]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Recursively yield { file, mtimeMs, sizeBytes, broken } for every plain
|
|
92
|
+
* text-like (see TEXT_EXTENSIONS) file under `dir` — see factory-droid.js's
|
|
93
|
+
* walk() for the identical symlink-handling reasoning.
|
|
94
|
+
*/
|
|
95
|
+
function* walk(dir, depth) {
|
|
96
|
+
if (depth > MAX_WALK_DEPTH) return;
|
|
97
|
+
let entries;
|
|
98
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
99
|
+
catch { return; }
|
|
100
|
+
|
|
101
|
+
for (const e of entries) {
|
|
102
|
+
const full = path.join(dir, e.name);
|
|
103
|
+
if (isDirFollowingSymlink(full, e)) {
|
|
104
|
+
yield* walk(full, depth + 1);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const isFile = isFileFollowingSymlink(full, e);
|
|
108
|
+
if (!isFile) {
|
|
109
|
+
if (e.isSymbolicLink()) yield { file: full, broken: true };
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!TEXT_EXTENSIONS.has(path.extname(e.name))) continue; // e.g. a captured screenshot — out of scope
|
|
113
|
+
let stat;
|
|
114
|
+
try { stat = fs.statSync(full); } catch { yield { file: full, broken: true }; continue; }
|
|
115
|
+
yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function* files() {
|
|
120
|
+
yield* walk(CLI_BRAIN_ROOT, 0);
|
|
121
|
+
yield* walk(EDITOR_BRAIN_ROOT, 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read one transcript/artifact file as raw text lines. Identical streaming/
|
|
126
|
+
* timeout/partial-read discipline to claude-code.js's readLines().
|
|
127
|
+
*/
|
|
128
|
+
async function readLines(file) {
|
|
129
|
+
let stat;
|
|
130
|
+
try { stat = fs.statSync(file); }
|
|
131
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
132
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
133
|
+
|
|
134
|
+
const lines = [];
|
|
135
|
+
let bytesRead = 0;
|
|
136
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
137
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
138
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
for await (const line of rl) {
|
|
142
|
+
lines.push(line);
|
|
143
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1;
|
|
144
|
+
}
|
|
145
|
+
return { lines, status: "complete", bytesRead };
|
|
146
|
+
} catch {
|
|
147
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
rl.close();
|
|
151
|
+
stream.destroy();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { id, label, available, files, readLines };
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const { createInterface } = require("readline/promises");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cline (VS Code extension, historically shipped as "Claude Dev") session
|
|
10
|
+
* history.
|
|
11
|
+
*
|
|
12
|
+
* VERIFICATION STATUS: the path and file layout below were read directly out
|
|
13
|
+
* of Cline's own current source on GitHub (cline/cline) during this source's
|
|
14
|
+
* research — not inferred from a blog post and not guessed from Roo Code's
|
|
15
|
+
* near-identical fork, even though the two are in fact near-identical.
|
|
16
|
+
* Specifically:
|
|
17
|
+
* - apps/vscode/src/core/storage/disk.ts — GlobalFileNames constants and
|
|
18
|
+
* ensureTaskDirectoryExists()/getGlobalStorageDir(), which resolve
|
|
19
|
+
* through the VS Code extension's own `globalStorageUri` (NOT
|
|
20
|
+
* workspaceStorage) into `tasks/<taskId>/`.
|
|
21
|
+
* - apps/vscode/package.json — `"name": "claude-dev"`, `"publisher":
|
|
22
|
+
* "saoudrizwan"`, confirming the on-disk globalStorage folder is really
|
|
23
|
+
* still `saoudrizwan.claude-dev` (Cline shipped as "Claude Dev" and kept
|
|
24
|
+
* its original package.json identity across the product's later rename
|
|
25
|
+
* to Cline — this is NOT the same as the current "Cline" display name,
|
|
26
|
+
* and guessing `cline.cline` or similar would have been wrong).
|
|
27
|
+
* What this could NOT be checked against: a real Cline install on the
|
|
28
|
+
* machine this source was built on — VS Code itself isn't installed there.
|
|
29
|
+
* See CONTRIBUTING.md for what "verified" is supposed to mean and treat
|
|
30
|
+
* findings from this source accordingly until someone with Cline actually
|
|
31
|
+
* installed confirms it against real data.
|
|
32
|
+
*
|
|
33
|
+
* Cline writes one JSON file per concern into a per-task directory under its
|
|
34
|
+
* extension's VS Code globalStorage folder:
|
|
35
|
+
*
|
|
36
|
+
* <VS Code User dir>/globalStorage/saoudrizwan.claude-dev/tasks/<taskId>/
|
|
37
|
+
* api_conversation_history.json - full message history sent to the model
|
|
38
|
+
* ui_messages.json - the rendered chat transcript
|
|
39
|
+
* context_history.json - context-window bookkeeping
|
|
40
|
+
* task_metadata.json - files touched, model/token usage
|
|
41
|
+
* settings.json - a per-task settings snapshot
|
|
42
|
+
*
|
|
43
|
+
* Filenames are deliberately NOT allow-listed beyond "every *.json file
|
|
44
|
+
* directly inside tasks/<taskId>/": GlobalFileNames in Cline's own source has
|
|
45
|
+
* gained entries over time (context_history.json is a relatively recent
|
|
46
|
+
* addition) and hard-coding today's list is exactly the kind of thing likely
|
|
47
|
+
* to go stale the same way cursor.js's docstring describes for Cursor's own
|
|
48
|
+
* key names. A task's `checkpoints/` subdirectory (shadow-git snapshots used
|
|
49
|
+
* for file revert) is deliberately NOT walked — those are git object stores,
|
|
50
|
+
* not text transcripts.
|
|
51
|
+
*
|
|
52
|
+
* Base directory: VS Code has a portable/remote/Insiders/fork multiverse of
|
|
53
|
+
* possible per-profile "User" directories. This source checks the two by far
|
|
54
|
+
* most common ones on each OS — standard VS Code ("Code") and VS Code
|
|
55
|
+
* Insiders ("Code - Insiders") — and deliberately does NOT attempt every
|
|
56
|
+
* fork (VSCodium, etc.) or the separate ~/.vscode-server tree used by
|
|
57
|
+
* remote-SSH sessions: a named, narrower scope rather than a guess at an
|
|
58
|
+
* exhaustive list.
|
|
59
|
+
*
|
|
60
|
+
* Also out of scope, named rather than silently skipped: Cline's task-title
|
|
61
|
+
* history INDEX (the array a "History" panel is built from) is not a plain
|
|
62
|
+
* file — it is read via VS Code's own `context.globalState` API, which
|
|
63
|
+
* persists into a *different*, central per-profile database
|
|
64
|
+
* (globalStorage/state.vscdb, shared by every installed extension, keyed by
|
|
65
|
+
* extension id) rather than anywhere under this extension's own
|
|
66
|
+
* globalStorage folder. Parsing that shared, VS-Code-owned database is out of
|
|
67
|
+
* scope for this source; the per-task JSON files above are where actual
|
|
68
|
+
* conversation content — and anything pasted into it — lives.
|
|
69
|
+
*/
|
|
70
|
+
const EXT_ID = "saoudrizwan.claude-dev";
|
|
71
|
+
|
|
72
|
+
function vscodeUserDirs() {
|
|
73
|
+
const home = os.homedir();
|
|
74
|
+
const variants = ["Code", "Code - Insiders"];
|
|
75
|
+
if (process.platform === "darwin") {
|
|
76
|
+
return variants.map((v) => path.join(home, "Library", "Application Support", v, "User"));
|
|
77
|
+
}
|
|
78
|
+
if (process.platform === "win32") {
|
|
79
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
80
|
+
return variants.map((v) => path.join(appData, v, "User"));
|
|
81
|
+
}
|
|
82
|
+
// Linux and other XDG-following unix platforms.
|
|
83
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
84
|
+
return variants.map((v) => path.join(configHome, v, "User"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tasksDirs() {
|
|
88
|
+
return vscodeUserDirs().map((userDir) => path.join(userDir, "globalStorage", EXT_ID, "tasks"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Bounds for readLines() — same rationale and same values as claude-code.js:
|
|
92
|
+
// generous headroom over any real transcript, plus a hard stop against a
|
|
93
|
+
// hung read. Not backed by a real Cline transcript this tool was tested
|
|
94
|
+
// against (no install to test with) — see the verification-status note above.
|
|
95
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
|
|
96
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
97
|
+
|
|
98
|
+
function id() { return "cline"; }
|
|
99
|
+
function label() { return "Cline"; }
|
|
100
|
+
|
|
101
|
+
function available() {
|
|
102
|
+
return tasksDirs().some((dir) => {
|
|
103
|
+
try { return fs.statSync(dir).isDirectory(); } catch { return false; }
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Same defensive symlink-following pattern as claude-code.js's
|
|
109
|
+
* isDirFollowingSymlink/isFileFollowingSymlink — see that file's docstring
|
|
110
|
+
* for the full reasoning. Duplicated rather than imported: each source in
|
|
111
|
+
* this project is meant to be a small, self-contained file a reviewer can
|
|
112
|
+
* audit on its own (see CONTRIBUTING.md and cursor.js's own note on this).
|
|
113
|
+
*/
|
|
114
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
115
|
+
if (checkFn(dirent)) return true;
|
|
116
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
117
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
120
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every *.json file directly
|
|
124
|
+
* inside every task directory, across every candidate VS Code User dir.
|
|
125
|
+
*
|
|
126
|
+
* broken:true marks a tasks/ entry or a *.json entry that looked like it
|
|
127
|
+
* should resolve (chiefly a dangling symlink) but didn't — never silently
|
|
128
|
+
* skipped, same convention as claude-code.js and cursor.js.
|
|
129
|
+
*/
|
|
130
|
+
function* files() {
|
|
131
|
+
for (const tasksDir of tasksDirs()) {
|
|
132
|
+
let taskEntries;
|
|
133
|
+
try { taskEntries = fs.readdirSync(tasksDir, { withFileTypes: true }); }
|
|
134
|
+
catch { continue; } // this VS Code variant/profile simply has no Cline tasks dir — normal, not broken
|
|
135
|
+
|
|
136
|
+
for (const taskEntry of taskEntries) {
|
|
137
|
+
const taskDir = path.join(tasksDir, taskEntry.name);
|
|
138
|
+
if (!isDirFollowingSymlink(taskDir, taskEntry)) {
|
|
139
|
+
if (taskEntry.isSymbolicLink()) yield { file: taskDir, broken: true };
|
|
140
|
+
continue; // a stray non-directory entry under tasks/ is out of scope, not broken
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let fileEntries;
|
|
144
|
+
try { fileEntries = fs.readdirSync(taskDir, { withFileTypes: true }); }
|
|
145
|
+
catch { yield { file: taskDir, broken: true }; continue; }
|
|
146
|
+
|
|
147
|
+
for (const e of fileEntries) {
|
|
148
|
+
if (!e.name.endsWith(".json")) continue;
|
|
149
|
+
const file = path.join(taskDir, e.name);
|
|
150
|
+
if (!isFileFollowingSymlink(file, e)) {
|
|
151
|
+
if (e.isSymbolicLink()) yield { file, broken: true };
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
let stat;
|
|
155
|
+
try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
|
|
156
|
+
yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read one JSON file as an array of raw text lines.
|
|
164
|
+
*
|
|
165
|
+
* Cline writes these via `JSON.stringify(value, null, 2)` (confirmed from
|
|
166
|
+
* disk.ts) — real multi-line, indented text, not a single giant line — so
|
|
167
|
+
* the same streamed readline/promises approach claude-code.js uses for JSONL
|
|
168
|
+
* applies here essentially unchanged, and gets the same benefits: no
|
|
169
|
+
* whole-file-as-one-string V8 string-length ceiling, and a partial read (the
|
|
170
|
+
* file started streaming but the read failed partway) still returns
|
|
171
|
+
* whatever lines WERE read rather than discarding real content.
|
|
172
|
+
*
|
|
173
|
+
* Status vocabulary matches every other source in this project: "complete",
|
|
174
|
+
* "partial", "too-large", "failed".
|
|
175
|
+
*/
|
|
176
|
+
async function readLines(file) {
|
|
177
|
+
let stat;
|
|
178
|
+
try { stat = fs.statSync(file); }
|
|
179
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
180
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
181
|
+
|
|
182
|
+
const lines = [];
|
|
183
|
+
let bytesRead = 0;
|
|
184
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
185
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
186
|
+
|
|
187
|
+
// Same rationale as claude-code.js: no natural timeout exists anywhere in
|
|
188
|
+
// Node's stream/readline stack, and a retargeted symlink can make the
|
|
189
|
+
// underlying open() block forever with no event ever firing. Destroying
|
|
190
|
+
// the stream is what actually unblocks that.
|
|
191
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
for await (const line of rl) {
|
|
195
|
+
lines.push(line);
|
|
196
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
|
|
197
|
+
}
|
|
198
|
+
return { lines, status: "complete", bytesRead };
|
|
199
|
+
} catch {
|
|
200
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
201
|
+
} finally {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
rl.close();
|
|
204
|
+
stream.destroy();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { id, label, available, files, readLines };
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Codebuff (codebuff.com, formerly named "Manicode" — the on-disk config
|
|
9
|
+
* directory still uses the old name) local chat history.
|
|
10
|
+
*
|
|
11
|
+
* VERIFICATION STATUS: NOT checked against a real Codebuff install — neither
|
|
12
|
+
* `codebuff` nor `manicode` is on PATH, and no `~/.config/manicode*`
|
|
13
|
+
* directory exists on the machine this adapter was built on (checked PATH,
|
|
14
|
+
* npm -g, mdfind, the paths below directly). Ships anyway per
|
|
15
|
+
* CONTRIBUTING.md rule 3, on the strength of three independent, current,
|
|
16
|
+
* mutually-corroborating sources:
|
|
17
|
+
*
|
|
18
|
+
* 1. Codebuff's own official docs (codebuff.com/docs/advanced), which
|
|
19
|
+
* document the `CODEBUFF_DATA_DIR` override and the fact that history is
|
|
20
|
+
* scoped per "channel" (production/dev/staging).
|
|
21
|
+
* 2. A real, independent, third-party tool's own written documentation of
|
|
22
|
+
* the format: CodexBar (github.com/steipete/CodexBar,
|
|
23
|
+
* docs/codebuff.md), which states conversation history "is stored
|
|
24
|
+
* locally at ~/.config/manicode/projects/<project-name>/chats" and that
|
|
25
|
+
* `~/.config/manicode/credentials.json` is written after `codebuff
|
|
26
|
+
* login` — the "formerly manicode" naming and directory layout agree
|
|
27
|
+
* exactly with source 3 below.
|
|
28
|
+
* 3. The strongest source: ccusage (github.com/ccusage/ccusage, a real,
|
|
29
|
+
* independently maintained, actively developed CLI usage tracker with
|
|
30
|
+
* 18k+ real GitHub stars — sanity-checked directly, not assumed) ships
|
|
31
|
+
* its OWN tested Rust adapter for Codebuff
|
|
32
|
+
* (rust/adapters/codebuff/src/{paths,parser,loader}.rs), fetched and
|
|
33
|
+
* read directly, not summarized secondhand. Its unit tests embed a real
|
|
34
|
+
* fixture file at
|
|
35
|
+
* `projects/project-a/chats/2026-01-02T03-04-05.000Z/chat-messages.json`
|
|
36
|
+
* containing actual message-object shapes
|
|
37
|
+
* (`{"role":"user","text":"hello"}`,
|
|
38
|
+
* `{"id":"...","role":"assistant","timestamp":"...","metadata":{"model":"...","usage":{...}},"credits":1.25}`)
|
|
39
|
+
* — this is a real, working, unrelated tool's reverse-engineered
|
|
40
|
+
* understanding of the exact same file this source reads, the same
|
|
41
|
+
* evidentiary bar cursor.js's own docstring cites approvingly for its
|
|
42
|
+
* own two corroborating community tools.
|
|
43
|
+
*
|
|
44
|
+
* Directory layout (agreed by all three sources): one root per "channel" —
|
|
45
|
+
* `~/.config/manicode` (production), `~/.config/manicode-dev`,
|
|
46
|
+
* `~/.config/manicode-staging` — each containing
|
|
47
|
+
* `projects/<project>/chats/<chatId>/chat-messages.json`, overridable via
|
|
48
|
+
* the `CODEBUFF_DATA_DIR` env var (a comma-separated list of channel roots;
|
|
49
|
+
* source 1 and source 3 agree on both the env var name and its comma-list
|
|
50
|
+
* shape). `chat-messages.json` is a JSON ARRAY of message objects — not
|
|
51
|
+
* line-delimited — so, per the adapter-contract note about non-line-
|
|
52
|
+
* delimited storage, each message object becomes one scanned "line" (see
|
|
53
|
+
* extractTopLevelJsonObjects() below).
|
|
54
|
+
*
|
|
55
|
+
* Deliberately NOT scanned: `credentials.json` (Codebuff's own CLI auth
|
|
56
|
+
* token for the logged-in account) — it is not a session transcript, and
|
|
57
|
+
* every other source in this project scans transcripts only, not each
|
|
58
|
+
* tool's own credential store (cursor.js, for instance, does not read
|
|
59
|
+
* Cursor's OS keychain entries either).
|
|
60
|
+
*/
|
|
61
|
+
const CODEBUFF_DATA_DIR_ENV = "CODEBUFF_DATA_DIR";
|
|
62
|
+
const CHANNELS = ["manicode", "manicode-dev", "manicode-staging"];
|
|
63
|
+
|
|
64
|
+
function id() { return "codebuff"; }
|
|
65
|
+
function label() { return "Codebuff"; }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the "projects" root for every configured channel, deduped.
|
|
69
|
+
*
|
|
70
|
+
* Mirrors ccusage's own `codebuff_project_roots()` exactly: when
|
|
71
|
+
* CODEBUFF_DATA_DIR is set, each comma-separated entry is used as-is if its
|
|
72
|
+
* basename is already "projects", otherwise "projects" is appended — this
|
|
73
|
+
* lets a user point the env var either at a channel root or directly at its
|
|
74
|
+
* projects subdirectory, matching Codebuff's own documented flexibility.
|
|
75
|
+
*/
|
|
76
|
+
function codebuffProjectRoots() {
|
|
77
|
+
const envVal = process.env[CODEBUFF_DATA_DIR_ENV];
|
|
78
|
+
let roots;
|
|
79
|
+
if (envVal && envVal.trim() !== "") {
|
|
80
|
+
roots = envVal
|
|
81
|
+
.split(",")
|
|
82
|
+
.map((s) => s.trim())
|
|
83
|
+
.filter((s) => s !== "")
|
|
84
|
+
.map((p) => path.resolve(p));
|
|
85
|
+
} else {
|
|
86
|
+
const home = os.homedir();
|
|
87
|
+
roots = CHANNELS.map((channel) => path.join(home, ".config", channel));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const projectRoots = [];
|
|
92
|
+
for (const root of roots) {
|
|
93
|
+
const projectRoot = path.basename(root) === "projects" ? root : path.join(root, "projects");
|
|
94
|
+
let isDir = false;
|
|
95
|
+
try { isDir = fs.statSync(projectRoot).isDirectory(); } catch { isDir = false; }
|
|
96
|
+
if (isDir && !seen.has(projectRoot)) {
|
|
97
|
+
seen.add(projectRoot);
|
|
98
|
+
projectRoots.push(projectRoot);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return projectRoots;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function available() {
|
|
105
|
+
return codebuffProjectRoots().length > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Same defensive symlink-following pattern as claude-code.js's
|
|
110
|
+
* isDirFollowingSymlink/isFileFollowingSymlink — duplicated rather than
|
|
111
|
+
* imported per this project's one-small-self-contained-file-per-source
|
|
112
|
+
* convention (see cursor.js's own docstring for why).
|
|
113
|
+
*/
|
|
114
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
115
|
+
if (checkFn(dirent)) return true;
|
|
116
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
117
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve one candidate `chat-messages.json` path into zero or one files()
|
|
123
|
+
* entries — same lstat-first, follow-if-symlink shape as cursor.js's
|
|
124
|
+
* statIfPresent, and for the same reason: this path is constructed (joined
|
|
125
|
+
* onto an already-resolved chat directory), not discovered via a Dirent, so
|
|
126
|
+
* there is no Dirent to reuse the isKindFollowingSymlink check against. A
|
|
127
|
+
* chat directory with no chat-messages.json yet (a brand new, still-empty
|
|
128
|
+
* chat) is normal and NOT broken; only a symlink that fails to resolve is.
|
|
129
|
+
*/
|
|
130
|
+
function* statFileIfPresent(file) {
|
|
131
|
+
let lst;
|
|
132
|
+
try { lst = fs.lstatSync(file); }
|
|
133
|
+
catch { return; }
|
|
134
|
+
|
|
135
|
+
if (lst.isSymbolicLink()) {
|
|
136
|
+
try {
|
|
137
|
+
const st = fs.statSync(file);
|
|
138
|
+
if (!st.isFile()) { yield { file, broken: true }; return; }
|
|
139
|
+
yield { file, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
|
|
140
|
+
} catch {
|
|
141
|
+
yield { file, broken: true };
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!lst.isFile()) return;
|
|
147
|
+
yield { file, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every chat-messages.json
|
|
152
|
+
* found under every configured channel's projects root:
|
|
153
|
+
* <projectsRoot>/<project>/chats/<chatId>/chat-messages.json.
|
|
154
|
+
*/
|
|
155
|
+
function* files() {
|
|
156
|
+
for (const projectsRoot of codebuffProjectRoots()) {
|
|
157
|
+
let projectEntries;
|
|
158
|
+
try { projectEntries = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
|
|
159
|
+
catch { continue; }
|
|
160
|
+
|
|
161
|
+
for (const proj of projectEntries) {
|
|
162
|
+
const projDir = path.join(projectsRoot, proj.name);
|
|
163
|
+
if (!isDirFollowingSymlink(projDir, proj)) {
|
|
164
|
+
if (proj.isSymbolicLink()) yield { file: projDir, broken: true };
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const chatsDir = path.join(projDir, "chats");
|
|
169
|
+
let chatEntries;
|
|
170
|
+
try { chatEntries = fs.readdirSync(chatsDir, { withFileTypes: true }); }
|
|
171
|
+
catch { continue; } // no "chats" subdir yet — normal for a project with no chat history
|
|
172
|
+
|
|
173
|
+
for (const chat of chatEntries) {
|
|
174
|
+
const chatDir = path.join(chatsDir, chat.name);
|
|
175
|
+
if (!isDirFollowingSymlink(chatDir, chat)) {
|
|
176
|
+
if (chat.isSymbolicLink()) yield { file: chatDir, broken: true };
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
yield* statFileIfPresent(path.join(chatDir, "chat-messages.json"));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A single chat's message history has not been observed anywhere in this
|
|
186
|
+
// source's research to approach this size — like cursor.js's MAX_DB_BYTES,
|
|
187
|
+
// this is a generous, untested-against-a-real-huge-file backstop against a
|
|
188
|
+
// corrupted or pathological file, not an empirically derived ceiling.
|
|
189
|
+
const MAX_BYTES = 512 * 1024 * 1024;
|
|
190
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read the whole file as text, bounded by a wall-clock deadline the same
|
|
194
|
+
* way claude-code.js bounds its line-by-line read: no timeout exists
|
|
195
|
+
* natively anywhere in Node's stream stack, so a symlink retargeted onto
|
|
196
|
+
* something with no natural EOF (e.g. a FIFO with no writer) would otherwise
|
|
197
|
+
* hang forever with no 'error' or 'end' ever firing. Destroying the stream
|
|
198
|
+
* is what actually unblocks that.
|
|
199
|
+
*
|
|
200
|
+
* Resolves with whatever text WAS accumulated even when the read errors or
|
|
201
|
+
* times out partway — the caller (readLines) still extracts every complete
|
|
202
|
+
* top-level JSON object out of that partial text rather than discarding it,
|
|
203
|
+
* the same "partial read is still real content" principle claude-code.js's
|
|
204
|
+
* and cursor.js's own docstrings insist on.
|
|
205
|
+
*/
|
|
206
|
+
function readWholeFileBounded(file) {
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
209
|
+
let text = "";
|
|
210
|
+
let bytesRead = 0;
|
|
211
|
+
let errored = false;
|
|
212
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
213
|
+
stream.on("data", (chunk) => {
|
|
214
|
+
text += chunk;
|
|
215
|
+
bytesRead += Buffer.byteLength(chunk, "utf-8");
|
|
216
|
+
});
|
|
217
|
+
stream.on("end", () => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
resolve({ text, bytesRead, complete: true });
|
|
220
|
+
});
|
|
221
|
+
stream.on("error", () => {
|
|
222
|
+
if (errored) return; // 'error' can fire once for the destroy() and once natively
|
|
223
|
+
errored = true;
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
resolve({ text, bytesRead, complete: false });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Extract every complete top-level `{...}` object out of arbitrary JSON
|
|
232
|
+
* text, tracking brace depth and string/escape state, WITHOUT requiring the
|
|
233
|
+
* surrounding `[ ... ]` array to be syntactically complete.
|
|
234
|
+
*
|
|
235
|
+
* This is what lets a partial read (timeout, mid-write file, I/O error)
|
|
236
|
+
* still surface every message that was fully written before the cutoff,
|
|
237
|
+
* instead of the whole file being discarded because JSON.parse on truncated
|
|
238
|
+
* input throws. It is also what lets a fully-successful read skip a real
|
|
239
|
+
* JSON.parse of the whole array entirely: each returned substring is used
|
|
240
|
+
* verbatim as one scanned "line", the same "don't re-serialize, keep the
|
|
241
|
+
* exact bytes the regexes depend on" reasoning cursor.js's valueToText()
|
|
242
|
+
* docstring gives for not round-tripping through JSON.parse/stringify.
|
|
243
|
+
*/
|
|
244
|
+
function extractTopLevelJsonObjects(text) {
|
|
245
|
+
const objects = [];
|
|
246
|
+
let depth = 0;
|
|
247
|
+
let start = -1;
|
|
248
|
+
let inString = false;
|
|
249
|
+
let escape = false;
|
|
250
|
+
for (let i = 0; i < text.length; i++) {
|
|
251
|
+
const ch = text[i];
|
|
252
|
+
if (inString) {
|
|
253
|
+
if (escape) escape = false;
|
|
254
|
+
else if (ch === "\\") escape = true;
|
|
255
|
+
else if (ch === '"') inString = false;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (ch === '"') { inString = true; continue; }
|
|
259
|
+
if (ch === "{") {
|
|
260
|
+
if (depth === 0) start = i;
|
|
261
|
+
depth++;
|
|
262
|
+
} else if (ch === "}") {
|
|
263
|
+
if (depth > 0) depth--;
|
|
264
|
+
if (depth === 0 && start !== -1) {
|
|
265
|
+
objects.push(text.slice(start, i + 1));
|
|
266
|
+
start = -1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return objects;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Read one chat-messages.json as an array of raw text "lines", one per
|
|
275
|
+
* top-level message object. Returns { lines, status, bytesRead } with the
|
|
276
|
+
* same status vocabulary as claude-code.js/cursor.js: "complete", "partial",
|
|
277
|
+
* "too-large", "failed".
|
|
278
|
+
*/
|
|
279
|
+
async function readLines(file) {
|
|
280
|
+
let stat;
|
|
281
|
+
try { stat = fs.statSync(file); }
|
|
282
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
283
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
284
|
+
|
|
285
|
+
const { text, bytesRead, complete } = await readWholeFileBounded(file);
|
|
286
|
+
const lines = extractTopLevelJsonObjects(text);
|
|
287
|
+
|
|
288
|
+
if (complete) return { lines, status: "complete", bytesRead };
|
|
289
|
+
// Errored or timed out partway: whatever complete objects WERE recovered
|
|
290
|
+
// are real content and may contain a real secret — never discard them
|
|
291
|
+
// just because the tail of the file didn't finish cleanly.
|
|
292
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = { id, label, available, files, readLines };
|