fix(gemini): make Windows hooks and agent tools valid
This commit is contained in:
6
.changeset/fix-3362-windows-powershell-gemini.md
Normal file
6
.changeset/fix-3362-windows-powershell-gemini.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3368
|
||||
---
|
||||
|
||||
**Gemini install output is valid on Windows PowerShell** - managed hook commands now use PowerShell's call operator when invoking quoted Node runners on Windows, and reinstall rewrites existing managed hooks without double-prefixing them. Gemini agent conversion also drops Claude-only `AskUserQuestion` / `ask_user` tool metadata and rewrites body references to runtime-neutral prompt wording. Fixes #3362.
|
||||
@@ -594,8 +594,15 @@ function resolveNodeRunner() {
|
||||
*
|
||||
* Returns true if any entry was rewritten.
|
||||
*/
|
||||
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) {
|
||||
function formatHookCommandForShell(command, opts) {
|
||||
const platform = (opts && opts.platform) || process.platform;
|
||||
return platform === 'win32' ? `& ${command}` : command;
|
||||
}
|
||||
|
||||
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
||||
if (!settings || !settings.hooks || !absoluteRunner) return false;
|
||||
if (!opts) opts = {};
|
||||
const platform = opts.platform || process.platform;
|
||||
const MANAGED_HOOK_FILES = new Set([
|
||||
'gsd-check-update.js',
|
||||
'gsd-statusline.js',
|
||||
@@ -613,7 +620,11 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) {
|
||||
if (!entry || !Array.isArray(entry.hooks)) continue;
|
||||
for (const h of entry.hooks) {
|
||||
if (!h || typeof h.command !== 'string') continue;
|
||||
const trimmed = h.command.trim();
|
||||
let trimmed = h.command.trim();
|
||||
const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
|
||||
if (hadPowerShellCallOperator) {
|
||||
trimmed = trimmed.replace(/^&\s+/, '').trim();
|
||||
}
|
||||
// Match two runner forms:
|
||||
// 1. Legacy bare-node form: `node <script>` (#2979/#3002)
|
||||
// 2. Cellar-path form: `"/usr/local/Cellar/node/<v>/bin/node" <script>`
|
||||
@@ -642,8 +653,10 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) {
|
||||
runnerToken = m[1];
|
||||
const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
|
||||
const stableRunner = normalizeNodePath(runnerPath);
|
||||
// Only process if the runner IS a Cellar path that normalizes to something different
|
||||
if (stableRunner === runnerPath) continue;
|
||||
// Process Cellar paths so they normalize to a stable symlink. On
|
||||
// Windows, also process already-absolute runners so PowerShell gets
|
||||
// the call operator needed to invoke a quoted executable path (#3362).
|
||||
if (stableRunner === runnerPath && platform !== 'win32') continue;
|
||||
scriptToken = m[5];
|
||||
scriptPath = m[6] || m[7] || m[8] || '';
|
||||
}
|
||||
@@ -654,10 +667,12 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) {
|
||||
const scriptBase = scriptPath.split(/[\\/]/).pop() || '';
|
||||
if (!MANAGED_HOOK_FILES.has(scriptBase)) continue;
|
||||
|
||||
// Skip if already using the desired stable runner
|
||||
if (runnerToken !== 'node' && runnerToken === absoluteRunner) continue;
|
||||
// Skip if already using the desired stable runner.
|
||||
if (runnerToken !== 'node' && runnerToken === absoluteRunner) {
|
||||
if (platform !== 'win32' || hadPowerShellCallOperator) continue;
|
||||
}
|
||||
|
||||
h.command = `${absoluteRunner} ${scriptToken}`;
|
||||
h.command = formatHookCommandForShell(`${absoluteRunner} ${scriptToken}`, opts);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -835,10 +850,11 @@ function cleanupLegacyCodexHooksJson(targetDir) {
|
||||
*
|
||||
* @param {string} configDir - Resolved absolute config directory path
|
||||
* @param {string} hookName - Hook filename (e.g. 'gsd-statusline.js')
|
||||
* @param {{ portableHooks?: boolean }} [opts] - Options
|
||||
* @param {{ portableHooks?: boolean, platform?: NodeJS.Platform }} [opts] - Options
|
||||
* portableHooks: when true, emit $HOME-relative paths instead of absolute paths.
|
||||
* Safe for Linux/macOS global installs and WSL/Docker bind-mount scenarios.
|
||||
* Not suitable for pure Windows (cmd.exe/PowerShell do not expand $HOME).
|
||||
* platform: test injection for shell command formatting. Defaults to process.platform.
|
||||
*/
|
||||
function buildHookCommand(configDir, hookName, opts) {
|
||||
if (!opts) opts = {};
|
||||
@@ -867,12 +883,12 @@ function buildHookCommand(configDir, hookName, opts) {
|
||||
const relative = normalized.startsWith(home)
|
||||
? '$HOME' + normalized.slice(home.length)
|
||||
: normalized;
|
||||
return `${runner} "${relative}/hooks/${hookName}"`;
|
||||
return formatHookCommandForShell(`${runner} "${relative}/hooks/${hookName}"`, opts);
|
||||
}
|
||||
|
||||
// Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046).
|
||||
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
|
||||
return `${runner} "${hooksPath}"`;
|
||||
return formatHookCommandForShell(`${runner} "${hooksPath}"`, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1285,7 +1301,6 @@ const claudeToGeminiTools = {
|
||||
WebSearch: 'google_web_search',
|
||||
WebFetch: 'web_fetch',
|
||||
TodoWrite: 'write_todos',
|
||||
AskUserQuestion: 'ask_user',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1318,8 +1333,15 @@ function convertGeminiToolName(claudeTool) {
|
||||
if (claudeTool.startsWith('mcp__')) {
|
||||
return null;
|
||||
}
|
||||
// Task/Agent: exclude — agents are auto-registered as callable tools
|
||||
if (claudeTool === 'Task' || claudeTool === 'Agent') {
|
||||
// Task/Agent: exclude — agents are auto-registered as callable tools.
|
||||
// AskUserQuestion: exclude — Gemini CLI does not expose an ask_user tool;
|
||||
// emitting it causes frontmatter validation errors (#3362).
|
||||
if (
|
||||
claudeTool === 'Task' ||
|
||||
claudeTool === 'Agent' ||
|
||||
claudeTool === 'AskUserQuestion' ||
|
||||
claudeTool === 'ask_user'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Check for explicit mapping
|
||||
@@ -4810,6 +4832,10 @@ function convertSlashCommandsToGeminiMentions(content) {
|
||||
function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
|
||||
// Apply Gemini-specific slash command namespacing
|
||||
let converted = convertSlashCommandsToGeminiMentions(content);
|
||||
// Gemini CLI does not expose Claude's AskUserQuestion tool. Convert body
|
||||
// references to runtime-neutral wording so converted agents do not instruct
|
||||
// Gemini to call a nonexistent tool (#3362).
|
||||
converted = converted.replace(/\b(?:AskUserQuestion|ask_user)\b/g, 'conversational prompting');
|
||||
// Strip HTML subscript tags — terminals can't render them. Done before
|
||||
// TOML conversion so the prompt body of a command file is also clean.
|
||||
converted = stripSubTags(converted);
|
||||
@@ -8777,7 +8803,7 @@ function install(isGlobal, runtime = 'claude') {
|
||||
// `node` command that recreates the #2979 failure.
|
||||
const localCmd = (hookFile) => localNodeRunner === null
|
||||
? null
|
||||
: localNodeRunner + ' ' + localPrefix + '/hooks/' + hookFile;
|
||||
: formatHookCommandForShell(localNodeRunner + ' ' + localPrefix + '/hooks/' + hookFile, hookOpts);
|
||||
const statuslineCommand = isGlobal
|
||||
? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts)
|
||||
: localCmd('gsd-statusline.js');
|
||||
|
||||
@@ -65,13 +65,15 @@ Both hooks are automatically registered during `npx get-shit-done-cc` installati
|
||||
- **Statusline** (writes bridge file): Registered as `statusLine` in settings.json
|
||||
- **Context Monitor** (reads bridge file): Registered as `PostToolUse` hook in settings.json (`AfterTool` for Gemini)
|
||||
|
||||
Manual registration should use the absolute Node executable path that ran the installer. On Windows PowerShell, prefix the command with `&` when that executable path is quoted.
|
||||
|
||||
Manual registration in `~/.claude/settings.json` (Claude Code):
|
||||
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "node ~/.claude/hooks/gsd-statusline.js"
|
||||
"command": "\"/usr/local/bin/node\" \"/Users/me/.claude/hooks/gsd-statusline.js\""
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
@@ -79,7 +81,7 @@ Manual registration in `~/.claude/settings.json` (Claude Code):
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ~/.claude/hooks/gsd-context-monitor.js"
|
||||
"command": "\"/usr/local/bin/node\" \"/Users/me/.claude/hooks/gsd-context-monitor.js\""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -98,7 +100,7 @@ For Gemini CLI (`~/.gemini/settings.json`), use `AfterTool` instead of `PostTool
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ~/.gemini/hooks/gsd-context-monitor.js"
|
||||
"command": "& \"C:/Program Files/nodejs/node.exe\" \"C:/Users/me/.gemini/hooks/gsd-context-monitor.js\""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -103,6 +103,26 @@ describe('Bug #2979: buildHookCommand for .js hooks emits absolute node runner',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #3362: Windows PowerShell hook commands use the call operator', () => {
|
||||
test('global install: .js hook command starts with & so quoted runners execute in PowerShell', () => {
|
||||
const cmd = buildHookCommand('C:/Program Files/Gemini/.gemini', 'gsd-check-update.js', {
|
||||
platform: 'win32',
|
||||
});
|
||||
assert.ok(cmd.startsWith('& '), `PowerShell commands need call operator, got: ${cmd}`);
|
||||
assert.ok(cmd.includes('"C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"'));
|
||||
});
|
||||
|
||||
test('portable install: .js hook command also uses & on Windows PowerShell', () => {
|
||||
const home = require('node:os').homedir().replace(/\\/g, '/');
|
||||
const cmd = buildHookCommand(`${home}/.gemini`, 'gsd-check-update.js', {
|
||||
portableHooks: true,
|
||||
platform: 'win32',
|
||||
});
|
||||
assert.ok(cmd.startsWith('& '), `PowerShell commands need call operator, got: ${cmd}`);
|
||||
assert.equal(parseHookCommand(cmd.slice(2)).hookPath, '$HOME/.gemini/hooks/gsd-check-update.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => {
|
||||
test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => {
|
||||
const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh');
|
||||
@@ -154,6 +174,55 @@ describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites ba
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
test('adds PowerShell call operator to existing quoted managed hooks on Windows', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: '"/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32' });
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT double-prefix managed hooks that already use the PowerShell call operator', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const before = settings.hooks.SessionStart[0].hooks[0].command;
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32' });
|
||||
assert.equal(changed, false);
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
test('rewrites PowerShell bare-node managed hooks to absolute runner without dropping &', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: '& node "C:/Users/me/.gemini/hooks/gsd-check-update.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32' });
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'& "/usr/local/bin/node" "C:/Users/me/.gemini/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
|
||||
@@ -18,6 +18,7 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
convertSlashCommandsToGeminiMentions,
|
||||
convertClaudeToGeminiMarkdown,
|
||||
convertClaudeToGeminiAgent,
|
||||
_resetGsdCommandRoster,
|
||||
install
|
||||
} = require('../bin/install.js');
|
||||
@@ -132,6 +133,24 @@ describe('Gemini Markdown Processor', () => {
|
||||
assert.doesNotMatch(result, /<sub>|<\/sub>/, '<sub> tags must be stripped');
|
||||
assert.match(result, /\/gsd:help/, 'slash command must still be converted');
|
||||
});
|
||||
|
||||
test('removes AskUserQuestion and ask_user from Gemini agent tools and body (#3362)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: tester',
|
||||
'tools: Read, AskUserQuestion, ask_user',
|
||||
'---',
|
||||
'Use AskUserQuestion or ask_user to ask the user.'
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeToGeminiAgent(input);
|
||||
|
||||
assert.match(result, /^ - read_file$/m, 'Read should still map to Gemini read_file');
|
||||
assert.doesNotMatch(result, /^ - ask_user$/m, 'ask_user must not be emitted as a Gemini tool');
|
||||
assert.doesNotMatch(result, /\bAskUserQuestion\b/, 'Claude tool references must be neutralized');
|
||||
assert.doesNotMatch(result, /\bask_user\b/, 'lowercase ask_user references must be neutralized');
|
||||
assert.match(result, /conversational prompting/, 'body should use runtime-neutral wording');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini Install (Behavioral)', () => {
|
||||
|
||||
@@ -265,7 +265,7 @@ Use \${PHASE} in shell examples.
|
||||
assert.ok(!result.includes('${PHASE}'), 'removes Gemini template-string pattern');
|
||||
});
|
||||
|
||||
test('excludes Claude agent dispatcher tools from Gemini frontmatter', () => {
|
||||
test('excludes Claude-only agent interaction tools from Gemini frontmatter', () => {
|
||||
const input = `---
|
||||
name: gsd-debug-session-manager
|
||||
description: Manages debug sessions.
|
||||
@@ -274,17 +274,21 @@ tools: Read, Task, Agent, AskUserQuestion
|
||||
|
||||
<role>
|
||||
Coordinate debugger agents.
|
||||
Offer choices via AskUserQuestion when user input is needed.
|
||||
</role>`;
|
||||
|
||||
const result = convertClaudeToGeminiAgent(input);
|
||||
const frontmatter = result.split('---')[1] || '';
|
||||
|
||||
assert.ok(frontmatter.includes(' - read_file'), 'maps Read -> read_file');
|
||||
assert.ok(frontmatter.includes(' - ask_user'), 'maps AskUserQuestion -> ask_user');
|
||||
assert.ok(!frontmatter.includes(' - ask_user'), 'does not emit invalid Gemini ask_user tool');
|
||||
assert.ok(!frontmatter.includes(' - task'), 'does not emit invalid Gemini task tool');
|
||||
assert.ok(!frontmatter.includes(' - agent'), 'does not emit invalid Gemini agent tool');
|
||||
assert.ok(!frontmatter.includes('Task'), 'does not preserve Claude-only Task tool');
|
||||
assert.ok(!frontmatter.includes('Agent'), 'does not preserve Claude-only Agent tool');
|
||||
assert.ok(!frontmatter.includes('AskUserQuestion'), 'does not preserve Claude-only AskUserQuestion tool');
|
||||
assert.ok(!result.includes('AskUserQuestion'), 'does not leave Claude-only tool references in the body');
|
||||
assert.ok(result.includes('conversational prompting'), 'uses runtime-neutral body wording for user prompts');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user