@flarecode/import-memory 0.2.1 → 0.2.3
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 +7 -4
- package/package.json +2 -5
- package/src/index.js +30 -5
- package/src/scanner.js +230 -49
package/README.md
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
# @flarecode/import-memory
|
|
2
2
|
|
|
3
|
-
One-command helper for
|
|
3
|
+
One-command helper for importing your local AI coding-agent preferences into FlareCode.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npx --yes @flarecode/import-memory@latest
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Run it from **anywhere**. It reads your global config (e.g. `~/.claude/CLAUDE.md`) and discovers rule files — `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.cursor/rules`, `.github/copilot-instructions.md` — across all your repos (common code folders, your Cursor/VS Code workspaces, and the current directory), plus a redacted summary of your Claude Code, Codex, Cursor, and VS Code settings.
|
|
10
|
+
|
|
11
|
+
Rule files are redacted and deduplicated on your machine before upload. Raw transcript contents are never uploaded. The shell wrapper at `https://flarecode.sh/import.sh` only detects a package runner and delegates to this npm package.
|
|
10
12
|
|
|
11
13
|
Useful flags:
|
|
12
14
|
|
|
13
15
|
```sh
|
|
14
16
|
npx --yes @flarecode/import-memory@latest --dry-run
|
|
15
17
|
npx --yes @flarecode/import-memory@latest --json
|
|
16
|
-
npx --yes @flarecode/import-memory@latest --
|
|
18
|
+
npx --yes @flarecode/import-memory@latest --repos ~/code,~/work
|
|
17
19
|
npx --yes @flarecode/import-memory@latest --repo ~/code/my-app
|
|
20
|
+
npx --yes @flarecode/import-memory@latest --no-discover
|
|
21
|
+
npx --yes @flarecode/import-memory@latest --source claude,codex,cursor
|
|
18
22
|
npx --yes @flarecode/import-memory@latest --since 90d
|
|
19
|
-
npx --yes @flarecode/import-memory@latest --no-transcripts
|
|
20
23
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flarecode/import-memory",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "One-command helper for discovering local coding-agent memory sources before importing them into FlareCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -11,10 +11,7 @@
|
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./src/scanner.js"
|
|
13
13
|
},
|
|
14
|
-
"files": [
|
|
15
|
-
"src",
|
|
16
|
-
"README.md"
|
|
17
|
-
],
|
|
14
|
+
"files": ["src", "README.md"],
|
|
18
15
|
"publishConfig": {
|
|
19
16
|
"access": "public"
|
|
20
17
|
},
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { exec } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import process from "node:process";
|
|
4
5
|
import { formatScanSummary, parseSince, parseSourceFilter, readRulesContent, scanSources } from "./scanner.js";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// Read from package.json (always shipped in the tarball) so --version never
|
|
8
|
+
// drifts from the published version.
|
|
9
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
7
10
|
const API_BASE = "https://api.flarecode.sh";
|
|
8
11
|
const APP_BASE = "https://app.flarecode.sh";
|
|
9
12
|
|
|
@@ -76,14 +79,18 @@ async function main(argv) {
|
|
|
76
79
|
|
|
77
80
|
if (!args.json) {
|
|
78
81
|
process.stdout.write("FlareCode memory import helper\n");
|
|
79
|
-
process.stdout.write("Scanning
|
|
80
|
-
process.stdout.write(
|
|
82
|
+
process.stdout.write("Scanning your global config and rule files across every repo — run it from anywhere.\n");
|
|
83
|
+
process.stdout.write(
|
|
84
|
+
"Only rule files and a settings summary are uploaded (redacted). Transcript contents stay on this machine.\n\n",
|
|
85
|
+
);
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
const result = await scanSources({
|
|
84
89
|
sources,
|
|
85
90
|
sinceMs,
|
|
86
91
|
repoPath: args.repo,
|
|
92
|
+
reposDirs: args.repos,
|
|
93
|
+
discover: !args.noDiscover,
|
|
87
94
|
includeTranscripts: !args.noTranscripts,
|
|
88
95
|
});
|
|
89
96
|
|
|
@@ -127,9 +134,11 @@ function parseArgs(argv) {
|
|
|
127
134
|
help: false,
|
|
128
135
|
version: false,
|
|
129
136
|
noTranscripts: false,
|
|
137
|
+
noDiscover: false,
|
|
130
138
|
source: null,
|
|
131
139
|
since: null,
|
|
132
140
|
repo: null,
|
|
141
|
+
repos: [],
|
|
133
142
|
};
|
|
134
143
|
|
|
135
144
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -140,18 +149,28 @@ function parseArgs(argv) {
|
|
|
140
149
|
else if (arg === "--help" || arg === "-h") out.help = true;
|
|
141
150
|
else if (arg === "--version" || arg === "-v") out.version = true;
|
|
142
151
|
else if (arg === "--no-transcripts") out.noTranscripts = true;
|
|
152
|
+
else if (arg === "--no-discover") out.noDiscover = true;
|
|
143
153
|
else if (arg === "--source") out.source = requireValue(argv, ++i, "--source");
|
|
144
154
|
else if (arg.startsWith("--source=")) out.source = arg.slice("--source=".length);
|
|
145
155
|
else if (arg === "--since") out.since = requireValue(argv, ++i, "--since");
|
|
146
156
|
else if (arg.startsWith("--since=")) out.since = arg.slice("--since=".length);
|
|
147
157
|
else if (arg === "--repo") out.repo = requireValue(argv, ++i, "--repo");
|
|
148
158
|
else if (arg.startsWith("--repo=")) out.repo = arg.slice("--repo=".length);
|
|
159
|
+
else if (arg === "--repos") out.repos.push(...splitList(requireValue(argv, ++i, "--repos")));
|
|
160
|
+
else if (arg.startsWith("--repos=")) out.repos.push(...splitList(arg.slice("--repos=".length)));
|
|
149
161
|
else throw new Error(`Unknown argument: ${arg}`);
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
return out;
|
|
153
165
|
}
|
|
154
166
|
|
|
167
|
+
function splitList(value) {
|
|
168
|
+
return value
|
|
169
|
+
.split(",")
|
|
170
|
+
.map((v) => v.trim())
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
}
|
|
173
|
+
|
|
155
174
|
function requireValue(argv, index, flag) {
|
|
156
175
|
const value = argv[index];
|
|
157
176
|
if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
|
|
@@ -164,14 +183,20 @@ function helpText() {
|
|
|
164
183
|
Usage:
|
|
165
184
|
npx --yes @flarecode/import-memory@latest [options]
|
|
166
185
|
|
|
186
|
+
By default it reads your global config (e.g. ~/.claude/CLAUDE.md) and discovers
|
|
187
|
+
rule files (AGENTS.md, CLAUDE.md, .cursorrules, Copilot instructions) across all
|
|
188
|
+
your repos — so it works no matter which directory you run it from.
|
|
189
|
+
|
|
167
190
|
Options:
|
|
168
191
|
--dry-run Scan and print a summary without preparing upload.
|
|
169
192
|
--skip-upload Scan and print a summary without uploading.
|
|
170
193
|
--json Print machine-readable scan output.
|
|
171
194
|
--source <list> Comma-separated: claude,codex,cursor,vscode,repo.
|
|
172
195
|
--since <duration> Only include files modified within 30d, 12w, 6m, or 1y.
|
|
173
|
-
--repo <path>
|
|
174
|
-
--
|
|
196
|
+
--repo <path> Also include rules from a specific repo.
|
|
197
|
+
--repos <dirs> Comma-separated parent folders to discover repos under.
|
|
198
|
+
--no-discover Only scan the current repo, not all discovered repos.
|
|
199
|
+
--no-transcripts Skip even counting transcripts.
|
|
175
200
|
--version, -v Print version.
|
|
176
201
|
--help, -h Print this help.
|
|
177
202
|
`;
|
package/src/scanner.js
CHANGED
|
@@ -4,11 +4,36 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
const DEFAULT_MAX_DEPTH = 6;
|
|
6
6
|
const DEFAULT_MAX_FILES_PER_SOURCE = 5000;
|
|
7
|
+
const DEFAULT_MAX_REPOS = 200;
|
|
8
|
+
const REPO_DISCOVERY_DEPTH = 3;
|
|
7
9
|
|
|
8
10
|
const SOURCE_IDS = new Set(["claude", "codex", "cursor", "vscode", "repo"]);
|
|
9
11
|
|
|
10
12
|
const TRANSCRIPT_EXTENSIONS = new Set([".jsonl", ".json", ".ndjson"]);
|
|
11
13
|
|
|
14
|
+
// Conventional parent folders to look for the user's repos in, so a run from
|
|
15
|
+
// anywhere (e.g. ~) still discovers rule files across every repo — not just the
|
|
16
|
+
// cwd. Relative to home.
|
|
17
|
+
const CODE_ROOTS = [
|
|
18
|
+
"code",
|
|
19
|
+
"Code",
|
|
20
|
+
"projects",
|
|
21
|
+
"Projects",
|
|
22
|
+
"src",
|
|
23
|
+
"dev",
|
|
24
|
+
"Developer",
|
|
25
|
+
"work",
|
|
26
|
+
"repos",
|
|
27
|
+
"git",
|
|
28
|
+
"github",
|
|
29
|
+
"GitHub",
|
|
30
|
+
"workspace",
|
|
31
|
+
"workspaces",
|
|
32
|
+
"conductor/workspaces",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const RULE_BASENAMES = new Set(["agents.md", "claude.md", ".cursorrules"]);
|
|
36
|
+
|
|
12
37
|
export function parseSourceFilter(value) {
|
|
13
38
|
if (!value) return [...SOURCE_IDS];
|
|
14
39
|
const parsed = value
|
|
@@ -36,11 +61,16 @@ export async function scanSources(options = {}) {
|
|
|
36
61
|
const sourceFilter = new Set(options.sources ?? [...SOURCE_IDS]);
|
|
37
62
|
const home = options.home ?? homedir();
|
|
38
63
|
const cwd = options.cwd ?? process.cwd();
|
|
39
|
-
const repoPath = options.repoPath ? expandHome(options.repoPath, home) : await findRepoRoot(cwd);
|
|
40
64
|
const sinceMs = options.sinceMs ?? null;
|
|
41
65
|
const includeTranscripts = options.includeTranscripts ?? true;
|
|
42
66
|
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
43
67
|
const maxFilesPerSource = options.maxFilesPerSource ?? DEFAULT_MAX_FILES_PER_SOURCE;
|
|
68
|
+
const discover = options.discover ?? true;
|
|
69
|
+
const maxRepos = options.maxRepos ?? DEFAULT_MAX_REPOS;
|
|
70
|
+
const explicitRepos = (options.repoPaths ?? (options.repoPath ? [options.repoPath] : [])).map((p) =>
|
|
71
|
+
expandHome(p, home),
|
|
72
|
+
);
|
|
73
|
+
const reposDirs = (options.reposDirs ?? []).map((p) => expandHome(p, home));
|
|
44
74
|
|
|
45
75
|
const sources = [];
|
|
46
76
|
|
|
@@ -100,12 +130,14 @@ export async function scanSources(options = {}) {
|
|
|
100
130
|
);
|
|
101
131
|
}
|
|
102
132
|
|
|
133
|
+
let repoPaths = [];
|
|
103
134
|
if (sourceFilter.has("repo")) {
|
|
135
|
+
repoPaths = await resolveRepoPaths({ home, cwd, explicitRepos, reposDirs, discover, maxRepos });
|
|
104
136
|
sources.push(
|
|
105
|
-
await
|
|
106
|
-
|
|
137
|
+
await scanReposSource({
|
|
138
|
+
repoPaths,
|
|
107
139
|
sinceMs,
|
|
108
|
-
maxDepth:
|
|
140
|
+
maxDepth: REPO_DISCOVERY_DEPTH,
|
|
109
141
|
maxFiles: maxFilesPerSource,
|
|
110
142
|
}),
|
|
111
143
|
);
|
|
@@ -127,7 +159,9 @@ export async function scanSources(options = {}) {
|
|
|
127
159
|
scannedAt: new Date().toISOString(),
|
|
128
160
|
platform: platform(),
|
|
129
161
|
home,
|
|
130
|
-
repoPath,
|
|
162
|
+
repoPath: repoPaths[0] ?? null,
|
|
163
|
+
repoPaths,
|
|
164
|
+
reposScanned: repoPaths.length,
|
|
131
165
|
sources,
|
|
132
166
|
foundSources: foundSources.length,
|
|
133
167
|
totals,
|
|
@@ -141,10 +175,10 @@ export function formatScanSummary(result) {
|
|
|
141
175
|
lines.push(`Scanned sources: ${result.sources.length}`);
|
|
142
176
|
lines.push(`Found sources: ${result.foundSources}`);
|
|
143
177
|
lines.push(
|
|
144
|
-
`Candidate files: ${result.totals.files} (${result.totals.
|
|
178
|
+
`Candidate files: ${result.totals.files} (${result.totals.ruleFiles} rules, ${result.totals.settingsFiles} settings, ${result.totals.transcriptFiles} transcripts)`,
|
|
145
179
|
);
|
|
146
180
|
lines.push(`Approx bytes: ${formatBytes(result.totals.bytes)}`);
|
|
147
|
-
if (result.
|
|
181
|
+
if (result.reposScanned > 0) lines.push(`Repos discovered: ${result.reposScanned}`);
|
|
148
182
|
lines.push("");
|
|
149
183
|
|
|
150
184
|
for (const source of result.sources) {
|
|
@@ -153,38 +187,46 @@ export function formatScanSummary(result) {
|
|
|
153
187
|
for (const root of source.roots) {
|
|
154
188
|
lines.push(` - ${root.exists ? "found" : "missing"} ${root.path}`);
|
|
155
189
|
}
|
|
156
|
-
|
|
157
|
-
|
|
190
|
+
const rules = source.files.filter((f) => f.kind === "rule");
|
|
191
|
+
const preview = [...rules, ...source.files.filter((f) => f.kind !== "rule")];
|
|
192
|
+
if (preview.length > 0) {
|
|
193
|
+
for (const file of preview.slice(0, 6)) {
|
|
158
194
|
lines.push(` ${file.kind}: ${file.path}`);
|
|
159
195
|
}
|
|
160
|
-
if (
|
|
196
|
+
if (preview.length > 6) lines.push(` ... ${preview.length - 6} more`);
|
|
161
197
|
}
|
|
162
198
|
}
|
|
163
199
|
|
|
164
200
|
lines.push("");
|
|
165
|
-
lines.push("No raw transcript contents were uploaded.");
|
|
166
201
|
return lines.join("\n");
|
|
167
202
|
}
|
|
168
203
|
|
|
204
|
+
const REDACT_PATTERNS = [
|
|
205
|
+
/sk-[a-zA-Z0-9]{20,}/g,
|
|
206
|
+
/npm_[a-zA-Z0-9]{20,}/g,
|
|
207
|
+
/ghp_[a-zA-Z0-9]{20,}/g,
|
|
208
|
+
/github_pat_[a-zA-Z0-9_]{20,}/g,
|
|
209
|
+
/xox[baprs]-[a-zA-Z0-9-]{10,}/g,
|
|
210
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
211
|
+
/AIza[0-9A-Za-z-_]{35}/g,
|
|
212
|
+
/Bearer\s+[a-zA-Z0-9._-]{20,}/gi,
|
|
213
|
+
/token["\s:=]+["']?[a-zA-Z0-9._-]{20,}/gi,
|
|
214
|
+
/secret["\s:=]+["']?[a-zA-Z0-9._-]{20,}/gi,
|
|
215
|
+
/password["\s:=]+["']?\S+/gi,
|
|
216
|
+
/apikey["\s:=]+["']?\S+/gi,
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
function redact(text) {
|
|
220
|
+
let out = text;
|
|
221
|
+
for (const pat of REDACT_PATTERNS) out = out.replace(pat, "[REDACTED]");
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Read rule + settings file contents for upload. Rule contents are deduped by a
|
|
226
|
+
// content hash so 28 worktrees of the same repo upload its rules once, not 28×.
|
|
169
227
|
export async function readRulesContent(scanResult) {
|
|
170
228
|
const out = { rulesContent: [], settingsSummary: [] };
|
|
171
|
-
const
|
|
172
|
-
/sk-[a-zA-Z0-9]{20,}/g,
|
|
173
|
-
/npm_[a-zA-Z0-9]{20,}/g,
|
|
174
|
-
/ghp_[a-zA-Z0-9]{20,}/g,
|
|
175
|
-
/AIza[0-9A-Za-z-_]{35}/g,
|
|
176
|
-
/Bearer\s+[a-zA-Z0-9._-]{20,}/gi,
|
|
177
|
-
/token["\s:=]+["']?[a-zA-Z0-9._-]{20,}/gi,
|
|
178
|
-
/secret["\s:=]+["']?[a-zA-Z0-9._-]{20,}/gi,
|
|
179
|
-
/password["\s:=]+["']?\S+/gi,
|
|
180
|
-
/apikey["\s:=]+["']?\S+/gi,
|
|
181
|
-
];
|
|
182
|
-
|
|
183
|
-
const redact = (text) => {
|
|
184
|
-
let redacted = text;
|
|
185
|
-
for (const pat of REDACT_PATTERNS) redacted = redacted.replace(pat, "[REDACTED]");
|
|
186
|
-
return redacted;
|
|
187
|
-
};
|
|
229
|
+
const seenRuleHashes = new Set();
|
|
188
230
|
|
|
189
231
|
for (const source of scanResult.sources) {
|
|
190
232
|
for (const file of source.files) {
|
|
@@ -192,8 +234,11 @@ export async function readRulesContent(scanResult) {
|
|
|
192
234
|
if (file.bytes > 100_000) continue;
|
|
193
235
|
try {
|
|
194
236
|
const raw = await readFile(file.path, "utf8");
|
|
195
|
-
const content = redact(raw).slice(0, 8000);
|
|
196
237
|
if (file.kind === "rule") {
|
|
238
|
+
const content = redact(raw).slice(0, 8000);
|
|
239
|
+
const h = hash(content);
|
|
240
|
+
if (seenRuleHashes.has(h)) continue;
|
|
241
|
+
seenRuleHashes.add(h);
|
|
197
242
|
out.rulesContent.push({ source: source.id, path: file.path, content });
|
|
198
243
|
} else {
|
|
199
244
|
const summary = extractSettingsSummary(raw, source.id);
|
|
@@ -300,26 +345,143 @@ async function scanSource(input) {
|
|
|
300
345
|
};
|
|
301
346
|
}
|
|
302
347
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
348
|
+
// Aggregate rule files across every discovered repo into a single "repo" source.
|
|
349
|
+
async function scanReposSource(input) {
|
|
350
|
+
const roots = [];
|
|
351
|
+
const files = [];
|
|
352
|
+
const seenPaths = new Set();
|
|
353
|
+
|
|
354
|
+
for (const repoPath of input.repoPaths) {
|
|
355
|
+
for (const root of repoRuleRoots(repoPath)) {
|
|
356
|
+
const expanded = expandHome(root.path);
|
|
357
|
+
const rootStat = await safeStat(expanded);
|
|
358
|
+
const exists = Boolean(rootStat);
|
|
359
|
+
if (roots.length < 40) roots.push({ path: expanded, role: root.role, exists });
|
|
360
|
+
if (!rootStat) continue;
|
|
361
|
+
if (rootStat.isDirectory()) {
|
|
362
|
+
const walked = await walkCandidateFiles(expanded, {
|
|
363
|
+
sourceId: "repo",
|
|
364
|
+
rootRole: root.role,
|
|
365
|
+
sinceMs: input.sinceMs,
|
|
366
|
+
includeTranscripts: false,
|
|
367
|
+
maxDepth: input.maxDepth,
|
|
368
|
+
maxFiles: input.maxFiles - files.length,
|
|
369
|
+
});
|
|
370
|
+
for (const f of walked.files) {
|
|
371
|
+
if (seenPaths.has(f.path)) continue;
|
|
372
|
+
seenPaths.add(f.path);
|
|
373
|
+
files.push(f);
|
|
374
|
+
}
|
|
375
|
+
} else if (rootStat.isFile()) {
|
|
376
|
+
const candidate = classifyCandidate(expanded, "repo", root.role);
|
|
377
|
+
if (candidate && passesFilters(candidate, rootStat, input.sinceMs, false) && !seenPaths.has(expanded)) {
|
|
378
|
+
seenPaths.add(expanded);
|
|
379
|
+
files.push(toFileSummary(expanded, candidate.kind, rootStat));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
313
383
|
}
|
|
314
|
-
|
|
384
|
+
|
|
385
|
+
return {
|
|
315
386
|
id: "repo",
|
|
316
387
|
label: "Repo rules",
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
388
|
+
exists: files.length > 0,
|
|
389
|
+
roots,
|
|
390
|
+
files,
|
|
391
|
+
truncated: false,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Build the full set of repos to scan: explicit (--repo), cwd, conventional
|
|
396
|
+
// code roots, expanded --repos dirs, and local folders referenced by Cursor/VS
|
|
397
|
+
// Code workspaces. Deduped.
|
|
398
|
+
async function resolveRepoPaths({ home, cwd, explicitRepos, reposDirs, discover, maxRepos }) {
|
|
399
|
+
const found = new Set();
|
|
400
|
+
|
|
401
|
+
for (const p of explicitRepos) {
|
|
402
|
+
const root = await findRepoRoot(p);
|
|
403
|
+
if (root) found.add(root);
|
|
404
|
+
}
|
|
405
|
+
const cwdRoot = await findRepoRoot(cwd);
|
|
406
|
+
if (cwdRoot) found.add(cwdRoot);
|
|
407
|
+
|
|
408
|
+
if (discover) {
|
|
409
|
+
const parents = [...reposDirs];
|
|
410
|
+
for (const name of CODE_ROOTS) parents.push(path.join(home, name));
|
|
411
|
+
for (const parent of parents) {
|
|
412
|
+
if (found.size >= maxRepos) break;
|
|
413
|
+
await discoverReposUnder(parent, found, maxRepos);
|
|
414
|
+
}
|
|
415
|
+
for (const wsFolder of await workspaceFolders(home)) {
|
|
416
|
+
if (found.size >= maxRepos) break;
|
|
417
|
+
const root = await findRepoRoot(wsFolder);
|
|
418
|
+
if (root) found.add(root);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return [...found].slice(0, maxRepos);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function discoverReposUnder(parent, found, maxRepos, depth = 0) {
|
|
426
|
+
if (found.size >= maxRepos || depth > REPO_DISCOVERY_DEPTH) return;
|
|
427
|
+
let entries;
|
|
428
|
+
try {
|
|
429
|
+
entries = await readdir(parent, { withFileTypes: true });
|
|
430
|
+
} catch {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
if (found.size >= maxRepos) return;
|
|
435
|
+
if (!entry.isDirectory() || shouldSkipEntry(entry.name)) continue;
|
|
436
|
+
const dir = path.join(parent, entry.name);
|
|
437
|
+
if (await isRepoDir(dir)) {
|
|
438
|
+
found.add(dir);
|
|
439
|
+
continue; // don't descend into a repo
|
|
440
|
+
}
|
|
441
|
+
await discoverReposUnder(dir, found, maxRepos, depth + 1);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function isRepoDir(dir) {
|
|
446
|
+
if (await exists(path.join(dir, ".git"))) return true;
|
|
447
|
+
return hasRepoRuleFile(dir);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Local repo folders the user has open in Cursor / VS Code, read from each
|
|
451
|
+
// workspace's workspace.json `folder` URI. Remote (ssh-remote, etc.) folders
|
|
452
|
+
// are skipped — only file:// paths can have local rule files.
|
|
453
|
+
async function workspaceFolders(home) {
|
|
454
|
+
const folders = [];
|
|
455
|
+
for (const base of [appSupportPath(home, "Cursor"), appSupportPath(home, "Code")]) {
|
|
456
|
+
const storage = path.join(base, "User", "workspaceStorage");
|
|
457
|
+
let dirs;
|
|
458
|
+
try {
|
|
459
|
+
dirs = await readdir(storage, { withFileTypes: true });
|
|
460
|
+
} catch {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
for (const d of dirs) {
|
|
464
|
+
if (!d.isDirectory()) continue;
|
|
465
|
+
try {
|
|
466
|
+
const raw = await readFile(path.join(storage, d.name, "workspace.json"), "utf8");
|
|
467
|
+
const folder = JSON.parse(raw)?.folder;
|
|
468
|
+
if (typeof folder === "string" && folder.startsWith("file://")) {
|
|
469
|
+
folders.push(fileUrlToPath(folder));
|
|
470
|
+
}
|
|
471
|
+
} catch {
|
|
472
|
+
// missing/unparseable workspace.json — skip
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return folders;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function fileUrlToPath(url) {
|
|
480
|
+
try {
|
|
481
|
+
return decodeURIComponent(new URL(url).pathname);
|
|
482
|
+
} catch {
|
|
483
|
+
return url.replace(/^file:\/\//, "");
|
|
484
|
+
}
|
|
323
485
|
}
|
|
324
486
|
|
|
325
487
|
async function walkCandidateFiles(root, input, depth = 0) {
|
|
@@ -365,7 +527,7 @@ function classifyCandidate(filePath, sourceId, rootRole) {
|
|
|
365
527
|
const normalized = filePath.split(path.sep).join("/");
|
|
366
528
|
|
|
367
529
|
if (sourceId === "repo") {
|
|
368
|
-
if (
|
|
530
|
+
if (RULE_BASENAMES.has(base)) return { kind: "rule" };
|
|
369
531
|
if (normalized.endsWith("/.github/copilot-instructions.md")) return { kind: "rule" };
|
|
370
532
|
if (normalized.includes("/.cursor/rules/")) return { kind: "rule" };
|
|
371
533
|
if (normalized.endsWith("/.vscode/settings.json")) return { kind: "settings" };
|
|
@@ -377,7 +539,7 @@ function classifyCandidate(filePath, sourceId, rootRole) {
|
|
|
377
539
|
}
|
|
378
540
|
|
|
379
541
|
if (base === "claude.json" || base === ".claude.json") return { kind: "settings" };
|
|
380
|
-
if (
|
|
542
|
+
if (RULE_BASENAMES.has(base)) return { kind: "rule" };
|
|
381
543
|
if (rootRole === "transcripts" && TRANSCRIPT_EXTENSIONS.has(ext)) return { kind: "transcript" };
|
|
382
544
|
if (rootRole === "workspace-storage" && (TRANSCRIPT_EXTENSIONS.has(ext) || base.endsWith(".vscdb"))) {
|
|
383
545
|
return { kind: "transcript" };
|
|
@@ -404,6 +566,7 @@ function toFileSummary(filePath, kind, fileStat) {
|
|
|
404
566
|
function claudeRoots(home) {
|
|
405
567
|
return [
|
|
406
568
|
{ path: path.join(home, ".claude", "projects"), role: "transcripts" },
|
|
569
|
+
{ path: path.join(home, ".claude", "CLAUDE.md"), role: "rules" },
|
|
407
570
|
{ path: path.join(home, ".claude", "settings.json"), role: "settings" },
|
|
408
571
|
{ path: path.join(home, ".claude.json"), role: "settings" },
|
|
409
572
|
];
|
|
@@ -501,7 +664,25 @@ async function safeStat(filePath) {
|
|
|
501
664
|
}
|
|
502
665
|
|
|
503
666
|
function shouldSkipEntry(name) {
|
|
504
|
-
return
|
|
667
|
+
return (
|
|
668
|
+
name === "node_modules" ||
|
|
669
|
+
name === ".git" ||
|
|
670
|
+
name === "vendor" ||
|
|
671
|
+
name === "dist" ||
|
|
672
|
+
name === "build" ||
|
|
673
|
+
name === "Library" ||
|
|
674
|
+
name === ".Trash"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Small stable FNV-1a hash, used for content/message dedupe.
|
|
679
|
+
function hash(s) {
|
|
680
|
+
let h = 2166136261;
|
|
681
|
+
for (let i = 0; i < s.length; i++) {
|
|
682
|
+
h ^= s.charCodeAt(i);
|
|
683
|
+
h = Math.imul(h, 16777619);
|
|
684
|
+
}
|
|
685
|
+
return (h >>> 0).toString(36);
|
|
505
686
|
}
|
|
506
687
|
|
|
507
688
|
function formatBytes(bytes) {
|