Compare commits

..

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
deef9d864b fix(slashcommand): use "commands" (plural) for OpenCode command directories
Closes #1918
2026-02-24 21:37:02 +09:00
16 changed files with 197 additions and 242 deletions

View File

@@ -1,47 +0,0 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { initConfigContext, resetConfigContext } from "./config-context"
import { runBunInstallWithDetails } from "./bun-install"
describe("bun-install", () => {
let originalPlatform: NodeJS.Platform
beforeEach(() => {
originalPlatform = process.platform
resetConfigContext()
initConfigContext("opencode", null)
})
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
resetConfigContext()
})
test("#given Windows with bun.exe on PATH #when runBunInstallWithDetails is called #then uses bun.exe", async () => {
Object.defineProperty(process, "platform", { value: "win32" })
const whichSpy = spyOn(Bun, "which")
.mockImplementation((binary: string) => {
if (binary === "bun.exe") {
return "C:\\Tools\\bun.exe"
}
return null
})
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
exited: Promise.resolve(0),
exitCode: 0,
kill: () => {},
} as unknown as ReturnType<typeof Bun.spawn>)
try {
const result = await runBunInstallWithDetails()
expect(result.success).toBe(true)
expect(spawnSpy).toHaveBeenCalledTimes(1)
expect(spawnSpy.mock.calls[0]?.[0]).toEqual(["C:\\Tools\\bun.exe", "install"])
} finally {
spawnSpy.mockRestore()
whichSpy.mockRestore()
}
})
})

View File

@@ -9,14 +9,6 @@ export interface BunInstallResult {
error?: string
}
function resolveBunCommand(): string {
if (process.platform === "win32") {
return Bun.which("bun.exe") ?? Bun.which("bun") ?? "bun.exe"
}
return Bun.which("bun") ?? "bun"
}
export async function runBunInstall(): Promise<boolean> {
const result = await runBunInstallWithDetails()
return result.success
@@ -24,8 +16,7 @@ export async function runBunInstall(): Promise<boolean> {
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const bunCommand = resolveBunCommand()
const proc = Bun.spawn([bunCommand, "install"], {
const proc = Bun.spawn(["bun", "install"], {
cwd: getConfigDir(),
stdout: "inherit",
stderr: "inherit",
@@ -48,7 +39,7 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually in ${getConfigDir()}: bun install`,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`,
}
}
@@ -64,7 +55,7 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
const message = err instanceof Error ? err.message : String(err)
return {
success: false,
error: `bun install failed: ${message}. Ensure Bun is installed and available in PATH: https://bun.sh/docs/installation`,
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
}
}
}

View File

@@ -0,0 +1,59 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { loadOpencodeGlobalCommands, loadOpencodeProjectCommands } from "./loader"
const testRoots: string[] = []
function createTempRoot(): string {
const root = join(tmpdir(), `command-loader-${Date.now()}-${Math.random().toString(16).slice(2)}`)
mkdirSync(root, { recursive: true })
testRoots.push(root)
return root
}
function writeCommand(dir: string, name: string): void {
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, `${name}.md`),
"---\ndescription: command from test\n---\nUse this command"
)
}
afterEach(() => {
for (const root of testRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
delete process.env.OPENCODE_CONFIG_DIR
})
describe("claude-code-command-loader OpenCode paths", () => {
it("loads commands from global OpenCode commands directory", async () => {
// given
const root = createTempRoot()
const opencodeConfigDir = join(root, "config")
writeCommand(join(opencodeConfigDir, "commands"), "global-opencode")
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
// when
const commands = await loadOpencodeGlobalCommands()
// then
expect(commands["global-opencode"]).toBeDefined()
})
it("loads commands from project OpenCode commands directory", async () => {
// given
const root = createTempRoot()
writeCommand(join(root, ".opencode", "commands"), "project-opencode")
// when
const commands = await loadOpencodeProjectCommands(root)
// then
expect(commands["project-opencode"]).toBeDefined()
})
})

View File

@@ -122,13 +122,13 @@ export async function loadProjectCommands(directory?: string): Promise<Record<st
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const opencodeCommandsDir = join(configDir, "command")
const opencodeCommandsDir = join(configDir, "commands")
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}
export async function loadOpencodeProjectCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "commands")
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}

