Merge pull request #2844 from code-yeongyu/fix/opencode-followup-gaps
fix: close remaining upstream path discovery gaps
This commit is contained in:
@@ -48,6 +48,20 @@ export async function loadOpencodeProjectSkills(directory?: string): Promise<Rec
|
||||
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
|
||||
}
|
||||
|
||||
export async function loadProjectAgentsSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
const agentsProjectSkillDirs = findProjectAgentsSkillDirs(directory ?? process.cwd())
|
||||
const allSkills = await Promise.all(
|
||||
agentsProjectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
|
||||
)
|
||||
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
|
||||
}
|
||||
|
||||
export async function loadGlobalAgentsSkills(): Promise<Record<string, CommandDefinition>> {
|
||||
const agentsGlobalDir = join(homedir(), ".agents", "skills")
|
||||
const skills = await loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" })
|
||||
return skillsToCommandDefinitionRecord(skills)
|
||||
}
|
||||
|
||||
export interface DiscoverSkillsOptions {
|
||||
includeClaudeCodePaths?: boolean
|
||||
directory?: string
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as sisyphusJunior from "../agents/sisyphus-junior"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import * as agentLoader from "../features/claude-code-agent-loader"
|
||||
import * as skillLoader from "../features/opencode-skill-loader"
|
||||
import type { LoadedSkill } from "../features/opencode-skill-loader"
|
||||
import { getAgentDisplayName } from "../shared/agent-display-names"
|
||||
import { applyAgentConfig } from "./agent-config-handler"
|
||||
import type { PluginComponents } from "./plugin-components-loader"
|
||||
@@ -51,6 +52,8 @@ describe("applyAgentConfig builtin override protection", () => {
|
||||
let discoverProjectClaudeSkillsSpy: ReturnType<typeof spyOn>
|
||||
let discoverOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>
|
||||
let discoverOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>
|
||||
let discoverProjectAgentsSkillsSpy: ReturnType<typeof spyOn>
|
||||
let discoverGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>
|
||||
let loadUserAgentsSpy: ReturnType<typeof spyOn>
|
||||
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
|
||||
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
|
||||
@@ -121,6 +124,14 @@ describe("applyAgentConfig builtin override protection", () => {
|
||||
skillLoader,
|
||||
"discoverOpencodeProjectSkills",
|
||||
).mockResolvedValue([])
|
||||
discoverProjectAgentsSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverProjectAgentsSkills",
|
||||
).mockResolvedValue([])
|
||||
discoverGlobalAgentsSkillsSpy = spyOn(
|
||||
skillLoader,
|
||||
"discoverGlobalAgentsSkills",
|
||||
).mockResolvedValue([])
|
||||
|
||||
loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
|
||||
loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
|
||||
@@ -139,6 +150,8 @@ describe("applyAgentConfig builtin override protection", () => {
|
||||
discoverProjectClaudeSkillsSpy.mockRestore()
|
||||
discoverOpencodeGlobalSkillsSpy.mockRestore()
|
||||
discoverOpencodeProjectSkillsSpy.mockRestore()
|
||||
discoverProjectAgentsSkillsSpy.mockRestore()
|
||||
discoverGlobalAgentsSkillsSpy.mockRestore()
|
||||
loadUserAgentsSpy.mockRestore()
|
||||
loadProjectAgentsSpy.mockRestore()
|
||||
migrateAgentConfigSpy.mockRestore()
|
||||
@@ -279,4 +292,45 @@ describe("applyAgentConfig builtin override protection", () => {
|
||||
// then
|
||||
expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", false)
|
||||
})
|
||||
|
||||
test("includes project and global .agents skills in builtin agent awareness", async () => {
|
||||
// given
|
||||
const projectAgentsSkill = {
|
||||
name: "project-agent-skill",
|
||||
definition: {
|
||||
name: "project-agent-skill",
|
||||
description: "Project agent skill",
|
||||
template: "template",
|
||||
},
|
||||
scope: "project",
|
||||
} satisfies LoadedSkill
|
||||
const globalAgentsSkill = {
|
||||
name: "global-agent-skill",
|
||||
definition: {
|
||||
name: "global-agent-skill",
|
||||
description: "Global agent skill",
|
||||
template: "template",
|
||||
},
|
||||
scope: "user",
|
||||
} satisfies LoadedSkill
|
||||
discoverProjectAgentsSkillsSpy.mockResolvedValue([projectAgentsSkill])
|
||||
discoverGlobalAgentsSkillsSpy.mockResolvedValue([globalAgentsSkill])
|
||||
|
||||
// when
|
||||
await applyAgentConfig({
|
||||
config: createBaseConfig(),
|
||||
pluginConfig: createPluginConfig(),
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginComponents: createPluginComponents(),
|
||||
})
|
||||
|
||||
// then
|
||||
const discoveredSkills = createBuiltinAgentsSpy.mock.calls[0]?.[6]
|
||||
expect(discoveredSkills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "project-agent-skill" }),
|
||||
expect.objectContaining({ name: "global-agent-skill" }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,8 +6,10 @@ import { AGENT_NAME_MAP } from "../shared/migration";
|
||||
import { getAgentDisplayName } from "../shared/agent-display-names";
|
||||
import {
|
||||
discoverConfigSourceSkills,
|
||||
discoverGlobalAgentsSkills,
|
||||
discoverOpencodeGlobalSkills,
|
||||
discoverOpencodeProjectSkills,
|
||||
discoverProjectAgentsSkills,
|
||||
discoverProjectClaudeSkills,
|
||||
discoverUserClaudeSkills,
|
||||
} from "../features/opencode-skill-loader";
|
||||
@@ -52,8 +54,10 @@ export async function applyAgentConfig(params: {
|
||||
discoveredConfigSourceSkills,
|
||||
discoveredUserSkills,
|
||||
discoveredProjectSkills,
|
||||
discoveredProjectAgentsSkills,
|
||||
discoveredOpencodeGlobalSkills,
|
||||
discoveredOpencodeProjectSkills,
|
||||
discoveredGlobalAgentsSkills,
|
||||
] = await Promise.all([
|
||||
discoverConfigSourceSkills({
|
||||
config: params.pluginConfig.skills,
|
||||
@@ -63,16 +67,22 @@ export async function applyAgentConfig(params: {
|
||||
includeClaudeSkillsForAwareness
|
||||
? discoverProjectClaudeSkills(params.ctx.directory)
|
||||
: Promise.resolve([]),
|
||||
includeClaudeSkillsForAwareness
|
||||
? discoverProjectAgentsSkills(params.ctx.directory)
|
||||
: Promise.resolve([]),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
discoverOpencodeProjectSkills(params.ctx.directory),
|
||||
includeClaudeSkillsForAwareness ? discoverGlobalAgentsSkills() : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const allDiscoveredSkills = [
|
||||
...discoveredConfigSourceSkills,
|
||||
...discoveredOpencodeProjectSkills,
|
||||
...discoveredProjectSkills,
|
||||
...discoveredProjectAgentsSkills,
|
||||
...discoveredOpencodeGlobalSkills,
|
||||
...discoveredUserSkills,
|
||||
...discoveredGlobalAgentsSkills,
|
||||
];
|
||||
|
||||
const browserProvider =
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import * as builtinCommands from "../features/builtin-commands";
|
||||
import * as commandLoader from "../features/claude-code-command-loader";
|
||||
import * as skillLoader from "../features/opencode-skill-loader";
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import type { PluginComponents } from "./plugin-components-loader";
|
||||
import { applyCommandConfig } from "./command-config-handler";
|
||||
|
||||
function createPluginComponents(): PluginComponents {
|
||||
return {
|
||||
commands: {},
|
||||
skills: {},
|
||||
agents: {},
|
||||
mcpServers: {},
|
||||
hooksConfigs: [],
|
||||
plugins: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginConfig(): OhMyOpenCodeConfig {
|
||||
return {};
|
||||
}
|
||||
|
||||
describe("applyCommandConfig", () => {
|
||||
let loadBuiltinCommandsSpy: ReturnType<typeof spyOn>;
|
||||
let loadUserCommandsSpy: ReturnType<typeof spyOn>;
|
||||
let loadProjectCommandsSpy: ReturnType<typeof spyOn>;
|
||||
let loadOpencodeGlobalCommandsSpy: ReturnType<typeof spyOn>;
|
||||
let loadOpencodeProjectCommandsSpy: ReturnType<typeof spyOn>;
|
||||
let discoverConfigSourceSkillsSpy: ReturnType<typeof spyOn>;
|
||||
let loadUserSkillsSpy: ReturnType<typeof spyOn>;
|
||||
let loadProjectSkillsSpy: ReturnType<typeof spyOn>;
|
||||
let loadOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>;
|
||||
let loadOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>;
|
||||
let loadProjectAgentsSkillsSpy: ReturnType<typeof spyOn>;
|
||||
let loadGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
loadBuiltinCommandsSpy = spyOn(builtinCommands, "loadBuiltinCommands").mockReturnValue({});
|
||||
loadUserCommandsSpy = spyOn(commandLoader, "loadUserCommands").mockResolvedValue({});
|
||||
loadProjectCommandsSpy = spyOn(commandLoader, "loadProjectCommands").mockResolvedValue({});
|
||||
loadOpencodeGlobalCommandsSpy = spyOn(commandLoader, "loadOpencodeGlobalCommands").mockResolvedValue({});
|
||||
loadOpencodeProjectCommandsSpy = spyOn(commandLoader, "loadOpencodeProjectCommands").mockResolvedValue({});
|
||||
discoverConfigSourceSkillsSpy = spyOn(skillLoader, "discoverConfigSourceSkills").mockResolvedValue([]);
|
||||
loadUserSkillsSpy = spyOn(skillLoader, "loadUserSkills").mockResolvedValue({});
|
||||
loadProjectSkillsSpy = spyOn(skillLoader, "loadProjectSkills").mockResolvedValue({});
|
||||
loadOpencodeGlobalSkillsSpy = spyOn(skillLoader, "loadOpencodeGlobalSkills").mockResolvedValue({});
|
||||
loadOpencodeProjectSkillsSpy = spyOn(skillLoader, "loadOpencodeProjectSkills").mockResolvedValue({});
|
||||
loadProjectAgentsSkillsSpy = spyOn(skillLoader, "loadProjectAgentsSkills").mockResolvedValue({});
|
||||
loadGlobalAgentsSkillsSpy = spyOn(skillLoader, "loadGlobalAgentsSkills").mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loadBuiltinCommandsSpy.mockRestore();
|
||||
loadUserCommandsSpy.mockRestore();
|
||||
loadProjectCommandsSpy.mockRestore();
|
||||
loadOpencodeGlobalCommandsSpy.mockRestore();
|
||||
loadOpencodeProjectCommandsSpy.mockRestore();
|
||||
discoverConfigSourceSkillsSpy.mockRestore();
|
||||
loadUserSkillsSpy.mockRestore();
|
||||
loadProjectSkillsSpy.mockRestore();
|
||||
loadOpencodeGlobalSkillsSpy.mockRestore();
|
||||
loadOpencodeProjectSkillsSpy.mockRestore();
|
||||
loadProjectAgentsSkillsSpy.mockRestore();
|
||||
loadGlobalAgentsSkillsSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("includes .agents skills in command config", async () => {
|
||||
// given
|
||||
loadProjectAgentsSkillsSpy.mockResolvedValue({
|
||||
"agents-project-skill": {
|
||||
description: "(project - Skill) Agents project skill",
|
||||
template: "template",
|
||||
},
|
||||
});
|
||||
loadGlobalAgentsSkillsSpy.mockResolvedValue({
|
||||
"agents-global-skill": {
|
||||
description: "(user - Skill) Agents global skill",
|
||||
template: "template",
|
||||
},
|
||||
});
|
||||
const config: Record<string, unknown> = { command: {} };
|
||||
|
||||
// when
|
||||
await applyCommandConfig({
|
||||
config,
|
||||
pluginConfig: createPluginConfig(),
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginComponents: createPluginComponents(),
|
||||
});
|
||||
|
||||
// then
|
||||
const commandConfig = config.command as Record<string, { description?: string }>;
|
||||
expect(commandConfig["agents-project-skill"]?.description).toContain("Agents project skill");
|
||||
expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { loadBuiltinCommands } from "../features/builtin-commands";
|
||||
import {
|
||||
discoverConfigSourceSkills,
|
||||
loadGlobalAgentsSkills,
|
||||
loadProjectAgentsSkills,
|
||||
loadUserSkills,
|
||||
loadProjectSkills,
|
||||
loadOpencodeGlobalSkills,
|
||||
@@ -36,7 +38,9 @@ export async function applyCommandConfig(params: {
|
||||
opencodeGlobalCommands,
|
||||
opencodeProjectCommands,
|
||||
userSkills,
|
||||
globalAgentsSkills,
|
||||
projectSkills,
|
||||
projectAgentsSkills,
|
||||
opencodeGlobalSkills,
|
||||
opencodeProjectSkills,
|
||||
] = await Promise.all([
|
||||
@@ -49,7 +53,9 @@ export async function applyCommandConfig(params: {
|
||||
loadOpencodeGlobalCommands(),
|
||||
loadOpencodeProjectCommands(params.ctx.directory),
|
||||
includeClaudeSkills ? loadUserSkills() : Promise.resolve({}),
|
||||
includeClaudeSkills ? loadGlobalAgentsSkills() : Promise.resolve({}),
|
||||
includeClaudeSkills ? loadProjectSkills(params.ctx.directory) : Promise.resolve({}),
|
||||
includeClaudeSkills ? loadProjectAgentsSkills(params.ctx.directory) : Promise.resolve({}),
|
||||
loadOpencodeGlobalSkills(),
|
||||
loadOpencodeProjectSkills(params.ctx.directory),
|
||||
]);
|
||||
@@ -59,11 +65,13 @@ export async function applyCommandConfig(params: {
|
||||
...skillsToCommandDefinitionRecord(configSourceSkills),
|
||||
...userCommands,
|
||||
...userSkills,
|
||||
...globalAgentsSkills,
|
||||
...opencodeGlobalCommands,
|
||||
...opencodeGlobalSkills,
|
||||
...systemCommands,
|
||||
...projectCommands,
|
||||
...projectSkills,
|
||||
...projectAgentsSkills,
|
||||
...opencodeProjectCommands,
|
||||
...opencodeProjectSkills,
|
||||
...params.pluginComponents.commands,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import { mkdirSync, realpathSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
|
||||
|
||||
function canonicalPath(path: string): string {
|
||||
return realpathSync(path)
|
||||
}
|
||||
|
||||
describe("project-discovery-dirs", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
@@ -33,9 +37,9 @@ describe("project-discovery-dirs", () => {
|
||||
|
||||
// then
|
||||
expect(directories).toEqual([
|
||||
join(projectDir, ".opencode", "skills"),
|
||||
join(projectDir, ".opencode", "skill"),
|
||||
join(TEST_DIR, ".opencode", "skills"),
|
||||
canonicalPath(join(projectDir, ".opencode", "skills")),
|
||||
canonicalPath(join(projectDir, ".opencode", "skill")),
|
||||
canonicalPath(join(TEST_DIR, ".opencode", "skills")),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -51,8 +55,8 @@ describe("project-discovery-dirs", () => {
|
||||
|
||||
// then
|
||||
expect(directories).toEqual([
|
||||
join(projectDir, ".opencode", "commands"),
|
||||
join(TEST_DIR, ".opencode", "command"),
|
||||
canonicalPath(join(projectDir, ".opencode", "commands")),
|
||||
canonicalPath(join(TEST_DIR, ".opencode", "command")),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -68,7 +72,21 @@ describe("project-discovery-dirs", () => {
|
||||
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
|
||||
|
||||
// then
|
||||
expect(claudeDirectories).toEqual([join(projectDir, ".claude", "skills")])
|
||||
expect(agentsDirectories).toEqual([join(TEST_DIR, ".agents", "skills")])
|
||||
expect(claudeDirectories).toEqual([canonicalPath(join(projectDir, ".claude", "skills"))])
|
||||
expect(agentsDirectories).toEqual([canonicalPath(join(TEST_DIR, ".agents", "skills"))])
|
||||
})
|
||||
|
||||
it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "apps", "cli")
|
||||
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
|
||||
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
|
||||
|
||||
// when
|
||||
const directories = findProjectOpencodeSkillDirs(childDir, projectDir)
|
||||
|
||||
// then
|
||||
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { existsSync, realpathSync } from "node:fs"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const resolvedPath = resolve(path)
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
try {
|
||||
return realpathSync(resolvedPath)
|
||||
} catch {
|
||||
return resolvedPath
|
||||
}
|
||||
}
|
||||
|
||||
function findAncestorDirectories(
|
||||
startDirectory: string,
|
||||
targetPaths: ReadonlyArray<ReadonlyArray<string>>,
|
||||
stopDirectory?: string,
|
||||
): string[] {
|
||||
const directories: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let currentDirectory = resolve(startDirectory)
|
||||
let currentDirectory = normalizePath(startDirectory)
|
||||
const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined
|
||||
|
||||
while (true) {
|
||||
for (const targetPath of targetPaths) {
|
||||
@@ -20,33 +36,66 @@ function findAncestorDirectories(
|
||||
directories.push(candidateDirectory)
|
||||
}
|
||||
|
||||
if (resolvedStopDirectory === currentDirectory) {
|
||||
return directories
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory)
|
||||
if (parentDirectory === currentDirectory) {
|
||||
return directories
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory
|
||||
currentDirectory = normalizePath(parentDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
export function findProjectClaudeSkillDirs(startDirectory: string): string[] {
|
||||
return findAncestorDirectories(startDirectory, [[".claude", "skills"]])
|
||||
function detectWorktreePath(directory: string): string | undefined {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function findProjectAgentsSkillDirs(startDirectory: string): string[] {
|
||||
return findAncestorDirectories(startDirectory, [[".agents", "skills"]])
|
||||
export function findProjectClaudeSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
|
||||
return findAncestorDirectories(
|
||||
startDirectory,
|
||||
[[".claude", "skills"]],
|
||||
stopDirectory ?? detectWorktreePath(startDirectory),
|
||||
)
|
||||
}
|
||||
|
||||
export function findProjectOpencodeSkillDirs(startDirectory: string): string[] {
|
||||
return findAncestorDirectories(startDirectory, [
|
||||
[".opencode", "skills"],
|
||||
[".opencode", "skill"],
|
||||
])
|
||||
export function findProjectAgentsSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
|
||||
return findAncestorDirectories(
|
||||
startDirectory,
|
||||
[[".agents", "skills"]],
|
||||
stopDirectory ?? detectWorktreePath(startDirectory),
|
||||
)
|
||||
}
|
||||
|
||||
export function findProjectOpencodeCommandDirs(startDirectory: string): string[] {
|
||||
return findAncestorDirectories(startDirectory, [
|
||||
[".opencode", "commands"],
|
||||
[".opencode", "command"],
|
||||
])
|
||||
export function findProjectOpencodeSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
|
||||
return findAncestorDirectories(
|
||||
startDirectory,
|
||||
[
|
||||
[".opencode", "skills"],
|
||||
[".opencode", "skill"],
|
||||
],
|
||||
stopDirectory ?? detectWorktreePath(startDirectory),
|
||||
)
|
||||
}
|
||||
|
||||
export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirectory?: string): string[] {
|
||||
return findAncestorDirectories(
|
||||
startDirectory,
|
||||
[
|
||||
[".opencode", "commands"],
|
||||
[".opencode", "command"],
|
||||
],
|
||||
stopDirectory ?? detectWorktreePath(startDirectory),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,4 +235,24 @@ Use plural command.
|
||||
expect(duplicates).toHaveLength(1)
|
||||
expect(duplicates[0]?.content).toContain("Use plural command.")
|
||||
})
|
||||
|
||||
it("discovers nested opencode project commands", () => {
|
||||
const commandsDir = join(projectDir, ".opencode", "commands", "refactor")
|
||||
|
||||
mkdirSync(commandsDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(commandsDir, "code.md"),
|
||||
`---
|
||||
description: Nested command
|
||||
---
|
||||
Use nested command.
|
||||
`,
|
||||
)
|
||||
|
||||
const commands = discoverCommandsSync(projectDir)
|
||||
const nestedCommand = commands.find((command) => command.name === "refactor/code")
|
||||
|
||||
expect(nestedCommand?.content).toContain("Use nested command.")
|
||||
expect(nestedCommand?.scope).toBe("opencode-project")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,17 +18,37 @@ export interface CommandDiscoveryOptions {
|
||||
enabledPluginsOverride?: Record<string, boolean>
|
||||
}
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
|
||||
const NESTED_COMMAND_SEPARATOR = "/"
|
||||
|
||||
function discoverCommandsFromDir(
|
||||
commandsDir: string,
|
||||
scope: CommandScope,
|
||||
prefix = "",
|
||||
): CommandInfo[] {
|
||||
if (!existsSync(commandsDir)) return []
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: CommandInfo[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const nestedPrefix = prefix
|
||||
? `${prefix}${NESTED_COMMAND_SEPARATOR}${entry.name}`
|
||||
: entry.name
|
||||
commands.push(
|
||||
...discoverCommandsFromDir(join(commandsDir, entry.name), scope, nestedPrefix),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
const baseCommandName = basename(entry.name, ".md")
|
||||
const commandName = prefix
|
||||
? `${prefix}${NESTED_COMMAND_SEPARATOR}${baseCommandName}`
|
||||
: baseCommandName
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
|
||||
Reference in New Issue
Block a user