@guilz-dev/belay 0.9.3 → 0.9.4

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 (83) hide show
  1. package/README.md +35 -1
  2. package/dist/adapters/codex/hooks.d.ts +1 -0
  3. package/dist/adapters/codex/hooks.js +3 -0
  4. package/dist/adapters/cursor/dispatcher-generation.d.ts +3 -0
  5. package/dist/adapters/cursor/dispatcher-generation.js +5 -0
  6. package/dist/adapters/cursor/hook-dispatch-entry.d.ts +7 -0
  7. package/dist/adapters/cursor/hook-dispatch-entry.js +126 -0
  8. package/dist/adapters/cursor/hook-router.d.ts +23 -0
  9. package/dist/adapters/cursor/hook-router.js +266 -0
  10. package/dist/adapters/cursor/hooks.d.ts +4 -0
  11. package/dist/adapters/cursor/hooks.js +99 -8
  12. package/dist/adapters/cursor/routing-layout.d.ts +5 -0
  13. package/dist/adapters/cursor/routing-layout.js +29 -0
  14. package/dist/adapters/cursor/runtime-entry.d.ts +17 -0
  15. package/dist/adapters/cursor/runtime-entry.js +39 -39
  16. package/dist/adapters/layouts/scope.d.ts +5 -0
  17. package/dist/adapters/layouts/scope.js +71 -0
  18. package/dist/adapters/shared/gate-runtime.js +1 -0
  19. package/dist/bundle/claude-runtime.mjs +1932 -1740
  20. package/dist/bundle/codex-runtime.mjs +1934 -1742
  21. package/dist/bundle/cursor-dispatcher.mjs +443 -0
  22. package/dist/bundle/cursor-runtime.mjs +2017 -1821
  23. package/dist/commands/doctor.js +191 -15
  24. package/dist/commands/health-snapshot.js +9 -4
  25. package/dist/commands/recovery-checkpoints.d.ts +3 -3
  26. package/dist/config-io.js +11 -2
  27. package/dist/core/audit-legacy-archive.js +43 -25
  28. package/dist/core/classify-tool.js +16 -8
  29. package/dist/core/effect-ir/argv-delegate.d.ts +9 -0
  30. package/dist/core/effect-ir/argv-delegate.js +42 -0
  31. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.d.ts +3 -0
  32. package/dist/core/effect-ir/shell-lower/argv-delegate-gate.js +40 -0
  33. package/dist/core/effect-ir/shell-lower/augment.d.ts +4 -0
  34. package/dist/core/effect-ir/shell-lower/augment.js +97 -0
  35. package/dist/core/effect-ir/shell-lower/context.d.ts +10 -0
  36. package/dist/core/effect-ir/shell-lower/context.js +1 -0
  37. package/dist/core/effect-ir/shell-lower/decode-process.d.ts +9 -0
  38. package/dist/core/effect-ir/shell-lower/decode-process.js +201 -0
  39. package/dist/core/effect-ir/shell-lower/decoders/belay.d.ts +2 -0
  40. package/dist/core/effect-ir/shell-lower/decoders/belay.js +29 -0
  41. package/dist/core/effect-ir/shell-lower/decoders/builtins.d.ts +7 -0
  42. package/dist/core/effect-ir/shell-lower/decoders/builtins.js +194 -0
  43. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.d.ts +4 -0
  44. package/dist/core/effect-ir/shell-lower/decoders/decode-package-exec.js +68 -0
  45. package/dist/core/effect-ir/shell-lower/decoders/docker.d.ts +3 -0
  46. package/dist/core/effect-ir/shell-lower/decoders/docker.js +43 -0
  47. package/dist/core/effect-ir/shell-lower/decoders/filesystem.d.ts +9 -0
  48. package/dist/core/effect-ir/shell-lower/decoders/filesystem.js +217 -0
  49. package/dist/core/effect-ir/shell-lower/decoders/prisma.d.ts +2 -0
  50. package/dist/core/effect-ir/shell-lower/decoders/prisma.js +54 -0
  51. package/dist/core/effect-ir/shell-lower/decoders/ruby.d.ts +11 -0
  52. package/dist/core/effect-ir/shell-lower/decoders/ruby.js +147 -0
  53. package/dist/core/effect-ir/shell-lower/decoders/toolchain.d.ts +7 -0
  54. package/dist/core/effect-ir/shell-lower/decoders/toolchain.js +155 -0
  55. package/dist/core/effect-ir/shell-lower/requirement.d.ts +12 -0
  56. package/dist/core/effect-ir/shell-lower/requirement.js +65 -0
  57. package/dist/core/effect-ir/shell-lower/segment.d.ts +11 -0
  58. package/dist/core/effect-ir/shell-lower/segment.js +82 -0
  59. package/dist/core/effect-ir/shell-lower/tokens.d.ts +26 -0
  60. package/dist/core/effect-ir/shell-lower/tokens.js +183 -0
  61. package/dist/core/effect-ir/shell-lower.d.ts +2 -7
  62. package/dist/core/effect-ir/shell-lower.js +60 -1502
  63. package/dist/core/glob.js +32 -16
  64. package/dist/core/integrity.d.ts +2 -2
  65. package/dist/core/integrity.js +52 -12
  66. package/dist/core/replay-scrub.d.ts +2 -0
  67. package/dist/core/replay-scrub.js +8 -0
  68. package/dist/core/shell-substitution.js +2 -2
  69. package/dist/core/shell-unparseable.js +3 -1
  70. package/dist/defaults.d.ts +7 -3
  71. package/dist/defaults.js +29 -22
  72. package/dist/installer/bootstrap.d.ts +1 -0
  73. package/dist/installer/bootstrap.js +2 -2
  74. package/dist/installer/runtime-artifacts.js +43 -13
  75. package/dist/installer.js +44 -9
  76. package/dist/node-resolution.d.ts +1 -0
  77. package/dist/node-resolution.js +61 -0
  78. package/dist/templates.d.ts +7 -4
  79. package/dist/templates.js +42 -4
  80. package/dist/types.d.ts +1 -0
  81. package/dist/version.d.ts +1 -1
  82. package/dist/version.js +1 -1
  83. package/package.json +1 -1