View File

@@ -0,0 +1,63 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { executeSlashCommand } from "./executor"
const testRoots: string[] = []
function createTempRoot(): string {
const root = join(tmpdir(), `auto-slash-executor-${Date.now()}-${Math.random().toString(16).slice(2)}`)
mkdirSync(root, { recursive: true })
testRoots.push(root)
return root
}
function writeCommand(dir: string, name: string): void {
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, `${name}.md`),
"---\ndescription: command from test\n---\nRun from OpenCode command directory"
)
}
afterEach(() => {
for (const root of testRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
delete process.env.OPENCODE_CONFIG_DIR
})
describe("auto-slash-command executor OpenCode paths", () => {
it("resolves commands from OpenCode global and project plural directories", async () => {
// given
const root = createTempRoot()
const opencodeConfigDir = join(root, "config")
writeCommand(join(opencodeConfigDir, "commands"), "global-cmd")
writeCommand(join(root, ".opencode", "commands"), "project-cmd")
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
const originalCwd = process.cwd()
process.chdir(root)
try {
// when
const globalResult = await executeSlashCommand(
{ command: "global-cmd", args: "", raw: "/global-cmd" },
{ skills: [] }
)
const projectResult = await executeSlashCommand(
{ command: "project-cmd", args: "", raw: "/project-cmd" },
{ skills: [] }
)
// then
expect(globalResult.success).toBe(true)
expect(projectResult.success).toBe(true)
} finally {
process.chdir(originalCwd)
}
})
})

View File

@@ -105,8 +105,8 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(configDir, "command")
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const opencodeGlobalDir = join(configDir, "commands")
const opencodeProjectDir = join(process.cwd(), ".opencode", "commands")
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")

View File

@@ -1,39 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { findBashPath } from "./shell-path"
describe("shell-path", () => {
let originalPlatform: NodeJS.Platform
let originalComspec: string | undefined
beforeEach(() => {
originalPlatform = process.platform
originalComspec = process.env.COMSPEC
})
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
if (originalComspec !== undefined) {
process.env.COMSPEC = originalComspec
return
}
delete process.env.COMSPEC
})
test("#given Windows platform with COMSPEC #when findBashPath is called #then returns COMSPEC path", () => {
Object.defineProperty(process, "platform", { value: "win32" })
process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe"
const result = findBashPath()
expect(result).toBe("C:\\Windows\\System32\\cmd.exe")
})
test("#given Windows platform without COMSPEC #when findBashPath is called #then returns default cmd path", () => {
Object.defineProperty(process, "platform", { value: "win32" })
delete process.env.COMSPEC
const result = findBashPath()
expect(result).toBe("C:\\Windows\\System32\\cmd.exe")
})
})

View File

@@ -2,7 +2,6 @@ import { existsSync } from "node:fs"
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
const DEFAULT_WINDOWS_CMD_PATH = "C:\\Windows\\System32\\cmd.exe"
function findShellPath(
defaultPaths: string[],
@@ -20,17 +19,9 @@ function findShellPath(
}
export function findZshPath(customZshPath?: string): string | null {
if (process.platform === "win32") {
return process.env.COMSPEC?.trim() || DEFAULT_WINDOWS_CMD_PATH
}
return findShellPath(DEFAULT_ZSH_PATHS, customZshPath)
}
export function findBashPath(): string | null {
if (process.platform === "win32") {
return process.env.COMSPEC?.trim() || DEFAULT_WINDOWS_CMD_PATH
}
return findShellPath(DEFAULT_BASH_PATHS)
}

View File

@@ -1,57 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { homedir } from "node:os"
import { join } from "node:path"
import { getCacheDir, getDataDir } from "./data-path"
describe("data-path", () => {
let originalPlatform: NodeJS.Platform
let originalEnv: Record<string, string | undefined>
beforeEach(() => {
originalPlatform = process.platform
originalEnv = {
LOCALAPPDATA: process.env.LOCALAPPDATA,
APPDATA: process.env.APPDATA,
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
}
})
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
for (const [key, value] of Object.entries(originalEnv)) {
if (value !== undefined) {
process.env[key] = value
} else {
delete process.env[key]
}
}
})
test("#given Windows with LOCALAPPDATA #when getDataDir is called #then returns LOCALAPPDATA", () => {
Object.defineProperty(process, "platform", { value: "win32" })
process.env.LOCALAPPDATA = "C:\\Users\\TestUser\\AppData\\Local"
const result = getDataDir()
expect(result).toBe("C:\\Users\\TestUser\\AppData\\Local")
})
test("#given Windows without LOCALAPPDATA #when getDataDir is called #then falls back to AppData Local", () => {
Object.defineProperty(process, "platform", { value: "win32" })
delete process.env.LOCALAPPDATA
const result = getDataDir()
expect(result).toBe(join(homedir(), "AppData", "Local"))
})
test("#given Windows with LOCALAPPDATA #when getCacheDir is called #then returns Local cache path", () => {
Object.defineProperty(process, "platform", { value: "win32" })
process.env.LOCALAPPDATA = "C:\\Users\\TestUser\\AppData\\Local"
const result = getCacheDir()
expect(result).toBe(join("C:\\Users\\TestUser\\AppData\\Local", "cache"))
})
})

