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.
Files changed (50) hide show
  1. package/README.md +225 -46
  2. package/SECURITY.md +29 -22
  3. package/package.json +1 -1
  4. package/src/cli.js +82 -16
  5. package/src/integrity.js +669 -0
  6. package/src/patterns.js +78 -5
  7. package/src/report.js +74 -7
  8. package/src/sources/agent-configs.js +308 -0
  9. package/src/sources/aider.js +361 -0
  10. package/src/sources/amazon-q.js +199 -0
  11. package/src/sources/antigravity-cli.js +155 -0
  12. package/src/sources/cline.js +208 -0
  13. package/src/sources/codebuff.js +295 -0
  14. package/src/sources/codex-cli.js +258 -0
  15. package/src/sources/cody.js +325 -0
  16. package/src/sources/continue.js +408 -0
  17. package/src/sources/copilot-chat.js +272 -0
  18. package/src/sources/copilot-cli.js +300 -0
  19. package/src/sources/crush.js +364 -0
  20. package/src/sources/cursor.js +374 -0
  21. package/src/sources/devin-cli.js +241 -0
  22. package/src/sources/factory-droid.js +153 -0
  23. package/src/sources/fx.js +136 -0
  24. package/src/sources/gemini-cli.js +242 -0
  25. package/src/sources/goose.js +366 -0
  26. package/src/sources/grok-cli.js +267 -0
  27. package/src/sources/hermes.js +282 -0
  28. package/src/sources/index.js +172 -8
  29. package/src/sources/jetbrains-ai-assistant.js +343 -0
  30. package/src/sources/jetbrains-junie.js +292 -0
  31. package/src/sources/kilo-code.js +430 -0
  32. package/src/sources/kimi-code.js +147 -0
  33. package/src/sources/kiro-cli.js +393 -0
  34. package/src/sources/kiro-ide.js +230 -0
  35. package/src/sources/llm.js +328 -0
  36. package/src/sources/mentat.js +143 -0
  37. package/src/sources/open-interpreter.js +224 -0
  38. package/src/sources/openclaw.js +218 -0
  39. package/src/sources/opencode.js +379 -0
  40. package/src/sources/openhands.js +181 -0
  41. package/src/sources/pearai.js +151 -0
  42. package/src/sources/pi-agent.js +130 -0
  43. package/src/sources/qodo-gen.js +189 -0
  44. package/src/sources/qwen-code.js +244 -0
  45. package/src/sources/roo-code.js +239 -0
  46. package/src/sources/trae.js +294 -0
  47. package/src/sources/void.js +273 -0
  48. package/src/sources/warp.js +395 -0
  49. package/src/sources/windsurf.js +256 -0
  50. 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 };