@raymondchins/agentmap 0.7.0 → 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,23 +185,41 @@ 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)
188
+ ### 3. Agent skills (Cursor, Claude Code, Codex, OpenCode, Gemini, Antigravity, Copilot)
189
189
 
190
190
  ```bash
191
191
  npx @raymondchins/agentmap --install-skill
192
192
  ```
193
193
 
194
- Copies a packaged **SKILL.md** (Claude Code / Codex / OpenCode) and a **Cursor rule**
195
- (`.cursor/rules/agentmap.mdc`, `alwaysApply: true`) into the current repo or global
196
- agent directories. Options:
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
197
 
198
198
  ```bash
199
- agentmap --install-skill --platform cursor # Cursor rule only
200
- agentmap --install-skill --platform claude # .claude/skills/agentmap/SKILL.md
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/
201
206
  agentmap --install-skill --global --platform claude # ~/.claude/skills/...
202
- agentmap --install-skill --dry-run # preview paths, no writes
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
203
209
  ```
204
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
+
205
223
  Pair with `--install-hooks` (Claude Code) or `--mcp` (Cursor MCP).
206
224
 
207
225
  ---
@@ -501,7 +519,7 @@ $ node agentmap.mjs --print | jq '.hubs[0]'
501
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}`. |
502
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. |
503
521
  | `--hook-status` | Report whether the post-commit hook, PreToolUse nudge, and `.gitignore` entry are installed (no writes). |
504
- | `--install-skill` | Install packaged agent skill + Cursor rule (`--platform claude\|cursor\|agents\|all`, default `all`; `--project` default, or `--global`; `--dry-run` preview). |
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). |
505
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. |
506
524
 
507
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()) {
@@ -1090,8 +1165,8 @@ Maintenance:
1090
1165
  --install-hooks [--dry-run]
1091
1166
  install git post-commit + copy the PreToolUse nudge +
1092
1167
  wire .claude/settings.json (--dry-run = preview, no writes)
1093
- --install-skill [--platform claude|cursor|agents|all] [--project|--global] [--dry-run]
1094
- install SKILL.md / Cursor rule for coding agents
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
1095
1170
  --hook-status report whether agentmap git/nudge wiring is installed
1096
1171
  --setup-mcp [--dry-run]
1097
1172
  configure MCP server for OpenCode & Antigravity IDE
@@ -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.7.0",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
+ }
@@ -1,50 +1,122 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // --install-skill: copy packaged SKILL.md / Cursor rule into project or global
3
- // agent directories (project or global scope).
2
+ // --install-skill: skill files + always-on docs/hooks per platform (project or global).
4
3
 
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
6
- import { homedir } from "node:os";
4
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
5
+ import { homedir, platform as osPlatform } from "node:os";
7
6
  import { join, dirname } from "node:path";
8
7
  import { fileURLToPath } from "node:url";
8
+ import {
9
+ atomicWrite,
10
+ readGuidanceSection,
11
+ mergeGuidanceBlock,
12
+ installGeminiHooks,
13
+ installOpencodePlugin,
14
+ } from "./install-helpers.mjs";
9
15
 
10
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");
11
19
 
12
- /** @type {Record<string, { label: string; src: string; dest: (root: string) => string; projectOnly?: boolean }>} */
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
+ * }>} */
13
35
  const PLATFORMS = {
14
36
  claude: {
15
37
  label: "Claude Code",
16
- src: join(SKILLS_DIR, "SKILL.md"),
17
- dest: (root) => join(root, ".claude", "skills", "agentmap", "SKILL.md"),
18
- },
19
- agents: {
20
- label: "Codex / OpenCode (.agents)",
21
- src: join(SKILLS_DIR, "SKILL.md"),
22
- dest: (root) => join(root, ".agents", "skills", "agentmap", "SKILL.md"),
38
+ src: SKILL_MD,
39
+ dest: (root) => skillPath(root, false, ".claude", "skills", "agentmap", "SKILL.md"),
23
40
  },
24
41
  cursor: {
25
42
  label: "Cursor",
26
- src: join(SKILLS_DIR, "cursor-rule.mdc"),
27
- dest: (root) => join(root, ".cursor", "rules", "agentmap.mdc"),
43
+ src: CURSOR_RULE,
44
+ dest: (root) => skillPath(root, false, ".cursor", "rules", "agentmap.mdc"),
28
45
  projectOnly: true,
29
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
+ },
30
99
  };
31
100
 
32
- function atomicWrite(dest, body) {
33
- mkdirSync(dirname(dest), { recursive: true });
34
- const tmp = `${dest}.tmp`;
35
- writeFileSync(tmp, body, "utf8");
36
- renameSync(tmp, dest);
37
- }
101
+ const DEFAULT_PLATFORMS = ["claude", "cursor", "codex", "opencode", "gemini", "antigravity", "copilot"];
38
102
 
39
103
  function parsePlatforms(raw) {
40
- if (!raw || raw === "all") return ["claude", "cursor", "agents"];
41
- const names = raw.split(/[,+]/).map((s) => s.trim().toLowerCase()).filter(Boolean);
42
- for (const n of names) {
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) {
43
115
  if (!PLATFORMS[n]) {
44
- throw new Error(`unknown platform '${n}' — choose: ${Object.keys(PLATFORMS).join(", ")}, all`);
116
+ throw new Error(`unknown platform '${n}' — choose: ${keys}, all`);
45
117
  }
46
118
  }
47
- return names;
119
+ return tokens;
48
120
  }
49
121
 
50
122
  function gitAddHint(paths) {
@@ -52,18 +124,58 @@ function gitAddHint(paths) {
52
124
  if (unique.length) console.log(`\nOptional: git add ${unique.map((p) => `"${p}"`).join(" ")}`);
53
125
  }
54
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
+
55
166
  /**
56
167
  * @param {{ platforms?: string; project?: boolean; global?: boolean; dryRun?: boolean }} opts
57
168
  */
58
169
  export function installSkill({ platforms: platformsArg = "all", project = true, global: globalScope = false, dryRun = false } = {}) {
59
170
  if (project && globalScope) throw new Error("use either --project or --global, not both");
60
- // read version lazily (inside the function, not at module load) so importing
61
- // this module is side-effect-free / off the CLI hot path.
62
171
  const VERSION = JSON.parse(readFileSync(join(SKILLS_DIR, "..", "package.json"), "utf8")).version;
63
172
  const scope = globalScope ? "global" : "project";
64
173
  const root = globalScope ? homedir() : process.cwd();
65
174
  const names = parsePlatforms(platformsArg);
66
175
  const targets = [];
176
+ const seenDest = new Set();
177
+ const mergedDocs = new Set();
178
+ const guidance = readGuidanceSection();
67
179
 
68
180
  if (dryRun) console.log(`--dry-run: would install agentmap skill (${scope} scope):`);
69
181
 
@@ -74,19 +186,24 @@ export function installSkill({ platforms: platformsArg = "all", project = true,
74
186
  continue;
75
187
  }
76
188
  if (!existsSync(cfg.src)) throw new Error(`packaged skill missing: ${cfg.src}`);
77
- const dest = cfg.dest(root);
78
- const body = readFileSync(cfg.src, "utf8");
79
- const versionFile = join(dirname(dest), ".agentmap_version");
80
-
81
- if (dryRun) {
82
- console.log(` ${cfg.label}: ${dest}`);
83
- continue;
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
+ }
84
203
  }
85
204
 
86
- atomicWrite(dest, body);
87
- writeFileSync(versionFile, VERSION + "\n", "utf8");
88
- console.log(` ${cfg.label} → ${dest}`);
89
- targets.push(dest);
205
+ installDocsForPlatform(cfg, { root, globalScope, dryRun, guidance, mergedDocs, targets });
206
+ installExtrasForPlatform(name, cfg, { root, globalScope, dryRun, targets });
90
207
  }
91
208
 
92
209
  if (!dryRun && targets.length) {
@@ -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
+ };