@raymondchins/agentmap 0.4.0 → 0.6.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 (2) hide show
  1. package/agentmap.mjs +209 -13
  2. package/package.json +2 -2
package/agentmap.mjs CHANGED
@@ -17,6 +17,9 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, readdir
17
17
  import { execSync, execFileSync } from "node:child_process";
18
18
  import { createHash } from "node:crypto";
19
19
  import { createRequire } from "node:module";
20
+ import { homedir } from "node:os";
21
+ import { fileURLToPath } from "node:url";
22
+ import { join, dirname } from "node:path";
20
23
 
21
24
  // Lazy ts-morph: its ~105ms module init only fires on a COLD rebuild. Warm cache
22
25
  // queries (the common case) never construct a Project, so they skip the load
@@ -28,7 +31,10 @@ const tsMorph = () => (_tsm ??= _require("ts-morph"));
28
31
 
29
32
  const MAP = ".claude/agentmap/map.json";
30
33
  const MAP_LEGACY = ".claude/agentmap.json"; // pre-namespacing path; read for migration
31
- const SCHEMA_VERSION = 2;
34
+ // Bumped 2 → 3: Vue SFC support. `.vue` files now appear in the map and the
35
+ // source-discovery / freshness checks treat them as first-class source files.
36
+ // Old caches (schema 2) are ignored so the first run after upgrade rebuilds.
37
+ const SCHEMA_VERSION = 3;
32
38
 
33
39
  // ---------------------------------------------------------------------------
34
40
  // Tuning constants — KEEP THESE VALUES IDENTICAL (output + marketing must not
@@ -85,7 +91,7 @@ const dirtyCount = () =>
85
91
  let p = l.slice(3); // strip "XY " status prefix
86
92
  if (p.includes(" -> ")) p = p.split(" -> ").pop(); // rename: keep the new path
87
93
  p = p.replace(/^"|"$/g, ""); // unquote space/special paths
88
- return /\.(ts|tsx|mjs|cjs|jsx|js)$/.test(p);
94
+ return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/.test(p);
89
95
  }).length;
90
96
  const tokEst = (s) => Math.ceil((s || "").length / 4); // rough chars/4 estimate
91
97
 
