diff --git a/.gitignore b/.gitignore index 3d573ee..3f42054 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,9 @@ vite.config.ts.timestamp-* output/ plugins/codex/.generated/ + +# Development workflow +.dev-flow/ + +# IDE +.idea/ diff --git a/README.md b/README.md index 458c39f..39c9ea5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ they already have. - `/codex:review` for a normal read-only Codex review - `/codex:adversarial-review` for a steerable challenge review - `/codex:rescue`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work and manage background jobs +- `/codex:run-skill` to invoke a Codex skill through the shared runtime ## Requirements @@ -202,6 +203,26 @@ Examples: /codex:cancel task-abc123 ``` +### `/codex:run-skill` + +Runs a Codex skill through the shared runtime. Use it to invoke any skill installed in your local Codex environment. + +Use it when you want: + +- to invoke a specific Codex skill from inside Claude Code +- to list the skills available in your local Codex installation + +Examples: + +```bash +/codex:run-skill --list +/codex:run-skill --skill ui-ux-pro-max design a landing page +/codex:run-skill --skill xlfoundry-plan --background 规划一个新功能 +/codex:run-skill --skill my-write-skill --write create new files +``` + +This command reads the local skill definition from `~/.codex/skills//SKILL.md`, includes the skill directory path for access to bundled resources (scripts, references, etc.), and sends everything to Codex along with your prompt. Results are returned to Claude Code. Use `--write` to allow the skill to modify files (default is read-only). + ### `/codex:setup` Checks whether Codex is installed and authenticated. diff --git a/plugins/codex/commands/run-skill.md b/plugins/codex/commands/run-skill.md new file mode 100644 index 0000000..79a7f79 --- /dev/null +++ b/plugins/codex/commands/run-skill.md @@ -0,0 +1,70 @@ +--- +description: Run a Codex skill through the shared runtime +argument-hint: '[--skill ] [--list] [--background|--wait] [--write] [prompt]' +allowed-tools: Read, Glob, Grep, Bash(node:*), AskUserQuestion +--- + +Run a Codex skill through the shared runtime. + +Raw slash-command arguments: +`$ARGUMENTS` + +Core constraint: +- This command is a thin forwarder to the Codex companion script. +- Do not fix issues, apply patches, or add independent analysis. +- Your only job is to run the skill and return Codex's output verbatim to the user. + +Execution mode rules: +- If the raw arguments include `--list`, run the command immediately and return the output. +- If the raw arguments include `--wait`, do not ask. Run the skill in the foreground. +- If the raw arguments include `--background`, do not ask. Run the skill in a Claude background task. +- If the raw arguments do not include `--skill ` and do not include `--list`, use `AskUserQuestion` exactly once to ask: + - Question: "Which Codex skill would you like to run, and what should it do?" + - Provide a free-text answer option. + - After receiving the answer, construct the full command with `--skill ` and the user's prompt. Do NOT use `$ARGUMENTS` for this execution path. +- Otherwise (raw arguments include `--skill`), use `AskUserQuestion` exactly once with two options, putting the recommended option first and suffixing its label with `(Recommended)`: + - `Run in background (Recommended)` + - `Wait for results` + +Argument handling: +- When `--skill` is provided in `$ARGUMENTS`, preserve the user's arguments exactly. +- When `--skill` was obtained via AskUserQuestion, build the command from the user's answer instead of `$ARGUMENTS`. +- Do not strip `--wait`, `--background`, or `--write` yourself. +- `--write` allows the skill to modify files (default is read-only sandbox). +- The companion script parses these flags, but Claude Code's `Bash(..., run_in_background: true)` is what actually detaches the run. + +List mode: +- When `--list` is present, run the command and return the output verbatim. +- Do not paraphrase or add commentary. + +Foreground flow: +- If `--skill` was in the raw arguments, run: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" run-skill "$ARGUMENTS" +``` +- If `--skill` was obtained via AskUserQuestion, build and run the command from the user's answer instead of `$ARGUMENTS`: +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" run-skill --skill "" +``` +- Return the command stdout verbatim, exactly as-is. +- Do not paraphrase, summarize, or add commentary before or after it. + +Background flow: +- If `--skill` was in the raw arguments, launch with: +```typescript +Bash({ + command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" run-skill "$ARGUMENTS"`, + description: "Codex skill run", + run_in_background: true +}) +``` +- If `--skill` was obtained via AskUserQuestion, build the command from the user's answer instead of `$ARGUMENTS`: +```typescript +Bash({ + command: `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" run-skill --skill "" `, + description: "Codex skill run", + run_in_background: true +}) +``` +- Do not call `BashOutput` or wait for completion in this turn. +- After launching the command, tell the user: "Codex skill run started in the background. Check `/codex:status` for progress." diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd..32d0b1c 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -51,6 +52,7 @@ import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs"; import { resolveWorkspaceRoot } from "./lib/workspace.mjs"; +import { listAvailableSkills, validateSkill } from "./lib/skills.mjs"; import { renderNativeReviewResult, renderReviewResult, @@ -80,7 +82,8 @@ function printUsage() { " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", - " node scripts/codex-companion.mjs cancel [job-id] [--json]" + " node scripts/codex-companion.mjs cancel [job-id] [--json]", + " node scripts/codex-companion.mjs run-skill [--skill ] [--list] [prompt]" ].join("\n") ); } @@ -555,8 +558,8 @@ function renderQueuedTaskLaunch(payload) { } function getJobKindLabel(kind, jobClass) { - if (kind === "adversarial-review") { - return "adversarial-review"; + if (kind) { + return kind; } return jobClass === "review" ? "review" : "rescue"; } @@ -792,6 +795,108 @@ async function handleTask(argv) { ); } +async function executeSkillRun(request) { + const workspaceRoot = resolveWorkspaceRoot(request.cwd); + const result = await runAppServerTurn(workspaceRoot, { + prompt: request.prompt, + onProgress: request.onProgress, + persistThread: false, + sandbox: request.write ? "workspace-write" : "read-only" + }); + + const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; + const failureMessage = result.error?.message ?? result.stderr ?? ""; + const rendered = rawOutput || failureMessage || "Codex did not return a final message.\n"; + const payload = { + status: result.status, + threadId: result.threadId, + rawOutput + }; + + return { + exitStatus: result.status, + threadId: result.threadId, + turnId: result.turnId, + payload, + rendered, + summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `Codex skill ${request.skillName} finished.`)), + jobTitle: `Run Codex skill: ${request.skillName}`, + jobClass: "skill" + }; +} + +async function handleRunSkill(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["skill", "cwd"], + booleanOptions: ["list", "wait", "background", "json", "write"] + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); + + if (options.list) { + const skills = listAvailableSkills(codexHome); + if (skills.length === 0) { + outputCommandResult({ skills: [] }, "No Codex skills found in ~/.codex/skills/.", options.json); + return; + } + const lines = skills.map((s) => ` ${s.name}${s.description ? ` — ${s.description}` : ""}`); + outputCommandResult({ skills }, `Available Codex skills:\n${lines.join("\n")}`, options.json); + return; + } + + const skillName = options.skill; + if (!skillName) { + throw new Error("Specify a skill with --skill or use --list to see available skills."); + } + + ensureCodexAvailable(cwd); + const { entry: skillEntry, filePath: skillFilePath } = validateSkill(codexHome, skillName); + const skillContent = skillFilePath ? fs.readFileSync(skillFilePath, "utf8") : ""; + const skillDir = skillFilePath ? path.dirname(skillFilePath) : null; + + const userPrompt = positionals.join(" ").trim(); + const skillDirNote = skillDir + ? `Additional skill resources (scripts, references, etc.) are located at: ${skillDir}\nYou may read files from this directory as needed.\n` + : ""; + const parts = [ + `Execute the "${skillName}" Codex skill using the definition below. Do not run shell commands to search for skill files elsewhere.`, + "", + skillDirNote, + "## Skill Definition", + "", + skillContent, + "", + "## User Request", + "", + userPrompt || "(no additional request — execute the skill with default behavior)" + ]; + const prompt = parts.join("\n"); + + const job = createCompanionJob({ + prefix: "run-skill", + kind: "run-skill", + title: `Run Codex skill: ${skillName}`, + workspaceRoot, + jobClass: "skill", + summary: userPrompt || `Run skill ${skillName}` + }); + + await runForegroundCommand( + job, + (progress) => + executeSkillRun({ + cwd, + skillName, + prompt, + write: options.write, + onProgress: progress + }), + { json: options.json } + ); +} + async function handleTaskWorker(argv) { const { options } = parseCommandInput(argv, { valueOptions: ["cwd", "job-id"] @@ -1000,6 +1105,9 @@ async function main() { case "task": await handleTask(argv); break; + case "run-skill": + await handleRunSkill(argv); + break; case "task-worker": await handleTaskWorker(argv); break; diff --git a/plugins/codex/scripts/lib/skills.mjs b/plugins/codex/scripts/lib/skills.mjs new file mode 100644 index 0000000..57b6758 --- /dev/null +++ b/plugins/codex/scripts/lib/skills.mjs @@ -0,0 +1,188 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * @typedef {Object} SkillEntry + * @property {string} name - Directory name used as skill identifier. + * @property {string} description - Value from SKILL.md frontmatter `description` field. + */ + +/** + * Parse the YAML frontmatter from a SKILL.md file and extract `description`. + * Returns an empty string for description if the field is missing. + * + * @param {string} content - Raw file content. + * @returns {{ description: string } | null} + */ +function parseFrontmatter(content) { + const match = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) { + return null; + } + const body = match[1]; + const descMatch = body.match(/^description:\s*"?(.+?)"?\s*$/m); + const description = descMatch ? descMatch[1].trim() : ""; + return { description }; +} + +/** + * Resolve the filesystem path to a skill's SKILL.md. + * Checks user skills first, then falls back to .system skills. + * Returns null if the SKILL.md does not exist. + * + * @param {string} codexHome - Absolute path to the Codex home directory. + * @param {string} name - Skill name. + * @returns {string | null} + */ +function resolveSkillPath(codexHome, name) { + const candidates = [ + path.join(codexHome, "skills", name, "SKILL.md"), + path.join(codexHome, "skills", ".system", name, "SKILL.md") + ]; + for (const p of candidates) { + try { + fs.accessSync(p, fs.constants.R_OK); + return p; + } catch { + continue; + } + } + return null; +} + +/** + * Scan a single directory for a SKILL.md file and return a skill entry. + * + * @param {string} dirPath - Absolute path to the skill directory. + * @param {string} dirName - Directory name (used as skill name). + * @returns {SkillEntry | null} + */ +function readSkillEntry(dirPath, dirName) { + const skillFilePath = path.join(dirPath, "SKILL.md"); + let content; + try { + content = fs.readFileSync(skillFilePath, "utf8"); + } catch { + return null; + } + + const frontmatter = parseFrontmatter(content); + if (!frontmatter) { + return { name: dirName, description: "" }; + } + + return { name: dirName, description: frontmatter.description }; +} + +/** + * List all available Codex skills from the given Codex home directory. + * Scans `~/.codex/skills/` including the `.system` subdirectory. + * When the same skill name appears in both `.system` and user space, + * the user-space version wins (scanned last, overwrites earlier entry). + * + * @param {string} codexHome - Absolute path to the Codex home directory (e.g. ~/.codex). + * @returns {SkillEntry[]} + */ +export function listAvailableSkills(codexHome) { + const skillsDir = path.join(codexHome, "skills"); + + // Verify skills directory exists + try { + fs.accessSync(skillsDir, fs.constants.R_OK); + } catch { + return []; + } + + /** @type {Map} */ + const skillMap = new Map(); + + // Collect subdirectory groups to scan: .system first, then user skills + const groups = [path.join(skillsDir, ".system"), skillsDir]; + + for (const groupDir of groups) { + let groupEntries; + try { + groupEntries = fs.readdirSync(groupDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of groupEntries) { + if (entry.name === ".system") { + continue; + } + const entryPath = path.join(groupDir, entry.name); + // Follow symlinks with statSync; use Dirent for plain directories + let isDir; + if (entry.isSymbolicLink()) { + try { + isDir = fs.statSync(entryPath).isDirectory(); + } catch { + continue; + } + } else { + isDir = entry.isDirectory(); + } + if (!isDir) { + continue; + } + const skill = readSkillEntry(entryPath, entry.name); + if (skill) { + skillMap.set(skill.name, skill); + } + } + } + + return Array.from(skillMap.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Resolve and validate a skill, returning its entry and file path. + * Throws with available skill names if the skill is not found. + * + * @param {string} codexHome - Absolute path to the Codex home directory. + * @param {string} name - Skill name to validate. + * @returns {{ entry: SkillEntry, filePath: string | null }} + * @throws {Error} + */ +/** + * Verify that a skill name does not contain path traversal sequences. + * Symlinks within the skills directory are allowed — the user controls + * their own ~/.codex/skills/ directory. + * + * @param {string} name - Skill name to validate + * @throws {Error} + */ +function validateSkillName(name) { + if (name.includes("/") || name.includes("\\") || name === "." || name === "..") { + throw new Error(`Invalid skill name: "${name}"`); + } +} + +export function validateSkill(codexHome, name) { + validateSkillName(name); + + // Fast path: check user-space and .system directories directly + const candidates = [ + path.join(codexHome, "skills", name), + path.join(codexHome, "skills", ".system", name) + ]; + for (const dirPath of candidates) { + const entry = readSkillEntry(dirPath, name); + if (entry) { + const filePath = path.join(dirPath, "SKILL.md"); + return { entry, filePath }; + } + } + + // Fallback: full scan (catches edge cases) + const skills = listAvailableSkills(codexHome); + const found = skills.find((s) => s.name === name); + if (found) { + return { entry: found, filePath: resolveSkillPath(codexHome, name) }; + } + const available = skills.map((s) => ` - ${s.name}${s.description ? ` — ${s.description}` : ""}`).join("\n"); + throw new Error( + `Skill "${name}" not found. Available skills:\n${available || " (none)"}` + ); +} diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index ef5adb0..cd6b44a 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -78,6 +78,7 @@ test("continue is not exposed as a user-facing command", () => { "rescue.md", "result.md", "review.md", + "run-skill.md", "setup.md", "status.md" ]); @@ -165,9 +166,9 @@ test("result and cancel commands are exposed as deterministic runtime entrypoint const resultHandling = read("skills/codex-result-handling/SKILL.md"); assert.match(result, /disable-model-invocation:\s*true/); - assert.match(result, /codex-companion\.mjs" result \$ARGUMENTS/); + assert.match(result, /codex-companion\.mjs" result "\$ARGUMENTS"/); assert.match(cancel, /disable-model-invocation:\s*true/); - assert.match(cancel, /codex-companion\.mjs" cancel \$ARGUMENTS/); + assert.match(cancel, /codex-companion\.mjs" cancel "\$ARGUMENTS"/); assert.match(resultHandling, /do not turn a failed or incomplete Codex run into a Claude-side implementation attempt/i); assert.match(resultHandling, /if Codex was never successfully invoked, do not generate a substitute answer at all/i); }); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index debcadc..53bb4cc 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -297,6 +297,12 @@ rl.on("line", (line) => { throw new Error("thread/start.persistFullHistory requires experimentalApi capability"); } const thread = nextThread(state, message.params.cwd, message.params.ephemeral); + state.lastThreadStart = { + threadId: thread.id, + sandbox: message.params.sandbox ?? null, + ephemeral: message.params.ephemeral ?? null + }; + saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); send({ method: "thread/started", params: { thread: { id: thread.id } } }); break; diff --git a/tests/fixtures/test-companion-skill/SKILL.md b/tests/fixtures/test-companion-skill/SKILL.md new file mode 100644 index 0000000..996055d --- /dev/null +++ b/tests/fixtures/test-companion-skill/SKILL.md @@ -0,0 +1,20 @@ +--- +name: test-companion-skill +description: Integration test skill for codex-plugin-cc. Responds with COMPANION_TEST_OK and the task text. +--- + +# Test Companion Skill + +This skill is used for integration testing of the codex-plugin-cc `/codex:run-skill` command. + +When invoked, respond with **exactly** this format and nothing else: + +``` +COMPANION_TEST_OK: +``` + +Rules: +- Output only the single line above. +- Do not perform any file operations, code changes, or research. +- Do not add explanations or commentary. +- "The user's prompt text" means the text the user sent after the skill activation phrase. diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 9040837..6e1694f 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; @@ -2121,3 +2122,164 @@ test("setup and status honor --cwd when reading shared session runtime", () => { assert.equal(payload.sessionRuntime.mode, "shared"); assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock"); }); + +// --- run-skill failure path tests --- + +test("run-skill --list outputs no skills message when skills directory is empty", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const codexHome = path.join(binDir, "fake-codex-home"); + fs.mkdirSync(path.join(codexHome, "skills"), { recursive: true }); + + const result = run("node", [SCRIPT, "run-skill", "--list", "--cwd", repo], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_HOME: codexHome } + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /No Codex skills found/); +}); + +test("run-skill requires --skill or --list", () => { + const repo = makeTempDir(); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "run-skill", "--cwd", repo], { + cwd: repo, + env: process.env + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Specify a skill with --skill/); +}); + +test("run-skill reports error when Codex is not available", () => { + const repo = makeTempDir(); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const codexHome = path.join(os.tmpdir(), "codex-plugin-test-nocodex-" + process.pid); + fs.mkdirSync(path.join(codexHome, "skills", "test-skill"), { recursive: true }); + fs.writeFileSync(path.join(codexHome, "skills", "test-skill", "SKILL.md"), "---\ndescription: test\n---\n\nTest\n"); + + const emptyBin = makeTempDir(); + const result = run(process.execPath, [SCRIPT, "run-skill", "--skill", "test-skill", "--cwd", repo, "do something"], { + cwd: repo, + env: { ...process.env, PATH: emptyBin, CODEX_HOME: codexHome } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Codex CLI is not installed/); +}); + +test("run-skill reports auth error when Codex auth is expired", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "auth-run-fails"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const codexHome = path.join(binDir, "fake-codex-home"); + fs.mkdirSync(path.join(codexHome, "skills", "test-skill"), { recursive: true }); + fs.writeFileSync(path.join(codexHome, "skills", "test-skill", "SKILL.md"), "---\ndescription: test\n---\n\nTest\n"); + + const result = run("node", [SCRIPT, "run-skill", "--skill", "test-skill", "--cwd", repo, "check auth"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_HOME: codexHome } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /authentication expired; run codex login/); +}); + +test("run-skill reports error for non-existent skill", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const codexHome = path.join(binDir, "fake-codex-home"); + fs.mkdirSync(path.join(codexHome, "skills"), { recursive: true }); + + const result = run("node", [SCRIPT, "run-skill", "--skill", "nonexistent", "--cwd", repo, "test"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_HOME: codexHome } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Skill "nonexistent" not found/); +}); + +test("run-skill executes successfully via app server turn/start", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const codexHome = path.join(binDir, "fake-codex-home"); + const skillDir = path.join(codexHome, "skills", "test-skill"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\ndescription: A test skill\n---\n# Test Skill\n"); + + const result = run("node", [SCRIPT, "run-skill", "--skill", "test-skill", "--cwd", repo, "do something"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_HOME: codexHome } + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Handled the requested task/); + + // Verify prompt includes skill directory path for bundled resource access + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.match(fakeState.lastTurnStart.prompt, /skill.*resources.*located at/); + assert.match(fakeState.lastTurnStart.prompt, new RegExp(skillDir.replace(/[/\\]/g, "[/\\\\]"))); + // Default sandbox is read-only, ephemeral thread + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); + assert.equal(fakeState.lastThreadStart.ephemeral, true); +}); + +test("run-skill --write passes workspace-write sandbox", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const codexHome = path.join(binDir, "fake-codex-home"); + const skillDir = path.join(codexHome, "skills", "write-skill"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\ndescription: A write skill\n---\n# Write Skill\n"); + + const result = run("node", [SCRIPT, "run-skill", "--skill", "write-skill", "--write", "--cwd", repo, "create a file"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_HOME: codexHome } + }); + + assert.equal(result.status, 0, result.stderr); + + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "workspace-write"); +}); diff --git a/tests/skills.test.mjs b/tests/skills.test.mjs new file mode 100644 index 0000000..e69e390 --- /dev/null +++ b/tests/skills.test.mjs @@ -0,0 +1,237 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { makeTempDir } from "./helpers.mjs"; +import { listAvailableSkills, validateSkill } from "../plugins/codex/scripts/lib/skills.mjs"; + +/** + * Create a minimal SKILL.md file in a directory. + */ +function writeSkillMd(dir, { name = "test-skill", description = "A test skill" } = {}) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "SKILL.md"), + `---\nname: ${name}\ndescription: "${description}"\n---\n\nSkill content here.\n` + ); +} + +test("listAvailableSkills returns empty array when directory does not exist", () => { + const tmp = makeTempDir(); + const result = listAvailableSkills(path.join(tmp, "nonexistent")); + assert.deepEqual(result, []); +}); + +test("listAvailableSkills returns empty array for empty skills directory", () => { + const tmp = makeTempDir(); + fs.mkdirSync(path.join(tmp, "skills")); + const result = listAvailableSkills(tmp); + assert.deepEqual(result, []); +}); + +test("listAvailableSkills returns entries for valid skill directories", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + writeSkillMd(path.join(skillsDir, "alpha"), { description: "Alpha skill" }); + writeSkillMd(path.join(skillsDir, "beta"), { description: "Beta skill" }); + + const result = listAvailableSkills(tmp); + + assert.equal(result.length, 2); + assert.equal(result[0].name, "alpha"); + assert.equal(result[0].description, "Alpha skill"); + assert.equal(result[1].name, "beta"); + assert.equal(result[1].description, "Beta skill"); +}); + +test("listAvailableSkills skips directories without SKILL.md", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + writeSkillMd(path.join(skillsDir, "valid-skill"), { description: "Valid" }); + fs.mkdirSync(path.join(skillsDir, "no-skill-file")); + + const result = listAvailableSkills(tmp); + + assert.equal(result.length, 1); + assert.equal(result[0].name, "valid-skill"); +}); + +test("listAvailableSkills returns empty description when SKILL.md has no frontmatter", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + const skillDir = path.join(skillsDir, "bare"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "Just some content without frontmatter.\n"); + + const result = listAvailableSkills(tmp); + + assert.equal(result.length, 1); + assert.equal(result[0].name, "bare"); + assert.equal(result[0].description, ""); +}); + +test("listAvailableSkills follows symlinks", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + const realDir = path.join(tmp, "real-skill"); + writeSkillMd(realDir, { description: "Linked skill" }); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.symlinkSync(realDir, path.join(skillsDir, "linked")); + + const result = listAvailableSkills(tmp); + + assert.equal(result.length, 1); + assert.equal(result[0].name, "linked"); + assert.equal(result[0].description, "Linked skill"); +}); + +test("listAvailableSkills deduplicates user skill over .system skill", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + const systemDir = path.join(skillsDir, ".system"); + + writeSkillMd(path.join(systemDir, "shared"), { description: "System version" }); + writeSkillMd(path.join(skillsDir, "shared"), { description: "User version" }); + + const result = listAvailableSkills(tmp); + + assert.equal(result.length, 1); + assert.equal(result[0].name, "shared"); + assert.equal(result[0].description, "User version"); +}); + +test("listAvailableSkills returns .system skills when no user override", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + writeSkillMd(path.join(skillsDir, ".system", "sys-skill"), { description: "System only" }); + + const result = listAvailableSkills(tmp); + + assert.equal(result.length, 1); + assert.equal(result[0].name, "sys-skill"); + assert.equal(result[0].description, "System only"); +}); + +test("listAvailableSkills sorts results by name", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + writeSkillMd(path.join(skillsDir, "zulu"), { description: "Z" }); + writeSkillMd(path.join(skillsDir, "alpha"), { description: "A" }); + writeSkillMd(path.join(skillsDir, "mango"), { description: "M" }); + + const result = listAvailableSkills(tmp); + + assert.deepEqual( + result.map((s) => s.name), + ["alpha", "mango", "zulu"] + ); +}); + +test("validateSkill returns skill info when skill exists", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + writeSkillMd(path.join(skillsDir, "my-skill"), { description: "My skill" }); + + const result = validateSkill(tmp, "my-skill"); + + assert.equal(result.entry.name, "my-skill"); + assert.equal(result.entry.description, "My skill"); + assert.ok(result.filePath); + assert.match(result.filePath, /my-skill\/SKILL\.md$/); +}); + +test("validateSkill throws with available skills when skill not found", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + writeSkillMd(path.join(skillsDir, "exists"), { description: "Available" }); + + assert.throws( + () => validateSkill(tmp, "missing"), + (err) => { + assert.ok(err instanceof Error); + assert.match(err.message, /Skill "missing" not found/); + assert.match(err.message, /exists/); + return true; + } + ); +}); + +test("validateSkill throws with '(none)' when no skills are available", () => { + const tmp = makeTempDir(); + fs.mkdirSync(path.join(tmp, "skills")); + + assert.throws( + () => validateSkill(tmp, "anything"), + (err) => { + assert.ok(err instanceof Error); + assert.match(err.message, /Skill "anything" not found/); + assert.match(err.message, /\(none\)/); + return true; + } + ); +}); + +test("listAvailableSkills skips broken symlinks gracefully", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.symlinkSync("/nonexistent/path/broken-skill", path.join(skillsDir, "broken")); + + const result = listAvailableSkills(tmp); + assert.deepEqual(result, []); +}); + +test("listAvailableSkills skips non-directory entries", () => { + const tmp = makeTempDir(); + const skillsDir = path.join(tmp, "skills"); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.writeFileSync(path.join(skillsDir, "not-a-dir.txt"), "text file"); + writeSkillMd(path.join(skillsDir, "real-skill"), { description: "Real" }); + + const result = listAvailableSkills(tmp); + assert.equal(result.length, 1); + assert.equal(result[0].name, "real-skill"); +}); + +test("validateSkill rejects path traversal in skill name", () => { + const tmp = makeTempDir(); + fs.mkdirSync(path.join(tmp, "skills"), { recursive: true }); + + // ".." as standalone name + assert.throws( + () => validateSkill(tmp, ".."), + /Invalid skill name/ + ); + + // "." as standalone name + assert.throws( + () => validateSkill(tmp, "."), + /Invalid skill name/ + ); + + // ".." as a segment + assert.throws( + () => validateSkill(tmp, "../etc"), + /Invalid skill name/ + ); + + // Separator characters + assert.throws( + () => validateSkill(tmp, "sub/dir"), + /Invalid skill name/ + ); + + assert.throws( + () => validateSkill(tmp, "sub\\dir"), + /Invalid skill name/ + ); + + // "foo..bar" should NOT be rejected (not a path traversal) + // It should fail with "not found" instead of "Invalid skill name" + assert.throws( + () => validateSkill(tmp, "foo..bar"), + /not found/ + ); +});