@guilz-dev/belay 0.9.2 → 0.9.3

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 (55) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/codex/runtime-entry.d.ts +3 -0
  3. package/dist/adapters/codex/runtime-entry.js +27 -4
  4. package/dist/adapters/cursor/cwd-resolution.d.ts +10 -0
  5. package/dist/adapters/cursor/cwd-resolution.js +58 -0
  6. package/dist/adapters/cursor/hooks.d.ts +1 -0
  7. package/dist/adapters/cursor/hooks.js +21 -0
  8. package/dist/adapters/cursor/runtime-entry.d.ts +1 -0
  9. package/dist/adapters/cursor/runtime-entry.js +107 -6
  10. package/dist/adapters/shared/gate-runtime.js +26 -3
  11. package/dist/adapters/shared/repo-root.js +20 -1
  12. package/dist/bundle/claude-runtime.mjs +855 -338
  13. package/dist/bundle/codex-runtime.mjs +877 -342
  14. package/dist/bundle/cursor-runtime.mjs +3886 -3141
  15. package/dist/cli.js +33 -3
  16. package/dist/commands/doctor.js +24 -1
  17. package/dist/commands/health-snapshot.d.ts +3 -0
  18. package/dist/commands/health-snapshot.js +56 -0
  19. package/dist/commands/report.js +14 -0
  20. package/dist/commands/status.js +15 -0
  21. package/dist/commands/where.d.ts +4 -0
  22. package/dist/commands/where.js +52 -0
  23. package/dist/core/approval-repo-lookup.d.ts +16 -0
  24. package/dist/core/approval-repo-lookup.js +48 -0
  25. package/dist/core/audit-io.d.ts +1 -1
  26. package/dist/core/audit-io.js +1 -1
  27. package/dist/core/audit-query.d.ts +1 -0
  28. package/dist/core/audit-query.js +7 -0
  29. package/dist/core/audit-serialize.d.ts +3 -0
  30. package/dist/core/audit-serialize.js +39 -3
  31. package/dist/core/audit-summary.d.ts +9 -0
  32. package/dist/core/audit-summary.js +58 -1
  33. package/dist/core/audit-types.d.ts +4 -0
  34. package/dist/core/effect-ir/shell-lower.js +93 -40
  35. package/dist/core/replay-scrub.d.ts +1 -0
  36. package/dist/core/replay-scrub.js +22 -3
  37. package/dist/core/shell-tokenizer.d.ts +28 -0
  38. package/dist/core/shell-tokenizer.js +111 -29
  39. package/dist/core/verdict/docker-compose-run.d.ts +18 -0
  40. package/dist/core/verdict/docker-compose-run.js +136 -0
  41. package/dist/core/verdict/launcher-resolve.js +28 -28
  42. package/dist/core/verdict/parser.d.ts +3 -0
  43. package/dist/core/verdict/parser.js +23 -40
  44. package/dist/core/verdict/recursive-invocation.d.ts +20 -0
  45. package/dist/core/verdict/recursive-invocation.js +224 -0
  46. package/dist/defaults.js +7 -0
  47. package/dist/installer/scope-config.d.ts +2 -2
  48. package/dist/installer.d.ts +10 -1
  49. package/dist/installer.js +43 -2
  50. package/dist/types.d.ts +34 -1
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +5 -2
  54. package/skills/belay/SKILL.md +5 -0
  55. package/skills/belay/belay-report.md +4 -1
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { expandMakeExpression, normalizeMakeRecipeLine, parseMakefileVariables, parsePhonyTargets, } from './makefile-expand.js';
3
+ import { expandMakeExpression, normalizeMakeRecipeLine, parseMakefileVariables, } from './makefile-expand.js';
4
4
  const MAX_RESOLVE_DEPTH = 8;