@@ -96,7 +102,8 @@ const getOrSet = (m, k, make) => { let v = m.get(k); if (v === undefined) { v =
96
102
  // "path:mtimeMs:size" for source files so the cache can be trusted between runs
97
103
  // without a full reparse. Skips node_modules/.git/.next. Any error ⇒ "" (caller
98
104
  // falls through to build, i.e. current behavior). Never used on the git path.
99
- const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs)$/;
105
+ // Includes `.vue` so editing a Vue SFC invalidates the non-git cache too.
106
+ const SRC_EXT = /\.(ts|tsx|mts|cts|jsx|js|mjs|cjs|vue)$/;
100
107
  function sourceFingerprint() {
101
108
  try {
102
109
  const entries = [];
@@ -121,6 +128,70 @@ function sourceFingerprint() {
121
128
  } catch { return ""; }
122
129
  }
123
130
 
131
+ // =============================================================================
132
+ // Vue Single File Component support — best-effort, zero-dependency.
133
+ //
134
+ // agentmap is TS/JS-first. Vue `.vue` SFCs are NOT TypeScript; the Vue compiler
135
+ // (`@vue/compiler-sfc`) is intentionally NOT a dependency (CONTRIBUTING near-
136
+ // zero-deps rule). Instead we extract ONLY the `<script>` / `<script setup>`
137
+ // block text with a conservative regex and feed it to ts-morph as a VIRTUAL
138
+ // source file (e.g. `App.vue.ts`). A virtual→real path map (see build())
139
+ // rewrites every user-facing path back to the real `.vue` path so no
140
+ // `.vue.ts` / `.vue.js` ever leaks into JSON or prose.
141
+ //
142
+ // Non-goals: no template AST, no `<style>` parsing, no Nuxt auto-import
143
+ // resolution, no Svelte/Astro. Only `<script>` blocks that look like JS/TS.
144
+ // =============================================================================
145
+
146
+ // Find the first top-level `<script ...>` block (optionally `<script setup ...>`)
147
+ // whose opening tag does NOT carry `src="..."` (external script reference —
148
+ // the actual JS lives in another file agentmap already indexes on its own).
149
+ // Handles single + double quoted lang/src attributes and `lang="ts"`/`ts`.
150
+ // Returns { lang, setup, text } for the matched block, or null if none.
151
+ //
152
+ // Greedy-free: stops at the FIRST `</script>` on its own. Vue forbids nested
153
+ // `<script>` tags, so a non-greedy match up to `</script>` is safe. We do NOT
154
+ // support `<script>` + `<script setup>` in the same SFC for indexing — we pick
155
+ // the richer one: prefer `setup` block if present, else the normal block.
156
+ function extractVueScripts(text) {
157
+ const blocks = [];
158
+ // Open-tag matcher is QUOTE-AWARE: attribute values may legitimately contain
159
+ // `>` (e.g. `<script setup lang="ts" generic="T extends Record<string, unknown>">`
160
+ // — a common Vue 3 idiom for typed generic components). We require all
161
+ // attributes to be either bare (`setup`) or quoted (`name="value"` or
162
+ // `name='value'`), which matches valid SFC syntax. Bareword and unquoted forms
163
+ // are intentionally not matched because they're not valid HTML and would
164
+ // almost certainly indicate a parsing bug we want to surface, not silently
165
+ // misparse.
166
+ const re = /<script(\s+[a-zA-Z][\w-]*(\s*=\s*(?:"[^"]*"|'[^']*'))?)*\s*\/?>/gi;
167
+ let m;
168
+ while ((m = re.exec(text)) !== null) {
169
+ const attrs = (m[0].slice(7, -1) || "").trim(); // strip <script…> wrapper
170
+ // find body: text after the opening tag up to </script>
171
+ const openEnd = m.index + m[0].length;
172
+ const closeStart = text.toLowerCase().indexOf("</script>", openEnd);
173
+ if (closeStart === -1) break; // unterminated — stop scanning
174
+ const body = text.slice(openEnd, closeStart);
175
+ // external script reference → skip (the target file is indexed directly).
176
+ if (/\bsrc\s*=\s*["'][^"']+["']/i.test(attrs)) continue;
177
+ if (!body.trim()) continue; // empty body (e.g. <script/>) — not useful
178
+ const setup = /\bsetup\b/i.test(attrs);
179
+ const lang = (attrs.match(/\blang\s*=\s*["']([^"']+)["']/i) || [])[1] || "js";
180
+ blocks.push({ lang: lang.toLowerCase(), setup, text: body });
181
+ re.lastIndex = closeStart + "</script>".length; // resume after </script>
182
+ }
183
+ if (!blocks.length) return null;
184
+ // Prefer a setup block (the modern idiom) when present; else the plain block.
185
+ return blocks.find((b) => b.setup) || blocks[0];
186
+ }
187
+
188
+ // Virtual file path mapping for a `.vue` source. The virtual path is what
189
+ // ts-morph sees (so `.ts`/`.js` parsing kicks in); the real path is what every
190
+ // user-facing output shows. `lang="ts"` → `.vue.ts`, otherwise `.vue.js`.
191
+ function vueVirtualPath(realPath, lang) {
192
+ return lang === "ts" ? `${realPath}.ts` : `${realPath}.js`;
193
+ }
194
+
124
195
  // Feature = first real route segment under app/ (or src/app/), skipping route
125
196
  // groups (parens), dynamic segments ([id]) and parallel routes (@slot).
126
197
  function featureOf(path) {
@@ -246,13 +317,25 @@ function makeProject() {
246
317
  const loaded = new Set(project.getSourceFiles().map((s) => s.getFilePath()));
247
318
  const cwdp = process.cwd().replace(/\\/g, "/");
248
319
  const listed = sh("git ls-files --cached --others --exclude-standard").split("\n").filter(Boolean);
320
+ // `.vue` discovery: same channel as TS/JS (git ls-files when available, else
321
+ // a broad glob fallback). We do NOT hand `.vue` straight to ts-morph (it is
322
+ // not TS/JS). Instead, for each `.vue` file we read its `<script>` block via
323
+ // extractVueScripts() and register it as a VIRTUAL source file
324
+ // (`App.vue.ts` / `App.vue.js`). A virtual→real path map is returned alongside
325
+ // the project so build() can rewrite every user-facing path back to `.vue`.
326
+ const vueFiles = [];
249
327
  if (listed.length) {
250
328
  const missing = [];
251
329
  for (const f of listed) {
252
- if (!/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(f)) continue;
253
- const segs = f.split("/");
254
- if (segs.includes("node_modules") || segs.includes(".next")) continue;
255
- if (!loaded.has(`${cwdp}/${f}`)) missing.push(f);
330
+ if (/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(f)) {
331
+ const segs = f.split("/");
332
+ if (segs.includes("node_modules") || segs.includes(".next")) continue;
333
+ if (!loaded.has(`${cwdp}/${f}`)) missing.push(f);
334
+ } else if (f.endsWith(".vue")) {
335
+ const segs = f.split("/");
336
+ if (segs.includes("node_modules") || segs.includes(".next")) continue;
337
+ vueFiles.push(f);
338
+ }
256
339
  }
257
340
  if (missing.length) project.addSourceFilesAtPaths(missing);
258
341
  } else {
@@ -262,8 +345,38 @@ function makeProject() {
262
345
  "components/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "lib/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
263
346
  "pages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", "*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
264
347
  ]);
348
+ // Non-git `.vue` fallback: walk the tree like sourceFingerprint() does.
349
+ try {
350
+ const walk = (dir) => {
351
+ for (const name of readdirSync(dir)) {
352
+ if (name === "node_modules" || name === ".git" || name === ".next") continue;
353
+ const full = dir + "/" + name;
354
+ // lstatSync (NOT statSync) + skip symlinks, matching sourceFingerprint():
355
+ // a circular symlink would otherwise recurse until the stack overflows.
356
+ let st; try { st = lstatSync(full); } catch { continue; }
357
+ if (st.isSymbolicLink()) continue;
358
+ if (st.isDirectory()) walk(full);
359
+ else if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, ""));
360
+ }
361
+ };
362
+ walk(".");
363
+ } catch { /* ignore — proceed without Vue */ }
265
364
  }
266
- return project;
365
+ // Build the virtual→real map and register each `<script>` block as a virtual
366
+ // ts-morph source. Files without a usable `<script>` block are silently
367
+ // skipped (template/style-only SFCs contribute nothing to the import graph).
368
+ const vueMap = Object.create(null); // virtualPath → realPath
369
+ const vueReal = Object.create(null); // realPath → true (for resolver)
370
+ for (const f of vueFiles) {
371
+ let text; try { text = readFileSync(f, "utf8"); } catch { continue; }
372
+ const block = extractVueScripts(text);
373
+ if (!block || !block.text.trim()) continue;
374
+ const vpath = vueVirtualPath(f, block.lang);
375
+ project.createSourceFile(`${cwdp}/${vpath}`, block.text, { overwrite: true });
376
+ vueMap[`${cwdp}/${vpath}`] = `${cwdp}/${f}`;
377
+ vueReal[`${cwdp}/${f}`] = true;
378
+ }
379
+ return { project, vueMap, vueReal };
267
380
  }
268
381
 
269
382
  // ---------------------------------------------------------------------------
@@ -273,11 +386,18 @@ function makeProject() {
273
386
  // ---------------------------------------------------------------------------
274
387
  function build() {
275
388
  const t0 = Date.now();
276
- const project = makeProject();
389
+ const { project, vueMap, vueReal } = makeProject();
277
390
  const { SyntaxKind } = tsMorph();
278
391
  const CallExpression = SyntaxKind.CallExpression;
279
392
  const cwd = process.cwd().replace(/\\/g, "/");
280
- const rel = (p) => p.replace(cwd + "/", "");
393
+ // rel() rewrites ts-morph file paths to repo-relative keys. For Vue virtual
394
+ // sources (`App.vue.ts`), vueMap rewrites back to the real `.vue` path so
395
+ // users never see virtual paths in the map, hubs, --relates, or --find.
396
+ const rel = (p) => {
397
+ const abs = p.replace(/\\/g, "/");
398
+ const real = vueMap[abs];
399
+ return (real || abs).replace(cwd + "/", "");
400
+ };
281
401
  const files = {}, dependents = {}, features = {};
282
402
  // PATH-SEGMENT exclusion (not substring) so e.g. components/.next-demo or
283
403
  // src/node_modules_helper.ts are NOT wrongly excluded.
@@ -298,10 +418,19 @@ function build() {
298
418
  };
299
419
  const baseAbs = join(fromAbsDir, spec);
300
420
  const tryGet = (abs) => { const sf = project.getSourceFile(abs); return sf ? sf : null; };
421
+ // Vue SFC: `import X from "./C.vue"` (exact) ALWAYS wins — the user wrote
422
+ // `.vue` explicitly, so we honor that. This check must stay BEFORE the
423
+ // TS/JS loop.
424
+ if (vueReal[baseAbs]) return rel(baseAbs);
301
425
  let sf = tryGet(baseAbs);
302
426
  if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}.${e}`); if (sf) break; }
303
427
  if (!sf) for (const e of RES_EXT) { sf = tryGet(`${baseAbs}/index.${e}`); if (sf) break; }
304
- return sf ? rel(sf.getFilePath()) : null;
428
+ // TS/JS SHADOW WINS: when a same-name .ts/.js exists, the extensionless
429
+ // `import "./C"` resolves to it (TS/JS-first priority is preserved). Only
430
+ // fall through to `.vue` as a last resort, when no TS/JS shadow exists.
431
+ if (sf) return rel(sf.getFilePath());
432
+ if (vueReal[`${baseAbs}.vue`]) return rel(`${baseAbs}.vue`);
433
+ return null;
305
434
  };
306
435
 
307
436
  const sourceFiles = project.getSourceFiles();
@@ -598,7 +727,7 @@ function parseSettings(text, settingsPath) {
598
727
  try { return JSON.parse(text) || {}; }
599
728
  catch {
600
729
  try { return JSON.parse(stripJsonComments(text)) || {}; }
601
- catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run --install-hooks`); }
730
+ catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run`); }
602
731
  }
603
732
  }
604
733
 
@@ -717,6 +846,65 @@ function installHooks({ dryRun = false } = {}) {
717
846
  console.log("\nDone — the map auto-refreshes on commit, and greps are nudged to agentmap first.");
718
847
  }
719
848
 
849
+ // ---------------------------------------------------------------------------
850
+ // --setup-mcp: register the agentmap MCP server in the global configs of
851
+ // MCP-capable IDEs that aren't Claude Code (which uses --install-hooks instead).
852
+ // Merge-safe + idempotent; with { dryRun:true } it prints the plan and writes
853
+ // nothing. Throws on the first malformed config so the caller can stderr+exit 1.
854
+ // ---------------------------------------------------------------------------
855
+ function setupMcp({ dryRun = false } = {}) {
856
+ const mcpPath = fileURLToPath(new URL("./mcp.mjs", import.meta.url));
857
+
858
+ // npx materializes the package under a `_npx` cache dir that gets garbage-
859
+ // collected, so a config pointing at that path would rot. When invoked via npx,
860
+ // pin to the published spec instead; otherwise reference the resolved file.
861
+ const isNpx = mcpPath.includes("_npx");
862
+ const command = isNpx ? "npx" : process.execPath;
863
+ const args = isNpx ? ["-y", "@raymondchins/agentmap", "--mcp"] : [mcpPath];
864
+
865
+ // Each target: a global config file + how to graft the agentmap entry into it.
866
+ // Antigravity is written to BOTH paths on purpose — older builds read only the
867
+ // IDE-specific ~/.gemini/antigravity path, newer unified builds read the shared
868
+ // ~/.gemini/config path, so writing both is version-proof.
869
+ const targets = [
870
+ {
871
+ label: "OpenCode",
872
+ path: join(homedir(), ".config", "opencode", "opencode.json"),
873
+ graft: (cfg) => { (cfg.mcp ??= {}).agentmap = { type: "stdio", command, args, enabled: true }; },
874
+ },
875
+ {
876
+ label: "Antigravity IDE",
877
+ path: join(homedir(), ".gemini", "antigravity", "mcp_config.json"),
878
+ graft: (cfg) => { (cfg.mcpServers ??= {}).agentmap = { command, args }; },
879
+ },
880
+ {
881
+ label: "Antigravity (shared)",
882
+ path: join(homedir(), ".gemini", "config", "mcp_config.json"),
883
+ graft: (cfg) => { (cfg.mcpServers ??= {}).agentmap = { command, args }; },
884
+ },
885
+ ];
886
+
887
+ if (dryRun) console.log("--dry-run: would configure MCP server (no changes written):");
888
+
889
+ for (const { label, path, graft } of targets) {
890
+ // Reuse parseSettings so JSONC (comments) is tolerated and a malformed file
891
+ // throws a clear error WITHOUT clobbering the original (we never write on the
892
+ // failure path, so no .bak dance is needed).
893
+ let cfg = {};
894
+ if (existsSync(path)) cfg = parseSettings(readFileSync(path, "utf8"), path);
895
+ graft(cfg);
896
+
897
+ if (dryRun) {
898
+ console.log(` ${label}: would write to ${path}`);
899
+ } else {
900
+ mkdirSync(dirname(path), { recursive: true });
901
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
902
+ console.log(`configured ${label} MCP server → ${path}`);
903
+ }
904
+ }
905
+ }
906
+
907
+
720
908
  // ---------------------------------------------------------------------------
721
909
  // CLI
722
910
  // ---------------------------------------------------------------------------
@@ -739,7 +927,7 @@ const out = (obj, prose) => { if (wantJson) console.log(JSON.stringify(obj)); el
739
927
  // NOT in this set is an unknown flag → usage error (exit 2), not a silent build.
740
928
  const KNOWN = new Set([
741
929
  "--json", "--print",
742
- "--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--mcp",
930
+ "--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--setup-mcp", "--mcp",
743
931
  "--any", "--find", "--relates", "--map", "--focus", "--tokens",
744
932
  "--symbols", "--feature", "--features", "--hubs",
745
933
  ]);
@@ -776,6 +964,9 @@ Maintenance:
776
964
  --install-hooks [--dry-run]
777
965
  install git post-commit + copy the PreToolUse nudge +
778
966
  wire .claude/settings.json (--dry-run = preview, no writes)
967
+ --setup-mcp [--dry-run]
968
+ configure MCP server for OpenCode & Antigravity IDE
969
+ (--dry-run = preview, no writes)
779
970
  --mcp start a stdio MCP server (for MCP-capable agents)
780
971
  --help, -h show this help
781
972
  --version, -v print the version
@@ -811,6 +1002,11 @@ else if (has("--install-hooks")) {
811
1002
  try { installHooks({ dryRun: has("--dry-run") }); process.exit(0); }
812
1003
  catch (e) { console.error(`agentmap --install-hooks failed: ${e?.message || e}`); process.exit(1); }
813
1004
  }
1005
+ // --setup-mcp: configure MCP server for OpenCode & Antigravity IDE.
1006
+ else if (has("--setup-mcp")) {
1007
+ try { setupMcp({ dryRun: has("--dry-run") }); process.exit(0); }
1008
+ catch (e) { console.error(`agentmap --setup-mcp failed: ${e?.message || e}`); process.exit(1); }
1009
+ }
814
1010
  // Unknown-flag guard: any "-"-prefixed token not in KNOWN → usage error (exit
815
1011
  // 2). Runs BEFORE the bare-build fallthrough so a typo never silently rebuilds.
816
1012
  // Bare invocation with NO flags still builds (handled in the final else).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raymondchins/agentmap",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "map": "node agentmap.mjs",
21
- "test": "node --test test/*.test.mjs",
21
+ "test": "node --test test/*.test.mjs test/**/*.test.mjs",
22
22
  "eval": "node eval/eval.mjs"
23
23
  },
24
24
  "engines": {