pr-checkmate 3.3.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/LICENSE +8 -3
  2. package/README.md +59 -4
  3. package/cspell.json +1 -0
  4. package/dist/cjs/agent/hook.d.ts +38 -0
  5. package/dist/cjs/agent/hook.js +175 -0
  6. package/dist/cjs/agent/registration.d.ts +10 -0
  7. package/dist/cjs/agent/registration.js +28 -0
  8. package/dist/cjs/agent/setup.d.ts +21 -0
  9. package/dist/cjs/agent/setup.js +229 -0
  10. package/dist/cjs/cli.js +88 -0
  11. package/dist/cjs/config/constants.d.ts +2 -0
  12. package/dist/cjs/config/constants.js +5 -1
  13. package/dist/cjs/config/pr-checkmate.template.json +10 -0
  14. package/dist/cjs/core/checks/findings.js +33 -7
  15. package/dist/cjs/core/checks/git/diff-smell.js +3 -1
  16. package/dist/cjs/core/checks/git/large-files.d.ts +1 -0
  17. package/dist/cjs/core/checks/git/large-files.js +3 -3
  18. package/dist/cjs/core/checks/languages/lint.js +27 -0
  19. package/dist/cjs/core/checks/languages/locate.d.ts +7 -0
  20. package/dist/cjs/core/checks/languages/locate.js +68 -0
  21. package/dist/cjs/core/checks/languages/prettier.js +15 -2
  22. package/dist/cjs/core/checks/languages/typecheck.js +4 -1
  23. package/dist/cjs/core/checks/pr/pr-size-check.d.ts +2 -0
  24. package/dist/cjs/core/checks/pr/pr-size-check.js +5 -5
  25. package/dist/cjs/core/checks/security/agent-instructions.d.ts +4 -0
  26. package/dist/cjs/core/checks/security/agent-instructions.js +149 -0
  27. package/dist/cjs/core/checks/security/diff-security.d.ts +7 -0
  28. package/dist/cjs/core/checks/security/diff-security.js +21 -1
  29. package/dist/cjs/core/checks/security/security.d.ts +0 -13
  30. package/dist/cjs/core/checks/security/security.js +27 -0
  31. package/dist/cjs/core/checks/security/workflow-security.js +33 -0
  32. package/dist/cjs/core/commands.d.ts +6 -0
  33. package/dist/cjs/core/commands.js +11 -1
  34. package/dist/cjs/core/finding-store.d.ts +16 -0
  35. package/dist/cjs/core/finding-store.js +73 -0
  36. package/dist/cjs/core/registry.js +2 -0
  37. package/dist/cjs/core/reporters.js +9 -0
  38. package/dist/cjs/core/run-checks.js +15 -5
  39. package/dist/cjs/core/sarif.d.ts +15 -0
  40. package/dist/cjs/core/sarif.js +60 -0
  41. package/dist/cjs/core/types.d.ts +7 -2
  42. package/dist/cjs/index.d.ts +2 -1
  43. package/dist/cjs/index.js +4 -1
  44. package/dist/cjs/init.js +11 -0
  45. package/dist/cjs/interfaces/index.d.ts +12 -0
  46. package/dist/cjs/mcp/config-tools.d.ts +30 -0
  47. package/dist/cjs/mcp/config-tools.js +320 -0
  48. package/dist/cjs/mcp/dependency.d.ts +34 -0
  49. package/dist/cjs/mcp/dependency.js +413 -0
  50. package/dist/cjs/mcp/protocol.d.ts +91 -0
  51. package/dist/cjs/mcp/protocol.js +181 -0
  52. package/dist/cjs/mcp/review.d.ts +64 -0
  53. package/dist/cjs/mcp/review.js +150 -0
  54. package/dist/cjs/mcp/serve.d.ts +8 -0
  55. package/dist/cjs/mcp/serve.js +49 -0
  56. package/dist/cjs/mcp/settings.d.ts +13 -0
  57. package/dist/cjs/mcp/settings.js +50 -0
  58. package/dist/cjs/mcp/tools.d.ts +23 -0
  59. package/dist/cjs/mcp/tools.js +618 -0
  60. package/dist/cjs/utils/execa.d.ts +7 -0
  61. package/dist/cjs/utils/execa.js +41 -1
  62. package/dist/cjs/utils/result-collector.d.ts +17 -1
  63. package/dist/cjs/utils/result-collector.js +7 -2
  64. package/dist/esm/agent/hook.d.ts +38 -0
  65. package/dist/esm/agent/hook.js +167 -0
  66. package/dist/esm/agent/registration.d.ts +10 -0
  67. package/dist/esm/agent/registration.js +21 -0
  68. package/dist/esm/agent/setup.d.ts +21 -0
  69. package/dist/esm/agent/setup.js +220 -0
  70. package/dist/esm/cli.js +86 -1
  71. package/dist/esm/config/constants.d.ts +2 -0
  72. package/dist/esm/config/constants.js +4 -0
  73. package/dist/esm/config/pr-checkmate.template.json +10 -0
  74. package/dist/esm/core/checks/findings.js +33 -7
  75. package/dist/esm/core/checks/git/diff-smell.js +3 -1
  76. package/dist/esm/core/checks/git/large-files.d.ts +1 -0
  77. package/dist/esm/core/checks/git/large-files.js +1 -1
  78. package/dist/esm/core/checks/languages/lint.js +28 -1
  79. package/dist/esm/core/checks/languages/locate.d.ts +7 -0
  80. package/dist/esm/core/checks/languages/locate.js +60 -0
  81. package/dist/esm/core/checks/languages/prettier.js +15 -2
  82. package/dist/esm/core/checks/languages/typecheck.js +4 -1
  83. package/dist/esm/core/checks/pr/pr-size-check.d.ts +2 -0
  84. package/dist/esm/core/checks/pr/pr-size-check.js +2 -2
  85. package/dist/esm/core/checks/security/agent-instructions.d.ts +4 -0
  86. package/dist/esm/core/checks/security/agent-instructions.js +146 -0
  87. package/dist/esm/core/checks/security/diff-security.d.ts +7 -0
  88. package/dist/esm/core/checks/security/diff-security.js +20 -0
  89. package/dist/esm/core/checks/security/security.d.ts +0 -13
  90. package/dist/esm/core/checks/security/security.js +27 -0
  91. package/dist/esm/core/checks/security/workflow-security.js +33 -0
  92. package/dist/esm/core/commands.d.ts +6 -0
  93. package/dist/esm/core/commands.js +10 -0
  94. package/dist/esm/core/finding-store.d.ts +16 -0
  95. package/dist/esm/core/finding-store.js +67 -0
  96. package/dist/esm/core/registry.js +2 -0
  97. package/dist/esm/core/reporters.js +9 -0
  98. package/dist/esm/core/run-checks.js +15 -5
  99. package/dist/esm/core/sarif.d.ts +15 -0
  100. package/dist/esm/core/sarif.js +57 -0
  101. package/dist/esm/core/types.d.ts +7 -2
  102. package/dist/esm/index.d.ts +2 -1
  103. package/dist/esm/index.js +2 -0
  104. package/dist/esm/init.js +12 -1
  105. package/dist/esm/interfaces/index.d.ts +12 -0
  106. package/dist/esm/mcp/config-tools.d.ts +30 -0
  107. package/dist/esm/mcp/config-tools.js +310 -0
  108. package/dist/esm/mcp/dependency.d.ts +34 -0
  109. package/dist/esm/mcp/dependency.js +408 -0
  110. package/dist/esm/mcp/protocol.d.ts +91 -0
  111. package/dist/esm/mcp/protocol.js +175 -0
  112. package/dist/esm/mcp/review.d.ts +64 -0
  113. package/dist/esm/mcp/review.js +139 -0
  114. package/dist/esm/mcp/serve.d.ts +8 -0
  115. package/dist/esm/mcp/serve.js +43 -0
  116. package/dist/esm/mcp/settings.d.ts +13 -0
  117. package/dist/esm/mcp/settings.js +43 -0
  118. package/dist/esm/mcp/tools.d.ts +23 -0
  119. package/dist/esm/mcp/tools.js +611 -0
  120. package/dist/esm/utils/execa.d.ts +7 -0
  121. package/dist/esm/utils/execa.js +40 -1
  122. package/dist/esm/utils/result-collector.d.ts +17 -1
  123. package/dist/esm/utils/result-collector.js +7 -2
  124. package/package.json +7 -3
  125. package/pr-checkmate.schema.json +35 -0
  126. package/src/config/cspell.json +1 -0
  127. package/src/config/pr-checkmate.schema.json +35 -0
