@raymondchins/agentmap 0.6.1 → 0.8.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 CHANGED
@@ -185,6 +185,43 @@ internally on `tool_name`. For reference, or to wire it by hand:
185
185
  That's the "forced to use it" in the tagline: the map stays current on its own, and the
186
186
  agent is steered to it the moment it reaches for a dependency-shaped grep or Bash search.
187
187
 
188
+ ### 3. Agent skills (Cursor, Claude Code, Codex, OpenCode, Gemini, Antigravity, Copilot)
189
+
190
+ ```bash
191
+ npx @raymondchins/agentmap --install-skill
192
+ ```
193
+
194
+ Copies packaged **SKILL.md** files and a **Cursor rule** (`.cursor/rules/agentmap.mdc`,
195
+ `alwaysApply: true`) into the current repo or global agent directories. Paths follow
196
+ each platform's official skill-directory conventions. Options:
197
+
198
+ ```bash
199
+ agentmap --install-skill --platform cursor # Cursor rule only (project)
200
+ agentmap --install-skill --platform claude # .claude/skills/agentmap/SKILL.md
201
+ agentmap --install-skill --platform codex # .codex/skills/ (project) or ~/.codex/skills/ (global)
202
+ agentmap --install-skill --platform opencode # .opencode/skills/ (project) or ~/.config/opencode/skills/ (global)
203
+ agentmap --install-skill --platform gemini # .gemini/skills/ (project); global ~/.gemini/skills/ (Windows global: ~/.agents/skills/)
204
+ agentmap --install-skill --platform antigravity # .agents/skills/ (project) or ~/.gemini/config/skills/ (global)
205
+ agentmap --install-skill --platform copilot # .copilot/skills/ or ~/.copilot/skills/
206
+ agentmap --install-skill --global --platform claude # ~/.claude/skills/...
207
+ agentmap --install-skill --platform agents # legacy .agents/skills/ (project or global); excluded from default `all`
208
+ agentmap --install-skill --dry-run # preview paths, no writes
209
+ ```
210
+
211
+ `--platform all` installs: claude, cursor, codex, opencode, gemini, antigravity, copilot (not legacy `agents`).
212
+
213
+ Some platforms also get **always-on** docs and hooks in the same command:
214
+
215
+ | `--platform` | Skill | Also installs (project) | Global docs |
216
+ |--------------|-------|-------------------------|-------------|
217
+ | `gemini` | `.gemini/skills/…/SKILL.md` | `GEMINI.md` + `.gemini/settings.json` BeforeTool nudge | `~/.gemini/GEMINI.md` |
218
+ | `codex` | `.codex/skills/…/SKILL.md` | `AGENTS.md` merge-safe `<!-- agentmap:begin/end -->` block | `~/.codex/AGENTS.md` |
219
+ | `opencode` | `.opencode/skills/…/SKILL.md` | `AGENTS.md` + `.opencode/plugins/agentmap-nudge.js` | `~/.config/opencode/AGENTS.md` |
220
+
221
+ Codex and OpenCode share one repo-root `AGENTS.md` on project install. Existing content outside the marked block is preserved.
222
+
223
+ Pair with `--install-hooks` (Claude Code) or `--mcp` (Cursor MCP).
224
+
188
225
  ---
189
226
 
190
227
  ## Quickstart
@@ -481,6 +518,8 @@ $ node agentmap.mjs --print | jq '.hubs[0]'
481
518
  | `--version` / `-v` | Print the version from `package.json` and exit 0. |
482
519
  | `--json` | **Global modifier.** When present, every command prints exactly one JSON object to stdout (no prose). Shapes vary per command: `--json --hubs` → `{command,fileCount,sha,hubs:[string]}`, `--json --find X` → `{command,query,matches:[{file,name,kind}]}`, `--json --relates X` → `{command,file,pagerank,exports,imports,dependents,related}`, `--json --any X` → `{command,query,kind,…payload}`, etc. Bare `--json` (no query flag) → `{command:"build",fileCount,features,topHub}`. |
483
520
  | `--install-hooks` | Copy `hooks/post-commit` into `.git/hooks/` (chmod 0755), ensure `.claude/agentmap.json` is in `.gitignore`, and auto-wire the Claude Code `PreToolUse(Grep)` nudge into `.claude/settings.json` (merge-safe + idempotent). Exit 0 on success, stderr + exit 1 on failure. |
521
+ | `--hook-status` | Report whether the post-commit hook, PreToolUse nudge, and `.gitignore` entry are installed (no writes). |
522
+ | `--install-skill` | Install skills + always-on docs/hooks per platform (`--platform claude\|cursor\|codex\|opencode\|gemini\|antigravity\|copilot\|agents\|all`, default `all`; `--project` default, or `--global`; `--dry-run` preview). |
484
523
  | `--mcp` | Start agentmap as a **stdio MCP server** so non-Claude-Code agents (Cursor, Cline, any MCP client) can call every flag as a first-class tool. |
485
524
 
486
525
  **Exit-code contract:** `0` = success / match / help / version; `1` = query returned zero results (`--any`, `--find`, `--relates`, `--feature` with no match); `2` = usage error (missing required arg, unknown flag). Any token starting with `-` that matches no known flag prints an error to stderr and exits 2.
package/agentmap.mjs CHANGED
@@ -281,6 +281,77 @@ function identMul(ident, defineCount, mentioned) {
281
281
  return mul;
282
282
  }
283
283
 
