From bb85e40a787443003d205d338a3ea047b3e5ce3c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 2 Apr 2026 10:36:18 +0900 Subject: [PATCH] fix(command-discovery): skip non-directory .claude/commands path When .claude/commands exists as a file instead of a directory, readdirSync throws ENOTDIR and crashes command discovery, stalling OMO initialization. Add statSync().isDirectory() guard with a warning log. Fixes #3010 --- .../slashcommand/command-discovery.test.ts | 49 +++++++++++++++++++ src/tools/slashcommand/command-discovery.ts | 8 ++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/tools/slashcommand/command-discovery.test.ts b/src/tools/slashcommand/command-discovery.test.ts index dd979e6f9..21b74e899 100644 --- a/src/tools/slashcommand/command-discovery.test.ts +++ b/src/tools/slashcommand/command-discovery.test.ts @@ -267,3 +267,52 @@ Use nested command. expect(startWorkCommand?.metadata.agent).toBe("atlas") }) }) + +describe("non-directory commands path", () => { + let testDir: string + let savedEnv: Record + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "omo-cmd-file-")) + savedEnv = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + } + process.env.CLAUDE_CONFIG_DIR = join(testDir, "claude-config") + process.env.OPENCODE_CONFIG_DIR = join(testDir, "opencode-config") + mkdirSync(join(testDir, "claude-config"), { recursive: true }) + mkdirSync(join(testDir, "opencode-config"), { recursive: true }) + }) + + afterEach(() => { + Object.entries(savedEnv).forEach(([k, v]) => { + if (v === undefined) delete process.env[k] + else process.env[k] = v + }) + rmSync(testDir, { recursive: true, force: true }) + }) + + it("#given .claude/commands is a file #when discoverCommandsSync runs #then returns without crashing", () => { + const projectDir = join(testDir, "project") + mkdirSync(join(projectDir, ".claude"), { recursive: true }) + writeFileSync(join(projectDir, ".claude", "commands"), "") // file, not directory + + // Should not throw + const commands = discoverCommandsSync(projectDir) + expect(commands).toBeInstanceOf(Array) + }) + + it("#given .claude/commands is a directory #when discoverCommandsSync runs #then discovers commands normally", () => { + const projectDir = join(testDir, "project") + mkdirSync(join(projectDir, ".claude", "commands"), { recursive: true }) + writeFileSync( + join(projectDir, ".claude", "commands", "test-cmd.md"), + "---\ndescription: Test\n---\nTest command content.\n", + ) + + const commands = discoverCommandsSync(projectDir) + const testCmd = commands.find((c) => c.name === "test-cmd") + expect(testCmd).toBeDefined() + expect(testCmd?.content).toContain("Test command content.") + }) +}) diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index dc8922381..7d220ab4f 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync } from "fs" +import { existsSync, readdirSync, readFileSync, statSync } from "fs" import { basename, join } from "path" import { parseFrontmatter, @@ -9,7 +9,7 @@ import { } from "../../shared" import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" import { isMarkdownFile } from "../../shared/file-utils" -import { getClaudeConfigDir } from "../../shared" +import { getClaudeConfigDir, log } from "../../shared" import { loadBuiltinCommands } from "../../features/builtin-commands" import type { CommandInfo, CommandMetadata, CommandScope } from "./types" @@ -26,6 +26,10 @@ function discoverCommandsFromDir( prefix = "", ): CommandInfo[] { if (!existsSync(commandsDir)) return [] + if (!statSync(commandsDir).isDirectory()) { + log(`[command-discovery] Skipping non-directory path: ${commandsDir}`) + return [] + } const entries = readdirSync(commandsDir, { withFileTypes: true }) const commands: CommandInfo[] = []