@@ -1,6 +1,46 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isolateInheritedStdio = isolateInheritedStdio;
3
4
  exports.execa = execa;
5
+ /**
6
+ * Whether children that would inherit our standard streams are kept off them.
7
+ *
8
+ * A few checks run a tool with `stdio: 'inherit'` so its output reaches the
9
+ * terminal. That is right for the CLI and fatal for the MCP server, whose stdout
10
+ * *is* the protocol: gitleaks printing its banner there is a line that is not
11
+ * JSON, and the client drops the connection. Swapping `process.stdout.write`
12
+ * cannot help, because an inherited descriptor never passes through it — so the
13
+ * redirection has to happen where the child is spawned.
14
+ */
15
+ let childOutput = null;
16
+ /**
17
+ * Keep children that would inherit our streams off them, and stop them sharing
18
+ * stdin. `stderr` (the MCP server) keeps their output as the server's log;
19
+ * `discard` (the commit hook) drops it, because a hook's stderr is read by the
20
+ * model and tool banners are not what it needs to hear. No other command is affected.
21
+ */
22
+ function isolateInheritedStdio(output = 'stderr') {
23
+ childOutput = output === 'stderr' ? 2 : 'ignore';
24
+ }
25
+ /** Redirect inherited stdout/stderr per {@link isolateInheritedStdio}; stdin is never ours to give away. */
26
+ function isolated(options) {
27
+ if (childOutput === null || !options)
28
+ return options;
29
+ const { stdio } = options;
30
+ if (stdio === 'inherit')
31
+ return { ...options, stdio: ['ignore', childOutput, childOutput] };
32
+ if (Array.isArray(stdio)) {
33
+ const next = stdio.map((entry, fd) => {
34
+ if (fd === 0 && entry === 'inherit')
35
+ return 'ignore';
36
+ if ((fd === 1 || fd === 2) && entry === 'inherit')
37
+ return childOutput;
38
+ return entry;
39
+ });
40
+ return { ...options, stdio: next };
41
+ }
42
+ return options;
43
+ }
4
44
  /**
5
45
  * Run a command via execa, loading the module lazily on first call.
6
46
  *
@@ -9,5 +49,5 @@ exports.execa = execa;
9
49
  */
