Skip to content

Commit 4d45ad9

Browse files
fix(doctor): resolve bare relative hook scripts and PowerShell -File operands
1 parent 3ffa025 commit 4d45ad9

3 files changed

Lines changed: 51 additions & 18 deletions

File tree

‎docs/diagnostics.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ that registration statically and never writes `~/.cursor/hooks.json`.
712712

713713
| Code | Severity | Meaning | Recovery |
714714
| --- | --- | --- | --- |
715-
| `AB7321` | info / error | Info: an installed `.cursor-plugin/plugin.json` plugin registers plugin-scoped hooks (events and command count listed) and every `${CURSOR_PLUGIN_ROOT}`-relative script exists (`hooks.state = registered`). Error: the declared hooks file is missing (`missing`), is not a `{ version, hooks: { <event>: [{ command }] } }` document, or a referenced script is absent (`stale`). | Reinstall the plugin from a bundle whose emitted hooks document and scripts are intact. |
715+
| `AB7321` | info / error | Info: an installed `.cursor-plugin/plugin.json` plugin registers plugin-scoped hooks (events and command count listed) and the script each command executes — `${CURSOR_PLUGIN_ROOT}/…` or any relative path, including an interpreter's entry operand — exists under the plugin root (`hooks.state = registered`). Error: the declared hooks file is missing (`missing`), is not a `{ version, hooks: { <event>: [{ command }] } }` document, or an executed script is absent (`stale`). | Reinstall the plugin from a bundle whose emitted hooks document and scripts are intact. |
716716
| `AB7322` | warning | `~/.cursor/hooks.json` registers a command that points into an installed plugin directory (Cursor would deliver that hook twice), or the file is not a valid hooks document. | Remove the plugin-pointing entries or repair the file; manifest registration alone is sufficient. |
717717
| `AB7323` | info / warning / error | A staged marketplace repository under `~/.cursor/agent-bundle/marketplaces/<name>` (from `install cursor --mode marketplace`) is imported by Cursor (matching plugin under `~/.cursor/plugins/cache`; info, `registered`), still awaiting the Customize "Add Plugins from Local Repository" step (warning, `unregistered`), or incomplete (error, `corrupt`: manifests missing or failing the pinned schemas, no resolvable Git HEAD, HEAD naming a commit object that does not exist, or a working tree that differs from committed HEAD — verified read-only through `git cat-file -e` / `git --no-optional-locks status` when `git` is available). | Complete the Customize import, use `--mode local`, or remove the staged directory and rerun the installer. |
718718