View File

@@ -10,10 +10,6 @@ import * as os from "node:os"
* including Windows, so we match that behavior exactly.
*/
export function getDataDir(): string {
if (process.platform === "win32") {
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local")
}
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")
}
@@ -31,11 +27,6 @@ export function getOpenCodeStorageDir(): string {
* - All platforms: XDG_CACHE_HOME or ~/.cache
*/
export function getCacheDir(): string {
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local")
return path.join(localAppData, "cache")
}
return process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache")
}

View File

@@ -179,7 +179,7 @@ describe("opencode-config-dir", () => {
expect(result).toBe(join(homedir(), ".config", "opencode"))
})
test("returns %APPDATA%/opencode on Windows by default", () => {
test("returns ~/.config/opencode on Windows by default", () => {
// given opencode CLI binary detected, platform is Windows
Object.defineProperty(process, "platform", { value: "win32" })
delete process.env.APPDATA
@@ -188,8 +188,8 @@ describe("opencode-config-dir", () => {
// when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200", checkExisting: false })
// then returns %APPDATA%/opencode
expect(result).toBe(join(homedir(), "AppData", "Roaming", "opencode"))
// then returns ~/.config/opencode (cross-platform default)
expect(result).toBe(join(homedir(), ".config", "opencode"))
})
})

View File

@@ -42,32 +42,29 @@ function getTauriConfigDir(identifier: string): string {
}
}
function getCliConfigDir(checkExisting = true): string {
function getCliConfigDir(): string {
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
if (envConfigDir) {
return resolve(envConfigDir)
}
if (process.platform === "win32") {
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming")
const appdataDir = join(appData, "opencode")
const crossPlatformDir = join(homedir(), ".config", "opencode")
if (!checkExisting) {
return appdataDir
}
const appdataConfig = join(appdataDir, "opencode.json")
const crossPlatformConfig = join(crossPlatformDir, "opencode.json")
if (existsSync(appdataConfig)) {
return appdataDir
}
if (existsSync(crossPlatformConfig)) {
return crossPlatformDir
}
return appdataDir
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming")
const appdataDir = join(appData, "opencode")
const appdataConfig = join(appdataDir, "opencode.json")
if (existsSync(appdataConfig)) {
return appdataDir
}
return crossPlatformDir
}
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
@@ -78,14 +75,14 @@ export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string
const { binary, version, checkExisting = true } = options
if (binary === "opencode") {
return getCliConfigDir(checkExisting)
return getCliConfigDir()
}
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
const tauriDir = getTauriConfigDir(identifier)
if (checkExisting) {
const legacyDir = getCliConfigDir(true)
const legacyDir = getCliConfigDir()
const legacyConfig = join(legacyDir, "opencode.json")
const legacyConfigC = join(legacyDir, "opencode.jsonc")

View File

@@ -10,7 +10,6 @@ describe("shell-env", () => {
originalEnv = {
SHELL: process.env.SHELL,
PSModulePath: process.env.PSModulePath,
COMSPEC: process.env.COMSPEC,
}
})
@@ -58,7 +57,6 @@ describe("shell-env", () => {
test("#given Windows platform without PSModulePath #when detectShellType is called #then returns cmd", () => {
delete process.env.PSModulePath
delete process.env.SHELL
delete process.env.COMSPEC
Object.defineProperty(process, "platform", { value: "win32" })
const result = detectShellType()
@@ -79,35 +77,12 @@ describe("shell-env", () => {
test("#given PSModulePath takes priority over SHELL #when both are set #then returns powershell", () => {
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
process.env.SHELL = "/bin/bash"
process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe"
Object.defineProperty(process, "platform", { value: "win32" })
const result = detectShellType()
expect(result).toBe("powershell")
})
test("#given Windows COMSPEC points to powershell #when detectShellType is called #then returns powershell", () => {
delete process.env.PSModulePath
delete process.env.SHELL
process.env.COMSPEC = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
Object.defineProperty(process, "platform", { value: "win32" })
const result = detectShellType()
expect(result).toBe("powershell")
})
test("#given Windows COMSPEC points to bash executable #when detectShellType is called #then returns unix", () => {
delete process.env.PSModulePath
delete process.env.SHELL
process.env.COMSPEC = "C:\\Program Files\\Git\\bin\\bash.exe"
Object.defineProperty(process, "platform", { value: "win32" })
const result = detectShellType()
expect(result).toBe("unix")
})
})
describe("shellEscape", () => {

View File

@@ -13,29 +13,11 @@ export function detectShellType(): ShellType {
return "powershell"
}
if (process.platform === "win32") {
const comspec = process.env.COMSPEC ?? process.env.ComSpec
const normalizedComspec = comspec?.toLowerCase()
if (normalizedComspec?.includes("powershell") || normalizedComspec?.includes("pwsh")) {
return "powershell"
}
if (normalizedComspec?.includes("bash") || normalizedComspec?.includes("zsh")) {
return "unix"
}
if (process.env.SHELL) {
return "unix"
}
return "cmd"
}
if (process.env.SHELL) {
return "unix"
}
return "unix"
return process.platform === "win32" ? "cmd" : "unix"
}
/**

View File

@@ -52,8 +52,8 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(configDir, "command")
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
const opencodeGlobalDir = join(configDir, "commands")
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "commands")
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")

View File

@@ -1,6 +1,27 @@
import { describe, expect, it } from "bun:test"
/// <reference types="bun-types" />
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import * as slashcommand from "./index"
const testRoots: string[] = []
function createTempRoot(): string {
const root = join(tmpdir(), `slashcommand-discovery-${Date.now()}-${Math.random().toString(16).slice(2)}`)
mkdirSync(root, { recursive: true })
testRoots.push(root)
return root
}
afterEach(() => {
for (const root of testRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
delete process.env.OPENCODE_CONFIG_DIR
})
describe("slashcommand module exports", () => {
it("exports discovery API only", () => {
// given
@@ -14,4 +35,32 @@ describe("slashcommand module exports", () => {
expect(exportNames).not.toContain("createSlashcommandTool")
expect(exportNames).not.toContain("slashcommand")
})
it("discovers commands from OpenCode plural command directories", () => {
// given
const root = createTempRoot()
const opencodeConfigDir = join(root, "config")
const globalCommandsDir = join(opencodeConfigDir, "commands")
const projectCommandsDir = join(root, ".opencode", "commands")
mkdirSync(globalCommandsDir, { recursive: true })
mkdirSync(projectCommandsDir, { recursive: true })
writeFileSync(
join(globalCommandsDir, "global-cmd.md"),
"---\ndescription: global command\n---\nGlobal command body"
)
writeFileSync(
join(projectCommandsDir, "project-cmd.md"),
"---\ndescription: project command\n---\nProject command body"
)
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
// when
const commands = slashcommand.discoverCommandsSync(root)
// then
expect(commands.some((cmd) => cmd.name === "global-cmd" && cmd.scope === "opencode")).toBe(true)
expect(commands.some((cmd) => cmd.name === "project-cmd" && cmd.scope === "opencode-project")).toBe(true)
})
})