From 458e4b9e7f76435c45b7ee16064c0775b2a9f11f Mon Sep 17 00:00:00 2001 From: km-anthropic Date: Sun, 3 Aug 2025 21:05:33 -0700 Subject: [PATCH] feat: ship slash commands with GitHub Action (#381) * feat: add slash command shipping infrastructure - Created /slash-commands/ directory to store bundled slash commands - Added code-review.md slash command for automated PR reviews - Modified setup-claude-code-settings.ts to copy slash commands to ~/.claude/ - Added test coverage for slash command installation - Commands are automatically installed when the GitHub Action runs * fix: simplify slash command implementation to match codebase patterns - Reverted to using Bun's $ shell syntax consistently with the rest of the codebase - Simplified slash command copying to basic shell commands - Removed unnecessary fs/promises complexity - Maintained all functionality and test coverage - More appropriate for GitHub Action context where inputs are trusted * remove test slash command * fix: rename slash_commands_dir to experimental_slash_commands_dir - Added 'experimental' prefix as suggested by Ashwin - Updated all references in action.yml and base-action - Restored accidentally removed code-review.md file --------- Co-authored-by: km-anthropic --- action.yml | 1 + base-action/action.yml | 4 ++ base-action/src/index.ts | 6 +- base-action/src/setup-claude-code-settings.ts | 14 ++++ .../test/setup-claude-code-settings.test.ts | 70 ++++++++++++++++++- 5 files changed, 93 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index cd4c67e..0fd6567 100644 --- a/action.yml +++ b/action.yml @@ -205,6 +205,7 @@ runs: INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} + INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands # Model configuration ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }} diff --git a/base-action/action.yml b/base-action/action.yml index 8e0a556..8a5d28c 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -61,6 +61,9 @@ inputs: description: "Timeout in minutes for Claude Code execution" required: false default: "10" + experimental_slash_commands_dir: + description: "Experimental: Directory containing slash command files to install" + required: false # Authentication settings anthropic_api_key: @@ -143,6 +146,7 @@ runs: INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }} INPUT_CLAUDE_ENV: ${{ inputs.claude_env }} INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }} + INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }} # Provider configuration ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} diff --git a/base-action/src/index.ts b/base-action/src/index.ts index ac6fc6f..f4d3724 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -10,7 +10,11 @@ async function run() { try { validateEnvironmentVariables(); - await setupClaudeCodeSettings(process.env.INPUT_SETTINGS); + await setupClaudeCodeSettings( + process.env.INPUT_SETTINGS, + undefined, // homeDir + process.env.INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR, + ); const promptConfig = await preparePrompt({ prompt: process.env.INPUT_PROMPT || "", diff --git a/base-action/src/setup-claude-code-settings.ts b/base-action/src/setup-claude-code-settings.ts index 0fe6841..6c40cfe 100644 --- a/base-action/src/setup-claude-code-settings.ts +++ b/base-action/src/setup-claude-code-settings.ts @@ -5,6 +5,7 @@ import { readFile } from "fs/promises"; export async function setupClaudeCodeSettings( settingsInput?: string, homeDir?: string, + slashCommandsDir?: string, ) { const home = homeDir ?? homedir(); const settingsPath = `${home}/.claude/settings.json`; @@ -65,4 +66,17 @@ export async function setupClaudeCodeSettings( await $`echo ${JSON.stringify(settings, null, 2)} > ${settingsPath}`.quiet(); console.log(`Settings saved successfully`); + + if (slashCommandsDir) { + console.log( + `Copying slash commands from ${slashCommandsDir} to ${home}/.claude/`, + ); + try { + await $`test -d ${slashCommandsDir}`.quiet(); + await $`cp ${slashCommandsDir}/*.md ${home}/.claude/ 2>/dev/null || true`.quiet(); + console.log(`Slash commands copied successfully`); + } catch (e) { + console.log(`Slash commands directory not found or error copying: ${e}`); + } + } } diff --git a/base-action/test/setup-claude-code-settings.test.ts b/base-action/test/setup-claude-code-settings.test.ts index f9ee487..c5a103b 100644 --- a/base-action/test/setup-claude-code-settings.test.ts +++ b/base-action/test/setup-claude-code-settings.test.ts @@ -3,7 +3,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { setupClaudeCodeSettings } from "../src/setup-claude-code-settings"; import { tmpdir } from "os"; -import { mkdir, writeFile, readFile, rm } from "fs/promises"; +import { mkdir, writeFile, readFile, rm, readdir } from "fs/promises"; import { join } from "path"; const testHomeDir = join( @@ -147,4 +147,72 @@ describe("setupClaudeCodeSettings", () => { expect(settings.newKey).toBe("newValue"); expect(settings.model).toBe("claude-opus-4-20250514"); }); + + test("should copy slash commands to .claude directory when path provided", async () => { + const testSlashCommandsDir = join(testHomeDir, "test-slash-commands"); + await mkdir(testSlashCommandsDir, { recursive: true }); + await writeFile( + join(testSlashCommandsDir, "test-command.md"), + "---\ndescription: Test command\n---\nTest content", + ); + + await setupClaudeCodeSettings(undefined, testHomeDir, testSlashCommandsDir); + + const testCommandPath = join(testHomeDir, ".claude", "test-command.md"); + const content = await readFile(testCommandPath, "utf-8"); + expect(content).toContain("Test content"); + }); + + test("should skip slash commands when no directory provided", async () => { + await setupClaudeCodeSettings(undefined, testHomeDir); + + const settingsContent = await readFile(settingsPath, "utf-8"); + const settings = JSON.parse(settingsContent); + expect(settings.enableAllProjectMcpServers).toBe(true); + }); + + test("should handle missing slash commands directory gracefully", async () => { + const nonExistentDir = join(testHomeDir, "non-existent"); + + await setupClaudeCodeSettings(undefined, testHomeDir, nonExistentDir); + + const settingsContent = await readFile(settingsPath, "utf-8"); + expect(JSON.parse(settingsContent).enableAllProjectMcpServers).toBe(true); + }); + + test("should skip non-.md files in slash commands directory", async () => { + const testSlashCommandsDir = join(testHomeDir, "test-slash-commands"); + await mkdir(testSlashCommandsDir, { recursive: true }); + await writeFile(join(testSlashCommandsDir, "not-markdown.txt"), "ignored"); + await writeFile(join(testSlashCommandsDir, "valid.md"), "copied"); + await writeFile(join(testSlashCommandsDir, "another.md"), "also copied"); + + await setupClaudeCodeSettings(undefined, testHomeDir, testSlashCommandsDir); + + const copiedFiles = await readdir(join(testHomeDir, ".claude")); + expect(copiedFiles).toContain("valid.md"); + expect(copiedFiles).toContain("another.md"); + expect(copiedFiles).not.toContain("not-markdown.txt"); + expect(copiedFiles).toContain("settings.json"); // Settings should also exist + }); + + test("should handle slash commands path that is a file not directory", async () => { + const testFile = join(testHomeDir, "not-a-directory.txt"); + await writeFile(testFile, "This is a file, not a directory"); + + await setupClaudeCodeSettings(undefined, testHomeDir, testFile); + + const settingsContent = await readFile(settingsPath, "utf-8"); + expect(JSON.parse(settingsContent).enableAllProjectMcpServers).toBe(true); + }); + + test("should handle empty slash commands directory", async () => { + const emptyDir = join(testHomeDir, "empty-slash-commands"); + await mkdir(emptyDir, { recursive: true }); + + await setupClaudeCodeSettings(undefined, testHomeDir, emptyDir); + + const settingsContent = await readFile(settingsPath, "utf-8"); + expect(JSON.parse(settingsContent).enableAllProjectMcpServers).toBe(true); + }); });