284
+ // Read baseUrl+paths from a tsconfig/jsconfig file. Returns null when absent.
285
+ // Follows `extends` recursively (depth-capped) so a package tsconfig that only
286
+ // `extends` a shared base (Turborepo tsconfig.base.json holding all `paths`)
287
+ // still contributes its inherited baseUrl/paths. Child overrides parent.
288
+ function readTsconfigAliasOpts(cfgPath, _depth = 0) {
289
+ try {
290
+ const raw = JSON.parse(readFileSync(cfgPath, "utf8")) || {};
291
+ const co = raw.compilerOptions || {};
292
+ // Resolve inherited opts from `extends` first (parent), then layer self on top.
293
+ let inherited = null;
294
+ if (raw.extends && _depth < 10) {
295
+ const exts = Array.isArray(raw.extends) ? raw.extends : [raw.extends];
296
+ const here = dirname(cfgPath);
297
+ for (const ext of exts) {
298
+ if (typeof ext !== "string" || !ext) continue;
299
+ // Only resolve path-like extends (./, ../, absolute). Bare package
300
+ // extends (e.g. "@tsconfig/strict") live in node_modules and don't
301
+ // carry repo-local `paths`, so skip them safely.
302
+ if (!/^(\.\.?\/|\/)/.test(ext)) continue;
303
+ let base = join(here, ext);
304
+ if (!existsSync(base) && existsSync(base + ".json")) base += ".json";
305
+ else if (!/\.json$/.test(base) && existsSync(join(base, "tsconfig.json"))) base = join(base, "tsconfig.json");
306
+ if (!existsSync(base)) continue;
307
+ const parent = readTsconfigAliasOpts(base, _depth + 1);
308
+ if (parent) inherited = { ...(inherited || {}), ...parent };
309
+ }
310
+ }
311
+ const self = {};
312
+ if (co.baseUrl) self.baseUrl = co.baseUrl;
313
+ if (co.paths) self.paths = co.paths;
314
+ const out = { ...(inherited || {}), ...self };
315
+ if (!Object.keys(out).length) return null;
316
+ return out;
317
+ } catch { return null; }
318
+ }
319
+
320
+ // Collect package-level alias configs from tsconfig/jsconfig files in the repo.
321
+ // Deepest-dir-first sort so nearestAliasConfig can pick the longest prefix match.
322
+ function discoverPackageAliasConfigs(rootAbs, listed) {
323
+ const root = rootAbs.replace(/\\/g, "/");
324
+ const configs = [];
325
+ const cfgRels = listed.length
326
+ ? listed.filter((f) => /(^|\/)tsconfig\.json$/.test(f) || /(^|\/)jsconfig\.json$/.test(f))
327
+ : [];
328
+ for (const rel of cfgRels) {
329
+ const full = join(root, rel);
330
+ if (!existsSync(full)) continue;
331
+ const opts = readTsconfigAliasOpts(full);
332
+ if (!opts) continue;
333
+ configs.push({
334
+ dir: join(root, dirname(rel)).replace(/\\/g, "/"),
335
+ baseUrl: opts.baseUrl || ".",
336
+ paths: opts.paths || {},
337
+ });
338
+ }
339
+ configs.sort((a, b) => b.dir.length - a.dir.length);
340
+ return configs;
341
+ }
342
+
343
+ // Longest matching tsconfig dir wins (monorepo package boundary).
344
+ function nearestAliasConfig(fromAbsDir, configs, rootAbs, rootOpts) {
345
+ const norm = fromAbsDir.replace(/\\/g, "/");
346
+ let best = null;
347
+ for (const c of configs) {
348
+ const d = c.dir;
349
+ if (norm === d || norm.startsWith(d + "/")) { best = c; break; } // configs sorted deepest-first
350
+ }
351
+ if (best) return best;
352
+ return { dir: rootAbs, baseUrl: rootOpts.baseUrl || ".", paths: rootOpts.paths || {} };
353
+ }
354
+
284
355
  // Construct a ts-morph Project robustly: use tsconfig.json when present + valid;
285
356
  // else (missing / malformed / solution-style references that index 0 files) fall
286
357
  // back to broad source globs so the tool degrades gracefully instead of crashing.
@@ -296,11 +367,8 @@ function makeProject() {
296
367
  for (const cfg of ["tsconfig.json", "jsconfig.json"]) {
297
368
  try {
298
369
  if (!existsSync(cfg)) continue;
299
- const co = (JSON.parse(readFileSync(cfg, "utf8")) || {}).compilerOptions || {};
300
- const out = {};
301
- if (co.baseUrl) out.baseUrl = co.baseUrl;
302
- if (co.paths) out.paths = co.paths;
303
- if (Object.keys(out).length) return out;
370
+ const opts = readTsconfigAliasOpts(cfg);
371
+ if (opts) return opts;
304
372
  } catch { /* ignore — proceed without paths */ }
305
373
  }
306
374
  return {};
@@ -329,6 +397,7 @@ function makeProject() {
329
397
  const loaded = new Set(project.getSourceFiles().map((s) => s.getFilePath()));
330
398
  const cwdp = process.cwd().replace(/\\/g, "/");
331
399
  const listed = sh("git ls-files --cached --others --exclude-standard").split("\n").filter(Boolean);
400
+ const packageAliasConfigs = discoverPackageAliasConfigs(cwdp, listed);
332
401
  // `.vue` discovery: same channel as TS/JS (git ls-files when available, else
333
402
  // a broad glob fallback). We do NOT hand `.vue` straight to ts-morph (it is
334
403
  // not TS/JS). Instead, for each `.vue` file we read its `<script>` block via
@@ -390,7 +459,7 @@ function makeProject() {
390
459
  vueMap[`${cwdp}/${vpath}`] = `${cwdp}/${f}`;
391
460
  vueReal[`${cwdp}/${f}`] = true;
392
461
  }
393
- return { project, vueMap, vueReal, aliasOpts };
462
+ return { project, vueMap, vueReal, aliasOpts, packageAliasConfigs };
394
463
  }
395
464
 
396
465
  // ---------------------------------------------------------------------------
@@ -400,7 +469,7 @@ function makeProject() {
400
469
  // ---------------------------------------------------------------------------
401
470
  function build() {
402
471
  const t0 = Date.now();
403
- const { project, vueMap, vueReal, aliasOpts } = makeProject();
472
+ const { project, vueMap, vueReal, aliasOpts, packageAliasConfigs } = makeProject();
404
473
  const { SyntaxKind } = tsMorph();
405
474
  const CallExpression = SyntaxKind.CallExpression;
406
475
  const cwd = process.cwd().replace(/\\/g, "/");
@@ -439,14 +508,15 @@ function build() {
439
508
  if (vueReal[`${abs}.vue`]) return rel(`${abs}.vue`);
440
509
  return null;
441
510
  };
442
- // #3 fix: tsconfig/jsconfig baseUrl+paths alias resolution ("@/x", "~/x") for
443
- // the side-effect/dynamic/require edges too. Static imports already resolve via
444
- // ts-morph's getModuleSpecifierSourceFile(); without this, `import "@/lib/x"`,
445
- // `import("@/lib/x")` and `require("@/lib/x")` silently formed NO edge.
511
+ // #3 fix + monorepo: tsconfig/jsconfig baseUrl+paths alias resolution ("@/x",
512
+ // "#/x", "~/x") for side-effect/dynamic/require edges AND static imports when
513
+ // ts-morph can't resolve (cwd tsconfig lacks package paths). Per importing
514
+ // file, use the nearest discovered tsconfig paths.
446
515
  const ROOTABS = process.cwd().replace(/\\/g, "/");
447
- const aliasBase = aliasOpts.baseUrl ? joinPosix(ROOTABS, aliasOpts.baseUrl) : ROOTABS;
448
- const aliasEntries = Object.entries(aliasOpts.paths || {});
449
- const resolveAlias = (spec) => {
516
+ const resolveAlias = (spec, fromAbsDir) => {
517
+ const cfg = nearestAliasConfig(fromAbsDir, packageAliasConfigs, ROOTABS, aliasOpts);
518
+ const aliasBase = joinPosix(cfg.dir, cfg.baseUrl || ".");
519
+ const aliasEntries = Object.entries(cfg.paths || {});
450
520
  for (const [pat, targets] of aliasEntries) {
451
521
  const star = pat.indexOf("*");
452
522
  let sub = null;
@@ -466,7 +536,7 @@ function build() {
466
536
  return null;
467
537
  };
468
538
  const resolveSpec = (fromAbsDir, spec) => {
469
- if (!spec.startsWith(".")) return resolveAlias(spec); // alias (baseUrl/paths) or null for non-relative
539
+ if (!spec.startsWith(".")) return resolveAlias(spec, fromAbsDir); // alias (baseUrl/paths) or null for non-relative
470
540
  // normalize fromAbsDir + spec into an absolute-ish posix path
471
541
  const join = (a, b) => {
472
542
  const parts = (a + "/" + b).split("/"); const st = [];
@@ -524,11 +594,16 @@ function build() {
524
594
  if (imp.getNamespaceImport()) names.push("*");
525
595
  addEdge(rel(t.getFilePath()), names.length ? names : ["*"]);
526
596
  } else {
527
- // 6b: side-effect import (`import "./x"`) no source file via ts-morph,
528
- // but a relative specifier resolving in-project still counts as an edge.
597
+ // 6b: side-effect or alias import — ts-morph may not resolve when cwd
598
+ // tsconfig lacks package paths; resolveSpec uses nearest tsconfig paths.
529
599
  const spec = imp.getModuleSpecifierValue();
530
600
  const tp = resolveSpec(fromDir, spec);
531
- if (tp) addEdge(tp, ["*"]);
601
+ if (tp) {
602
+ const names = imp.getNamedImports().filter((n) => !n.isTypeOnly()).map((n) => n.getName());
603
+ if (imp.getDefaultImport()) names.push("default");
604
+ if (imp.getNamespaceImport()) names.push("*");
605
+ addEdge(tp, names.length ? names : ["*"]);
606
+ }
532
607
  }
533
608
  }
534
609
  for (const exp of sf.getExportDeclarations()) {
@@ -917,6 +992,60 @@ function installHooks({ dryRun = false } = {}) {
917
992
  console.log("\nDone — the map auto-refreshes on commit, and greps are nudged to agentmap first.");
918
993
  }
919
994
 
995
+ // Marker string baked into hooks/post-commit — used by --hook-status to detect our
996
+ // hook even when chained with other tools in the same file.
997
+ const POST_COMMIT_MARKER = "agentmap — git post-commit hook";
998
+ const NUDGE_REL = ".claude/hooks/agentmap-nudge.mjs";
999
+ const MAP_IGNORE_LINE = ".claude/agentmap/";
1000
+
1001
+ function hookStatus() {
1002
+ const gitDir = sh("git rev-parse --git-dir");
1003
+ if (!gitDir) {
1004
+ console.log("not a git repository — run inside the repo you want to check");
1005
+ return;
1006
+ }
1007
+ const hooksDir = `${gitDir}/hooks`;
1008
+ const postCommitPath = `${hooksDir}/post-commit`;
1009
+
1010
+ let postCommit = "not installed";
1011
+ if (existsSync(postCommitPath)) {
1012
+ const body = readFileSync(postCommitPath, "utf8");
1013
+ postCommit = body.includes(POST_COMMIT_MARKER) ? "installed" : "not installed (hook exists but agentmap not found)";
1014
+ }
1015
+
1016
+ let nudge = existsSync(NUDGE_REL) ? "installed" : "not installed";
1017
+
1018
+ let grepWire = "not wired";
1019
+ let bashWire = "not wired";
1020
+ const settingsPath = ".claude/settings.json";
1021
+ if (existsSync(settingsPath)) {
1022
+ try {
1023
+ const settings = parseSettings(readFileSync(settingsPath, "utf8"), settingsPath);
1024
+ const entries = settings.hooks?.PreToolUse || [];
1025
+ const has = (matcher) => entries.some(
1026
+ (e) => e?.matcher === matcher && Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
1027
+ );
1028
+ grepWire = has("Grep") ? "wired" : "not wired";
1029
+ bashWire = has("Bash") ? "wired" : "not wired";
1030
+ } catch {
1031
+ grepWire = "not wired (invalid settings.json)";
1032
+ bashWire = "not wired (invalid settings.json)";
1033
+ }
1034
+ }
1035
+
1036
+ let gitignore = "missing entry";
1037
+ if (existsSync(".gitignore")) {
1038
+ const ok = readFileSync(".gitignore", "utf8").split(/\r?\n/).some((l) => l.trim() === MAP_IGNORE_LINE);
1039
+ gitignore = ok ? "ok" : "missing entry";
1040
+ }
1041
+
1042
+ console.log(`post-commit: ${postCommit}`);
1043
+ console.log(`nudge (${NUDGE_REL}): ${nudge}`);
1044
+ console.log(`PreToolUse(Grep): ${grepWire}`);
1045
+ console.log(`PreToolUse(Bash): ${bashWire}`);
1046
+ console.log(`.gitignore (${MAP_IGNORE_LINE}): ${gitignore}`);
1047
+ }
1048
+
920
1049
  // ---------------------------------------------------------------------------
921
1050
  // --setup-mcp: register the agentmap MCP server in the global configs of
922
1051
  // MCP-capable IDEs that aren't Claude Code (which uses --install-hooks instead).
@@ -998,7 +1127,8 @@ const out = (obj, prose) => { if (wantJson) console.log(JSON.stringify(obj)); el
998
1127
  // NOT in this set is an unknown flag → usage error (exit 2), not a silent build.
999
1128
  const KNOWN = new Set([
1000
1129
  "--json", "--print",
1001
- "--help", "-h", "--version", "-v", "--install-hooks", "--dry-run", "--setup-mcp", "--mcp",
1130
+ "--help", "-h", "--version", "-v", "--install-hooks", "--hook-status", "--install-skill", "--platform", "--project", "--global",
1131
+ "--dry-run", "--setup-mcp", "--mcp",
1002
1132
  "--any", "--find", "--relates", "--map", "--focus", "--tokens",
1003
1133
  "--symbols", "--feature", "--features", "--hubs",
1004
1134
  ]);
@@ -1007,7 +1137,7 @@ const KNOWN = new Set([
1007
1137
  // so a dash-leading query like `--any "-O/bin/sh"` is bound as the query, not
1008
1138
  // mistaken for an unknown flag. (arg() already rejects a "--"-leading value, so
1009
1139
  // `--any --foo` still falls through to the missing-arg guard instead.)
1010
- const VALUE_FLAGS = new Set(["--any", "--find", "--relates", "--feature", "--focus", "--tokens", "--symbols"]);
1140
+ const VALUE_FLAGS = new Set(["--any", "--find", "--relates", "--feature", "--focus", "--tokens", "--symbols", "--platform"]);
1011
1141
  const valueIdx = new Set();
1012
1142
  for (let i = 0; i < args.length - 1; i++) if (VALUE_FLAGS.has(args[i])) valueIdx.add(i + 1);
1013
1143
 
@@ -1035,6 +1165,9 @@ Maintenance:
1035
1165
  --install-hooks [--dry-run]
1036
1166
  install git post-commit + copy the PreToolUse nudge +
1037
1167
  wire .claude/settings.json (--dry-run = preview, no writes)
1168
+ --install-skill [--platform claude|cursor|codex|opencode|gemini|antigravity|copilot|agents|all] [--project|--global] [--dry-run]
1169
+ install skills + always-on docs/hooks per platform
1170
+ --hook-status report whether agentmap git/nudge wiring is installed
1038
1171
  --setup-mcp [--dry-run]
1039
1172
  configure MCP server for OpenCode & Antigravity IDE
1040
1173
  (--dry-run = preview, no writes)
@@ -1073,6 +1206,29 @@ else if (has("--install-hooks")) {
1073
1206
  try { installHooks({ dryRun: has("--dry-run") }); process.exit(0); }
1074
1207
  catch (e) { console.error(`agentmap --install-hooks failed: ${e?.message || e}`); process.exit(1); }
1075
1208
  }
1209
+ // --install-skill: copy packaged SKILL.md / Cursor rule (see skills/install.mjs).
1210
+ else if (has("--install-skill")) {
1211
+ try {
1212
+ // lazy import keeps skills/install.mjs (and its package.json read) OFF the
1213
+ // hot path — warm --any/--find queries must not load it.
1214
+ const { installSkill } = await import("./skills/install.mjs");
1215
+ installSkill({
1216
+ platforms: arg("--platform") || "all",
1217
+ project: !has("--global"),
1218
+ global: has("--global"),
1219
+ dryRun: has("--dry-run"),
1220
+ });
1221
+ process.exit(0);
1222
+ } catch (e) {
1223
+ console.error(`agentmap --install-skill failed: ${e?.message || e}`);
1224
+ process.exit(1);
1225
+ }
1226
+ }
1227
+ // --hook-status: report post-commit / nudge / settings wiring (no writes).
1228
+ else if (has("--hook-status")) {
1229
+ try { hookStatus(); process.exit(0); }
1230
+ catch (e) { console.error(`agentmap --hook-status failed: ${e?.message || e}`); process.exit(1); }
1231
+ }
1076
1232
  // --setup-mcp: configure MCP server for OpenCode & Antigravity IDE.
1077
1233
  else if (has("--setup-mcp")) {
1078
1234
  try { setupMcp({ dryRun: has("--dry-run") }); process.exit(0); }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ // Gemini CLI BeforeTool nudge — non-blocking context when a search looks like
4
+ // dependency / blast-radius / reuse work. Stdlib only; copied into the project
5
+ // by `agentmap --install-skill --platform gemini`.
6
+
7
+ let raw = "";
8
+ process.stdin.setEncoding("utf8");
9
+ process.stdin.on("data", (c) => (raw += c));
10
+ process.stdin.on("end", () => {
11
+ try {
12
+ const payload = JSON.parse(raw || "{}");
13
+ const tool = String(payload.tool_name || "");
14
+ const ti = payload.tool_input || {};
15
+
16
+ const DEP_RE =
17
+ /\b(import|require\s*\(|imported\s+by|depends|dependents?|dependency)\b|from\s+["']|(^|\|)\s*export\b/i;
18
+ const GENERIC_DENYLIST =
19
+ /<(Promise|Array|Map|Set|Record|Partial|Readonly|Pick|Omit|Required|Exclude|Extract|NonNullable|ReturnType|Awaited|Parameters|InstanceType)\b/;
20
+ const COMPONENT_TAG_RE = /<[A-Z][\w.]*/;
21
+ const INTENT_RE =
22
+ /\bwhere\s+is\b|\bwho\s+(imports|uses|renders)\b|\breuse\b|\b(existing|shared)\s+(util|component|hook|helper)\b|\bis\s+there\s+(an?\s+)?(existing|shared)\b/i;
23
+ const SYMBOL_RE = /\b[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*\b/;
24
+
25
+ let fire = false;
26
+ const pattern = String(ti.pattern || ti.query || ti.content || "");
27
+ const cmd = String(ti.command || ti.cmd || "");
28
+
29
+ if (/grep|search|ripgrep/i.test(tool)) {
30
+ if (pattern.length > 0 && pattern.length <= 2000) {
31
+ fire =
32
+ DEP_RE.test(pattern) ||
33
+ (COMPONENT_TAG_RE.test(pattern) && !GENERIC_DENYLIST.test(pattern)) ||
34
+ INTENT_RE.test(pattern);
35
+ }
36
+ } else if (/shell|bash|terminal|command/i.test(tool) && cmd) {
37
+ const SEARCHER_RE = /(^|[;&]\s*)(rg|ripgrep|grep|egrep|fgrep|ag|ack)\b/;
38
+ if (SEARCHER_RE.test(cmd)) {
39
+ const DATA_FILE_RE = /\.(log|txt|out|csv|tsv|jsonl|ndjson|json|md|ya?ml|xml)(\b|$)/i;
40
+ const hasDataFileTarget = cmd.split(/\s+/).some((tok) => DATA_FILE_RE.test(tok));
41
+ if (!hasDataFileTarget) {
42
+ fire =
43
+ DEP_RE.test(cmd) ||
44
+ (COMPONENT_TAG_RE.test(cmd) && !GENERIC_DENYLIST.test(cmd)) ||
45
+ INTENT_RE.test(cmd) ||
46
+ SYMBOL_RE.test(cmd);
47
+ }
48
+ }
49
+ }
50
+
51
+ if (fire) {
52
+ const msg =
53
+ "Dependency / blast-radius / reuse search detected. Prefer agentmap first: " +
54
+ "`npx @raymondchins/agentmap --any <query>` (file → symbol → feature → git-grep), " +
55
+ "`--relates <path>`, `--find <symbol>`. Rebuild with `npx @raymondchins/agentmap` if stale.";
56
+ process.stdout.write(
57
+ JSON.stringify({
58
+ hookSpecificOutput: {
59
+ hookEventName: "BeforeTool",
60
+ additionalContext: msg,
61
+ },
62
+ }),
63
+ );
64
+ } else {
65
+ process.stdout.write("{}");
66
+ }
67
+ } catch {
68
+ process.stdout.write("{}");
69
+ }
70
+ process.exit(0);
71
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raymondchins/agentmap",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,6 +14,7 @@
14
14
  "agentmap.mjs",
15
15
  "mcp.mjs",
16
16
  "hooks",
17
+ "skills",
17
18
  "NOTICE"
18
19
  ],
19
20
  "scripts": {
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: agentmap
3
+ description: >-
4
+ Use for TypeScript/JavaScript codebase navigation — symbol lookup, blast radius,
5
+ reuse checks, and repo orientation. Prefer agentmap before serial grep when
6
+ exploring imports, exports, or where a symbol lives. Package is
7
+ @raymondchins/agentmap (not the unrelated npm package agentmap).
8
+ ---
9
+
10
+ # agentmap
11
+
12
+ Queryable, ranked **import graph** for TS/JS repos (PageRank hubs, symbol ranking, token-budgeted digest). Faster and more accurate than grep for structural questions.
13
+
14
+ ## When to use
15
+
16
+ - **Where is X defined?** / **who imports this file?** / **what breaks if I edit this?**
17
+ - **Reuse check** before adding a util, component, or type
18
+ - **Session start** — orient to a large monorepo cheaply
19
+
20
+ ## When not to use
21
+
22
+ - Raw string / config value search (try `agentmap --any` first — layer 4 is live `git grep`)
23
+ - Non-TS/JS files, runtime call graphs, or full semantic "how does it work?" (use codebase search)
24
+ - Next-style `--feature` on TanStack Router / non-`app/` layouts (often empty)
25
+
26
+ ## Commands (run in repo root)
27
+
28
+ ```bash
29
+ # Smart router — default first move
30
+ agentmap --any <query>
31
+
32
+ # Reuse / symbol definition
33
+ agentmap --find <SymbolName>
34
+
35
+ # Blast radius (exports, imports, dependents, related files)
36
+ agentmap --relates <path/to/file.ts>
37
+
38
+ # Token-budgeted ranked digest
39
+ agentmap --map --tokens 400
40
+ agentmap --map --focus <path> --tokens 400
41
+
42
+ # Hub files (PageRank)
43
+ agentmap --hubs
44
+
45
+ # JSON for tools
46
+ agentmap --json --any <query>
47
+ ```
48
+
49
+ Install: `npm i -g @raymondchins/agentmap` or `npx @raymondchins/agentmap`. Map cache: `.claude/agentmap/` (gitignored).
50
+
51
+ ## Agent platforms
52
+
53
+ | Platform | Setup |
54
+ |----------|--------|
55
+ | **Claude Code** | `agentmap --install-hooks` (post-commit refresh + PreToolUse grep nudge) |
56
+ | **Cursor / MCP clients** | `agentmap --mcp` in MCP config, or Shell + commands above |
57
+ | **This skill** | `agentmap --install-skill` |
58
+
59
+ ## Workflow
60
+
61
+ 1. If the map may be stale after edits, run `agentmap` (no flags) or rely on post-commit hook.
62
+ 2. Start with `agentmap --any <symbol or topic>`.
63
+ 3. Before editing a hub file, run `agentmap --relates <that-file>`.
64
+ 4. Fall back to grep only when agentmap returns no useful structure hit.
65
+
66
+ ## Package name
67
+
68
+ ```bash
69
+ npx @raymondchins/agentmap --any Procedure
70
+ # NOT: npm install agentmap (different package)
71
+ ```
@@ -0,0 +1,27 @@
1
+ ---
2
+ description: agentmap ranked TS/JS repo map
3
+ alwaysApply: true
4
+ ---
5
+
6
+ This project can use **agentmap** (`@raymondchins/agentmap`) — a queryable import/symbol map at `.claude/agentmap/`.
7
+
8
+ **Before Grep/Glob for structural questions** (where is a symbol, who imports a file, reuse check, blast radius), prefer:
9
+
10
+ ```bash
11
+ agentmap --any <query> # file → symbol → feature → git-grep fallback
12
+ agentmap --find <Symbol> # exported symbols matching name
13
+ agentmap --relates <file.ts> # imports, dependents, related files
14
+ agentmap --map --tokens 400 # cheap repo orientation
15
+ ```
16
+
17
+ Use Read/Grep directly when:
18
+
19
+ 1. agentmap already oriented you and you need exact lines to edit
20
+ 2. The map is missing — run `agentmap` once to build `.claude/agentmap/`
21
+ 3. Searching raw strings, logs, or non-TS files
22
+
23
+ **Cursor MCP (optional):** `agentmap --mcp` exposes the same queries as tools.
24
+
25
+ **Note:** `--features` only detects Next.js `app/` routes; TanStack `src/routes/` repos often show `features (0)`.
26
+
27
+ Package: `@raymondchins/agentmap` — not the unrelated PyPI/npm `agentmap` package.
@@ -0,0 +1,24 @@
1
+ ## agentmap (repo map)
2
+
3
+ This project can use **agentmap** (`@raymondchins/agentmap`) — a queryable import/symbol map at `.claude/agentmap/`.
4
+
5
+ **Before Grep/Glob for structural questions** (where is a symbol, who imports a file, reuse check, blast radius), prefer:
6
+
7
+ ```bash
8
+ agentmap --any <query> # file → symbol → feature → git-grep fallback
9
+ agentmap --find <Symbol> # exported symbols matching name
10
+ agentmap --relates <file.ts> # imports, dependents, related files
11
+ agentmap --map --tokens 400 # cheap repo orientation
12
+ ```
13
+
14
+ Use Read/Grep directly when:
15
+
16
+ 1. agentmap already oriented you and you need exact lines to edit
17
+ 2. The map is missing — run `agentmap` once to build `.claude/agentmap/`
18
+ 3. Searching raw strings, logs, or non-TS files
19
+
20
+ **MCP (optional):** `agentmap --mcp` exposes the same queries as tools.
21
+
22
+ **Note:** `--features` only detects Next.js `app/` routes; TanStack `src/routes/` repos often show `features (0)`.
23
+
24
+ Package: `@raymondchins/agentmap` — not the unrelated PyPI/npm `agentmap` package.
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Shared helpers for --install-skill (docs merge, hooks, plugins).
3
+
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const SKILLS_DIR = dirname(fileURLToPath(import.meta.url));
9
+ export const GUIDANCE = join(SKILLS_DIR, "guidance.md");
10
+ const GEMINI_NUDGE_SRC = join(SKILLS_DIR, "..", "hooks", "agentmap-gemini-nudge.mjs");
11
+ const OPENCODE_PLUGIN_SRC = join(SKILLS_DIR, "opencode-agentmap-nudge.js");
12
+
13
+ export const MARK_BEGIN = "<!-- agentmap:begin -->";
14
+ export const MARK_END = "<!-- agentmap:end -->";
15
+
16
+ export function atomicWrite(dest, body) {
17
+ mkdirSync(dirname(dest), { recursive: true });
18
+ const tmp = `${dest}.tmp`;
19
+ writeFileSync(tmp, body, "utf8");
20
+ renameSync(tmp, dest);
21
+ }
22
+
23
+ function stripJsonComments(src) {
24
+ let out = "";
25
+ let inStr = false, esc = false, inLine = false, inBlock = false;
26
+ for (let i = 0; i < src.length; i++) {
27
+ const c = src[i], n = src[i + 1];
28
+ if (inLine) { if (c === "\n") { inLine = false; out += c; } continue; }
29
+ if (inBlock) { if (c === "*" && n === "/") { inBlock = false; i++; } continue; }
30
+ if (inStr) {
31
+ out += c;
32
+ if (esc) esc = false;
33
+ else if (c === "\\") esc = true;
34
+ else if (c === '"') inStr = false;
35
+ continue;
36
+ }
37
+ if (c === '"') { inStr = true; out += c; continue; }
38
+ if (c === "/" && n === "/") { inLine = true; i++; continue; }
39
+ if (c === "/" && n === "*") { inBlock = true; i++; continue; }
40
+ out += c;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function parseSettings(text, settingsPath) {
46
+ try { return JSON.parse(text) || {}; }
47
+ catch {
48
+ try { return JSON.parse(stripJsonComments(text)) || {}; }
49
+ catch { throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run`); }
50
+ }
51
+ }
52
+
53
+ export function readGuidanceSection() {
54
+ if (!existsSync(GUIDANCE)) throw new Error(`packaged guidance missing: ${GUIDANCE}`);
55
+ return readFileSync(GUIDANCE, "utf8");
56
+ }
57
+
58
+ export function mergeGuidanceBlock(existing, section, title) {
59
+ const block = `${MARK_BEGIN}\n${section.trim()}\n${MARK_END}`;
60
+ const re = /<!-- agentmap:begin -->[\s\S]*?<!-- agentmap:end -->/;
61
+ if (existing && re.test(existing)) return existing.replace(re, block);
62
+ const header = title ? `# ${title}\n\n` : "";
63
+ if (!existing?.trim()) return `${header}${block}\n`;
64
+ return `${existing.trimEnd()}\n\n${block}\n`;
65
+ }
66
+
67
+ /** @returns {string[]} relative paths touched */
68
+ export function installGeminiHooks(root, dryRun) {
69
+ if (root !== process.cwd()) return [];
70
+ const nudgeRel = ".gemini/hooks/agentmap-nudge.mjs";
71
+ const nudgeDest = join(root, nudgeRel);
72
+ const settingsPath = ".gemini/settings.json";
73
+ const NUDGE_CMD = `node "$GEMINI_PROJECT_DIR/.gemini/hooks/agentmap-nudge.mjs"`;
74
+ const targets = [nudgeRel, settingsPath];
75
+
76
+ let settings = {};
77
+ if (existsSync(settingsPath)) {
78
+ settings = parseSettings(readFileSync(settingsPath, "utf8"), settingsPath);
79
+ }
80
+ settings.hooks ??= {};
81
+ settings.hooks.BeforeTool ??= [];
82
+ const matcher = "run_shell_command|grep|search";
83
+ const already = settings.hooks.BeforeTool.some(
84
+ (e) => e?.matcher === matcher && Array.isArray(e?.hooks) &&
85
+ e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("agentmap-nudge")),
86
+ );
87
+
88
+ if (dryRun) return targets;
89
+ if (!existsSync(GEMINI_NUDGE_SRC)) throw new Error(`packaged hook missing: ${GEMINI_NUDGE_SRC}`);
90
+ mkdirSync(dirname(nudgeDest), { recursive: true });
91
+ writeFileSync(nudgeDest, readFileSync(GEMINI_NUDGE_SRC, "utf8"));
92
+
93
+ if (!already) {
94
+ settings.hooks.BeforeTool.push({
95
+ matcher,
96
+ hooks: [{
97
+ name: "agentmap-nudge",
98
+ type: "command",
99
+ command: NUDGE_CMD,
100
+ timeout: 5000,
101
+ description: "Nudge structural searches toward agentmap",
102
+ }],
103
+ });
104
+ atomicWrite(settingsPath, JSON.stringify(settings, null, 2) + "\n");
105
+ }
106
+ return targets;
107
+ }
108
+
109
+ /** @returns {string[]} relative paths touched */
110
+ export function installOpencodePlugin(root, dryRun) {
111
+ if (root !== process.cwd()) return [];
112
+ const dest = ".opencode/plugins/agentmap-nudge.js";
113
+ if (dryRun) return [dest];
114
+ if (!existsSync(OPENCODE_PLUGIN_SRC)) throw new Error(`packaged plugin missing: ${OPENCODE_PLUGIN_SRC}`);
115
+ atomicWrite(join(root, dest), readFileSync(OPENCODE_PLUGIN_SRC, "utf8"));
116
+ return [dest];
117
+ }
@@ -0,0 +1,214 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // --install-skill: skill files + always-on docs/hooks per platform (project or global).
3
+
4
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
5
+ import { homedir, platform as osPlatform } from "node:os";
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import {
9
+ atomicWrite,
10
+ readGuidanceSection,
11
+ mergeGuidanceBlock,
12
+ installGeminiHooks,
13
+ installOpencodePlugin,
14
+ } from "./install-helpers.mjs";
15
+
16
+ const SKILLS_DIR = dirname(fileURLToPath(import.meta.url));
17
+ const SKILL_MD = join(SKILLS_DIR, "SKILL.md");
18
+ const CURSOR_RULE = join(SKILLS_DIR, "cursor-rule.mdc");
19
+
20
+ /** @param {string} root @param {boolean} _globalScope */
21
+ function skillPath(root, _globalScope, ...segments) {
22
+ return join(root, ...segments);
23
+ }
24
+
25
+ /** @type {Record<string, {
26
+ * label: string;
27
+ * src: string;
28
+ * dest: (root: string, globalScope: boolean) => string;
29
+ * projectOnly?: boolean;
30
+ * legacy?: boolean;
31
+ * docs?: (root: string, globalScope: boolean) => string;
32
+ * hooks?: boolean;
33
+ * plugin?: boolean;
34
+ * }>} */
35
+ const PLATFORMS = {
36
+ claude: {
37
+ label: "Claude Code",
38
+ src: SKILL_MD,
39
+ dest: (root) => skillPath(root, false, ".claude", "skills", "agentmap", "SKILL.md"),
40
+ },
41
+ cursor: {
42
+ label: "Cursor",
43
+ src: CURSOR_RULE,
44
+ dest: (root) => skillPath(root, false, ".cursor", "rules", "agentmap.mdc"),
45
+ projectOnly: true,
46
+ },
47
+ codex: {
48
+ label: "OpenAI Codex",
49
+ src: SKILL_MD,
50
+ dest: (root) => skillPath(root, false, ".codex", "skills", "agentmap", "SKILL.md"),
51
+ docs: (root, globalScope) =>
52
+ globalScope ? join(root, ".codex", "AGENTS.md") : join(root, "AGENTS.md"),
53
+ },
54
+ opencode: {
55
+ label: "OpenCode",
56
+ src: SKILL_MD,
57
+ dest: (root, globalScope) =>
58
+ globalScope
59
+ ? join(root, ".config", "opencode", "skills", "agentmap", "SKILL.md")
60
+ : join(root, ".opencode", "skills", "agentmap", "SKILL.md"),
61
+ docs: (root, globalScope) =>
62
+ globalScope ? join(root, ".config", "opencode", "AGENTS.md") : join(root, "AGENTS.md"),
63
+ plugin: true,
64
+ },
65
+ gemini: {
66
+ label: "Gemini CLI",
67
+ src: SKILL_MD,
68
+ dest: (root, globalScope) => {
69
+ if (!globalScope) return skillPath(root, false, ".gemini", "skills", "agentmap", "SKILL.md");
70
+ if (osPlatform() === "win32") return skillPath(root, true, ".agents", "skills", "agentmap", "SKILL.md");
71
+ return skillPath(root, true, ".gemini", "skills", "agentmap", "SKILL.md");
72
+ },
73
+ docs: (root, globalScope) => {
74
+ if (!globalScope) return join(root, "GEMINI.md");
75
+ if (osPlatform() === "win32") return join(root, ".agents", "GEMINI.md");
76
+ return join(root, ".gemini", "GEMINI.md");
77
+ },
78
+ hooks: true,
79
+ },
80
+ antigravity: {
81
+ label: "Antigravity",
82
+ src: SKILL_MD,
83
+ dest: (root, globalScope) =>
84
+ globalScope
85
+ ? skillPath(root, true, ".gemini", "config", "skills", "agentmap", "SKILL.md")
86
+ : skillPath(root, false, ".agents", "skills", "agentmap", "SKILL.md"),
87
+ },
88
+ agents: {
89
+ label: "Amp / legacy .agents",
90
+ src: SKILL_MD,
91
+ dest: (root) => skillPath(root, false, ".agents", "skills", "agentmap", "SKILL.md"),
92
+ legacy: true,
93
+ },
94
+ copilot: {
95
+ label: "GitHub Copilot CLI",
96
+ src: SKILL_MD,
97
+ dest: (root) => skillPath(root, false, ".copilot", "skills", "agentmap", "SKILL.md"),
98
+ },
99
+ };
100
+
101
+ const DEFAULT_PLATFORMS = ["claude", "cursor", "codex", "opencode", "gemini", "antigravity", "copilot"];
102
+
103
+ function parsePlatforms(raw) {
104
+ const keys = Object.keys(PLATFORMS).join(", ");
105
+ if (!raw || raw === "all") return [...DEFAULT_PLATFORMS];
106
+ const tokens = raw.split(/[,+]/).map((s) => s.trim().toLowerCase()).filter(Boolean);
107
+ if (tokens.includes("all")) {
108
+ const extra = tokens.filter((t) => t !== "all");
109
+ for (const n of extra) {
110
+ if (!PLATFORMS[n]) throw new Error(`unknown platform '${n}' — choose: ${keys}, all`);
111
+ }
112
+ return [...new Set([...DEFAULT_PLATFORMS, ...extra])];
113
+ }
114
+ for (const n of tokens) {
115
+ if (!PLATFORMS[n]) {
116
+ throw new Error(`unknown platform '${n}' — choose: ${keys}, all`);
117
+ }
118
+ }
119
+ return tokens;
120
+ }
121
+
122
+ function gitAddHint(paths) {
123
+ const unique = [...new Set(paths.map((p) => p.replace(/\/[^/]+$/, "") || p))];
124
+ if (unique.length) console.log(`\nOptional: git add ${unique.map((p) => `"${p}"`).join(" ")}`);
125
+ }
126
+
127
+ function installDocsForPlatform(cfg, { root, globalScope, dryRun, guidance, mergedDocs, targets }) {
128
+ if (!cfg.docs) return;
129
+ const dest = cfg.docs(root, globalScope);
130
+ if (mergedDocs.has(dest)) return;
131
+ mergedDocs.add(dest);
132
+ const title = dest.endsWith("GEMINI.md") ? "agentmap" : undefined;
133
+ if (dryRun) {
134
+ console.log(` ${cfg.label} docs: ${dest}`);
135
+ return;
136
+ }
137
+ const existing = existsSync(dest) ? readFileSync(dest, "utf8") : "";
138
+ atomicWrite(dest, mergeGuidanceBlock(existing, guidance, title));
139
+ console.log(` ${cfg.label} docs → ${dest}`);
140
+ targets.push(dest);
141
+ }
142
+
143
+ function installExtrasForPlatform(name, cfg, { root, globalScope, dryRun, targets }) {
144
+ if (cfg.hooks && !globalScope) {
145
+ const hookTargets = installGeminiHooks(root, dryRun);
146
+ for (const t of hookTargets) {
147
+ if (dryRun) console.log(` ${cfg.label} hooks: ${t}`);
148
+ else {
149
+ console.log(` ${cfg.label} hooks → ${t}`);
150
+ targets.push(t);
151
+ }
152
+ }
153
+ }
154
+ if (cfg.plugin && !globalScope) {
155
+ const pluginTargets = installOpencodePlugin(root, dryRun);
156
+ for (const t of pluginTargets) {
157
+ if (dryRun) console.log(` ${cfg.label} plugin: ${t}`);
158
+ else {
159
+ console.log(` ${cfg.label} plugin → ${t}`);
160
+ targets.push(t);
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * @param {{ platforms?: string; project?: boolean; global?: boolean; dryRun?: boolean }} opts
168
+ */
169
+ export function installSkill({ platforms: platformsArg = "all", project = true, global: globalScope = false, dryRun = false } = {}) {
170
+ if (project && globalScope) throw new Error("use either --project or --global, not both");
171
+ const VERSION = JSON.parse(readFileSync(join(SKILLS_DIR, "..", "package.json"), "utf8")).version;
172
+ const scope = globalScope ? "global" : "project";
173
+ const root = globalScope ? homedir() : process.cwd();
174
+ const names = parsePlatforms(platformsArg);
175
+ const targets = [];
176
+ const seenDest = new Set();
177
+ const mergedDocs = new Set();
178
+ const guidance = readGuidanceSection();
179
+
180
+ if (dryRun) console.log(`--dry-run: would install agentmap skill (${scope} scope):`);
181
+
182
+ for (const name of names) {
183
+ const cfg = PLATFORMS[name];
184
+ if (cfg.projectOnly && globalScope) {
185
+ console.log(` skip ${cfg.label}: Cursor rule is project-scoped only (re-run with --project)`);
186
+ continue;
187
+ }
188
+ if (!existsSync(cfg.src)) throw new Error(`packaged skill missing: ${cfg.src}`);
189
+ const dest = cfg.dest(root, globalScope);
190
+ const skipSkill = seenDest.has(dest);
191
+ if (skipSkill) {
192
+ console.log(` skip ${cfg.label} skill: same path as another platform (${dest})`);
193
+ } else {
194
+ seenDest.add(dest);
195
+ if (dryRun) {
196
+ console.log(` ${cfg.label} skill: ${dest}`);
197
+ } else {
198
+ atomicWrite(dest, readFileSync(cfg.src, "utf8"));
199
+ writeFileSync(join(dirname(dest), ".agentmap_version"), VERSION + "\n", "utf8");
200
+ console.log(` ${cfg.label} skill → ${dest}`);
201
+ targets.push(dest);
202
+ }
203
+ }
204
+
205
+ installDocsForPlatform(cfg, { root, globalScope, dryRun, guidance, mergedDocs, targets });
206
+ installExtrasForPlatform(name, cfg, { root, globalScope, dryRun, targets });
207
+ }
208
+
209
+ if (!dryRun && targets.length) {
210
+ console.log(`\nagentmap --install-skill: installed ${targets.length} file(s) (${scope}).`);
211
+ if (!globalScope) gitAddHint(targets);
212
+ console.log("Pair with: agentmap --install-hooks (Claude Code) or agentmap --mcp (Cursor MCP).");
213
+ }
214
+ }
@@ -0,0 +1,38 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // OpenCode plugin — logs a reminder when bash/grep looks like a structural search.
3
+ // Always-on guidance lives in AGENTS.md (installed by --install-skill).
4
+ // Non-blocking: uses client.app.log when available.
5
+
6
+ const DEP_RE =
7
+ /\b(import|require\s*\(|imported\s+by|depends|dependents?|dependency)\b|from\s+["']|(^|\|)\s*export\b/i;
8
+ const INTENT_RE =
9
+ /\bwhere\s+is\b|\bwho\s+(imports|uses|renders)\b|\breuse\b|\b(existing|shared)\s+(util|component|hook|helper)\b/i;
10
+ const SYMBOL_RE = /\b[A-Z][a-z0-9]+[A-Z][A-Za-z0-9]*\b/;
11
+
12
+ function shouldNudge(tool, args) {
13
+ const pattern = String(args?.pattern || args?.query || "");
14
+ const cmd = String(args?.command || "");
15
+ if (tool === "grep" && pattern) return DEP_RE.test(pattern) || INTENT_RE.test(pattern);
16
+ if (tool === "bash" && cmd) {
17
+ if (!/(^|[;&]\s*)(rg|ripgrep|grep|egrep|fgrep|ag|ack)\b/.test(cmd)) return false;
18
+ return DEP_RE.test(cmd) || INTENT_RE.test(cmd) || SYMBOL_RE.test(cmd);
19
+ }
20
+ return false;
21
+ }
22
+
23
+ export const AgentmapNudge = async ({ client }) => {
24
+ return {
25
+ "tool.execute.before": async (input, output) => {
26
+ if (!shouldNudge(input.tool, output.args)) return;
27
+ const message =
28
+ "Structural search: prefer agentmap --any <query>, --relates <path>, or --find <symbol> before serial grep.";
29
+ try {
30
+ await client?.app?.log?.({
31
+ body: { service: "agentmap", level: "info", message },
32
+ });
33
+ } catch {
34
+ // advisory only
35
+ }
36
+ },
37
+ };
38
+ };