10
50
  async function execa(file, args, options) {
11
51
  const { execa: run } = await import('execa');
12
- return (await run(file, args, options));
52
+ return (await run(file, args, isolated(options)));
13
53
  }
@@ -1,8 +1,24 @@
1
1
  export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip';
2
+ /**
3
+ * One thing a check found, with where it is. Optional on every result: only the
4
+ * checks that can say where report it, and a result without any is not an error —
5
+ * it just has a summary and no location.
6
+ */
7
+ export interface Finding {
8
+ file: string;
9
+ /** 1-based line in the file as it will be committed. */
10
+ line?: number;
11
+ /** Which rule or pattern matched, as the check names it. */
12
+ rule: string;
13
+ /** The offending text, shortened. Never a secret: scanners that find those leave it out. */
14
+ snippet?: string;
15
+ severity: 'error' | 'warn';
16
+ }
2
17
  export interface CheckResult {
3
18
  name: string;
4
19
  status: CheckStatus;
5
20
  details?: string;
21
+ findings?: Finding[];
6
22
  }
7
23
  /**
8
24
  *
@@ -12,7 +28,7 @@ export declare class ResultCollector {
12
28
  /**
13
29
  *
14
30
  */
15
- register(name: string, status: CheckStatus, details?: string): void;
31
+ register(name: string, status: CheckStatus, details?: string, findings?: Finding[]): void;
16
32
  /**
17
33
  *
18
34
  */