‎packages/agent-bundle/src/install/cursor-hooks-registration.ts‎

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -142,39 +142,58 @@ const inlineSourceOptions = new Set(['--eval', '--print', '-Command', '-c', '-co
142142
/** Interpreter options that consume the next token (a path or value), which is therefore not the entry script. */
143143
const valueOptions = new Set([
144144
'--conditions', '--define', '--env-file', '--experimental-loader', '--import', '--input-type', '--loader',
145-
'--preload', '--require', '--stack-trace-limit', '--title', '-C', '-ExecutionPolicy', '-File', '-I', '-W', '-X',
145+
'--preload', '--require', '--stack-trace-limit', '--title', '-C', '-ExecutionPolicy', '-I', '-W', '-X',
146146
'-d', '-o', '-r',
147147
]);
148148

149+
const powerShell = new Set(['powershell', 'pwsh']);
150+
151+
interface ExecutedPath {
152+
/** `executable` is the command word itself (PATH lookup unless it names a path); `operand` is a script argument. */
153+
readonly kind: 'executable' | 'operand';
154+
readonly token: string;
155+
}
156+
149157
/**
150158
* The file a hook command executes: the executable token itself, or, for a known interpreter, the entry-script
151-
* operand after skipping options (value-taking options consume their operand; `--opt=value` consumes nothing).
152-
* `undefined` when the interpreter runs inline source (`node -e`, `sh -c`, `python -m`) or has no operand.
153-
* Other arguments (`--output ./state/result.json`) are runtime inputs/outputs, never the executed file.
159+
* operand after skipping options (value-taking options consume their operand; `--opt=value` consumes nothing;
160+
* PowerShell's `-File <path>` selects the script). `undefined` when the interpreter runs inline source
161+
* (`node -e`, `sh -c`, `python -m`) or has no operand. Other arguments (`--output ./state/result.json`) are
162+
* runtime inputs/outputs, never the executed file.
154163
*/
155-
const executedPath = (tokens: readonly string[]): string | undefined => {
164+
const executedPath = (tokens: readonly string[]): ExecutedPath | undefined => {
156165
const [executable, ...operands] = tokens;
157166
if (executable === undefined) return undefined;
158167
const interpreter = executable.replace(/^.*[\\/]/u, '').replace(/\.exe$/iu, '');
159-
if (!scriptInterpreters.has(interpreter)) return executable;
168+
if (!scriptInterpreters.has(interpreter)) return { kind: 'executable', token: executable };
169+
const operand = (token: string | undefined): ExecutedPath | undefined =>
170+
token === undefined ? undefined : { kind: 'operand', token };
160171
for (let index = 0; index < operands.length; index += 1) {
161172
const token = operands[index] ?? '';
162-
if (!token.startsWith('-') || token === '-') return token;
173+
if (!token.startsWith('-') || token === '-') return operand(token);
174+
if (powerShell.has(interpreter) && /^-f(?:ile)?$/iu.test(token)) return operand(operands[index + 1]);
163175
if (inlineSourceOptions.has(token)) return undefined;
164176
if (valueOptions.has(token) && !token.includes('=')) index += 1;
165177
}
166178
return undefined;
167179
};
168180

169-
const pluginRelativePath = (token: string, pluginDirectory: string): string | undefined => {
181+
/**
182+
* Where a hook's executed file lives under the plugin root: `${CURSOR_PLUGIN_ROOT}/...` spellings, and any other
183+
* non-absolute path (plugin hooks run from the plugin root, and the pinned schema resolves relative paths against
184+
* the hook source root). A bare executable word without a separator is a PATH lookup, not a plugin file.
185+
*/
186+
const pluginRelativePath = (executed: ExecutedPath, pluginDirectory: string): string | undefined => {
187+
const { kind, token } = executed;
170188
if (token.startsWith(`${pluginRootToken}/`)) return join(pluginDirectory, token.slice(pluginRootToken.length + 1));
171-
if (token.startsWith('./') || token.startsWith('.\\')) return join(pluginDirectory, token);
172-
return undefined;
189+
if (token.startsWith('${') || token.startsWith('~') || isAbsolute(token) || /^[A-Za-z]:[\\/]/u.test(token)) return undefined;
190+
if (kind === 'executable' && !/[\\/]/u.test(token)) return undefined;
191+
return join(pluginDirectory, token.replaceAll('\\', '/'));
173192
};
174193

175194
/**
176-
* The plugin-root file a hook command executes (`${CURSOR_PLUGIN_ROOT}/...` or `./`-relative, since plugin
177-
* hooks run from the plugin root); empty when the executed file lives elsewhere or the command runs inline source.
195+
* The plugin-root file a hook command executes; empty when the executed file lives elsewhere (absolute, `~`, or a
196+
* PATH lookup) or the command runs inline source.
178197
*/
179198
const pluginRelativeScripts = (command: string, pluginDirectory: string): readonly string[] => {
180199
const executed = executedPath(shellWords(command));
@@ -240,13 +259,15 @@ const userHookDuplicates = async (
240259
// ./plugins/local/foo/state.json`) does not deliver that plugin's hook. `insidePlugin` matches on a path
241260
// component boundary so `plugins/local/foo` does not claim hooks aimed at `plugins/local/foo-tools`.
242261
const pointsIntoPlugin = (command: string): boolean => {
243-
const token = executedPath(shellWords(command));
244-
if (token === undefined || token.length === 0) return false;
262+
const executed = executedPath(shellWords(command));
263+
if (executed === undefined || executed.token.length === 0) return false;
264+
const { kind, token } = executed;
245265
if (token.startsWith('~/')) return insidePlugin(resolve(home, token.slice(2)));
246-
// Relative spellings may use either separator (`.\plugins\local\foo` on Windows); normalise before resolving.
247-
if (/^\.\.?[\\/]/u.test(token)) return insidePlugin(resolve(userHookCwd, token.replaceAll('\\', '/')));
248266
if (isAbsolute(token)) return insidePlugin(resolve(token));
249-
return false;
267+
// A bare executable word is a PATH lookup; any other relative spelling (either separator, e.g.
268+
// `.\plugins\local\foo` on Windows) resolves against ~/.cursor.
269+
if (kind === 'executable' && !/[\\/]/u.test(token)) return false;
270+
return insidePlugin(resolve(userHookCwd, token.replaceAll('\\', '/')));
250271
};
251272
const duplicates = parsed.commands.filter(pointsIntoPlugin);
252273
if (duplicates.length === 0) return { diagnostics: Object.freeze([]), duplicates: Object.freeze([]) };

‎packages/agent-bundle/tests/doctor.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,6 +1589,18 @@ it('proves plugin-scoped Cursor hook registration and flags stale, missing, and
15891589
expect(hostReport(optionOperand, 'cursor').inventory.findings[0]?.hooks).toMatchObject({ state: 'stale' });
15901590
expect(hookDiagnostics(optionOperand)[0]?.message).toContain(join(pluginRoot, 'hooks/gone.mjs'));
15911591

1592+
// Bare relative operands resolve against the plugin root too (pinned schema: relative to the hook source root),
1593+
// and PowerShell's -File operand is the entry script.
1594+
for (const command of ['node hooks/gone.mjs', 'pwsh -NoProfile -File hooks/gone.mjs', 'hooks/gone.mjs --flag']) {
1595+
await writeJson(join(pluginRoot, 'hooks/hooks.json'), { hooks: { stop: [{ command }] }, version: 1 });
1596+
const bareRelative = await doctor();
1597+
expect(hostReport(bareRelative, 'cursor').inventory.findings[0]?.hooks).toMatchObject({ state: 'stale' });
1598+
expect(hookDiagnostics(bareRelative)[0]?.message).toContain(join(pluginRoot, 'hooks/gone.mjs'));
1599+
}
1600+
// A bare executable word is a PATH lookup, not a plugin file.
1601+
await writeJson(join(pluginRoot, 'hooks/hooks.json'), { hooks: { stop: [{ command: 'gone-tool --flag hooks/gone.mjs' }] }, version: 1 });
1602+
expect(hostReport(await doctor(), 'cursor').inventory.findings[0]?.hooks).toMatchObject({ commands: 1, state: 'registered' });
1603+
15921604
// Inline source runs no script file, so there is nothing under the plugin root to probe.
15931605
await writeJson(join(pluginRoot, 'hooks/hooks.json'), {
15941606
hooks: { stop: [{ command: 'node -e "process.exit(0)" ./hooks/gone.mjs' }] },

0 commit comments

Comments
 (0)