diff --git a/node/src/commands/agent/init.ts b/node/src/commands/agent/init.ts index d19e3414..2873c42e 100644 --- a/node/src/commands/agent/init.ts +++ b/node/src/commands/agent/init.ts @@ -7,7 +7,7 @@ import { SkillManager } from "../../utils/skill-manager.js"; import fs from "fs"; import path from "path"; import os from "os"; -import { execSync, spawnSync } from "child_process"; +import { execFileSync, execSync, spawnSync } from "child_process"; import { fileURLToPath } from "url"; import { createRequire } from "module"; import { askYesNo } from "../../utils/prompt.js"; @@ -1184,10 +1184,15 @@ function registerGeminiSkills(skillsDir: string): void { const absPath = path.resolve(skillsDir, skill.name); if (!fs.existsSync(absPath)) continue; try { - execSync(`gemini skills link ${JSON.stringify(absPath)}`, { - stdio: ["ignore", "pipe", "pipe"], - timeout: 10000, - }); + const execOpts = { stdio: ["ignore", "pipe", "pipe"] as any, timeout: 10000 }; + // No POSIX shell: the path comes from the working directory, whose name + // may hold shell syntax. Windows needs cmd.exe to run gemini's .cmd shim, + // and cmd.exe treats a double-quoted path literally. + if (process.platform === "win32") { + execSync(`gemini skills link ${JSON.stringify(absPath)}`, execOpts); + } else { + execFileSync("gemini", ["skills", "link", absPath], execOpts); + } console.log(fmt.success(`Registered ${skill.name} with Gemini CLI`)); } catch (e: any) { const msg = (e?.stderr?.toString?.() || e?.message || "").trim(); diff --git a/node/src/commands/agent/install-hook.ts b/node/src/commands/agent/install-hook.ts index 90a79803..e99bd914 100644 --- a/node/src/commands/agent/install-hook.ts +++ b/node/src/commands/agent/install-hook.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import fs from "fs"; import os from "os"; import path from "path"; -import { execSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { fileURLToPath } from 'url'; import { fmt } from "../../utils/formatter.js"; @@ -115,7 +115,7 @@ async function installGlobalHook(hookName: string, templateName: string): Promis fs.chmodSync(hookPath, 0o755); try { - execSync(`git config --global core.hooksPath "${globalHooksDir}"`, { stdio: "pipe" }); + execFileSync("git", ["config", "--global", "core.hooksPath", globalHooksDir], { stdio: "pipe" }); console.log(fmt.success(`Installed Rafter ${hookName} hook globally`)); console.log(` Location: ${hookPath}`); console.log(` Git config: core.hooksPath = ${globalHooksDir}`); diff --git a/node/src/commands/agent/status.ts b/node/src/commands/agent/status.ts index 57bf2638..efcfe16d 100644 --- a/node/src/commands/agent/status.ts +++ b/node/src/commands/agent/status.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import fs from "fs"; import path from "path"; import os from "os"; -import { execSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { fileURLToPath } from "url"; import { getRafterDir, getAuditLogPath, getBinDir } from "../../core/config-defaults.js"; import { AuditLogger } from "../../core/audit-logger.js"; @@ -85,7 +85,7 @@ export function createStatusCommand(): Command { } catch { if (fs.existsSync(localBetterleaks)) { try { - const ver = execSync(`"${localBetterleaks}" version`, { timeout: 5000, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); + const ver = execFileSync(localBetterleaks, ["version"], { timeout: 5000, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); betterleaksStatus = `${ver} (local)`; } catch { betterleaksStatus = `${localBetterleaks} (binary error)`; diff --git a/node/tests/agent-init-gemini-link.test.ts b/node/tests/agent-init-gemini-link.test.ts new file mode 100644 index 00000000..1952f9e9 --- /dev/null +++ b/node/tests/agent-init-gemini-link.test.ts @@ -0,0 +1,56 @@ +/** + * `agent init --local --with-gemini` registers skills with `gemini skills link + * `, where the path is under the working directory. A directory name may + * hold shell syntax, so the path must reach gemini as one literal argument and + * never be interpreted by a shell. + * + * Runs the built CLI with a stub `gemini` on PATH that logs its arguments. + */ +import { describe, it, expect, beforeAll } from "vitest"; +import { execSync, spawnSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import os from "os"; + +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const CLI_DIST = path.join(PROJECT_ROOT, "dist", "index.js"); + +beforeAll(() => { + if (!fs.existsSync(CLI_DIST)) { + execSync("pnpm run build", { cwd: PROJECT_ROOT, stdio: "inherit" }); + } +}); + +describe.skipIf(process.platform === "win32")("agent init --local --with-gemini", () => { + it("passes a working-directory path with shell syntax to gemini literally", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-gemini-link-")); + try { + const bin = path.join(root, "bin"); + const log = path.join(root, "calls.log"); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, "gemini"), + `#!/bin/sh\nfor a in "$@"; do printf '%s\\n' "$a" >> '${log}'; done\nexit 0\n`, + { mode: 0o755 }, + ); + const home = path.join(root, "home"); + fs.mkdirSync(home); + const project = path.join(root, "vendor", "$(touch MARKER)"); + fs.mkdirSync(project, { recursive: true }); + + const r = spawnSync(process.execPath, [CLI_DIST, "agent", "init", "--local", "--with-gemini"], { + cwd: project, + encoding: "utf-8", + timeout: 60_000, + env: { ...process.env, HOME: home, XDG_CONFIG_HOME: path.join(home, ".config"), PATH: `${bin}:${process.env.PATH}`, CI: "1" }, + }); + + expect(r.status).toBe(0); + expect(fs.existsSync(path.join(project, "MARKER"))).toBe(false); + const args = fs.readFileSync(log, "utf-8").split("\n"); + expect(args).toContain(path.join(project, ".agents", "skills", "rafter")); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +});