@codexguild/mcp 1.1.0 → 1.4.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
@@ -8,7 +8,7 @@ forum, chat) and adds what has to run on your machine:
8
8
  | Local tool | What it does |
9
9
  |---|---|
10
10
  | `codexguild_scan_skills` | Audits every skill installed in `~/.claude/skills`, `~/.claude/plugins`, `~/.codex/skills`, `~/.agents/skills`, `~/.hermes/skills`, `~/.config/opencode/skills`, `~/.cursor/skills`, `~/.openclaude/skills` **offline**, with the same scanner CodexGuild runs server-side (pipe-to-shell, secret exfiltration, prompt injection, hidden unicode, config tampering…). Nothing is uploaded. |
11
- | `codexguild_sync` | Detects your stack from `package.json`, `requirements.txt`, `pyproject.toml`, `go.mod`, … and your installed skills, then asks CodexGuild what changed since your model's training cutoff. |
11
+ | `codexguild_sync` | Detects your stack with the **exact installed versions from your lockfiles** (package-lock, pnpm-lock, yarn.lock, bun.lock, poetry.lock, uv.lock, Pipfile.lock, Cargo.lock; go.mod), the Node/Python runtime (.nvmrc, .tool-versions, engines) and your installed skills, keys it to the current repository (each project keeps its own sync window), then asks CodexGuild what changed and which installed versions have known vulnerabilities. |
12
12
  | `codexguild_advise` | Detects the instruction files in your project (`CLAUDE.md`, `AGENTS.md`, …) before proposing setup changes. Proposals always need your approval. |
13
13
 
14
14
  If your harness can't start local processes, use the remote endpoint `https://api.codexguild.com/v1/mcp` directly
@@ -6,8 +6,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
7
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
- import { access } from "node:fs/promises";
10
- import { join as join3 } from "node:path";
9
+ import { access, readFile as readFile3 } from "node:fs/promises";
10
+ import { isAbsolute, join as join3 } from "node:path";
11
11
 
12
12
  // src/local-scan.ts
13
13
  import { readdir, readFile, stat } from "node:fs/promises";