5
5
  const PNPM_BUILTIN_COMMANDS = new Set([
6
6
  'add',
@@ -229,7 +229,6 @@ function resolveMakeRecipe(cwd, repoRoot, target, cliVars = {}) {
229
229
  }
230
230
  const makefileContent = readFileSync(makefilePath, 'utf8');
231
231
  const makefileVars = parseMakefileVariables(makefileContent);
232
- const phonyTargets = parsePhonyTargets(makefileContent);
233
232
  const targets = parseMakefileRecipeContent(makefileContent);
234
233
  if (!targets.has(target)) {
235
234
  return { recipes: [], opaque: true, reason: 'make_target_undefined' };
@@ -237,42 +236,34 @@ function resolveMakeRecipe(cwd, repoRoot, target, cliVars = {}) {
237
236
  const recipeLines = [];
238
237
  const visiting = new Set();
239
238
  const visited = new Set();
240
- let opaquePrerequisites = false;
239
+ let hasDynamicPrerequisite = false;
240
+ let hasUndefinedPrerequisite = false;
241
+ let hasDependencyCycle = false;
241
242
  const collect = (name) => {
242
243
  if (visited.has(name)) {
243
- return true;
244
+ return;
244
245
  }
245
246
  if (visiting.has(name)) {
246
- return false;
247
+ hasDependencyCycle = true;
248
+ return;
247
249
  }
248
250
  const entry = targets.get(name);
249
251
  if (!entry) {
250
- return true;
252
+ if (!existsSync(path.resolve(path.dirname(makefilePath), name))) {
253
+ hasUndefinedPrerequisite = true;
254
+ }
255
+ return;
251
256
  }
252
257
  visiting.add(name);
253
- opaquePrerequisites ||= entry.opaquePrerequisites;
258
+ hasDynamicPrerequisite ||= entry.opaquePrerequisites;
254
259
  for (const prerequisite of entry.prerequisites) {
255
- if (targets.has(prerequisite) && !collect(prerequisite)) {
256
- return false;
257
- }
258
- }
259
- // When the requested target has its own recipes, treat those as the
260
- // authorization authority. Skip .PHONY / `_`-prefixed prerequisite
261
- // recipes (e.g. `_start_test_deps` docker-compose up) so setup-only
262
- // side effects do not obscure the target's direct command.
263
- const skipPhonyPrerequisiteRecipes = name !== target &&
264
- (phonyTargets.has(name) || name.startsWith('_')) &&
265
- (targets.get(target)?.recipes.length ?? 0) > 0;
266
- if (!skipPhonyPrerequisiteRecipes) {
267
- recipeLines.push(...entry.recipes);
260
+ collect(prerequisite);
268
261
  }
262
+ recipeLines.push(...entry.recipes);
269
263
  visiting.delete(name);
270
264
  visited.add(name);
271
- return true;
272
265
  };
273
- if (!collect(target)) {
274
- return { recipes: recipeLines, opaque: true, reason: 'make_dependency_cycle' };
275
- }
266
+ collect(target);
276
267
  const expandedRecipes = [];
277
268
  for (const line of recipeLines) {
278
269
  const normalized = normalizeMakeRecipeLine(line);
@@ -287,18 +278,24 @@ function resolveMakeRecipe(cwd, repoRoot, target, cliVars = {}) {
287
278
  return { recipes: expandedRecipes, opaque: true, reason: 'make_recipe_dynamic' };
288
279
  }
289
280
  }
290
- if (opaquePrerequisites) {
281
+ if (hasDependencyCycle) {
282
+ return { recipes: expandedRecipes, opaque: true, reason: 'make_dependency_cycle' };
283
+ }
284
+ if (hasDynamicPrerequisite) {
291
285
  return { recipes: expandedRecipes, opaque: true, reason: 'make_prerequisite_dynamic' };
292
286
  }
287
+ if (hasUndefinedPrerequisite) {
288
+ return { recipes: expandedRecipes, opaque: true, reason: 'make_prerequisite_undefined' };
289
+ }
293
290
  return { recipes: expandedRecipes, opaque: false, reason: 'make_recipe_resolved' };
294
291
  }
295
292
  export function resolveLauncherRecipe(params) {
296
- if (params.depth >= MAX_RESOLVE_DEPTH) {
297
- return { recipes: [], opaque: true, reason: 'launcher_depth_exceeded' };
298
- }
299
293
  const tokens = params.tokens;
300
294
  const scriptName = npmScriptName(tokens);
301
295
  if (scriptName) {
296
+ if (params.depth >= MAX_RESOLVE_DEPTH) {
297
+ return { recipes: [], opaque: true, reason: 'launcher_depth_exceeded' };
298
+ }
302
299
  const resolution = resolveNpmRecipe(params.cwd, params.repoRoot, scriptName, forwardedArgs(tokens));
303
300
  if (tokens[0] === 'pnpm' &&
304
301
  tokens[1] &&
@@ -313,6 +310,9 @@ export function resolveLauncherRecipe(params) {
313
310
  return resolution;
314
311
  }
315
312
  if (tokens[0] === 'make') {
313
+ if (params.depth >= MAX_RESOLVE_DEPTH) {
314
+ return { recipes: [], opaque: true, reason: 'launcher_depth_exceeded' };
315
+ }
316
316
  if (tokens.includes('-n') || tokens.includes('--dry-run')) {
317
317
  return null;
318
318
  }
@@ -1,3 +1,5 @@
1
+ import { type ShellToken } from '../shell-tokenizer.js';
2
+ import { type RecursiveInvocation } from './recursive-invocation.js';
1
3
  import type { VerdictOpacity } from './types.js';
2
4
  export interface ParsedSegment {
3
5
  tokens: string[];
@@ -16,6 +18,7 @@ export declare function peelTransparentWrappers(tokens: string[]): {
16
18
  export declare function isVariableIndirectHead(head: string): boolean;
17
19
  export declare function extractEvalBody(tokens: string[]): string | null;
18
20
  export declare function extractRecursiveScript(tokens: string[]): string | null;
21
+ export declare function decodeRecursiveInvocationTokens(tokens: readonly ShellToken[]): RecursiveInvocation;
19
22
  export declare function extractDockerComposeRunScript(tokens: string[]): string | null;
20
23
  /**
21
24
  * True when a recursive script is evaluated from a command argument rather
@@ -2,11 +2,12 @@ import path from 'node:path';
2
2
  import { findCommandSubstitutions, findStructuralCommandSubstitutions, } from '../shell-substitution.js';
3
3
  import { commandKey, tokenizeShell } from '../shell-tokenizer.js';
4
4
  import { detectUnparseableShell } from '../shell-unparseable.js';
5
+ import { decodeDockerComposeRunValues } from './docker-compose-run.js';
6
+ import { decodeRecursiveInvocation, shellTokensFromValues, } from './recursive-invocation.js';
5
7
  const ENV_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S+)$/;
6
8
  const MAX_WRAPPER_PEEL_DEPTH = 32;
7
9
  const SHELL_INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'dash', 'fish']);
8
10
  const CODE_INTERPRETERS = new Set(['python', 'python3', 'node', 'ruby', 'perl', 'osascript']);
9
- const SCRIPT_FLAGS = new Set(['-c', '-lc', '-e', '--eval']);
10
11
  const INTERPRETER_SCRIPT_EXTENSIONS = new Set([
11
12
  '.js',
12
13
  '.mjs',
@@ -30,10 +31,6 @@ export function peelTransparentWrappers(tokens) {
30
31
  let encounteredXargs = false;
31
32
  let peelDepth = 0;
32
33
  while (current.length > 0) {
33
- if (peelDepth >= MAX_WRAPPER_PEEL_DEPTH) {
34
- return { tokens: current, xargsStdinOpaque: false, encounteredXargs, opaque: true };
35
- }
36
- peelDepth += 1;
37
34
  while (current.length > 0 && ENV_PREFIX_PATTERN.test(current[0] ?? '')) {
38
35
  current.shift();
39
36
  }
@@ -43,6 +40,10 @@ export function peelTransparentWrappers(tokens) {
43
40
  const head = normalizeHead(current[0] ?? '');
44
41
  if (head === 'xargs') {
45
42
  encounteredXargs = true;
43
+ if (peelDepth >= MAX_WRAPPER_PEEL_DEPTH) {
44
+ return { tokens: current, xargsStdinOpaque: false, encounteredXargs, opaque: true };
45
+ }
46
+ peelDepth += 1;
46
47
  const wrapper = peelXargsWrapper(current);
47
48
  if (wrapper.kind === 'opaque') {
48
49
  xargsStdinOpaque = current.length === 1;
@@ -60,6 +61,10 @@ export function peelTransparentWrappers(tokens) {
60
61
  if (!wrapper) {
61
62
  break;
62
63
  }
64
+ if (peelDepth >= MAX_WRAPPER_PEEL_DEPTH) {
65
+ return { tokens: current, xargsStdinOpaque: false, encounteredXargs, opaque: true };
66
+ }
67
+ peelDepth += 1;
63
68
  if (wrapper.kind === 'opaque') {
64
69
  return { tokens: current, xargsStdinOpaque: false, encounteredXargs, opaque: true };
65
70
  }
@@ -304,39 +309,19 @@ export function extractRecursiveScript(tokens) {
304
309
  const body = filtered.slice(1).join(' ').trim();
305
310
  return body || null;
306
311
  }
307
- if (SHELL_INTERPRETERS.has(head) || CODE_INTERPRETERS.has(head)) {
308
- const flagIndex = filtered.findIndex((token) => SCRIPT_FLAGS.has(token));
309
- if (flagIndex !== -1) {
310
- const body = filtered[flagIndex + 1] ?? '';
311
- return body || null;
312
- }
313
- }
314
- return null;
312
+ const invocation = decodeRecursiveInvocation(shellTokensFromValues(filtered, { detectExpansion: false }));
313
+ return invocation.kind === 'static' ? invocation.script || null : null;
314
+ }
315
+ export function decodeRecursiveInvocationTokens(tokens) {
316
+ const values = tokens.map((token) => token.value);
317
+ const { tokens: filtered, opaque } = peelTransparentWrappers(values);
318
+ if (opaque)
319
+ return { kind: 'none' };
320
+ return decodeRecursiveInvocation(tokens.slice(values.length - filtered.length));
315
321
  }
316
322
  export function extractDockerComposeRunScript(tokens) {
317
- const head = normalizeHead(tokens[0] ?? '');
318
- const usesCompose = head === 'docker-compose' || (head === 'docker' && (tokens[1] ?? '') === 'compose');
319
- if (!usesCompose) {
320
- return null;
321
- }
322
- if (!tokens.includes('run')) {
323
- return null;
324
- }
325
- const runIndex = tokens.indexOf('run');
326
- const tail = tokens.slice(runIndex + 1);
327
- for (let index = 0; index < tail.length; index += 1) {
328
- const shellHead = normalizeHead(tail[index] ?? '');
329
- if (!SHELL_INTERPRETERS.has(shellHead)) {
330
- continue;
331
- }
332
- const flag = tail[index + 1] ?? '';
333
- if (flag !== '-lc' && flag !== '-c') {
334
- continue;
335
- }
336
- const body = tail[index + 2] ?? '';
337
- return body || null;
338
- }
339
- return null;
323
+ const invocation = decodeDockerComposeRunValues(tokens);
324
+ return invocation.kind === 'recursive' ? invocation.script || null : null;
340
325
  }
341
326
  /**
342
327
  * True when a recursive script is evaluated from a command argument rather
@@ -352,8 +337,7 @@ export function isDynamicRecursiveEvaluation(tokens) {
352
337
  if (head === 'eval') {
353
338
  return true;
354
339
  }
355
- return ((SHELL_INTERPRETERS.has(head) || CODE_INTERPRETERS.has(head)) &&
356
- filtered.some((token) => SCRIPT_FLAGS.has(token)));
340
+ return decodeRecursiveInvocation(shellTokensFromValues(filtered)).kind !== 'none';
357
341
  }
358
342
  export function isCommandInspection(tokens) {
359
343
  return (normalizeHead(tokens[0] ?? '') === 'command' && peelCommandWrapper(tokens).kind === 'preserve');
@@ -370,8 +354,7 @@ export function isBareInterpreter(tokens) {
370
354
  if (!SHELL_INTERPRETERS.has(head) && !CODE_INTERPRETERS.has(head)) {
371
355
  return false;
372
356
  }
373
- const hasScriptFlag = peeled.some((token) => SCRIPT_FLAGS.has(token));
374
- if (hasScriptFlag) {
357
+ if (decodeRecursiveInvocation(shellTokensFromValues(peeled)).kind !== 'none') {
375
358
  return false;
376
359
  }
377
360
  const args = peeled.slice(1);
@@ -0,0 +1,20 @@
1
+ import type { ShellToken } from '../shell-tokenizer.js';
2
+ export type RecursiveInvocation = {
3
+ kind: 'static';
4
+ interpreter: string;
5
+ script: string;
6
+ } | {
7
+ kind: 'dynamic';
8
+ interpreter: string;
9
+ signal: 'shell.script_expanded';
10
+ } | {
11
+ kind: 'none';
12
+ } | {
13
+ kind: 'indeterminate';
14
+ interpreter: string;
15
+ signal: 'shell.interpreter_argv_incomplete' | 'shell.interpreter_option_unknown';
16
+ };
17
+ export declare function decodeRecursiveInvocation(tokens: readonly ShellToken[]): RecursiveInvocation;
18
+ export declare function shellTokensFromValues(values: readonly string[], options?: {
19
+ detectExpansion?: boolean;
20
+ }): ShellToken[];
@@ -0,0 +1,224 @@
1
+ import path from 'node:path';
2
+ const SHELL_INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'dash', 'fish']);
3
+ const PYTHON_INTERPRETERS = new Set(['python', 'python3']);
4
+ const SHELL_SHORT_OPTIONS = new Set(['c', 'l', 'e', 'x', 'u']);
5
+ const SHELL_NON_SCRIPT_SHORT_OPTIONS = new Set(['n']);
6
+ const SHELL_TERMINAL_OPTIONS = new Map([
7
+ ['bash', new Set(['--help', '--version'])],
8
+ ['zsh', new Set(['--version'])],
9
+ ['fish', new Set(['-h', '--help', '-v', '--version'])],
10
+ ]);
11
+ const SHELL_VALUE_OPTIONS = new Set(['-O', '+O', '--init-file', '--rcfile']);
12
+ const NODE_TERMINAL_OPTIONS = new Set(['-h', '--help', '--help-all', '-v', '--version']);
13
+ const NODE_FILE_OPTIONS = new Set(['-c', '--check']);
14
+ const PYTHON_PROFILE = {
15
+ scriptOptions: new Set(['-c']),
16
+ terminalOptions: new Set(['-h', '--help', '-V', '-VV', '--version']),
17
+ terminalValueOptions: new Set(['-m']),
18
+ flagOptions: new Set([
19
+ '-b',
20
+ '-bb',
21
+ '-B',
22
+ '-d',
23
+ '-E',
24
+ '-I',
25
+ '-O',
26
+ '-OO',
27
+ '-P',
28
+ '-q',
29
+ '-s',
30
+ '-S',
31
+ '-u',
32
+ '-v',
33
+ '-x',
34
+ ]),
35
+ valueOptions: new Set(['-W', '-X']),
36
+ attachedValuePrefixes: ['-W', '-X'],
37
+ };
38
+ const RUBY_PROFILE = {
39
+ scriptOptions: new Set(['-e']),
40
+ terminalOptions: new Set(['-h', '--help', '-v', '--version', '--copyright']),
41
+ terminalValueOptions: new Set([]),
42
+ flagOptions: new Set(['-d', '--debug', '-w']),
43
+ valueOptions: new Set(['-I']),
44
+ attachedValuePrefixes: ['-I'],
45
+ };
46
+ const PERL_PROFILE = {
47
+ scriptOptions: new Set(['-e']),
48
+ terminalOptions: new Set(['-h', '--help', '-v', '--version']),
49
+ terminalValueOptions: new Set([]),
50
+ flagOptions: new Set([]),
51
+ valueOptions: new Set(['-I']),
52
+ attachedValuePrefixes: ['-I'],
53
+ };
54
+ const OSASCRIPT_PROFILE = {
55
+ scriptOptions: new Set(['-e']),
56
+ terminalOptions: new Set(['-h', '--help']),
57
+ terminalValueOptions: new Set([]),
58
+ flagOptions: new Set([]),
59
+ valueOptions: new Set(['-l']),
60
+ attachedValuePrefixes: [],
61
+ };
62
+ function normalizeInterpreter(value) {
63
+ return path.basename(value);
64
+ }
65
+ function scriptResult(interpreter, token) {
66
+ if (!token) {
67
+ return { kind: 'indeterminate', interpreter, signal: 'shell.interpreter_argv_incomplete' };
68
+ }
69
+ if (token.parts.some((part) => part.hasExpansion)) {
70
+ return { kind: 'dynamic', interpreter, signal: 'shell.script_expanded' };
71
+ }
72
+ return { kind: 'static', interpreter, script: token.value };
73
+ }
74
+ function decodeShell(words, interpreter) {
75
+ for (let index = 1; index < words.length; index += 1) {
76
+ const option = words[index]?.value ?? '';
77
+ if (option === '--')
78
+ return { kind: 'none' };
79
+ if (SHELL_TERMINAL_OPTIONS.get(interpreter)?.has(option))
80
+ return { kind: 'none' };
81
+ if (interpreter === 'bash' && SHELL_VALUE_OPTIONS.has(option)) {
82
+ const operand = words[index + 1]?.value;
83
+ if (!operand || operand.startsWith('-')) {
84
+ return { kind: 'indeterminate', interpreter, signal: 'shell.interpreter_option_unknown' };
85
+ }
86
+ index += 1;
87
+ continue;
88
+ }
89
+ if (!option.startsWith('-') || option === '-')
90
+ return { kind: 'none' };
91
+ const flags = [...option.slice(1)];
92
+ if (flags.length === 0) {
93
+ return { kind: 'indeterminate', interpreter, signal: 'shell.interpreter_option_unknown' };
94
+ }
95
+ if (flags.every((flag) => SHELL_SHORT_OPTIONS.has(flag))) {
96
+ if (!flags.includes('c'))
97
+ continue;
98
+ return scriptResult(interpreter, words[index + 1]);
99
+ }
100
+ if (flags.every((flag) => SHELL_SHORT_OPTIONS.has(flag) || SHELL_NON_SCRIPT_SHORT_OPTIONS.has(flag)) &&
101
+ flags.some((flag) => SHELL_NON_SCRIPT_SHORT_OPTIONS.has(flag))) {
102
+ return { kind: 'none' };
103
+ }
104
+ return { kind: 'indeterminate', interpreter, signal: 'shell.interpreter_option_unknown' };
105
+ }
106
+ return { kind: 'none' };
107
+ }
108
+ function decodeSeparated(words, interpreter, profile) {
109
+ for (let index = 1; index < words.length; index += 1) {
110
+ const option = words[index]?.value ?? '';
111
+ if (option === '--' || !option.startsWith('-') || option === '-')
112
+ return { kind: 'none' };
113
+ if (profile.scriptOptions.has(option)) {
114
+ return scriptResult(interpreter, words[index + 1]);
115
+ }
116
+ if (profile.terminalOptions.has(option))
117
+ return { kind: 'none' };
118
+ if (profile.terminalValueOptions.has(option)) {
119
+ const operand = words[index + 1]?.value;
120
+ if (!operand || operand.startsWith('-')) {
121
+ return { kind: 'indeterminate', interpreter, signal: 'shell.interpreter_option_unknown' };
122
+ }
123
+ return { kind: 'none' };
124
+ }
125
+ if (profile.flagOptions.has(option)) {
126
+ continue;
127
+ }
128
+ if (profile.valueOptions.has(option)) {
129
+ const operand = words[index + 1]?.value;
130
+ if (!operand || operand.startsWith('-')) {
131
+ return { kind: 'indeterminate', interpreter, signal: 'shell.interpreter_option_unknown' };
132
+ }
133
+ index += 1;
134
+ continue;
135
+ }
136
+ if (profile.attachedValuePrefixes.some((prefix) => option.startsWith(prefix) && option.length > prefix.length)) {
137
+ continue;
138
+ }
139
+ return { kind: 'indeterminate', interpreter, signal: 'shell.interpreter_option_unknown' };
140
+ }
141
+ return { kind: 'none' };
142
+ }
143
+ function decodeNode(words, interpreter) {
144
+ const option = words[1]?.value ?? '';
145
+ if (option === '--' || !option.startsWith('-') || option === '-')
146
+ return { kind: 'none' };
147
+ if (option === '-e' || option === '--eval') {
148
+ return scriptResult(interpreter, words[2]);
149
+ }
150
+ if (option.startsWith('--eval=')) {
151
+ const script = option.slice('--eval='.length);
152
+ if (words[1]?.parts.some((part) => part.hasExpansion)) {
153
+ return { kind: 'dynamic', interpreter, signal: 'shell.script_expanded' };
154
+ }
155
+ return { kind: 'static', interpreter, script };
156
+ }
157
+ if (NODE_TERMINAL_OPTIONS.has(option))
158
+ return { kind: 'none' };
159
+ if (NODE_FILE_OPTIONS.has(option))
160
+ return { kind: 'none' };
161
+ return { kind: 'indeterminate', interpreter, signal: 'shell.interpreter_option_unknown' };
162
+ }
163
+ function decodeEval(words) {
164
+ const arguments_ = words.slice(1);
165
+ if (arguments_.length === 0)
166
+ return { kind: 'none' };
167
+ if (arguments_.some((word) => word.parts.some((part) => part.hasExpansion))) {
168
+ return { kind: 'dynamic', interpreter: 'eval', signal: 'shell.script_expanded' };
169
+ }
170
+ return {
171
+ kind: 'static',
172
+ interpreter: 'eval',
173
+ script: arguments_.map((word) => word.value).join(' '),
174
+ };
175
+ }
176
+ export function decodeRecursiveInvocation(tokens) {
177
+ if (tokens.some((token) => token.kind === 'operator'))
178
+ return { kind: 'none' };
179
+ const words = tokens.filter((token) => token.kind === 'word');
180
+ const interpreter = normalizeInterpreter(words[0]?.value ?? '');
181
+ if (!interpreter)
182
+ return { kind: 'none' };
183
+ if (interpreter === 'eval')
184
+ return decodeEval(words);
185
+ if (SHELL_INTERPRETERS.has(interpreter))
186
+ return decodeShell(words, interpreter);
187
+ if (PYTHON_INTERPRETERS.has(interpreter)) {
188
+ return decodeSeparated(words, interpreter, PYTHON_PROFILE);
189
+ }
190
+ if (interpreter === 'node')
191
+ return decodeNode(words, interpreter);
192
+ if (interpreter === 'ruby')
193
+ return decodeSeparated(words, interpreter, RUBY_PROFILE);
194
+ if (interpreter === 'perl')
195
+ return decodeSeparated(words, interpreter, PERL_PROFILE);
196
+ if (interpreter === 'osascript')
197
+ return decodeSeparated(words, interpreter, OSASCRIPT_PROFILE);
198
+ return { kind: 'none' };
199
+ }
200
+ export function shellTokensFromValues(values, options = {}) {
201
+ let offset = 0;
202
+ return values.map((value) => {
203
+ const start = offset;
204
+ const end = start + value.length;
205
+ offset = end + 1;
206
+ return {
207
+ kind: 'word',
208
+ value,
209
+ raw: value,
210
+ start,
211
+ end,
212
+ parts: [
213
+ {
214
+ value,
215
+ raw: value,
216
+ start,
217
+ end,
218
+ quote: 'unquoted',
219
+ hasExpansion: options.detectExpansion !== false && (value.includes('$') || value.includes('`')),
220
+ },
221
+ ],
222
+ };
223
+ });
224
+ }
package/dist/defaults.js CHANGED
@@ -123,6 +123,13 @@ export function getManagedHookEntries(platform = process.platform, hooksDir, rep
123
123
  placement: 'append',
124
124
  },
125
125
  },
126
+ {
127
+ event: 'postToolUseFailure',
128
+ definition: {
129
+ command: runnerCommand(platform, resolvedHooksDir, resolvedRepo, 'belay-audit', 'postToolUseFailure'),
130
+ placement: 'append',
131
+ },
132
+ },
126
133
  {
127
134
  event: 'stop',
128
135
  definition: {
@@ -1,8 +1,8 @@
1
1
  import { type ScopedPaths } from '../adapters/layouts/scope.js';
2
2
  import type { AdapterName } from '../adapters/layouts/types.js';
3
3
  import type { BelayConfigV4 } from '../core/config.js';
4
- import type { InitOptions, UpgradeOptions } from '../types.js';
4
+ import type { InitOptions, UninstallOptions, UpgradeOptions } from '../types.js';
5
5
  export type OperationScope = 'project' | 'global';
6
- export declare function resolveOperationScope(repoRoot: string, adapter: AdapterName, options?: InitOptions | UpgradeOptions): Promise<OperationScope>;
6
+ export declare function resolveOperationScope(repoRoot: string, adapter: AdapterName, options?: InitOptions | UpgradeOptions | UninstallOptions): Promise<OperationScope>;
7
7
  export declare function applyInstallScope(repoRoot: string, adapter: AdapterName, scope: OperationScope, config?: BelayConfigV4): Promise<BelayConfigV4>;
8
8
  export declare function pathsForOperation(adapter: AdapterName, scope: OperationScope, repoRoot: string): ScopedPaths;
@@ -1,5 +1,5 @@
1
1
  import type { AdapterName } from './adapters/layouts/types.js';
2
- import type { HooksFile, InitOptions, UpgradeOptions } from './types.js';
2
+ import type { HooksFile, InitOptions, UninstallOptions, UpgradeOptions } from './types.js';
3
3
  export type { InstallScope } from './adapters/layouts/scope.js';
4
4
  export declare function loadHooksFile(hooksPath: string): Promise<HooksFile>;
5
5
  export declare function mergeHooksFile(current: HooksFile, platform?: NodeJS.Platform, hooksDir?: string, repoRoot?: string): HooksFile;
@@ -10,6 +10,10 @@ export declare function initCursorProject(options?: InitOptions): Promise<{
10
10
  export declare function upgradeCursorProject(options?: UpgradeOptions): Promise<{
11
11
  repoRoot: string;
12
12
  }>;
13
+ export declare function uninstallCursorProject(options?: UninstallOptions): Promise<{
14
+ repoRoot: string;
15
+ scope: 'project' | 'global';
16
+ }>;
13
17
  export declare function initProject(options?: InitOptions): Promise<{
14
18
  repoRoot: string;
15
19
  withSkill: boolean;
@@ -20,3 +24,8 @@ export declare function upgradeProject(options?: UpgradeOptions): Promise<{
20
24
  repoRoot: string;
21
25
  adapter: AdapterName;
22
26
  }>;
27
+ export declare function uninstallProject(options?: UninstallOptions): Promise<{
28
+ repoRoot: string;
29
+ adapter: AdapterName;
30
+ scope: 'project' | 'global';
31
+ }>;
package/dist/installer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { mergeCursorHooksFile } from './adapters/cursor/hooks.js';
4
+ import { 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';
@@ -94,6 +94,38 @@ export async function upgradeCursorProject(options = {}) {
94
94
  await archiveLegacyAuditLogIfNeeded(repoRoot, config);
95
95
  return { repoRoot };
96
96
  }
97
+ const BELAY_HOOK_ARTIFACTS = [
98
+ 'belay-before-submit.mjs',
99
+ 'belay-shell-gate.mjs',
100
+ 'belay-tool-gate.mjs',
101
+ 'belay-audit.mjs',
102
+ 'belay-runner',
103
+ 'belay-runner.cmd',
104
+ ];
105
+ async function removeBelayHookArtifacts(paths) {
106
+ for (const fileName of BELAY_HOOK_ARTIFACTS) {
107
+ await rm(path.join(paths.hooksDir, fileName), { force: true });
108
+ }
109
+ await rm(paths.runtimeDir, { recursive: true, force: true });
110
+ await rm(paths.skillsDir, { recursive: true, force: true });
111
+ if (paths.commandsDir) {
112
+ await rm(path.join(paths.commandsDir, 'belay.md'), { force: true });
113
+ }
114
+ }
115
+ export async function uninstallCursorProject(options = {}) {
116
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
117
+ const scope = await resolveOperationScope(repoRoot, 'cursor', options);
118
+ const paths = resolveScopedPaths(cursorLayout, scope, repoRoot);
119
+ const hooksSettingsExisted = existsSync(paths.hooksSettingsPath);
120
+ const hooksFile = await loadHooksFile(paths.hooksSettingsPath);
121
+ const stripped = stripCursorHooksFile(hooksFile, process.platform, paths.hooksDir, repoRoot);
122
+ if (hooksSettingsExisted) {
123
+ await mkdir(path.dirname(paths.hooksSettingsPath), { recursive: true });
124
+ await writeFile(paths.hooksSettingsPath, `${JSON.stringify(stripped, null, 2)}\n`, 'utf8');
125
+ }
126
+ await removeBelayHookArtifacts(paths);
127
+ return { repoRoot, scope };
128
+ }
97
129
  function resolveAdapterName(options, repoRoot) {
98
130
  if (options.adapter === 'claude') {
99
131
  return 'claude';
@@ -225,3 +257,12 @@ export async function upgradeProject(options = {}) {
225
257
  await refreshIntegrityManifest(repoRoot, adapterName);
226
258
  return { repoRoot, adapter: adapterName };
227
259
  }
260
+ export async function uninstallProject(options = {}) {
261
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
262
+ const adapterName = resolveAdapterName(options, repoRoot);
263
+ if (adapterName !== 'cursor') {
264
+ throw new Error(`belay uninstall is only supported for Cursor adapter today. Use --adapter cursor or uninstall hooks manually for ${adapterName}.`);
265
+ }
266
+ const result = await uninstallCursorProject({ ...options, targetDir: repoRoot });
267
+ return { repoRoot: result.repoRoot, adapter: adapterName, scope: result.scope };
268
+ }
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type { BelayConfig, BelayConfigV1, BelayConfigV2, BelayConfigV3, BelayControlPlaneConfig, BelayEgressConfig, BelayOverridesConfig, BelayPolicyConfig, BelayRedactionConfig, BelaySandboxConfig, BelayTransactionalConfig, UnknownLocalEffectPolicy, } from './core/config.js';
2
2
  export type { ApprovalRecord, ApprovalStateFile, Assessment, BelayMode, ClassifyResult, HookVerdict, } from './core/types.js';
3
3
  import type { InstallScope } from './adapters/layouts/scope.js';
4
- import type { RecentAskEntry } from './core/audit-summary.js';
4
+ import type { RecentAskEntry, RecentHostDenialEntry } from './core/audit-summary.js';
5
5
  import type { BelayEgressConfig, BelayOverridesConfig, BelayPolicyConfig, BelaySandboxConfig } from './core/config.js';
6
6
  import type { ApprovalRecord, ClassifyResult } from './core/types.js';
7
7
  export interface HookEntry {
@@ -42,6 +42,32 @@ export interface UpgradeOptions {
42
42
  /** Opt-in: migrate implicit factory-default ollama judge to host default provider. */
43
43
  migrateJudgeDefault?: boolean;
44
44
  }
45
+ export interface UninstallOptions {
46
+ targetDir?: string;
47
+ adapter?: AdapterName;
48
+ scope?: InstallScope;
49
+ }
50
+ export interface WhereOptions {
51
+ targetDir?: string;
52
+ adapter?: AdapterName;
53
+ scope?: InstallScope;
54
+ json?: boolean;
55
+ }
56
+ export interface WhereReport {
57
+ cwd: string;
58
+ repoRoot: string;
59
+ adapter: AdapterName;
60
+ installScope: 'project' | 'global';
61
+ configPresent: boolean;
62
+ cliExecutable?: string;
63
+ cliPackageRoot: string;
64
+ configPath: string;
65
+ hooksSettingsPath: string;
66
+ hooksDir: string;
67
+ runtimeDir: string;
68
+ skillsDir: string;
69
+ commandsDir?: string;
70
+ }
45
71
  export interface DoctorOptions {
46
72
  targetDir?: string;
47
73
  fix?: boolean;
@@ -88,6 +114,10 @@ export interface StatusOptions {
88
114
  export interface HealthSnapshotOptions {
89
115
  targetDir?: string;
90
116
  adapter?: AdapterName;
117
+ /** Override the host home directory when inspecting adapter-level execution settings. */
118
+ homeDir?: string;
119
+ /** Override Cursor config-path environment variables for deterministic host inspection. */
120
+ cursorConfigEnv?: Partial<Pick<NodeJS.ProcessEnv, 'CURSOR_CONFIG_DIR' | 'XDG_CONFIG_HOME'>>;
91
121
  }
92
122
  export interface HealthSnapshot {
93
123
  repoRoot: string;
@@ -172,6 +202,9 @@ export interface AuditVisibilityReport {
172
202
  allowCount: number;
173
203
  silentPassRate: number;
174
204
  recentAsks: RecentAskEntry[];
205
+ hostDeniedAfterAllowCount?: number;
206
+ recentHostDenials?: RecentHostDenialEntry[];
207
+ unrecognizedHostFailureCount?: number;
175
208
  warnings: string[];
176
209
  notes: string[];
177
210
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const PACKAGE_VERSION = "0.9.2";
1
+ export declare const PACKAGE_VERSION = "0.9.3";