Files
oh-my-openagent/src/features/claude-code-command-loader/loader.ts
Nguyen Khac Trung Kien e65d57285f fix: respect OPENCODE_CONFIG_DIR environment variable across all config paths
Multiple files were hardcoding ~/.config/opencode paths instead of using
getOpenCodeConfigDir() which respects the OPENCODE_CONFIG_DIR env var.

This broke profile isolation features like OCX ghost mode, where users
set OPENCODE_CONFIG_DIR to a custom path but oh-my-opencode.json and
other configs weren't being read from that location.

Changes:
- plugin-config.ts: Use getOpenCodeConfigDir() directly
- cli/doctor/checks: Use getOpenCodeConfigDir() for auth and config checks
- tools/lsp/config.ts: Use getOpenCodeConfigDir() for LSP config paths
- command loaders: Use getOpenCodeConfigDir() for global command dirs
- hooks: Use getOpenCodeConfigDir() for hook config paths
- config-path.ts: Mark getUserConfigDir() as deprecated
- tests: Ensure OPENCODE_CONFIG_DIR is properly isolated in tests
2026-01-22 12:15:09 +07:00

145 lines
4.7 KiB
TypeScript

import { promises as fs, type Dirent } from "fs"
import { join, basename } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir, getOpenCodeConfigDir } from "../../shared"
import { log } from "../../shared/logger"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
async function loadCommandsFromDir(
commandsDir: string,
scope: CommandScope,
visited: Set<string> = new Set(),
prefix: string = ""
): Promise<LoadedCommand[]> {
try {
await fs.access(commandsDir)
} catch {
return []
}
let realPath: string
try {
realPath = await fs.realpath(commandsDir)
} catch (error) {
log(`Failed to resolve command directory: ${commandsDir}`, error)
return []
}
if (visited.has(realPath)) {
return []
}
visited.add(realPath)
let entries: Dirent[]
try {
entries = await fs.readdir(commandsDir, { withFileTypes: true })
} catch (error) {
log(`Failed to read command directory: ${commandsDir}`, error)
return []
}
const commands: LoadedCommand[] = []
for (const entry of entries) {
if (entry.isDirectory()) {
if (entry.name.startsWith(".")) continue
const subDirPath = join(commandsDir, entry.name)
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix)
commands.push(...subCommands)
continue
}
if (!isMarkdownFile(entry)) continue
const commandPath = join(commandsDir, entry.name)
const baseCommandName = basename(entry.name, ".md")
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
try {
const content = await fs.readFile(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
${body.trim()}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const formattedDescription = `(${scope}) ${data.description || ""}`
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const definition: CommandDefinition = {
name: commandName,
description: formattedDescription,
template: wrappedTemplate,
agent: data.agent,
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
subtask: data.subtask,
argumentHint: data["argument-hint"],
handoffs: data.handoffs,
}
commands.push({
name: commandName,
path: commandPath,
definition,
scope,
})
} catch (error) {
log(`Failed to parse command: ${commandPath}`, error)
continue
}
}
return commands
}
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
const result: Record<string, CommandDefinition> = {}
for (const cmd of commands) {
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition
result[cmd.name] = openCodeCompatible as CommandDefinition
}
return result
}
export async function loadUserCommands(): Promise<Record<string, CommandDefinition>> {
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const commands = await loadCommandsFromDir(userCommandsDir, "user")
return commandsToRecord(commands)
}
export async function loadProjectCommands(): Promise<Record<string, CommandDefinition>> {
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const commands = await loadCommandsFromDir(projectCommandsDir, "project")
return commandsToRecord(commands)
}
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const opencodeCommandsDir = join(configDir, "command")
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}
export async function loadOpencodeProjectCommands(): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}
export async function loadAllCommands(): Promise<Record<string, CommandDefinition>> {
const [user, project, global, projectOpencode] = await Promise.all([
loadUserCommands(),
loadProjectCommands(),
loadOpencodeGlobalCommands(),
loadOpencodeProjectCommands(),
])
return { ...projectOpencode, ...global, ...project, ...user }
}