@@ -11,9 +11,14 @@ class ResultCollector {
11
11
  /**
12
12
  *
13
13
  */
14
- register(name, status, details) {
14
+ register(name, status, details, findings) {
15
15
  const idx = this.results.findIndex(r => r.name === name);
16
- const entry = { name, status, ...(details && { details }) };
16
+ const entry = {
17
+ name,
18
+ status,
19
+ ...(details && { details }),
20
+ ...(findings && findings.length > 0 && { findings }),
21
+ };
17
22
  if (idx >= 0) {
18
23
  this.results[idx] = entry;
19
24
  }
@@ -0,0 +1,38 @@
1
+ import { type ReviewScope } from '../mcp/review.js';
2
+ export declare const HOOK_COMMAND = "pr-checkmate hook";
3
+ /**
4
+ * Which commits in a shell line need a check, and what each will record.
5
+ *
6
+ * `git commit -a` / `-am` stages tracked modifications as it commits, so the index
7
+ * at hook time does not yet hold what will be recorded; those are checked as a
8
+ * working-tree snapshot instead, which errs towards reviewing a little more.
9
+ *
10
+ * @returns the scope to review, or `undefined` when the line commits nothing.
11
+ */
12
+ export declare function commitScopeFor(command: string): ReviewScope | undefined;
13
+ interface HookVerdict {
14
+ /** 2 blocks the tool call, 0 lets it through. */
15
+ exitCode: 0 | 2;
16
+ /** Shown to the model when blocked. */
17
+ stderr?: string;
18
+ }
19
+ /**
20
+ * What runs after a file edit. Only checks that are a pattern match over the added
21
+ * lines: they finish in well under a second, and a hook that waits on a type
22
+ * checker after every keystroke-sized edit would make the agent unusable.
23
+ *
24
+ * Deliberately not here: Diff Smells and Leftover Debug. Mid-edit, a `console.log`
25
+ * or a TODO is how work is done, and a nag for each one teaches the agent to
26
+ * ignore the hook. They are reported when it asks for `check_changes`, at the point
27
+ * where they would actually be a mistake. Secrets wait for the commit gate for the
28
+ * same reason of cost: the scanner starts a process.
29
+ */
30
+ export declare const EDIT_CHECKS: readonly string[];
31
+ /**
32
+ * Decide what to do with the call a harness is about to make.
33
+ *
34
+ * @param stdin Raw hook payload, as the harness wrote it.
35
+ * @param fallbackCwd Used when the payload carries no directory.
36
+ */
37
+ export declare function evaluateHook(stdin: string, fallbackCwd: string): Promise<HookVerdict>;
38
+ export {};
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The commit gate for AI coding agents — a Claude Code `PreToolUse` hook.
3
+ *
4
+ * An MCP tool is a request: the agent calls `check_changes` if it remembers to.
5
+ * A hook is a fact: the harness runs it whether the agent remembers or not. So the
6
+ * server makes conforming code easy and this makes committing nonconforming code
7
+ * hard — when the agent runs `git commit`, the staged changes are checked first
8
+ * and a failure comes back as the reason the commit did not happen.
9
+ *
10
+ * Exit code 2 is the harness's "block this call and show stderr to the model".
11
+ * Every other outcome exits 0, **including our own failures**: a gate that cannot
12
+ * run must not become a reason the agent cannot work.
13
+ */
14
+ import path from 'node:path';
15
+ import { resolveRepoRoot, review } from '../mcp/review.js';
16
+ import { formatReport } from '../mcp/tools.js';
17
+ export const HOOK_COMMAND = 'pr-checkmate hook';
18
+ /** Separators that begin a new command inside one shell line. */
19
+ const SEGMENT_SPLIT = /&&|\|\||[;|\n]/;
20
+ /** Global git options that take a value as the next token. */
21
+ const OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree']);
22
+ /**
23
+ * The git subcommand a shell segment runs, skipping global options
24
+ * (`git -C repo -c user.name=x commit` → `commit`).
25
+ */
26
+ function gitSubcommand(tokens) {
27
+ if (tokens[0] !== 'git')
28
+ return undefined;
29
+ let i = 1;
30
+ while (i < tokens.length && tokens[i].startsWith('-')) {
31
+ i += OPTIONS_WITH_VALUE.has(tokens[i]) ? 2 : 1;
32
+ }
33
+ return i < tokens.length ? { sub: tokens[i], rest: tokens.slice(i + 1) } : undefined;
34
+ }
35
+ /**
36
+ * Which commits in a shell line need a check, and what each will record.
37
+ *
38
+ * `git commit -a` / `-am` stages tracked modifications as it commits, so the index
39
+ * at hook time does not yet hold what will be recorded; those are checked as a
40
+ * working-tree snapshot instead, which errs towards reviewing a little more.
41
+ *
42
+ * @returns the scope to review, or `undefined` when the line commits nothing.
43
+ */
44
+ export function commitScopeFor(command) {
45
+ let scope;
46
+ for (const segment of command.split(SEGMENT_SPLIT)) {
47
+ const git = gitSubcommand(segment.trim().split(/\s+/));
48
+ if (git?.sub !== 'commit')
49
+ continue;
50
+ const stagesAll = git.rest.some(t => t === '--all' || /^-[A-Za-z]*a[A-Za-z]*$/.test(t));
51
+ if (stagesAll)
52
+ return 'worktree';
53
+ scope = 'staged';
54
+ }
55
+ return scope;
56
+ }
57
+ /**
58
+ * What runs after a file edit. Only checks that are a pattern match over the added
59
+ * lines: they finish in well under a second, and a hook that waits on a type
60
+ * checker after every keystroke-sized edit would make the agent unusable.
61
+ *
62
+ * Deliberately not here: Diff Smells and Leftover Debug. Mid-edit, a `console.log`
63
+ * or a TODO is how work is done, and a nag for each one teaches the agent to
64
+ * ignore the hook. They are reported when it asks for `check_changes`, at the point
65
+ * where they would actually be a mistake. Secrets wait for the commit gate for the
66
+ * same reason of cost: the scanner starts a process.
67
+ */
68
+ export const EDIT_CHECKS = [
69
+ 'Merge Conflict',
70
+ 'Diff Security',
71
+ 'Banned Imports',
72
+ 'Custom Rules',
73
+ 'Migration Safety',
74
+ 'Dockerfile Security',
75
+ 'Workflow Security',
76
+ ];
77
+ /** Tools that write a file and say which one. */
78
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
79
+ /** More than this and a slow disk or a huge repository has made the hook a cost, not a help. */
80
+ const EDIT_BUDGET_MS = 10000;
81
+ /** Paths nobody wrote by hand: reviewing them only produces noise. */
82
+ const GENERATED = /(^|\/)(node_modules|dist|build|\.git|vendor)\//;
83
+ /**
84
+ * Review one file the agent just wrote and tell it about problems in *that file*.
85
+ *
86
+ * Runs after the edit, so it cannot block anything: exit 2 here only hands the
87
+ * message to the model. The review sees the whole working tree (the checks are
88
+ * diff-based) but only findings that point into the edited file are returned, so a
89
+ * problem the agent has not touched and cannot be blamed for stays out of its way.
90
+ */
91
+ async function evaluateEdit(payload, fallbackCwd) {
92
+ const target = payload.tool_input?.file_path;
93
+ if (typeof target !== 'string' || !target)
94
+ return { exitCode: 0 };
95
+ const abs = path.resolve(payload.cwd ?? fallbackCwd, target);
96
+ const root = await resolveRepoRoot(path.dirname(abs));
97
+ if (!root)
98
+ return { exitCode: 0 };
99
+ const rel = path.relative(root, abs).split(path.sep).join('/');
100
+ if (rel.startsWith('..') || GENERATED.test(rel))
101
+ return { exitCode: 0 };
102
+ const timedOut = Symbol('timeout');
103
+ let timer;
104
+ const outcome = await Promise.race([
105
+ review({ cwd: root, scope: 'worktree', only: [...EDIT_CHECKS] }),
106
+ new Promise(resolve => {
107
+ timer = setTimeout(() => resolve(timedOut), EDIT_BUDGET_MS);
108
+ }),
109
+ ]);
110
+ clearTimeout(timer);
111
+ if (outcome === timedOut)
112
+ return { exitCode: 0 };
113
+ const lines = [];
114
+ for (const result of outcome.report.results) {
115
+ for (const f of result.findings ?? []) {
116
+ if (f.file !== rel)
117
+ continue;
118
+ lines.push(` ${f.file}${f.line ? `:${f.line}` : ''} [${result.name}] ${f.rule}${f.snippet ? ` — ${f.snippet}` : ''}`);
119
+ }
120
+ }
121
+ if (lines.length === 0)
122
+ return { exitCode: 0 };
123
+ return {
124
+ exitCode: 2,
125
+ stderr: `pr-checkmate found problems in ${rel} — fix them in this file:\n${lines.join('\n')}`,
126
+ };
127
+ }
128
+ /**
129
+ * Decide what to do with the call a harness is about to make.
130
+ *
131
+ * @param stdin Raw hook payload, as the harness wrote it.
132
+ * @param fallbackCwd Used when the payload carries no directory.
133
+ */
134
+ export async function evaluateHook(stdin, fallbackCwd) {
135
+ try {
136
+ const payload = JSON.parse(stdin);
137
+ if (payload.hook_event_name === 'PostToolUse') {
138
+ return payload.tool_name && EDIT_TOOLS.has(payload.tool_name)
139
+ ? await evaluateEdit(payload, fallbackCwd)
140
+ : { exitCode: 0 };
141
+ }
142
+ const command = payload.tool_input?.command;
143
+ if ((payload.tool_name && payload.tool_name !== 'Bash') || typeof command !== 'string') {
144
+ return { exitCode: 0 };
145
+ }
146
+ const scope = commitScopeFor(command);
147
+ if (!scope)
148
+ return { exitCode: 0 };
149
+ const root = await resolveRepoRoot(path.resolve(payload.cwd ?? fallbackCwd));
150
+ if (!root)
151
+ return { exitCode: 0 };
152
+ const outcome = await review({ cwd: root, scope });
153
+ if (outcome.report.ok)
154
+ return { exitCode: 0 };
155
+ const { text } = formatReport(outcome.report, { ...outcome, advice: '' });
156
+ return {
157
+ exitCode: 2,
158
+ stderr: `Commit blocked by pr-checkmate — the pull request gate would fail on these changes.\n${text}\nFix the findings, stage the fixes, and commit again.`,
159
+ };
160
+ }
161
+ catch (err) {
162
+ return {
163
+ exitCode: 0,
164
+ stderr: `pr-checkmate hook skipped, could not complete: ${err instanceof Error ? err.message : String(err)}`,
165
+ };
166
+ }
167
+ }
@@ -0,0 +1,10 @@
1
+ export declare const MCP_SERVER_NAME = "pr-checkmate";
2
+ /**
3
+ * Whether the project's `.mcp.json` already registers the server. A missing or
4
+ * broken file counts as no.
5
+ *
6
+ * Kept apart from `setup.ts` on purpose: the run summary asks this question, and
7
+ * importing `setup.ts` from there closes a loop through the hook and the MCP
8
+ * review back into the run that is printing.
9
+ */
10
+ export declare function isMcpRegistered(cwd: string): boolean;
@@ -0,0 +1,21 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export const MCP_SERVER_NAME = 'pr-checkmate';
4
+ /**
5
+ * Whether the project's `.mcp.json` already registers the server. A missing or
6
+ * broken file counts as no.
7
+ *
8
+ * Kept apart from `setup.ts` on purpose: the run summary asks this question, and
9
+ * importing `setup.ts` from there closes a loop through the hook and the MCP
10
+ * review back into the run that is printing.
11
+ */
12
+ export function isMcpRegistered(cwd) {
13
+ try {
14
+ const parsed = JSON.parse(fs.readFileSync(path.join(cwd, '.mcp.json'), 'utf-8'));
15
+ const servers = parsed?.mcpServers;
16
+ return typeof servers === 'object' && servers !== null && MCP_SERVER_NAME in servers;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ /** Whether `name` resolves to an executable on PATH — the question the client will ask when it starts the server. */
2
+ export declare function isOnPath(name: string, env?: NodeJS.ProcessEnv): boolean;
3
+ export declare const BLOCK_START = "<!-- pr-checkmate:agent-rules:start -->";
4
+ export declare const BLOCK_END = "<!-- pr-checkmate:agent-rules:end -->";
5
+ type SetupStatus = 'created' | 'updated' | 'unchanged' | 'skipped';
6
+ interface SetupAction {
7
+ file: string;
8
+ status: SetupStatus;
9
+ detail: string;
10
+ }
11
+ interface SetupOptions {
12
+ /** Install the commit-gate hook. Default true. */
13
+ hook?: boolean;
14
+ /** Whether the CLI is on PATH. Detected when omitted; injectable for tests. */
15
+ cliOnPath?: boolean;
16
+ }
17
+ /** Configure a repository for agents. Throws, naming the file, if a JSON file cannot be read safely. */
18
+ export declare function setupAgent(cwd: string, opts?: SetupOptions): SetupAction[];
19
+ /** Print what changed and the one step the repository cannot do for the user. */
20
+ export declare function reportSetup(actions: SetupAction[]): void;
21
+ export {};
@@ -0,0 +1,220 @@
1
+ // cspell:ignore PATHEXT
2
+ /**
3
+ * `pr-checkmate agent-setup` — wire a repository so AI agents use the gate by default.
4
+ *
5
+ * Three pieces, each covering a failure of the one before:
6
+ *
7
+ * 1. `.mcp.json` registers the server, so the tools exist;
8
+ * 2. an instructions block in `AGENTS.md` / `CLAUDE.md` tells the agent *when* to
9
+ * call them — tools an agent was never told to use mostly go unused;
10
+ * 3. a Claude Code hook checks staged changes on `git commit`, so the gate holds
11
+ * even when the agent forgets steps 1 and 2.
12
+ *
13
+ * Idempotent, and it only ever adds: an existing entry is left as the user wrote
14
+ * it, a block we own is refreshed in place, and a settings file we cannot parse is
15
+ * reported instead of overwritten.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { logger } from '../utils/index.js';
20
+ import { packageVersion } from '../utils/package-version.js';
21
+ import { HOOK_COMMAND } from './hook.js';
22
+ import { MCP_SERVER_NAME } from './registration.js';
23
+ const CLI = 'pr-checkmate';
24
+ /** Whether `name` resolves to an executable on PATH — the question the client will ask when it starts the server. */
25
+ export function isOnPath(name, env = process.env) {
26
+ const exts = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD').split(';') : [''];
27
+ return (env.PATH ?? '')
28
+ .split(path.delimiter)
29
+ .filter(Boolean)
30
+ .some(dir => exts.some(ext => {
31
+ try {
32
+ fs.accessSync(path.join(dir, name + ext), fs.constants.X_OK);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }));
39
+ }
40
+ /**
41
+ * How a client should start the server. A bare `pr-checkmate` is only right when the
42
+ * CLI is installed globally; otherwise the entry would be written, look fine, and
43
+ * fail silently the first time a client tried to use it. `npx` always resolves.
44
+ */
45
+ function mcpEntry(onPath) {
46
+ // Pinned to the version writing the entry: an unpinned `npx -y` runs whatever is
47
+ // published next on every contributor's machine, which is exactly what the Agent
48
+ // Instructions check flags in other people's `.mcp.json`.
49
+ return onPath
50
+ ? { command: CLI, args: ['mcp'] }
51
+ : { command: 'npx', args: ['-y', `${CLI}@${packageVersion()}`, 'mcp'] };
52
+ }
53
+ export const BLOCK_START = '<!-- pr-checkmate:agent-rules:start -->';
54
+ export const BLOCK_END = '<!-- pr-checkmate:agent-rules:end -->';
55
+ /** What the agent is told. Kept short: it is read on every session. */
56
+ const AGENT_RULES_BLOCK = `${BLOCK_START}
57
+ ## Code quality gate (pr-checkmate)
58
+
59
+ Pull requests in this repository are checked by pr-checkmate. Write code that passes the first time:
60
+
61
+ 1. Before writing code, call \`get_rules\` on the \`pr-checkmate\` MCP server to see what the gate enforces.
62
+ 2. While working, and before every commit, call \`check_changes\`. Fix every ❌ finding and call it again until it passes.
63
+ 3. Before adding any package, call \`check_dependency\` — it catches names that do not exist, versions with known advisories and look-alike names. Before committing configuration, environment or credential files, call \`scan_secrets\`.
64
+ 4. When you believe the work is done, call \`verify\` once (it also runs the slow checks) and fix what it reports before telling the user you are finished.
65
+ 5. Fix findings in the code. Do not silence them with \`eslint-disable\`, \`--no-verify\`, or by editing \`pr-checkmate.json\` unless the user asks you to.
66
+
67
+ If the MCP server is unavailable, run \`pr-checkmate precommit\` before committing.
68
+ ${BLOCK_END}
69
+ `;
70
+ function isRecord(value) {
71
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
72
+ }
73
+ /** Read a JSON object, `{}` when the file is absent, and an error that names the file when it is not JSON. */
74
+ function readJsonObject(file) {
75
+ if (!fs.existsSync(file))
76
+ return { data: {}, existed: false };
77
+ try {
78
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
79
+ if (isRecord(parsed))
80
+ return { data: parsed, existed: true };
81
+ }
82
+ catch {
83
+ // fall through to the shared message
84
+ }
85
+ throw new Error(`${file} is not a valid JSON object — fix it by hand, then run agent-setup again`);
86
+ }
87
+ function writeJson(file, data) {
88
+ fs.mkdirSync(path.dirname(file), { recursive: true });
89
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
90
+ }
91
+ /** Register the MCP server in the project's `.mcp.json`. */
92
+ function setupMcpConfig(cwd, onPath) {
93
+ const file = path.join(cwd, '.mcp.json');
94
+ const { data, existed } = readJsonObject(file);
95
+ const servers = isRecord(data.mcpServers) ? data.mcpServers : {};
96
+ const entry = mcpEntry(onPath);
97
+ if (MCP_SERVER_NAME in servers) {
98
+ return {
99
+ file: '.mcp.json',
100
+ status: 'unchanged',
101
+ detail: `"${MCP_SERVER_NAME}" already registered`,
102
+ };
103
+ }
104
+ writeJson(file, { ...data, mcpServers: { ...servers, [MCP_SERVER_NAME]: entry } });
105
+ return {
106
+ file: '.mcp.json',
107
+ status: existed ? 'updated' : 'created',
108
+ detail: `registered "${MCP_SERVER_NAME}" (runs: ${entry.command} ${entry.args.join(' ')})`,
109
+ };
110
+ }
111
+ /** Insert or refresh our block in one instructions file. */
112
+ function upsertBlock(file, name) {
113
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : undefined;
114
+ if (existing === undefined) {
115
+ fs.writeFileSync(file, `${AGENT_RULES_BLOCK}`);
116
+ return { file: name, status: 'created', detail: 'wrote the agent instructions' };
117
+ }
118
+ const start = existing.indexOf(BLOCK_START);
119
+ const end = existing.indexOf(BLOCK_END);
120
+ let next;
121
+ if (start !== -1 && end > start) {
122
+ next =
123
+ existing.slice(0, start) +
124
+ AGENT_RULES_BLOCK.trimEnd() +
125
+ existing.slice(end + BLOCK_END.length);
126
+ }
127
+ else {
128
+ next = `${existing.trimEnd()}\n\n${AGENT_RULES_BLOCK}`;
129
+ }
130
+ if (next === existing)
131
+ return { file: name, status: 'unchanged', detail: 'instructions already current' };
132
+ fs.writeFileSync(file, next);
133
+ return { file: name, status: 'updated', detail: 'added the agent instructions' };
134
+ }
135
+ /**
136
+ * Put the instructions where agents read them. `AGENTS.md` is the shared
137
+ * convention; `CLAUDE.md` is what Claude Code reads. Both are updated when both
138
+ * exist, and `AGENTS.md` is created when neither does.
139
+ */
140
+ function setupInstructions(cwd) {
141
+ const targets = ['AGENTS.md', 'CLAUDE.md'].filter(name => fs.existsSync(path.join(cwd, name)));
142
+ if (targets.length === 0)
143
+ targets.push('AGENTS.md');
144
+ return targets.map(name => upsertBlock(path.join(cwd, name), name));
145
+ }
146
+ /** The two hook entries: the commit gate before `git commit`, the file check after an edit. */
147
+ const HOOK_ENTRIES = [
148
+ { event: 'PreToolUse', matcher: 'Bash' },
149
+ { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit' },
150
+ ];
151
+ /** Whether `entries` already holds our hook command. */
152
+ function hasOurHook(entries) {
153
+ return entries.some(entry => isRecord(entry) &&
154
+ Array.isArray(entry.hooks) &&
155
+ entry.hooks.some(h => isRecord(h) && h.command === HOOK_COMMAND));
156
+ }
157
+ /**
158
+ * Add the commit gate and the per-edit check to `.claude/settings.json`, each only
159
+ * if it is not there yet. Both run the same command; which one fires is decided by
160
+ * the event the harness reports.
161
+ */
162
+ function setupHook(cwd) {
163
+ const rel = path.join('.claude', 'settings.json');
164
+ const file = path.join(cwd, rel);
165
+ const { data, existed } = readJsonObject(file);
166
+ const hooks = isRecord(data.hooks) ? { ...data.hooks } : {};
167
+ let added = 0;
168
+ for (const { event, matcher } of HOOK_ENTRIES) {
169
+ const current = Array.isArray(hooks[event]) ? hooks[event] : [];
170
+ if (hasOurHook(current))
171
+ continue;
172
+ hooks[event] = [...current, { matcher, hooks: [{ type: 'command', command: HOOK_COMMAND }] }];
173
+ added++;
174
+ }
175
+ if (added === 0)
176
+ return { file: rel, status: 'unchanged', detail: 'hooks already installed' };
177
+ writeJson(file, { ...data, hooks });
178
+ return {
179
+ file: rel,
180
+ status: existed ? 'updated' : 'created',
181
+ detail: 'git commit runs the gate on staged changes; each file edit is checked for security problems',
182
+ };
183
+ }
184
+ /** Configure a repository for agents. Throws, naming the file, if a JSON file cannot be read safely. */
185
+ export function setupAgent(cwd, opts = {}) {
186
+ const onPath = opts.cliOnPath ?? isOnPath(CLI);
187
+ const settings = path.join('.claude', 'settings.json');
188
+ const actions = [setupMcpConfig(cwd, onPath), ...setupInstructions(cwd)];
189
+ if (opts.hook === false) {
190
+ actions.push({
191
+ file: settings,
192
+ status: 'skipped',
193
+ detail: 'commit gate not requested (--no-hook)',
194
+ });
195
+ }
196
+ else if (!onPath) {
197
+ // The hook runs on every Bash call the agent makes. Through `npx` that is a
198
+ // package resolution per call — a cost paid all session for a gate that only
199
+ // matters at commit time — so it waits for an install the hook can rely on.
200
+ actions.push({
201
+ file: settings,
202
+ status: 'skipped',
203
+ detail: `commit gate needs "${CLI}" on PATH — run "npm install -g ${CLI}", then agent-setup again`,
204
+ });
205
+ }
206
+ else {
207
+ actions.push(setupHook(cwd));
208
+ }
209
+ return actions;
210
+ }
211
+ /** Print what changed and the one step the repository cannot do for the user. */
212
+ export function reportSetup(actions) {
213
+ for (const a of actions)
214
+ logger.info(`[agent-setup]: ${a.status.padEnd(9)} ${a.file} — ${a.detail}`);
215
+ logger.info('');
216
+ logger.info('[agent-setup]: Installing the CLI globally is recommended (faster start, enables the commit gate):');
217
+ logger.info('[agent-setup]: npm install -g pr-checkmate');
218
+ logger.info('[agent-setup]: To make it available in every project for Claude Code, run once:');
219
+ logger.info('[agent-setup]: claude mcp add --scope user pr-checkmate -- pr-checkmate mcp');
220
+ }