package/dist/core/glob.js CHANGED
@@ -1,26 +1,42 @@
1
+ import { escapeRegex } from './approval.js';
2
+ function globPatternToRegexSource(pattern) {
3
+ let result = '';
4
+ let index = 0;
5
+ while (index < pattern.length) {
6
+ const char = pattern[index];
7
+ if (char === '*' && pattern[index + 1] === '*') {
8
+ index += 2;
9
+ if (pattern[index] === '/') {
10
+ result += '(?:.*/)?';
11
+ index += 1;
12
+ }
13
+ else {
14
+ result += '.*';
15
+ }
16
+ continue;
17
+ }
18
+ if (char === '*') {
19
+ result += '[^/]*';
20
+ index += 1;
21
+ continue;
22
+ }
23
+ result += escapeRegex(char);
24
+ index += 1;
25
+ }
26
+ return result;
27
+ }
28
+ function globPatternMatches(pattern, normalized, baseName) {
29
+ const regex = new RegExp(`^${globPatternToRegexSource(pattern)}$`);
30
+ return regex.test(normalized) || regex.test(baseName);
31
+ }
1
32
  export function matchesSensitivePath(filePath, patterns) {
2
33
  const normalized = filePath.replaceAll('\\', '/');
3
34
  const segments = normalized.split('/');
4
35
  const baseName = segments.at(-1) ?? normalized;
5
36
  for (const pattern of patterns) {
6
37
  const normalizedPattern = pattern.replaceAll('\\', '/');
7
- if (normalizedPattern.includes('**')) {
8
- const parts = normalizedPattern.split('**').map((part) => part.replace(/^\/+|\/+$/g, ''));
9
- const prefix = parts[0]?.replace(/\/+$/, '') ?? '';
10
- const suffix = parts[1]?.replace(/^\/+/, '') ?? '';
11
- if (prefix && !normalized.startsWith(prefix)) {
12
- continue;
13
- }
14
- if (suffix && !normalized.includes(suffix)) {
15
- continue;
16
- }
17
- if (prefix || suffix) {
18
- return true;
19
- }
20
- }
21
38
  if (normalizedPattern.includes('*')) {
22
- const regex = new RegExp(`^${normalizedPattern.replaceAll('.', '\\.').replaceAll('*', '.*')}$`);
23
- if (regex.test(normalized) || regex.test(baseName)) {
39
+ if (globPatternMatches(normalizedPattern, normalized, baseName)) {
24
40
  return true;
25
41
  }
26
42
  continue;
@@ -8,9 +8,9 @@ export interface IntegrityManifest {
8
8
  }
9
9
  export declare function sha256File(filePath: string): Promise<string>;
10
10
  export declare function integrityManifestPath(layout: AdapterLayout, repoRoot: string): string;
11
- export declare function runtimeIntegrityFiles(_layout: AdapterLayout, paths: ScopedPaths): string[];
11
+ export declare function runtimeIntegrityFiles(layout: AdapterLayout, paths: ScopedPaths): string[];
12
12
  export declare function writeIntegrityManifest(repoRoot: string, layout: AdapterLayout, filePaths: string[]): Promise<void>;
13
- export declare function verifyIntegrityManifest(repoRoot: string, layout: AdapterLayout): Promise<{
13
+ export declare function verifyIntegrityManifest(repoRoot: string, layout: AdapterLayout, requiredFilePaths?: string[]): Promise<{
14
14
  ok: boolean;
15
15
  mismatches: string[];
16
16
  }>;
@@ -3,8 +3,24 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { getAdapterLayout } from '../adapters/layouts/index.js';
6
- import { resolveScopedPaths } from '../adapters/layouts/scope.js';
6
+ import { isPathInside, resolveScopedPaths } from '../adapters/layouts/scope.js';
7
7
  import { resolveAdapterName } from '../config-io.js';
8
+ const GLOBAL_INTEGRITY_PREFIX = '@global/';
9
+ function portableRelativePath(from, to) {
10
+ return path.relative(from, to).split(path.sep).join('/');
11
+ }
12
+ function integrityManifestFileKey(repoRoot, layout, filePath) {
13
+ const absolutePath = path.resolve(filePath);
14
+ if (isPathInside(absolutePath, repoRoot)) {
15
+ return portableRelativePath(repoRoot, absolutePath);
16
+ }
17
+ const globalPaths = resolveScopedPaths(layout, 'global', repoRoot);
18
+ const globalAgentDir = path.dirname(globalPaths.hooksSettingsPath);
19
+ if (isPathInside(absolutePath, globalAgentDir)) {
20
+ return `${GLOBAL_INTEGRITY_PREFIX}${portableRelativePath(globalAgentDir, absolutePath)}`;
21
+ }
22
+ throw new Error(`Integrity file is outside the repository and global adapter root: ${filePath}`);
23
+ }
8
24
  export async function sha256File(filePath) {
9
25
  const content = await readFile(filePath);
10
26
  return createHash('sha256').update(content).digest('hex');
@@ -12,11 +28,8 @@ export async function sha256File(filePath) {
12
28
  export function integrityManifestPath(layout, repoRoot) {
13
29
  return path.join(layout.repoLocalStateDir(repoRoot), 'integrity-manifest.json');
14
30
  }
15
- export function runtimeIntegrityFiles(_layout, paths) {
31
+ export function runtimeIntegrityFiles(layout, paths) {
16
32
  const files = [paths.configPath];
17
- if (paths.scope !== 'project') {
18
- return files;
19
- }
20
33
  const hooksDir = paths.hooksDir;
21
34
  const runtimeDir = paths.runtimeDir;
22
35
  return [
@@ -28,7 +41,9 @@ export function runtimeIntegrityFiles(_layout, paths) {
28
41
  path.join(hooksDir, 'belay-audit.mjs'),
29
42
  path.join(hooksDir, 'belay-runner'),
30
43
  path.join(hooksDir, 'belay-runner.cmd'),
44
+ ...(layout.name === 'cursor' ? [path.join(hooksDir, 'belay-runner.ps1')] : []),
31
45
  path.join(runtimeDir, 'core.mjs'),
46
+ ...(layout.name === 'cursor' ? [path.join(runtimeDir, 'dispatcher.mjs')] : []),
32
47
  ];
33
48
  }
34
49
  export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
@@ -37,8 +52,8 @@ export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
37
52
  if (!existsSync(filePath)) {
38
53
  continue;
39
54
  }
40
- const relativePath = path.relative(repoRoot, filePath);
41
- files[relativePath] = await sha256File(filePath);
55
+ const manifestKey = integrityManifestFileKey(repoRoot, layout, filePath);
56
+ files[manifestKey] = await sha256File(filePath);
42
57
  }
43
58
  const manifest = {
44
59
  version: 1,
@@ -49,22 +64,47 @@ export async function writeIntegrityManifest(repoRoot, layout, filePaths) {
49
64
  await mkdir(path.dirname(manifestPath), { recursive: true });
50
65
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
51
66
  }
52
- export async function verifyIntegrityManifest(repoRoot, layout) {
67
+ export async function verifyIntegrityManifest(repoRoot, layout, requiredFilePaths = []) {
53
68
  const manifestPath = integrityManifestPath(layout, repoRoot);
54
69
  if (!existsSync(manifestPath)) {
55
70
  return { ok: false, mismatches: ['missing integrity-manifest.json'] };
56
71
  }
57
72
  const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
58
73
  const mismatches = [];
59
- for (const [relativePath, expectedHash] of Object.entries(manifest.files ?? {})) {
60
- const absolutePath = path.join(repoRoot, relativePath);
74
+ const requiredPathsByKey = new Map(requiredFilePaths.map((requiredPath) => [
75
+ integrityManifestFileKey(repoRoot, layout, requiredPath),
76
+ path.resolve(requiredPath),
77
+ ]));
78
+ for (const requiredPath of requiredFilePaths) {
79
+ const requiredKey = integrityManifestFileKey(repoRoot, layout, requiredPath);
80
+ if (!Object.hasOwn(manifest.files ?? {}, requiredKey)) {
81
+ mismatches.push(`missing integrity pin ${requiredKey}`);
82
+ }
83
+ }
84
+ for (const [manifestKey, expectedHash] of Object.entries(manifest.files ?? {})) {
85
+ let absolutePath;
86
+ if (manifestKey.startsWith(GLOBAL_INTEGRITY_PREFIX)) {
87
+ const requiredPath = requiredPathsByKey.get(manifestKey);
88
+ if (!requiredPath) {
89
+ mismatches.push(`unrecognized global integrity pin ${manifestKey}`);
90
+ continue;
91
+ }
92
+ absolutePath = requiredPath;
93
+ }
94
+ else {
95
+ absolutePath = path.resolve(repoRoot, manifestKey);
96
+ if (!isPathInside(absolutePath, repoRoot)) {
97
+ mismatches.push(`integrity pin escapes repository ${manifestKey}`);
98
+ continue;
99
+ }
100
+ }
61
101
  if (!existsSync(absolutePath)) {
62
- mismatches.push(`missing ${relativePath}`);
102
+ mismatches.push(`missing ${manifestKey}`);
63
103
  continue;
64
104
  }
65
105
  const actualHash = await sha256File(absolutePath);
66
106
  if (actualHash !== expectedHash) {
67
- mismatches.push(`hash mismatch ${relativePath}`);
107
+ mismatches.push(`hash mismatch ${manifestKey}`);
68
108
  }
69
109
  }
70
110
  return { ok: mismatches.length === 0, mismatches };
@@ -3,6 +3,8 @@ import type { ScrubOptions } from './types.js';
3
3
  export declare function redactToolInvocationId(value: unknown, rawToolUseId?: string): unknown;
4
4
  /** Subagent fingerprint input — must match classify-subagent `fingerprintSource`. */
5
5
  export declare function subagentFingerprintSource(payload: Record<string, unknown>, scrubOptions: ScrubOptions): unknown;
6
+ /** Stable tool-input source for fingerprints — redacts volatile ids only, not secret values. */
7
+ export declare function fingerprintToolInputSource(payload: Record<string, unknown>): Record<string, unknown>;
6
8
  /**
7
9
  * Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
8
10
  * Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
@@ -40,6 +40,14 @@ export function subagentFingerprintSource(payload, scrubOptions) {
40
40
  }
41
41
  return scrubValue(payload, scrubOptions);
42
42
  }
43
+ /** Stable tool-input source for fingerprints — redacts volatile ids only, not secret values. */
44
+ export function fingerprintToolInputSource(payload) {
45
+ const toolInput = payload.tool_input;
46
+ if (!toolInput || typeof toolInput !== 'object') {
47
+ return {};
48
+ }
49
+ return redactToolInvocationId(toolInput, typeof payload.tool_use_id === 'string' ? payload.tool_use_id : undefined);
50
+ }
43
51
  /**
44
52
  * Scrubbed payload used for replay envelope hashing — aligned with classifier fingerprints.
45
53
  * Tool: scrubbed `tool_input`. Subagent: description/prompt subset. Other: full payload.
@@ -16,7 +16,7 @@ export function findCommandSubstitutions(command) {
16
16
  index += 1;
17
17
  continue;
18
18
  }
19
- if (char === '\\' && (inSingle || inDouble)) {
19
+ if (char === '\\' && inDouble) {
20
20
  escaping = true;
21
21
  index += 1;
22
22
  continue;
@@ -156,7 +156,7 @@ function extractBalancedParenContent(command, start) {
156
156
  index += 1;
157
157
  continue;
158
158
  }
159
- if (char === '\\' && (inSingle || inDouble)) {
159
+ if (char === '\\' && inDouble) {
160
160
  escaping = true;
161
161
  index += 1;
162
162
  continue;
@@ -42,7 +42,9 @@ function hasUnclosedQuote(command) {
42
42
  continue;
43
43
  }
44
44
  if (char === '\\') {
45
- escaping = true;
45
+ if (quote !== "'") {
46
+ escaping = true;
47
+ }
46
48
  continue;
47
49
  }
48
50
  if (quote) {
@@ -5,13 +5,17 @@ export type ManagedHookDefinition = {
5
5
  command: string;
6
6
  placement: 'prepend' | 'append';
7
7
  matcher?: string;
8
+ failClosed?: boolean;
8
9
  };
9
- export declare function getManagedHookEntries(platform?: NodeJS.Platform, hooksDir?: string, repoRoot?: string): Array<{
10
+ export type CursorManagedHookDefinition = ManagedHookDefinition & {
11
+ failClosed: true;
12
+ };
13
+ export declare function getManagedHookEntries(platform?: NodeJS.Platform, hooksDir?: string, repoRoot?: string, runnerPathMode?: 'absolute' | 'legacy-relative' | 'legacy-absolute' | 'legacy-quoted-absolute' | 'legacy-bare-powershell-absolute'): Array<{
10
14
  event: string;
11
- definition: ManagedHookDefinition;
15
+ definition: CursorManagedHookDefinition;
12
16
  }>;
13
17
  /** @deprecated Use getManagedHookEntries instead. */
14
- export declare function getManagedHookEvents(platform?: NodeJS.Platform): Record<string, ManagedHookDefinition>;
18
+ export declare function getManagedHookEvents(platform?: NodeJS.Platform): Record<string, CursorManagedHookDefinition>;
15
19
  export declare const EMPTY_APPROVALS: {
16
20
  readonly version: 1;
17
21
  readonly approvals: readonly [];
package/dist/defaults.js CHANGED
@@ -1,29 +1,41 @@
1
1
  import path from 'node:path';
2
2
  import { cursorLayout } from './adapters/layouts/cursor.js';
3
- import { buildRunnerInvocation } from './adapters/layouts/scope.js';
3
+ import { buildAbsoluteRunnerInvocation, buildLegacyAbsoluteRunnerInvocation, buildLegacyBarePowerShellRunnerInvocation, buildLegacyQuotedAbsoluteRunnerInvocation, buildRunnerInvocation, } from './adapters/layouts/scope.js';
4
4
  import { DEFAULT_CONFIG_V3 } from './core/config.js';
5
5
  export { PACKAGE_NAME } from './branding.js';
6
6
  export const DEFAULT_CONFIG = DEFAULT_CONFIG_V3;
7
- function runnerCommand(platform, hooksDir, repoRoot, hookScript, ...args) {
8
- return buildRunnerInvocation(platform, hooksDir, repoRoot, hookScript, ...args);
7
+ function runnerCommand(platform, hooksDir, _repoRoot, hookScript, runnerPathMode, ...args) {
8
+ if (runnerPathMode === 'absolute') {
9
+ return buildAbsoluteRunnerInvocation(platform, hooksDir, hookScript, ...args);
10
+ }
11
+ if (runnerPathMode === 'legacy-absolute') {
12
+ return buildLegacyAbsoluteRunnerInvocation(platform, hooksDir, hookScript, ...args);
13
+ }
14
+ if (runnerPathMode === 'legacy-quoted-absolute') {
15
+ return buildLegacyQuotedAbsoluteRunnerInvocation(platform, hooksDir, hookScript, ...args);
16
+ }
17
+ if (runnerPathMode === 'legacy-bare-powershell-absolute') {
18
+ return buildLegacyBarePowerShellRunnerInvocation(platform, hooksDir, hookScript, ...args);
19
+ }
20
+ return buildRunnerInvocation(platform, hooksDir, _repoRoot, hookScript, ...args);
9
21
  }
10
- export function getManagedHookEntries(platform = process.platform, hooksDir, repoRoot) {
22
+ export function getManagedHookEntries(platform = process.platform, hooksDir, repoRoot, runnerPathMode = 'absolute') {
11
23
  const resolvedRepo = path.resolve(repoRoot ?? process.cwd());
12
24
  const resolvedHooksDir = hooksDir ?? cursorLayout.hooksDir(resolvedRepo);
13
- const toolGate = runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-tool-gate', 'preToolUse');
14
- const subagentGate = runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-tool-gate', 'subagentStart');
15
- return [
25
+ const toolGate = runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-tool-gate', runnerPathMode, 'preToolUse');
26
+ const subagentGate = runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-tool-gate', runnerPathMode, 'subagentStart');
27
+ const entries = [
16
28
  {
17
29
  event: 'beforeSubmitPrompt',
18
30
  definition: {
19
- command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-before-submit'),
31
+ command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-before-submit', runnerPathMode),
20
32
  placement: 'prepend',
21
33
  },
22
34
  },
23
35
  {
24
36
  event: 'beforeShellExecution',
25
37
  definition: {
26
- command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-shell-gate'),
38
+ command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-shell-gate', runnerPathMode),
27
39
  placement: 'prepend',
28
40
  },
29
41
  },
@@ -59,15 +71,6 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
59
71
  matcher: 'Delete',
60
72
  },
61
73
  },
62
- {
63
- event: 'preToolUse',
64
- definition: {
65
- command: toolGate,
66
- placement: 'prepend',
67
- // Agent Shell tool (preToolUse) — distinct from terminal beforeShellExecution.
68
- matcher: 'Shell',
69
- },
70
- },
71
74
  {
72
75
  event: 'subagentStart',
73
76
  definition: {
@@ -119,32 +122,36 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
119
122
  {
120
123
  event: 'postToolUse',
121
124
  definition: {
122
- command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', 'postToolUse'),
125
+ command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', runnerPathMode, 'postToolUse'),
123
126
  placement: 'append',
124
127
  },
125
128
  },
126
129
  {
127
130
  event: 'postToolUseFailure',
128
131
  definition: {
129
- command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', 'postToolUseFailure'),
132
+ command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', runnerPathMode, 'postToolUseFailure'),
130
133
  placement: 'append',
131
134
  },
132
135
  },
133
136
  {
134
137
  event: 'stop',
135
138
  definition: {
136
- command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', 'stop'),
139
+ command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', runnerPathMode, 'stop'),
137
140
  placement: 'append',
138
141
  },
139
142
  },
140
143
  {
141
144
  event: 'sessionEnd',
142
145
  definition: {
143
- command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', 'sessionEnd'),
146
+ command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', runnerPathMode, 'sessionEnd'),
144
147
  placement: 'append',
145
148
  },
146
149
  },
147
150
  ];
151
+ return entries.map(({ event, definition }) => ({
152
+ event,
153
+ definition: { ...definition, failClosed: true },
154
+ }));
148
155
  }
149
156
  /** @deprecated Use getManagedHookEntries instead. */
150
157
  export function getManagedHookEvents(platform = process.platform) {
@@ -1,5 +1,6 @@
1
1
  import type { ScopedPaths } from '../adapters/layouts/scope.js';
2
2
  import type { AdapterName } from '../adapters/layouts/types.js';
3
3
  import type { BelayConfigV3 } from '../core/config.js';
4
+ export declare const CURSOR_COMMAND_ARTIFACTS: readonly ["belay-approve.md", "belay-why.md", "belay-explain.md", "belay-status.md", "belay-report.md", "belay-recover.md"];
4
5
  export declare function bootstrapStateFiles(repoRoot: string, config: BelayConfigV3, paths: ScopedPaths): Promise<void>;
5
6
  export declare function writeSkillArtifacts(adapterName: AdapterName, paths: ScopedPaths): Promise<void>;
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { approvedApprovalsPath, pendingApprovalsPath } from '../config-io.js';
5
5
  import { EMPTY_APPROVALS } from '../defaults.js';
6
6
  const BUNDLED_SKILL_TEMPLATE_URL = new URL('../../skills/belay/SKILL.md', import.meta.url);
7
- const BUNDLED_COMMAND_TEMPLATES = [
7
+ export const CURSOR_COMMAND_ARTIFACTS = [
8
8
  'belay-approve.md',
9
9
  'belay-why.md',
10
10
  'belay-explain.md',
@@ -53,7 +53,7 @@ export async function writeSkillArtifacts(adapterName, paths) {
53
53
  await writeFile(path.join(paths.skillsDir, 'SKILL.md'), bundledSkill, 'utf8');
54
54
  if (adapterName === 'cursor' && paths.commandsDir) {
55
55
  await mkdir(paths.commandsDir, { recursive: true });
56
- for (const fileName of BUNDLED_COMMAND_TEMPLATES) {
56
+ for (const fileName of CURSOR_COMMAND_ARTIFACTS) {
57
57
  const bundledCommand = await readBundledTemplate(new URL(`../../skills/belay/${fileName}`, import.meta.url));
58
58
  await writeFile(path.join(paths.commandsDir, fileName), bundledCommand, 'utf8');
59
59
  }
@@ -1,23 +1,53 @@
1
- import { chmod, mkdir, writeFile } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
- import { buildRunnerScript, buildWindowsRunnerScript } from '../node-resolution.js';
4
- import { renderAuditHook, renderBeforeSubmitHook, renderRuntimeCore, renderShellGateHook, renderToolGateHook, } from '../templates.js';
4
+ import { buildRunnerScript, buildWindowsPowerShellRunnerScript, buildWindowsRunnerScript, } from '../node-resolution.js';
5
+ import { renderAuditHook, renderBeforeSubmitHook, renderCursorDispatcher, renderRuntimeCore, renderShellGateHook, renderToolGateHook, } from '../templates.js';
5
6
  async function writeFileMaybeExecutable(filePath, content, executable = false) {
6
7
  await mkdir(path.dirname(filePath), { recursive: true });
7
- await writeFile(filePath, content, 'utf8');
8
- if (executable) {
9
- await chmod(filePath, 0o755);
8
+ const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
9
+ try {
10
+ await writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx' });
11
+ if (executable) {
12
+ await chmod(temporaryPath, 0o755);
13
+ }
14
+ await rename(temporaryPath, filePath);
15
+ }
16
+ finally {
17
+ await rm(temporaryPath, { force: true });
10
18
  }
11
19
  }
12
20
  export async function writeRuntimeArtifacts(adapterName, paths) {
13
21
  const { hooksDir, runtimeDir } = paths;
22
+ const cursorOrigin = adapterName === 'cursor'
23
+ ? paths.scope === 'global'
24
+ ? { scope: 'global' }
25
+ : { scope: 'project', repoRoot: await realpath(paths.repoRoot) }
26
+ : undefined;
27
+ const artifacts = {
28
+ auditHook: renderAuditHook(adapterName, cursorOrigin),
29
+ beforeSubmitHook: renderBeforeSubmitHook(adapterName, cursorOrigin),
30
+ core: await renderRuntimeCore(adapterName),
31
+ dispatcher: adapterName === 'cursor' ? await renderCursorDispatcher() : undefined,
32
+ runner: buildRunnerScript(process.execPath),
33
+ runnerCmd: buildWindowsRunnerScript(process.execPath),
34
+ runnerPowerShell: adapterName === 'cursor' ? buildWindowsPowerShellRunnerScript(process.execPath) : undefined,
35
+ shellGateHook: renderShellGateHook(adapterName, cursorOrigin),
36
+ toolGateHook: renderToolGateHook(adapterName, cursorOrigin),
37
+ };
14
38
  await mkdir(hooksDir, { recursive: true });
15
39
  await mkdir(runtimeDir, { recursive: true });
16
- await writeFileMaybeExecutable(path.join(hooksDir, 'belay-before-submit.mjs'), renderBeforeSubmitHook());
17
- await writeFileMaybeExecutable(path.join(hooksDir, 'belay-shell-gate.mjs'), renderShellGateHook());
18
- await writeFileMaybeExecutable(path.join(hooksDir, 'belay-tool-gate.mjs'), renderToolGateHook());
19
- await writeFileMaybeExecutable(path.join(hooksDir, 'belay-audit.mjs'), renderAuditHook());
20
- await writeFileMaybeExecutable(path.join(runtimeDir, 'core.mjs'), await renderRuntimeCore(adapterName));
21
- await writeFileMaybeExecutable(path.join(hooksDir, 'belay-runner'), buildRunnerScript(process.execPath), true);
22
- await writeFileMaybeExecutable(path.join(hooksDir, 'belay-runner.cmd'), buildWindowsRunnerScript(process.execPath));
40
+ await writeFileMaybeExecutable(path.join(runtimeDir, 'core.mjs'), artifacts.core);
41
+ if (artifacts.dispatcher !== undefined) {
42
+ await writeFileMaybeExecutable(path.join(runtimeDir, 'dispatcher.mjs'), artifacts.dispatcher);
43
+ }
44
+ await writeFileMaybeExecutable(path.join(hooksDir, 'belay-runner'), artifacts.runner, true);
45
+ await writeFileMaybeExecutable(path.join(hooksDir, 'belay-runner.cmd'), artifacts.runnerCmd);
46
+ if (artifacts.runnerPowerShell !== undefined) {
47
+ await writeFileMaybeExecutable(path.join(hooksDir, 'belay-runner.ps1'), artifacts.runnerPowerShell);
48
+ }
49
+ await writeFileMaybeExecutable(path.join(hooksDir, 'belay-before-submit.mjs'), artifacts.beforeSubmitHook);
50
+ await writeFileMaybeExecutable(path.join(hooksDir, 'belay-shell-gate.mjs'), artifacts.shellGateHook);
51
+ await writeFileMaybeExecutable(path.join(hooksDir, 'belay-tool-gate.mjs'), artifacts.toolGateHook);
52
+ await writeFileMaybeExecutable(path.join(hooksDir, 'belay-audit.mjs'), artifacts.auditHook);
23
53
  }
package/dist/installer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { mergeCursorHooksFile, stripCursorHooksFile } from './adapters/cursor/hooks.js';
4
+ import { hasManagedCursorHookEntries, mergeCursorHooksFile, stripCursorHooksFile, } from './adapters/cursor/hooks.js';
5
5
  import { cursorLayout } from './adapters/layouts/cursor.js';
6
6
  import { resolveScopedPaths } from './adapters/layouts/scope.js';
7
7
  import { getAdapter } from './adapters/registry.js';
@@ -13,7 +13,7 @@ import { isFreshConfigInput, mergeConfig, normalizeConfig, } from './core/config
13
13
  import { runtimeIntegrityFiles, writeIntegrityManifest } from './core/integrity.js';
14
14
  import { hasValidCloudConsent, isCloudJudgeConfig, migrateImplicitLocalJudgeIfNeeded, resolveInitJudgeConfig, } from './core/judge-config.js';
15
15
  import { resolveJudgeTransport } from './core/judge-runtime-detection.js';
16
- import { bootstrapStateFiles, writeSkillArtifacts } from './installer/bootstrap.js';
16
+ import { bootstrapStateFiles, CURSOR_COMMAND_ARTIFACTS, writeSkillArtifacts, } from './installer/bootstrap.js';
17
17
  import { writeRuntimeArtifacts } from './installer/runtime-artifacts.js';
18
18
  import { applyInstallScope, resolveOperationScope } from './installer/scope-config.js';
19
19
  import { applyConfigPreset } from './presets.js';
@@ -64,7 +64,6 @@ export async function initCursorProject(options = {}) {
64
64
  const mergedHooks = mergeCursorHooksFile(hooksFile, process.platform, paths.hooksDir, repoRoot);
65
65
  await ensureDir(paths.hooksDir);
66
66
  const config = await mergeAndWriteConfig(repoRoot, 'cursor');
67
- await applyInstallScope(repoRoot, 'cursor', scope, config);
68
67
  await writeRuntimeArtifacts('cursor', paths);
69
68
  await bootstrapStateFiles(repoRoot, config, paths);
70
69
  if (withSkill) {
@@ -72,8 +71,12 @@ export async function initCursorProject(options = {}) {
72
71
  }
73
72
  await mkdir(path.dirname(paths.hooksSettingsPath), { recursive: true });
74
73
  await writeFile(paths.hooksSettingsPath, `${JSON.stringify(mergedHooks, null, 2)}\n`, 'utf8');
74
+ const installedConfig = await applyInstallScope(repoRoot, 'cursor', scope, config);
75
+ if (scope === 'global') {
76
+ await cleanupStaleProjectCursorInstall(repoRoot);
77
+ }
75
78
  await writeIntegrityManifest(repoRoot, cursorLayout, runtimeIntegrityFiles(cursorLayout, paths));
76
- await archiveLegacyAuditLogIfNeeded(repoRoot, config);
79
+ await archiveLegacyAuditLogIfNeeded(repoRoot, installedConfig);
77
80
  return { repoRoot, withSkill };
78
81
  }
79
82
  export async function upgradeCursorProject(options = {}) {
@@ -81,17 +84,31 @@ export async function upgradeCursorProject(options = {}) {
81
84
  const scope = await resolveOperationScope(repoRoot, 'cursor', options);
82
85
  const paths = resolveScopedPaths(cursorLayout, scope, repoRoot);
83
86
  const config = await mergeAndWriteConfig(repoRoot, 'cursor');
84
- await applyInstallScope(repoRoot, 'cursor', scope, config);
85
87
  await writeRuntimeArtifacts('cursor', paths);
86
88
  const hooksFile = await loadHooksFile(paths.hooksSettingsPath);
87
89
  const merged = mergeCursorHooksFile(hooksFile, process.platform, paths.hooksDir, repoRoot);
88
90
  await mkdir(path.dirname(paths.hooksSettingsPath), { recursive: true });
89
91
  await writeFile(paths.hooksSettingsPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
92
+ if (scope === 'project') {
93
+ const globalPaths = resolveScopedPaths(cursorLayout, 'global', repoRoot);
94
+ if (existsSync(globalPaths.hooksSettingsPath)) {
95
+ const globalHooks = await loadHooksFile(globalPaths.hooksSettingsPath);
96
+ if (hasManagedCursorHookEntries(globalHooks, process.platform, globalPaths.hooksDir, repoRoot)) {
97
+ await writeRuntimeArtifacts('cursor', globalPaths);
98
+ const mergedGlobal = mergeCursorHooksFile(globalHooks, process.platform, globalPaths.hooksDir, repoRoot);
99
+ await writeFile(globalPaths.hooksSettingsPath, `${JSON.stringify(mergedGlobal, null, 2)}\n`, 'utf8');
100
+ }
101
+ }
102
+ }
90
103
  if (options.withSkill) {
91
104
  await writeSkillArtifacts('cursor', paths);
92
105
  }
106
+ const installedConfig = await applyInstallScope(repoRoot, 'cursor', scope, config);
107
+ if (scope === 'global') {
108
+ await cleanupStaleProjectCursorInstall(repoRoot);
109
+ }
93
110
  await writeIntegrityManifest(repoRoot, cursorLayout, runtimeIntegrityFiles(cursorLayout, paths));
94
- await archiveLegacyAuditLogIfNeeded(repoRoot, config);
111
+ await archiveLegacyAuditLogIfNeeded(repoRoot, installedConfig);
95
112
  return { repoRoot };
96
113
  }
97
114
  const BELAY_HOOK_ARTIFACTS = [
@@ -101,16 +118,34 @@ const BELAY_HOOK_ARTIFACTS = [
101
118
  'belay-audit.mjs',
102
119
  'belay-runner',
103
120
  'belay-runner.cmd',
121
+ 'belay-runner.ps1',
104
122
  ];
105
123
  async function removeBelayHookArtifacts(paths) {
106
124
  for (const fileName of BELAY_HOOK_ARTIFACTS) {
107
125
  await rm(path.join(paths.hooksDir, fileName), { force: true });
108
126
  }
109
- await rm(paths.runtimeDir, { recursive: true, force: true });
110
- await rm(paths.skillsDir, { recursive: true, force: true });
127
+ for (const fileName of ['core.mjs', 'dispatcher.mjs']) {
128
+ await rm(path.join(paths.runtimeDir, fileName), { force: true });
129
+ }
130
+ await rm(path.join(paths.skillsDir, 'SKILL.md'), { force: true });
111
131
  if (paths.commandsDir) {
112
- await rm(path.join(paths.commandsDir, 'belay.md'), { force: true });
132
+ for (const fileName of CURSOR_COMMAND_ARTIFACTS) {
133
+ await rm(path.join(paths.commandsDir, fileName), { force: true });
134
+ }
135
+ }
136
+ }
137
+ async function cleanupStaleProjectCursorInstall(repoRoot) {
138
+ const projectPaths = resolveScopedPaths(cursorLayout, 'project', repoRoot);
139
+ if (!existsSync(projectPaths.hooksSettingsPath)) {
140
+ return;
141
+ }
142
+ const projectHooks = await loadHooksFile(projectPaths.hooksSettingsPath);
143
+ if (!hasManagedCursorHookEntries(projectHooks, process.platform, projectPaths.hooksDir, repoRoot)) {
144
+ return;
113
145
  }
146
+ const stripped = stripCursorHooksFile(projectHooks, process.platform, projectPaths.hooksDir, repoRoot);
147
+ await writeFile(projectPaths.hooksSettingsPath, `${JSON.stringify(stripped, null, 2)}\n`, 'utf8');
148
+ await removeBelayHookArtifacts(projectPaths);
114
149
  }
115
150
  export async function uninstallCursorProject(options = {}) {
116
151
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
@@ -6,3 +6,4 @@ export interface NodeResolutionResult {
6
6
  export declare function resolveNodeBinary(): NodeResolutionResult;
7
7
  export declare function buildRunnerScript(defaultNodePath: string): string;
8
8
  export declare function buildWindowsRunnerScript(defaultNodePath: string): string;
9
+ export declare function buildWindowsPowerShellRunnerScript(defaultNodePath: string): string;
@@ -235,3 +235,64 @@ echo {}
235
235
  exit /b 0
236
236
  `;
237
237
  }
238
+ export function buildWindowsPowerShellRunnerScript(defaultNodePath) {
239
+ const encodedDefault = Buffer.from(defaultNodePath, 'utf8').toString('base64');
240
+ return `$ErrorActionPreference = 'Stop'
241
+
242
+ $DefaultNode = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedDefault}'))
243
+ $HookName = if ($args.Count -gt 0) { [string] $args[0] } else { '' }
244
+ $HookArguments = if ($args.Count -gt 1) { @($args[1..($args.Count - 1)]) } else { @() }
245
+
246
+ function Resolve-Node {
247
+ if ($DefaultNode -and (Test-Path -LiteralPath $DefaultNode -PathType Leaf)) {
248
+ return $DefaultNode
249
+ }
250
+
251
+ $PathNode = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue
252
+ if ($null -ne $PathNode) {
253
+ return $PathNode.Source
254
+ }
255
+
256
+ if ($env:USERPROFILE) {
257
+ $NvmRoot = Join-Path $env:USERPROFILE '.nvm'
258
+ if (Test-Path -LiteralPath $NvmRoot -PathType Container) {
259
+ foreach ($VersionDirectory in Get-ChildItem -LiteralPath $NvmRoot -Directory | Sort-Object Name -Descending) {
260
+ $Candidate = Join-Path $VersionDirectory.FullName 'node.exe'
261
+ if (Test-Path -LiteralPath $Candidate -PathType Leaf) {
262
+ return $Candidate
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ return $null
269
+ }
270
+
271
+ function Fail-Audit {
272
+ [Console]::Error.WriteLine('belay: unable to resolve Node for audit hook')
273
+ [Console]::Out.WriteLine('{}')
274
+ exit 0
275
+ }
276
+
277
+ if (-not $HookName) {
278
+ Fail-Audit
279
+ }
280
+
281
+ $NodeBin = Resolve-Node
282
+ if (-not $NodeBin) {
283
+ if ($HookName -eq 'belay-before-submit') {
284
+ [Console]::Out.WriteLine('{"continue":false,"user_message":"belay could not resolve Node. Install or expose Node, run belay doctor, then retry."}')
285
+ exit 0
286
+ }
287
+ if ($HookName -in @('belay-shell-gate', 'belay-tool-gate')) {
288
+ [Console]::Out.WriteLine('{"permission":"deny","user_message":"belay could not resolve Node. Install or expose Node, run belay doctor, then retry."}')
289
+ exit 0
290
+ }
291
+ Fail-Audit
292
+ }
293
+
294
+ $ScriptPath = Join-Path $PSScriptRoot "$HookName.mjs"
295
+ & $NodeBin $ScriptPath @HookArguments
296
+ exit $LASTEXITCODE
297
+ `;
298
+ }