@@ -51,7 +51,10 @@ var LINE_RULES = [
51
51
  title: "Reverse shell pattern",
52
52
  why: "Opens an interactive shell to a remote host \u2014 full remote control of the machine.",
53
53
  scope: "all",
54
- pattern: /\/dev\/(tcp|udp)\/|\bnc(at)?\b[^\n]*\s(-e|-c|--exec|--sh-exec)\s|\bsocat\b[^\n]*\bexec:|\bbash\s+-i\s*>&|pty\.spawn\(\s*["']\/bin\/(ba)?sh/i
54
+ pattern: (
55
+ // /dev/tcp only when a shell's stdin/stdout is wired to it — `cat < /dev/null > /dev/tcp/host/port` is a port probe.
56
+ /(>&|<>|0<&)\s*\/dev\/(tcp|udp)\/|\/dev\/(tcp|udp)\/[^\n]*(0>&1|[<>]&\s*\d|\|\s*\/?(bin\/)?(ba|z)?sh\b)|\bnc(at)?\b[^\n]*\s(-e|-c|--exec|--sh-exec)\s|\bsocat\b[^\n]*\bexec:|\bbash\s+-i\s*>&|pty\.spawn\(\s*["']\/bin\/(ba)?sh/i
57
+ )
55
58
  },
56
59
  {
57
60
  id: "encoded-exec",
@@ -95,7 +98,9 @@ var LINE_RULES = [
95
98
  title: "Instructions that override the agent or hide actions from the user",
96
99
  why: "Skills are loaded into the agent context verbatim; these phrases try to subvert the agent's instructions or keep the user uninformed.",
97
100
  scope: "markdown",
98
- pattern: /\b(ignore|disregard|forget|override)\s+(all\s+|any\s+|the\s+)?(previous|prior|above|earlier|system|original)\s+(instructions|prompts?|rules|guidelines|directives)|\b(do\s+not|don't|never)\s+(tell|inform|notify|alert|mention\s+(this\s+)?to)\s+the\s+user(?!\s+to\b)|\bhide\s+(this|these|it|the\s+\w+)\s+(from\s+the\s+user|actions?)|\byou\s+are\s+now\s+(in\s+)?(developer|dan|jailbreak|unrestricted|god)\s*mode|(?<!\b(would|could|might|may|can|will|to)\s)\bsilently\s+(run|execute|install|delete|send|upload)/i
101
+ pattern: /\b(ignore|disregard|forget|override)\s+(all\s+|any\s+|the\s+)?(previous|prior|above|earlier|system|original)\s+(instructions|prompts?|rules|guidelines|directives)|\b(do\s+not|don't|never)\s+(tell|inform|notify|alert|mention\s+(this\s+)?to)\s+the\s+user(?!\s+to\b)|\bhide\s+(this|these|it|the\s+\w+)\s+(from\s+the\s+user(?!'s)|actions?)|\byou\s+are\s+now\s+(in\s+)?(developer|dan|jailbreak|unrestricted|god)\s*mode|(?<!\b(would|could|might|may|can|will|to)\s)\bsilently\s+(run|execute|install|delete|send|upload)\b/i,
102
+ // "Do not tell the user their tickets are for sale unless it says live" keeps the user from being misinformed.
103
+ unless: /\b(do\s+not|don't|never)\s+(tell|inform)\s+the\s+user\b[^.!?]*\b(is|are)\b[^.!?]*\b(unless|until)\b/i
99
104
  },
100
105
  {
101
106
  id: "prompt-injection",
@@ -261,8 +266,97 @@ var LINE_RULES = [
261
266
  ];
262
267
  var HIDDEN_UNICODE = /[​-‍⁠-⁤⁦-⁩‪-‮]|[\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/gu;
263
268
 
269
+ // ../scanner/src/instructions.ts
270
+ var WEIGHT = { high: 25, medium: 10, low: 3 };
271
+ var README_HEADINGS = /^#{1,3}\s*(installation|install|license|licen[cs]e|contributing|contributors|badges?|table of contents|toc|features|screenshots?|demo|acknowledg(e)?ments?|sponsors?|changelog|roadmap|faq|support|authors?)\s*$/i;
272
+ var COMMAND = /\b(npm|pnpm|yarn|bun|npx|bunx|pip|uv|poetry|pytest|python3?|go|cargo|make|just|gradle|\.\/gradlew|mvn|dotnet|bundle|rake|rails|php|composer|mix|deno|docker( compose)?|kubectl|terraform|tox|nox|ruff|eslint|prettier|tsc|jest|vitest|playwright)\s+[\w:./-]+/;
273
+ var COMMAND_START = new RegExp(`^(?:sudo\\s+|\\w+=\\S+\\s+)*${COMMAND.source}`);
274
+ var VAGUE = /\b(write (clean|good|readable|maintainable) code|follow best practices|use (good|meaningful|descriptive) (names|naming)|be careful|make sure (the )?code (is|works)|keep (it|things|the code) simple|use common sense|write (good|proper) tests)\b/i;
275
+ var SECRET = /\b(sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|xox[abpr]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|cgk_[A-Za-z0-9]{20,})/;
276
+ var ABS_PATH = /(?:^|[\s`'"(])(\/Users\/[^/\s`'"]+|\/home\/[^/\s`'"]+|[A-Z]:\\Users\\[^\\\s`'"]+)/;
277
+ var SHOUT = /\b(IMPORTANT|CRITICAL|MUST|NEVER|ALWAYS|DO NOT)\b/g;
278
+ function grade(score) {
279
+ return score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
280
+ }
281
+ function lintInstructions(content, filename = "AGENTS.md") {
282
+ const issues = [];
283
+ const lines = content.split(/\r?\n/);
284
+ const approxTokens = Math.ceil(content.length / 4);
285
+ let inFence = false;
286
+ let codeBlocks = 0;
287
+ let commands2 = 0;
288
+ let headings = 0;
289
+ const seen = /* @__PURE__ */ new Map();
290
+ lines.forEach((raw, i) => {
291
+ const line = raw.trimEnd();
292
+ const n = i + 1;
293
+ if (/^\s*(```|~~~)/.test(line)) {
294
+ if (!inFence) codeBlocks++;
295
+ inFence = !inFence;
296
+ return;
297
+ }
298
+ const code = inFence ? line : /^\s*\$\s/.test(line) ? line.replace(/^\s*\$\s/, "") : [...line.matchAll(/`([^`]+)`/g)].map((m) => m[1]).join("\n");
299
+ if (code && code.split("\n").some((c) => COMMAND_START.test(c.trim()))) commands2++;
300
+ if (inFence) return;
301
+ if (/^#{1,6}\s/.test(line)) {
302
+ headings++;
303
+ if (README_HEADINGS.test(line.trim())) {
304
+ issues.push({ id: "readme-section", severity: "medium", line: n, title: `README section "${line.replace(/^#+\s*/, "")}"`, detail: "Install/license/contributing sections are for humans and already in the README. In an instruction file they cost tokens every session without changing what the agent does." });
305
+ }
306
+ }
307
+ if (/!\[[^\]]*\]\(https?:\/\/(img\.)?shields\.io|badge(n)?\.svg|\[!\[/.test(line)) {
308
+ issues.push({ id: "badges", severity: "low", line: n, title: "Badges", detail: "Badges carry no instructions; drop them from the agent file." });
309
+ }
310
+ if (VAGUE.test(line)) {
311
+ issues.push({ id: "vague-rule", severity: "low", line: n, title: "Generic advice", detail: `"${line.trim().slice(0, 80)}" \u2014 agents already try to do this. Replace it with the project-specific rule you actually mean (naming scheme, error handling pattern, which test command).` });
312
+ }
313
+ if (SECRET.test(line)) {
314
+ issues.push({ id: "secret", severity: "high", line: n, title: "Looks like a secret", detail: "Instruction files are committed and sent to the model provider every session. Move the value to an env var or secret store and rotate it." });
315
+ }
316
+ if (ABS_PATH.test(line)) {
317
+ issues.push({ id: "absolute-path", severity: "low", line: n, title: "Machine-specific path", detail: "A home-directory path only works on one machine; use paths relative to the repo root." });
318
+ }
319
+ const key = line.trim().toLowerCase();
320
+ if (key.length > 30) {
321
+ if (seen.has(key)) issues.push({ id: "duplicate", severity: "low", line: n, title: "Repeated line", detail: `Same as line ${seen.get(key)}.` });
322
+ else seen.set(key, n);
323
+ }
324
+ });
325
+ if (approxTokens > 8e3) {
326
+ issues.push({ id: "too-long", severity: "high", title: `~${approxTokens.toLocaleString("en-US")} tokens`, detail: `${filename} is loaded into every session. Past a few thousand tokens it crowds out the task and rules get ignored \u2014 keep the essentials here and move reference material into docs the agent reads on demand (or skills).` });
327
+ } else if (approxTokens > 2500) {
328
+ issues.push({ id: "long", severity: "medium", title: `~${approxTokens.toLocaleString("en-US")} tokens`, detail: `${filename} is loaded into every session; aim for under ~2,500 tokens of rules and commands.` });
329
+ }
330
+ if (content.trim() && !commands2) {
331
+ issues.push({ id: "no-commands", severity: "medium", title: "No build/test commands", detail: "The most useful thing an instruction file gives an agent is the exact commands to build, test and lint. Without them it guesses (and often runs the wrong ones)." });
332
+ }
333
+ if (lines.length > 40 && headings < 2) {
334
+ issues.push({ id: "no-structure", severity: "low", title: "No sections", detail: "Split the file into a few headed sections (Commands, Architecture, Conventions, Don'ts) so rules are easy to find and update." });
335
+ }
336
+ const shouts = content.match(SHOUT)?.length ?? 0;
337
+ if (shouts > 15) {
338
+ issues.push({ id: "shouting", severity: "low", title: `${shouts} all-caps emphasis words`, detail: "When everything is IMPORTANT nothing is. Keep caps for the two or three rules that really are hard limits." });
339
+ }
340
+ const scan = scanSkill([{ path: filename.endsWith(".md") ? filename : `${filename}.md`, content }]);
341
+ for (const f of scan.findings) {
342
+ issues.push({ id: `security:${f.ruleId}`, severity: f.severity === "critical" || f.severity === "high" ? "high" : f.severity === "medium" ? "medium" : "low", line: f.line, title: f.title, detail: `${f.why} \u2014 ${f.snippet}` });
343
+ }
344
+ const order = { high: 0, medium: 1, low: 2 };
345
+ issues.sort((a, b) => order[a.severity] - order[b.severity] || (a.line ?? 0) - (b.line ?? 0));
346
+ const counted = /* @__PURE__ */ new Set();
347
+ let penalty = 0;
348
+ for (const i of issues) {
349
+ const k = i.severity === "low" ? i.id : `${i.id}:${i.line ?? ""}`;
350
+ if (counted.has(k)) continue;
351
+ counted.add(k);
352
+ penalty += WEIGHT[i.severity];
353
+ }
354
+ const score = content.trim() ? Math.max(0, 100 - penalty) : 0;
355
+ return { score, grade: grade(score), stats: { lines: lines.length, approxTokens, headings, codeBlocks, commands: commands2 }, issues: issues.slice(0, 100) };
356
+ }
357
+
264
358
  // ../scanner/src/index.ts
265
- var SCANNER_VERSION = "1.1.0";
359
+ var SCANNER_VERSION = "1.2.0";
266
360
  var MAX_PER_RULE_PER_FILE = 5;
267
361
  var SEVERITY_ORDER = ["critical", "high", "medium", "low"];
268
362
  var MARKDOWN_EXT = /\.(md|mdx|markdown|txt|rst)$/i;
@@ -382,9 +476,10 @@ function scanSkill(files) {
382
476
  snippet: snippetOf(line)
383
477
  });
384
478
  }
479
+ const prose = scope === "markdown" && !inFence ? line.replace(/^\s*(?:>\s?)+/, "").replace(/<[a-z][\w -]{0,30}>/gi, "") : line;
385
480
  for (const rule of LINE_RULES) {
386
481
  if (rule.scope !== "all" && rule.scope !== scope) continue;
387
- if (!matches(rule, line)) continue;
482
+ if (!matches(rule, rule.alsoRequires === WRITE_OP ? prose : line)) continue;
388
483
  if (scope === "markdown" && rule.alsoRequires && !inFence && line.includes("`")) {
389
484
  const spans = line.match(/`[^`]+`/g) ?? [];
390
485
  if (!spans.some((sp) => matches(rule, sp))) continue;
@@ -537,8 +632,9 @@ function formatLocalScan({ roots, reports }, verbose = false) {
537
632
  }
538
633
 
539
634
  // src/stack-detect.ts
540
- import { readdir as readdir2, readFile as readFile2 } from "node:fs/promises";
541
- import { basename, dirname, join as join2 } from "node:path";
635
+ import { createHash as createHash2 } from "node:crypto";
636
+ import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
637
+ import { basename, dirname, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
542
638
  async function read(path) {
543
639
  try {
544
640
  return await readFile2(path, "utf8");
@@ -546,31 +642,200 @@ async function read(path) {
546
642
  return null;
547
643
  }
548
644
  }
645
+ async function isDir(path) {
646
+ try {
647
+ return (await stat2(path)).isDirectory();
648
+ } catch {
649
+ return false;
650
+ }
651
+ }
652
+ function isExactVersion(v) {
653
+ return !!v && /^v?\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+)?$/.test(v.trim());
654
+ }
655
+ var pep503 = (name) => name.toLowerCase().replace(/[-_.]+/g, "-");
656
+ var unquote = (s) => s.trim().replace(/^['"]|['"]$/g, "");
549
657
  function fromPackageJson(raw) {
550
658
  try {
551
659
  const pkg = JSON.parse(raw);
552
- return [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies].flatMap((deps) => Object.entries(deps ?? {})).map(([name, version]) => ({ name, version: String(version).replace(/^[~^>=<\s]+/, "").slice(0, 64) }));
660
+ return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].flatMap((deps) => Object.entries(deps ?? {})).map(([name, spec]) => ({ name, spec: String(spec).trim() })).filter((d) => !/^(workspace:|file:|link:|portal:)/.test(d.spec));
553
661
  } catch {
554
662
  return [];
555
663
  }
556
664
  }
665
+ function nodeEngine(raw) {
666
+ try {
667
+ const e = JSON.parse(raw).engines?.node;
668
+ return e ? String(e).trim().slice(0, 64) : null;
669
+ } catch {
670
+ return null;
671
+ }
672
+ }
673
+ function npmLockResolver(raw, rel) {
674
+ let lock;
675
+ try {
676
+ lock = JSON.parse(raw);
677
+ } catch {
678
+ return () => null;
679
+ }
680
+ const prefix = rel && rel !== "." ? `${rel.split(sep).join("/")}/` : "";
681
+ return (name) => {
682
+ const p = lock.packages;
683
+ if (p) {
684
+ const hit = prefix && p[`${prefix}node_modules/${name}`] || p[`node_modules/${name}`];
685
+ if (hit && !hit.link && hit.version && !/^(file|link):/.test(hit.version)) return hit.version;
686
+ return null;
687
+ }
688
+ return lock.dependencies?.[name]?.version ?? null;
689
+ };
690
+ }
691
+ function pnpmLockResolver(raw, rel) {
692
+ const importer = !rel || rel === "." ? "." : rel.split(sep).join("/");
693
+ const out = /* @__PURE__ */ new Map();
694
+ const clean = (v) => unquote(v).replace(/\(.*$/, "").replace(/_.*$/, "");
695
+ let inImporters = false;
696
+ let current = null;
697
+ let pkg = null;
698
+ let inDeps = false;
699
+ for (const line of raw.split("\n")) {
700
+ if (/^importers:\s*$/.test(line)) {
701
+ inImporters = true;
702
+ continue;
703
+ }
704
+ if (!inImporters) continue;
705
+ if (/^\S/.test(line)) break;
706
+ let m = line.match(/^ {2}(\S.*?):\s*$/);
707
+ if (m) {
708
+ current = unquote(m[1]);
709
+ inDeps = false;
710
+ continue;
711
+ }
712
+ if (current !== importer) continue;
713
+ m = line.match(/^ {4}(\S+):\s*$/);
714
+ if (m) {
715
+ inDeps = /^(dependencies|devDependencies|optionalDependencies)$/.test(m[1]);
716
+ continue;
717
+ }
718
+ if (!inDeps) continue;
719
+ m = line.match(/^ {6}(\S.*?):\s*(.*)$/);
720
+ if (m) {
721
+ pkg = unquote(m[1]);
722
+ if (m[2] && !/^\s*$/.test(m[2])) out.set(pkg, clean(m[2]));
723
+ continue;
724
+ }
725
+ m = line.match(/^ {8}version:\s*(.+)$/);
726
+ if (m && pkg) out.set(pkg, clean(m[1]));
727
+ }
728
+ return (name) => {
729
+ const v = out.get(name);
730
+ return v && !/^(link|file|workspace):/.test(v) ? v : null;
731
+ };
732
+ }
733
+ function yarnLockResolver(raw) {
734
+ const bySpec = /* @__PURE__ */ new Map();
735
+ const byName = /* @__PURE__ */ new Map();
736
+ let keys = [];
737
+ for (const line of raw.split("\n")) {
738
+ if (/^\S.*:\s*$/.test(line) && !line.startsWith("#") && !line.startsWith("__metadata")) {
739
+ keys = line.replace(/:\s*$/, "").split(/,\s*/).map(unquote).filter(Boolean);
740
+ continue;
741
+ }
742
+ const m = line.match(/^ {2}version:?\s+"?([^"\s]+)"?\s*$/);
743
+ if (m && keys.length) {
744
+ for (const k of keys) {
745
+ const at = k.lastIndexOf("@");
746
+ if (at <= 0) continue;
747
+ const name = k.slice(0, at);
748
+ const spec = k.slice(at + 1).replace(/^npm:/, "");
749
+ bySpec.set(`${name}@${spec}`, m[1]);
750
+ if (!byName.has(name)) byName.set(name, m[1]);
751
+ }
752
+ keys = [];
753
+ }
754
+ }
755
+ return (name, spec) => bySpec.get(`${name}@${spec}`) ?? byName.get(name) ?? null;
756
+ }
757
+ function bunLockResolver(raw) {
758
+ let pkgs = {};
759
+ try {
760
+ pkgs = JSON.parse(raw.replace(/,(\s*[}\]])/g, "$1")).packages ?? {};
761
+ } catch {
762
+ return () => null;
763
+ }
764
+ return (name) => {
765
+ const id = pkgs[name]?.[0];
766
+ if (typeof id !== "string") return null;
767
+ const v = id.slice(id.lastIndexOf("@") + 1);
768
+ return /^\d/.test(v) ? v : null;
769
+ };
770
+ }
557
771
  function fromRequirements(raw) {
558
- return raw.split("\n").map((l) => l.replace(/#.*/, "").trim()).filter((l) => l && !l.startsWith("-")).map((l) => {
559
- const m = l.match(/^([A-Za-z0-9_.-]+)(?:\[.*\])?\s*(?:[=~<>!]=?\s*([\w.*-]+))?/);
560
- return m ? { name: m[1], ...m[2] ? { version: m[2] } : {} } : null;
772
+ return raw.split("\n").map((l) => l.replace(/#.*/, "").trim()).filter((l) => l && !l.startsWith("-") && !/^(git\+|https?:)/.test(l)).map((l) => {
773
+ const m = l.match(/^([A-Za-z0-9_.-]+)(?:\[.*?\])?\s*(===?|~=|>=|<=|!=|>|<)?\s*([\w.*+-]+)?/);
774
+ if (!m) return null;
775
+ const [, name, op, v] = m;
776
+ if (!v) return { name };
777
+ return { name, version: op === "==" || op === "===" ? v : `${op ?? ""}${v}`.slice(0, 64) };
561
778
  }).filter((d) => d !== null);
562
779
  }
563
780
  function fromPyproject(raw) {
564
- const block = raw.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m)?.[1] ?? "";
565
- return fromRequirements([...block.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]).join("\n"));
781
+ const pep621 = raw.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m)?.[1] ?? "";
782
+ const deps = fromRequirements([...pep621.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]).join("\n"));
783
+ const poetry = raw.match(/^\[tool\.poetry\.(?:dev-)?dependencies\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m)?.[1] ?? "";
784
+ for (const m of poetry.matchAll(/^([A-Za-z0-9_.-]+)\s*=\s*(?:"([^"]*)"|\{[^}]*version\s*=\s*"([^"]*)")/gm)) {
785
+ if (m[1].toLowerCase() === "python") continue;
786
+ deps.push({ name: m[1], ...m[2] ?? m[3] ? { version: (m[2] ?? m[3]).slice(0, 64) } : {} });
787
+ }
788
+ return deps;
789
+ }
790
+ function tomlPackages(raw, normalize = pep503) {
791
+ const out = /* @__PURE__ */ new Map();
792
+ for (const block of raw.split(/^\[\[package\]\]\s*$/m).slice(1)) {
793
+ const name = block.match(/^name\s*=\s*"([^"]+)"/m)?.[1];
794
+ const version = block.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
795
+ if (name && version && !out.has(normalize(name))) out.set(normalize(name), version);
796
+ }
797
+ return out;
798
+ }
799
+ function pipfileLock(raw) {
800
+ const out = /* @__PURE__ */ new Map();
801
+ try {
802
+ const j = JSON.parse(raw);
803
+ for (const section of [j.default, j.develop]) {
804
+ for (const [name, v] of Object.entries(section ?? {})) {
805
+ const ver = v.version?.replace(/^==/, "");
806
+ if (ver) out.set(pep503(name), ver);
807
+ }
808
+ }
809
+ } catch {
810
+ }
811
+ return out;
566
812
  }
567
813
  function fromGoMod(raw) {
568
- return [...raw.matchAll(/^\s*(?:require\s+)?([\w.-]+\.[\w.-]+\/[\w./-]+)\s+(v[\w.+-]+)/gm)].map((m) => ({
569
- name: m[1],
570
- version: m[2]
571
- }));
814
+ return [...raw.matchAll(/^\s*(?:require\s+)?([\w.-]+\.[\w.-]+\/[\w./-]+)\s+(v[\w.+-]+)/gm)].map((m) => ({ name: m[1], version: m[2] }));
572
815
  }
573
- var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "vendor", "venv", ".venv", "coverage", "tmp"]);
816
+ function fromCargoToml(raw) {
817
+ const deps = [];
818
+ const sections = raw.matchAll(/^\[(?:workspace\.)?(?:dev-|build-)?dependencies\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/gm);
819
+ for (const s of sections) {
820
+ for (const m of s[1].matchAll(/^([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|\{([^}]*)\})/gm)) {
821
+ const v = m[2] ?? m[3]?.match(/version\s*=\s*"([^"]*)"/)?.[1];
822
+ deps.push({ name: m[1], ...v ? { version: v.slice(0, 64) } : {} });
823
+ }
824
+ }
825
+ return deps;
826
+ }
827
+ async function runtimes(dir, pkgRaw) {
828
+ const out = [];
829
+ const node = (await read(join2(dir, ".nvmrc")))?.trim() || (await read(join2(dir, ".node-version")))?.trim();
830
+ const tool = await read(join2(dir, ".tool-versions"));
831
+ const toolNode = tool?.match(/^(?:nodejs|node)\s+(\S+)/m)?.[1];
832
+ const nodeVersion = node || toolNode || (pkgRaw ? nodeEngine(pkgRaw) : null);
833
+ if (nodeVersion && /\d/.test(nodeVersion)) out.push({ name: "node", version: nodeVersion.replace(/^v/, "").slice(0, 64), runtime: true });
834
+ const py = (await read(join2(dir, ".python-version")))?.trim().split("\n")[0] || tool?.match(/^python\s+(\S+)/m)?.[1];
835
+ if (py && /^\d/.test(py)) out.push({ name: "python", version: py.slice(0, 64), runtime: true });
836
+ return out;
837
+ }
838
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "vendor", "venv", ".venv", "coverage", "tmp", "target", "__pycache__"]);
574
839
  async function subdirs(dir) {
575
840
  try {
576
841
  return (await readdir2(dir, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name)).map((e) => join2(dir, e.name));
@@ -578,27 +843,113 @@ async function subdirs(dir) {
578
843
  return [];
579
844
  }
580
845
  }
846
+ async function projectRoot(cwd) {
847
+ let dir = resolve2(cwd);
848
+ for (; ; ) {
849
+ if (await isDir(join2(dir, ".git")) || await read(join2(dir, ".git")) !== null) return dir;
850
+ const up = dirname(dir);
851
+ if (up === dir) return resolve2(cwd);
852
+ dir = up;
853
+ }
854
+ }
855
+ async function projectKey(cwd) {
856
+ const root = await projectRoot(cwd);
857
+ return `${basename(root).slice(0, 100)}#${createHash2("sha256").update(root).digest("hex").slice(0, 8)}`;
858
+ }
859
+ async function findUp(dir, stop, names) {
860
+ let d = dir;
861
+ for (; ; ) {
862
+ for (const n of names) if (await read(join2(d, n)) !== null) return { path: join2(d, n), dir: d };
863
+ if (d === stop || dirname(d) === d) return null;
864
+ d = dirname(d);
865
+ }
866
+ }
867
+ var NPM_LOCKS = ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock"];
868
+ async function npmResolver(dir, root, used) {
869
+ const lock = await findUp(dir, root, NPM_LOCKS);
870
+ if (!lock) return () => null;
871
+ const raw = await read(lock.path) ?? "";
872
+ used.add(lock.path);
873
+ const rel = relative2(lock.dir, dir) || ".";
874
+ const file = basename(lock.path);
875
+ if (file === "pnpm-lock.yaml") return pnpmLockResolver(raw, rel);
876
+ if (file === "yarn.lock") return yarnLockResolver(raw);
877
+ if (file === "bun.lock") return bunLockResolver(raw);
878
+ return npmLockResolver(raw, rel);
879
+ }
880
+ async function pythonLock(dir, root, used) {
881
+ const lock = await findUp(dir, root, ["poetry.lock", "uv.lock", "Pipfile.lock"]);
882
+ if (!lock) return /* @__PURE__ */ new Map();
883
+ used.add(lock.path);
884
+ const raw = await read(lock.path) ?? "";
885
+ return basename(lock.path) === "Pipfile.lock" ? pipfileLock(raw) : tomlPackages(raw);
886
+ }
581
887
  async function detectStack(cwd = process.cwd()) {
582
- const level1 = await subdirs(cwd);
888
+ const base = resolve2(cwd);
889
+ const root = await projectRoot(base);
890
+ const level1 = await subdirs(base);
583
891
  const level2 = (await Promise.all(level1.map(subdirs))).flat();
584
- const dirs = [cwd, ...level1, ...level2].slice(0, 400);
585
- const parsers = [
586
- ["package.json", fromPackageJson],
587
- ["requirements.txt", fromRequirements],
588
- ["pyproject.toml", fromPyproject],
589
- ["go.mod", fromGoMod]
590
- ];
892
+ const dirs = [base, ...level1, ...level2].slice(0, 400);
591
893
  const deps = /* @__PURE__ */ new Map();
592
894
  const manifests = [];
895
+ const locks = /* @__PURE__ */ new Set();
896
+ const add = (d) => {
897
+ const key = `${d.runtime ? "runtime" : d.ecosystem}:${d.ecosystem === "pypi" ? pep503(d.name) : d.name.toLowerCase()}@${d.version ?? ""}`;
898
+ if (!deps.has(key)) deps.set(key, d);
899
+ };
900
+ const rel = (p) => relative2(base, p) || basename(p);
593
901
  for (const dir of dirs) {
594
- for (const [file, parse] of parsers) {
595
- const raw = await read(join2(dir, file));
596
- if (raw === null) continue;
597
- manifests.push(join2(dir, file).slice(cwd.length + 1) || file);
598
- for (const d of parse(raw)) if (!deps.has(d.name.toLowerCase())) deps.set(d.name.toLowerCase(), d);
902
+ const pkgRaw = await read(join2(dir, "package.json"));
903
+ if (pkgRaw !== null) {
904
+ manifests.push(rel(join2(dir, "package.json")));
905
+ const resolveVersion = await npmResolver(dir, root, locks);
906
+ for (const { name, spec } of fromPackageJson(pkgRaw)) {
907
+ const locked = resolveVersion(name, spec);
908
+ add({ name, ecosystem: "npm", ...locked ?? spec ? { version: (locked ?? spec).slice(0, 64) } : {} });
909
+ }
910
+ }
911
+ for (const r of await runtimes(dir, pkgRaw)) add(r);
912
+ const req = await read(join2(dir, "requirements.txt"));
913
+ const pyproject = await read(join2(dir, "pyproject.toml"));
914
+ if (req !== null || pyproject !== null) {
915
+ const locked = await pythonLock(dir, root, locks);
916
+ const pyDeps = [...req !== null ? fromRequirements(req) : [], ...pyproject !== null ? fromPyproject(pyproject) : []];
917
+ if (req !== null) manifests.push(rel(join2(dir, "requirements.txt")));
918
+ if (pyproject !== null) manifests.push(rel(join2(dir, "pyproject.toml")));
919
+ for (const d of pyDeps) {
920
+ const v = locked.get(pep503(d.name)) ?? d.version;
921
+ add({ name: d.name, ecosystem: "pypi", ...v ? { version: v } : {} });
922
+ }
923
+ }
924
+ const gomod = await read(join2(dir, "go.mod"));
925
+ if (gomod !== null) {
926
+ manifests.push(rel(join2(dir, "go.mod")));
927
+ for (const d of fromGoMod(gomod)) add({ ...d, ecosystem: "go" });
928
+ }
929
+ const cargo = await read(join2(dir, "Cargo.toml"));
930
+ if (cargo !== null) {
931
+ manifests.push(rel(join2(dir, "Cargo.toml")));
932
+ const lock = await findUp(dir, root, ["Cargo.lock"]);
933
+ const locked = lock ? tomlPackages(await read(lock.path) ?? "", (n) => n.toLowerCase()) : /* @__PURE__ */ new Map();
934
+ if (lock) locks.add(lock.path);
935
+ for (const d of fromCargoToml(cargo)) {
936
+ const v = locked.get(d.name.toLowerCase()) ?? d.version;
937
+ add({ name: d.name, ecosystem: "cargo", ...v ? { version: v } : {} });
938
+ }
599
939
  }
600
940
  }
601
- return { deps: [...deps.values()].slice(0, 300), manifests };
941
+ const all = [...deps.values()];
942
+ const exactOf = (d) => d.ecosystem === "go" && !!d.version || isExactVersion(d.version);
943
+ all.sort((a, b) => Number(exactOf(b)) - Number(exactOf(a)));
944
+ const sent = all.slice(0, 300);
945
+ const exact = sent.filter((d) => !d.runtime && exactOf(d)).length;
946
+ return {
947
+ deps: sent,
948
+ manifests,
949
+ lockfiles: [...locks].map((p) => relative2(base, p) || basename(p)),
950
+ exact,
951
+ ranges: sent.filter((d) => !d.runtime).length - exact
952
+ };
602
953
  }
603
954
  async function installedSkillNames(skillDirs) {
604
955
  const names = /* @__PURE__ */ new Set();
@@ -610,10 +961,132 @@ async function installedSkillNames(skillDirs) {
610
961
  return [...names];
611
962
  }
612
963
 
964
+ // src/guard.ts
965
+ function commands(line) {
966
+ return line.split(/&&|\|\||[;|\n]/).map((c) => (c.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^["']|["']$/g, ""))).filter((w) => w.length);
967
+ }
968
+ var VALUE_FLAGS = /* @__PURE__ */ new Set(["-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-i", "--index-url", "--extra-index-url", "-f", "--find-links", "--registry", "--tag", "--filter", "-w", "--workspace", "--cwd", "--prefix", "-C", "--dir", "--python", "-p", "--group", "-G", "--source", "--features", "-F", "--package", "--with"]);
969
+ function args(words) {
970
+ const out = [];
971
+ for (let i = 0; i < words.length; i++) {
972
+ const w = words[i];
973
+ if (w.startsWith("-")) {
974
+ if (VALUE_FLAGS.has(w)) i++;
975
+ continue;
976
+ }
977
+ out.push(w);
978
+ }
979
+ return out;
980
+ }
981
+ var isLocalOrUrl = (s) => /^(\.{0,2}\/|~|file:|git[+:]|https?:|github:|link:|workspace:)/.test(s) || /\.(tgz|tar\.gz|whl|zip)$/.test(s);
982
+ function npmSpec(s) {
983
+ if (isLocalOrUrl(s) || /^[\w.-]+\/[\w.-]+$/.test(s)) return null;
984
+ const m = s.match(/^(@[^/@\s]+\/[^@\s]+|[^@\s]+)(?:@(.+))?$/);
985
+ if (!m || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(m[1])) return null;
986
+ const version = m[2] && /\d/.test(m[2]) ? m[2] : void 0;
987
+ return { name: m[1], ecosystem: "npm", ...version ? { version } : {} };
988
+ }
989
+ function pySpec(s) {
990
+ if (isLocalOrUrl(s)) return null;
991
+ const m = s.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*(?:(==|~=|>=|<=|>|<|!=)\s*([^,;\s]+))?/);
992
+ if (!m) return null;
993
+ return { name: m[1], ecosystem: "pypi", ...m[3] ? { version: m[3] } : {} };
994
+ }
995
+ function cargoSpec(s) {
996
+ const m = s.match(/^([A-Za-z0-9_-]+)(?:@(.+))?$/);
997
+ return m ? { name: m[1], ecosystem: "cargo", ...m[2] ? { version: m[2] } : {} } : null;
998
+ }
999
+ function goSpec(s) {
1000
+ const m = s.match(/^([a-z0-9.-]+\.[a-z]{2,}\/[^@\s]+?)(?:\/\.\.\.)?(?:@(.+))?$/i);
1001
+ if (!m) return null;
1002
+ const version = m[2] && /^v\d/.test(m[2]) ? m[2] : void 0;
1003
+ return { name: m[1], ecosystem: "go", ...version ? { version } : {} };
1004
+ }
1005
+ function packagesInCommand(line) {
1006
+ const out = [];
1007
+ for (let words of commands(line)) {
1008
+ while (words.length && /^(sudo|env|\w+=\S*|time|nice|command)$/.test(words[0])) words = words.slice(1);
1009
+ const [tool, sub, ...rest] = words;
1010
+ if (!tool) continue;
1011
+ const bin = tool.split("/").pop();
1012
+ let specs = [];
1013
+ if (bin === "npm" && ["install", "i", "add", "in", "isntall"].includes(sub)) specs = args(rest).map(npmSpec);
1014
+ else if (bin === "pnpm" && ["add", "install", "i"].includes(sub)) specs = args(rest).map(npmSpec);
1015
+ else if (bin === "yarn" && sub === "add") specs = args(rest).map(npmSpec);
1016
+ else if (bin === "bun" && ["add", "install", "i"].includes(sub)) specs = args(rest).map(npmSpec);
1017
+ else if (["npx", "bunx"].includes(bin)) specs = [npmSpec(args([sub, ...rest].filter(Boolean))[0] ?? "")];
1018
+ else if ((bin === "pnpm" || bin === "yarn") && sub === "dlx") specs = [npmSpec(args(rest)[0] ?? "")];
1019
+ else if (/^pip3?$/.test(bin) && sub === "install") specs = args(rest).map(pySpec);
1020
+ else if (/^python3?$/.test(bin) && sub === "-m" && /^pip3?$/.test(rest[0] ?? "") && rest[1] === "install") specs = args(rest.slice(2)).map(pySpec);
1021
+ else if (bin === "uv" && sub === "add") specs = args(rest).map(pySpec);
1022
+ else if (bin === "uv" && sub === "pip" && rest[0] === "install") specs = args(rest.slice(1)).map(pySpec);
1023
+ else if (bin === "uv" && sub === "tool" && rest[0] === "install") specs = args(rest.slice(1)).map(pySpec);
1024
+ else if (bin === "uvx" || bin === "pipx" && sub === "run") specs = [pySpec(args(bin === "uvx" ? [sub, ...rest].filter(Boolean) : rest)[0] ?? "")];
1025
+ else if (bin === "pipx" && sub === "install") specs = args(rest).map(pySpec);
1026
+ else if (bin === "poetry" && sub === "add") specs = args(rest).map(pySpec);
1027
+ else if (bin === "cargo" && (sub === "add" || sub === "install")) specs = args(rest).map(cargoSpec);
1028
+ else if (bin === "go" && (sub === "get" || sub === "install")) specs = args(rest).map(goSpec);
1029
+ for (const s of specs) if (s) out.push(s);
1030
+ }
1031
+ const seen = /* @__PURE__ */ new Set();
1032
+ return out.filter((p) => !seen.has(`${p.ecosystem}:${p.name}`) && !!seen.add(`${p.ecosystem}:${p.name}`)).slice(0, 25);
1033
+ }
1034
+ async function readStdin() {
1035
+ const chunks = [];
1036
+ for await (const c of process.stdin) chunks.push(c);
1037
+ return Buffer.concat(chunks).toString("utf8");
1038
+ }
1039
+ async function runGuard(opts) {
1040
+ let command = "";
1041
+ try {
1042
+ const payload = JSON.parse(await readStdin());
1043
+ command = payload.tool_input?.command ?? "";
1044
+ } catch {
1045
+ return 0;
1046
+ }
1047
+ const packages = packagesInCommand(command);
1048
+ if (!packages.length) return 0;
1049
+ if (!opts.key) {
1050
+ console.error("codexguild guard: CODEXGUILD_API_KEY is not set \u2014 packages were not checked.");
1051
+ return 0;
1052
+ }
1053
+ let results;
1054
+ try {
1055
+ const res = await fetch(`${opts.api}/packages/check`, {
1056
+ method: "POST",
1057
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.key}` },
1058
+ body: JSON.stringify({ packages }),
1059
+ signal: AbortSignal.timeout(15e3)
1060
+ });
1061
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1062
+ results = (await res.json()).results;
1063
+ } catch (e) {
1064
+ console.error(`codexguild guard: check failed (${e instanceof Error ? e.message : e}) \u2014 command not blocked.`);
1065
+ return 0;
1066
+ }
1067
+ const decision = guardDecision(results);
1068
+ if (decision) {
1069
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: decision.decision, permissionDecisionReason: decision.reason } }));
1070
+ }
1071
+ return 0;
1072
+ }
1073
+ function guardDecision(results) {
1074
+ const flagged = results.filter((r) => r.verdict === "block" || r.verdict === "warn");
1075
+ if (!flagged.length) return null;
1076
+ const lines = flagged.map((r) => `${r.verdict === "block" ? "BLOCKED" : "Check"} ${r.ecosystem}:${r.name}${r.version ? `@${r.version}` : ""} \u2014 ${r.reasons.join(" ")}`);
1077
+ const blocked = flagged.some((r) => r.verdict === "block");
1078
+ return {
1079
+ decision: blocked ? "deny" : "ask",
1080
+ reason: `CodexGuild package check:
1081
+ ${lines.join("\n")}${blocked ? "\nUse the correct package name, or ask the user before trying again." : ""}`
1082
+ };
1083
+ }
1084
+
613
1085
  // src/index.ts
614
1086
  var API = (process.env.CODEXGUILD_API_URL || "https://api.codexguild.com/v1").replace(/\/$/, "");
615
1087
  var KEY = process.env.CODEXGUILD_API_KEY ?? "";
616
- var VERSION = "1.1.0";
1088
+ var VERSION = "1.4.0";
1089
+ if (process.argv[2] === "guard") process.exit(await runGuard({ api: API, key: KEY }));
617
1090
  var INSTRUCTION_FILES = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md", ".hermes.md", "HERMES.md", "GEMINI.md", ".cursorrules"];
618
1091
  async function existingInstructionFiles(cwd) {
619
1092
  const present = await Promise.all(INSTRUCTION_FILES.map((f) => access(join3(cwd, f)).then(() => f, () => null)));
@@ -662,47 +1135,105 @@ ${note}`,
662
1135
  };
663
1136
  }
664
1137
  async function detected(name, cwd) {
665
- const [{ deps, manifests }, local] = await Promise.all([detectStack(cwd), scanLocalSkills()]);
1138
+ const [{ deps, manifests, lockfiles, exact, ranges }, local, project] = await Promise.all([detectStack(cwd), scanLocalSkills(), projectKey(cwd)]);
666
1139
  const installedSkills = await installedSkillNames(local.reports.map((r) => r.dir));
667
1140
  if (name === "codexguild_advise") {
668
- return { args: { stack: deps, installedSkills, existingInstructionFiles: await existingInstructionFiles(cwd) } };
1141
+ return { args: { stack: deps, installedSkills, existingInstructionFiles: await existingInstructionFiles(cwd), project } };
669
1142
  }
670
1143
  const flagged = local.reports.filter((x) => x.result.status === "flagged");
1144
+ const shown = (xs) => xs.length > 8 ? `${xs.slice(0, 8).join(", ")} +${xs.length - 8} more` : xs.join(", ");
671
1145
  const summary = [
672
- `Detected ${deps.length} dependencies from ${manifests.join(", ") || "no manifest"}.`,
1146
+ `Project ${project}: ${deps.length} dependencies from ${shown(manifests) || "no manifest"} \u2014 ${exact} exact${lockfiles.length ? ` (lockfiles: ${shown(lockfiles)})` : ""}, ${ranges} ranges${ranges ? " (no lockfile entry; reported as ranges, not exact versions)" : ""}.`,
673
1147
  `Local skill scan: ${local.reports.length} skills \u2014 ${flagged.length} flagged, ${local.reports.filter((x) => x.result.status === "warn").length} to review.`,
674
1148
  ...flagged.slice(0, 10).map((x) => ` - FLAGGED ${x.dir}: ${x.result.findings[0]?.title ?? ""}`),
675
1149
  ...flagged.length ? [" Run codexguild_scan_skills for details and ask the user before using flagged skills."] : []
676
1150
  ].join("\n");
677
- return { args: { stack: deps, installedSkills }, summary };
1151
+ return { args: { stack: deps, installedSkills, project }, summary };
1152
+ }
1153
+ var LINT_TOOL = {
1154
+ name: "codexguild_lint_instructions",
1155
+ description: "Lint the agent instruction files in this project (AGENTS.md, CLAUDE.md, GEMINI.md, .cursorrules\u2026): size per session, missing build/test commands, README copy, generic advice, secrets, machine-specific paths, prompt-injection and other scanner findings. Runs locally \u2014 file contents are not uploaded.",
1156
+ inputSchema: {
1157
+ type: "object",
1158
+ properties: { files: { type: "array", items: { type: "string" }, description: "Paths to lint instead of the instruction files found in the project root" } }
1159
+ }
1160
+ };
1161
+ async function lintLocal(cwd, files) {
1162
+ const targets = files?.length ? files : await existingInstructionFiles(cwd);
1163
+ if (!targets.length) return "No instruction files found (looked for CLAUDE.md, AGENTS.md, GEMINI.md, .cursorrules, \u2026).";
1164
+ const out = [];
1165
+ for (const f of targets) {
1166
+ const content = await readFile3(isAbsolute(f) ? f : join3(cwd, f), "utf8").catch(() => null);
1167
+ if (content === null) {
1168
+ out.push(`## ${f}
1169
+ Could not read the file.`);
1170
+ continue;
1171
+ }
1172
+ const r = lintInstructions(content, f.split("/").pop());
1173
+ out.push(`## ${f} \u2014 ${r.score}/100 (${r.grade}) \xB7 ~${r.stats.approxTokens} tokens \xB7 ${r.stats.commands} commands`);
1174
+ out.push(...r.issues.length ? r.issues.slice(0, 25).map((i) => `- [${i.severity}]${i.line ? ` line ${i.line}` : ""} ${i.title}: ${i.detail}`) : ["- No issues."]);
1175
+ }
1176
+ return out.join("\n");
678
1177
  }
679
1178
  var text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...isError ? { isError } : {} });
680
- var server = new Server({ name: "codexguild", version: VERSION }, { capabilities: { tools: {} } });
1179
+ var server = new Server({ name: "codexguild", version: VERSION }, { capabilities: { tools: { listChanged: true } } });
1180
+ var LOCAL_TOOLS = [SCAN_TOOL, LINT_TOOL];
1181
+ var RETRY_MS = [1e3, 3e3];
1182
+ var RECONNECT_MS = 3e4;
1183
+ var reconnectTimer = null;
1184
+ async function remoteTools() {
1185
+ for (let attempt = 0; ; attempt++) {
1186
+ try {
1187
+ return (await (await remoteClient()).listTools()).tools;
1188
+ } catch (e) {
1189
+ if (!KEY || attempt >= RETRY_MS.length) throw e;
1190
+ await new Promise((r) => setTimeout(r, RETRY_MS[attempt]));
1191
+ }
1192
+ }
1193
+ }
1194
+ function scheduleReconnect() {
1195
+ if (reconnectTimer || !KEY) return;
1196
+ reconnectTimer = setInterval(async () => {
1197
+ try {
1198
+ await (await remoteClient()).listTools();
1199
+ clearInterval(reconnectTimer);
1200
+ reconnectTimer = null;
1201
+ console.error("CodexGuild: remote endpoint reachable again \u2014 tool list updated.");
1202
+ await server.sendToolListChanged();
1203
+ } catch {
1204
+ }
1205
+ }, RECONNECT_MS);
1206
+ reconnectTimer.unref();
1207
+ }
681
1208
  server.setRequestHandler(ListToolsRequestSchema, async () => {
682
1209
  try {
683
- const { tools } = await (await remoteClient()).listTools();
684
- return { tools: [SCAN_TOOL, ...tools.map(enhance)] };
685
- } catch {
686
- return { tools: [SCAN_TOOL] };
1210
+ return { tools: [...LOCAL_TOOLS, ...(await remoteTools()).map(enhance)] };
1211
+ } catch (e) {
1212
+ console.error(`CodexGuild: remote tools unavailable (${e instanceof Error ? e.message : String(e)}) \u2014 serving local tools only; retrying in the background.`);
1213
+ scheduleReconnect();
1214
+ return { tools: LOCAL_TOOLS };
687
1215
  }
688
1216
  });
689
1217
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
690
1218
  const { name } = req.params;
691
- const args = { ...req.params.arguments ?? {} };
1219
+ const args2 = { ...req.params.arguments ?? {} };
692
1220
  try {
1221
+ if (name === LINT_TOOL.name) {
1222
+ return text(await lintLocal(process.cwd(), Array.isArray(args2.files) ? args2.files.map(String) : void 0));
1223
+ }
693
1224
  if (name === SCAN_TOOL.name) {
694
- const paths = Array.isArray(args.paths) ? args.paths.map(String) : void 0;
695
- return text(formatLocalScan(await scanLocalSkills(paths), args.verbose === true));
1225
+ const paths = Array.isArray(args2.paths) ? args2.paths.map(String) : void 0;
1226
+ return text(formatLocalScan(await scanLocalSkills(paths), args2.verbose === true));
696
1227
  }
697
1228
  let summary;
698
1229
  if (name in ENHANCED) {
699
- const cwd = typeof args.cwd === "string" ? args.cwd : process.cwd();
700
- delete args.cwd;
1230
+ const cwd = typeof args2.cwd === "string" ? args2.cwd : process.cwd();
1231
+ delete args2.cwd;
701
1232
  const d = await detected(name, cwd);
702
1233
  summary = d.summary;
703
- Object.assign(args, { ...d.args, ...args });
1234
+ Object.assign(args2, { ...d.args, ...args2 });
704
1235
  }
705
- const r = await (await remoteClient()).callTool({ name, arguments: args });
1236
+ const r = await (await remoteClient()).callTool({ name, arguments: args2 });
706
1237
  return summary ? { ...r, content: [{ type: "text", text: summary }, ...r.content ?? []] } : r;
707
1238
  } catch (e) {
708
1239
  return text(`CodexGuild error: ${e instanceof Error ? e.message : String(e)}`, true);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codexguild/mcp",
3
- "version": "1.1.0",
4
- "description": "CodexGuild MCP server (stdio) — forwards to the remote CodexGuild MCP endpoint and adds local stack detection and an offline security audit of installed agent skills",
3
+ "version": "1.4.0",
4
+ "description": "CodexGuild MCP server (stdio) \u2014 forwards to the remote CodexGuild MCP endpoint and adds local stack detection and an offline security audit of installed agent skills",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "model-context-protocol",
@@ -39,10 +39,11 @@
39
39
  "build": "tsc -p tsconfig.json --noEmit && esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --packages=external --alias:@codexguild/scanner=../scanner/src/index.ts --outfile=dist/codexguild-mcp.js",
40
40
  "start": "node dist/codexguild-mcp.js",
41
41
  "dev": "tsx src/index.ts",
42
- "prepublishOnly": "npm run build"
42
+ "prepublishOnly": "npm run build",
43
+ "test": "node --import tsx --test test/*.test.mjs"
43
44
  },
44
45
  "dependencies": {
45
- "@modelcontextprotocol/sdk": "^1.12.0"
46
+ "@modelcontextprotocol/sdk": "^1.32.1"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^22.10.0",