From ea14a1a346164d51eb7cae78bcfe7adf9cbfc68b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 31 Mar 2026 22:08:00 -0700 Subject: [PATCH] fix(auto-slash-command): resolve project commands from session dir Use the plugin session directory instead of process.cwd() when resolving project slash commands. This restores project and opencode-project slashcommand behavior when the runtime cwd differs from the actual session workspace. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/auto-slash-command/executor.ts | 3 +- src/hooks/auto-slash-command/hook.ts | 2 + src/hooks/auto-slash-command/index.test.ts | 39 ++++++++++++++++++- src/plugin/hooks/create-skill-hooks.ts | 1 + .../execution-compatibility.test.ts | 28 +++++++++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index 579da6d34..eedd8881f 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -42,11 +42,12 @@ export interface ExecutorOptions { pluginsEnabled?: boolean enabledPluginsOverride?: Record agent?: string + directory?: string } async function discoverAllCommands(options?: ExecutorOptions): Promise { - const discoveredCommands = discoverCommandsSync(process.cwd(), { + const discoveredCommands = discoverCommandsSync(options?.directory ?? process.cwd(), { pluginsEnabled: options?.pluginsEnabled, enabledPluginsOverride: options?.enabledPluginsOverride, }) diff --git a/src/hooks/auto-slash-command/hook.ts b/src/hooks/auto-slash-command/hook.ts index 07aba8d78..73083f20d 100644 --- a/src/hooks/auto-slash-command/hook.ts +++ b/src/hooks/auto-slash-command/hook.ts @@ -68,6 +68,7 @@ export interface AutoSlashCommandHookOptions { skills?: LoadedSkill[] pluginsEnabled?: boolean enabledPluginsOverride?: Record + directory?: string } export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) { @@ -75,6 +76,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions skills: options?.skills, pluginsEnabled: options?.pluginsEnabled, enabledPluginsOverride: options?.enabledPluginsOverride, + directory: options?.directory, } const sessionProcessedCommands = createProcessedCommandStore() const sessionProcessedCommandExecutions = createProcessedCommandStore() diff --git a/src/hooks/auto-slash-command/index.test.ts b/src/hooks/auto-slash-command/index.test.ts index 37fa4ab6f..ad073b337 100644 --- a/src/hooks/auto-slash-command/index.test.ts +++ b/src/hooks/auto-slash-command/index.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test" +import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import type { LoadedSkill } from "../../features/opencode-skill-loader/types" import type { AutoSlashCommandHookInput, @@ -39,11 +42,45 @@ function createMockOutput(text: string): AutoSlashCommandHookOutput { } describe("createAutoSlashCommandHook", () => { + let tempDir = "" + let originalWorkingDirectory = "" + beforeEach(() => { logMock.mockClear() + tempDir = mkdtempSync(join(tmpdir(), "omo-auto-slash-hook-test-")) + originalWorkingDirectory = process.cwd() + }) + + afterEach(() => { + process.chdir(originalWorkingDirectory) + rmSync(tempDir, { recursive: true, force: true }) }) describe("slash command replacement", () => { + it("should resolve project commands from provided directory even when cwd differs", async () => { + // given + const projectDir = join(tempDir, "project") + const commandDir = join(projectDir, ".claude", "commands") + mkdirSync(commandDir, { recursive: true }) + writeFileSync( + join(commandDir, "project-only-command.md"), + `---\ndescription: Project command\n---\nExecute from project directory.\n`, + ) + process.chdir("/tmp") + + const hook = createAutoSlashCommandHook({ directory: projectDir }) + const input = createMockInput(`test-session-project-${Date.now()}`) + const output = createMockOutput("/project-only-command") + + // when + await hook["chat.message"](input, output) + + // then + expect(output.parts[0].text).toContain("") + expect(output.parts[0].text).toContain("Execute from project directory.") + expect(output.parts[0].text).toContain("**Scope**: project") + }) + it("should not modify message when command not found", async () => { // given a slash command that doesn't exist const hook = createAutoSlashCommandHook() diff --git a/src/plugin/hooks/create-skill-hooks.ts b/src/plugin/hooks/create-skill-hooks.ts index b0514d583..27de86f65 100644 --- a/src/plugin/hooks/create-skill-hooks.ts +++ b/src/plugin/hooks/create-skill-hooks.ts @@ -42,6 +42,7 @@ export function createSkillHooks(args: { skills: mergedSkills, pluginsEnabled: pluginConfig.claude_code?.plugins ?? true, enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, + directory: ctx.directory, })) : null diff --git a/src/tools/slashcommand/execution-compatibility.test.ts b/src/tools/slashcommand/execution-compatibility.test.ts index 92ef26216..c23fbe1a9 100644 --- a/src/tools/slashcommand/execution-compatibility.test.ts +++ b/src/tools/slashcommand/execution-compatibility.test.ts @@ -60,4 +60,32 @@ describe("slashcommand discovery and execution compatibility", () => { expect(result.replacementText).toContain("Execute from parent config.") expect(result.replacementText).toContain("**Scope**: opencode") }) + + it("executes project commands using the provided directory even when cwd differs", async () => { + // given + const projectDir = join(tempDir, "project") + const commandDir = join(projectDir, ".claude", "commands") + const commandName = "project-only-command" + + mkdirSync(commandDir, { recursive: true }) + writeFileSync( + join(commandDir, `${commandName}.md`), + `---\ndescription: Project command\n---\nExecute from project directory.\n`, + ) + process.chdir("/tmp") + + expect(discoverCommandsSync(projectDir).some(command => command.name === commandName)).toBe(true) + + // when + const result = await executeSlashCommand({ + command: commandName, + args: "", + raw: `/${commandName}`, + }, { skills: [], directory: projectDir }) + + // then + expect(result.success).toBe(true) + expect(result.replacementText).toContain("Execute from project directory.") + expect(result.replacementText).toContain("**Scope**: project") + }) })