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()))
|
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 {
|
export interface DiscoverSkillsOptions {
|
||||||
includeClaudeCodePaths?: boolean
|
includeClaudeCodePaths?: boolean
|
||||||
directory?: string
|
directory?: string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as sisyphusJunior from "../agents/sisyphus-junior"
|
|||||||
import type { OhMyOpenCodeConfig } from "../config"
|
import type { OhMyOpenCodeConfig } from "../config"
|
||||||
import * as agentLoader from "../features/claude-code-agent-loader"
|
import * as agentLoader from "../features/claude-code-agent-loader"
|
||||||
import * as skillLoader from "../features/opencode-skill-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 { getAgentDisplayName } from "../shared/agent-display-names"
|
||||||
import { applyAgentConfig } from "./agent-config-handler"
|
import { applyAgentConfig } from "./agent-config-handler"
|
||||||
import type { PluginComponents } from "./plugin-components-loader"
|
import type { PluginComponents } from "./plugin-components-loader"
|
||||||
@@ -51,6 +52,8 @@ describe("applyAgentConfig builtin override protection", () => {
|
|||||||
let discoverProjectClaudeSkillsSpy: ReturnType<typeof spyOn>
|
let discoverProjectClaudeSkillsSpy: ReturnType<typeof spyOn>
|
||||||
let discoverOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>
|
let discoverOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>
|
||||||
let discoverOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>
|
let discoverOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>
|
||||||
|
let discoverProjectAgentsSkillsSpy: ReturnType<typeof spyOn>
|
||||||
|
let discoverGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>
|
||||||
let loadUserAgentsSpy: ReturnType<typeof spyOn>
|
let loadUserAgentsSpy: ReturnType<typeof spyOn>
|
||||||
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
|
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
|
||||||
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
|
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
|
||||||
@@ -121,6 +124,14 @@ describe("applyAgentConfig builtin override protection", () => {
|
|||||||
skillLoader,
|
skillLoader,
|
||||||
"discoverOpencodeProjectSkills",
|
"discoverOpencodeProjectSkills",
|
||||||
).mockResolvedValue([])
|
).mockResolvedValue([])
|
||||||
|
discoverProjectAgentsSkillsSpy = spyOn(
|
||||||
|
skillLoader,
|
||||||
|
"discoverProjectAgentsSkills",
|
||||||
|
).mockResolvedValue([])
|
||||||
|
discoverGlobalAgentsSkillsSpy = spyOn(
|
||||||
|
skillLoader,
|
||||||
|
"discoverGlobalAgentsSkills",
|
||||||
|
).mockResolvedValue([])
|
||||||
|
|
||||||
loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
|
loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
|
||||||
loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
|
loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
|
||||||
@@ -139,6 +150,8 @@ describe("applyAgentConfig builtin override protection", () => {
|
|||||||
discoverProjectClaudeSkillsSpy.mockRestore()
|
discoverProjectClaudeSkillsSpy.mockRestore()
|
||||||
discoverOpencodeGlobalSkillsSpy.mockRestore()
|
discoverOpencodeGlobalSkillsSpy.mockRestore()
|
||||||
discoverOpencodeProjectSkillsSpy.mockRestore()
|
discoverOpencodeProjectSkillsSpy.mockRestore()
|
||||||
|
discoverProjectAgentsSkillsSpy.mockRestore()
|
||||||
|
discoverGlobalAgentsSkillsSpy.mockRestore()
|
||||||
loadUserAgentsSpy.mockRestore()
|
loadUserAgentsSpy.mockRestore()
|
||||||
loadProjectAgentsSpy.mockRestore()
|
loadProjectAgentsSpy.mockRestore()
|
||||||
migrateAgentConfigSpy.mockRestore()
|
migrateAgentConfigSpy.mockRestore()
|
||||||
@@ -279,4 +292,45 @@ describe("applyAgentConfig builtin override protection", () => {
|
|||||||
// then
|
// then
|
||||||
expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", false)
|
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 { getAgentDisplayName } from "../shared/agent-display-names";
|
||||||
import {
|
import {
|
||||||
discoverConfigSourceSkills,
|
discoverConfigSourceSkills,
|
||||||
|
discoverGlobalAgentsSkills,
|
||||||
discoverOpencodeGlobalSkills,
|
discoverOpencodeGlobalSkills,
|
||||||
discoverOpencodeProjectSkills,
|
discoverOpencodeProjectSkills,
|
||||||
|
discoverProjectAgentsSkills,
|
||||||
discoverProjectClaudeSkills,
|
discoverProjectClaudeSkills,
|
||||||
discoverUserClaudeSkills,
|
discoverUserClaudeSkills,
|
||||||
} from "../features/opencode-skill-loader";
|
} from "../features/opencode-skill-loader";
|
||||||
@@ -52,8 +54,10 @@ export async function applyAgentConfig(params: {
|
|||||||
discoveredConfigSourceSkills,
|
discoveredConfigSourceSkills,
|
||||||
discoveredUserSkills,
|
discoveredUserSkills,
|
||||||
discoveredProjectSkills,
|
discoveredProjectSkills,
|
||||||
|
discoveredProjectAgentsSkills,
|
||||||
discoveredOpencodeGlobalSkills,
|
discoveredOpencodeGlobalSkills,
|
||||||
discoveredOpencodeProjectSkills,
|
discoveredOpencodeProjectSkills,
|
||||||
|
discoveredGlobalAgentsSkills,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
discoverConfigSourceSkills({
|
discoverConfigSourceSkills({
|
||||||
config: params.pluginConfig.skills,
|
config: params.pluginConfig.skills,
|
||||||
@@ -63,16 +67,22 @@ export async function applyAgentConfig(params: {
|
|||||||
includeClaudeSkillsForAwareness
|
includeClaudeSkillsForAwareness
|
||||||
? discoverProjectClaudeSkills(params.ctx.directory)
|
? discoverProjectClaudeSkills(params.ctx.directory)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
includeClaudeSkillsForAwareness
|
||||||
|
? discoverProjectAgentsSkills(params.ctx.directory)
|
||||||
|
: Promise.resolve([]),
|
||||||
discoverOpencodeGlobalSkills(),
|
discoverOpencodeGlobalSkills(),
|
||||||
discoverOpencodeProjectSkills(params.ctx.directory),
|
discoverOpencodeProjectSkills(params.ctx.directory),
|
||||||
|
includeClaudeSkillsForAwareness ? discoverGlobalAgentsSkills() : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allDiscoveredSkills = [
|
const allDiscoveredSkills = [
|
||||||
...discoveredConfigSourceSkills,
|
...discoveredConfigSourceSkills,
|
||||||
...discoveredOpencodeProjectSkills,
|
...discoveredOpencodeProjectSkills,
|
||||||
...discoveredProjectSkills,
|
...discoveredProjectSkills,
|
||||||
|
...discoveredProjectAgentsSkills,
|
||||||
...discoveredOpencodeGlobalSkills,
|
...discoveredOpencodeGlobalSkills,
|
||||||
...discoveredUserSkills,
|
...discoveredUserSkills,
|
||||||
|
...discoveredGlobalAgentsSkills,
|
||||||
];
|
];
|
||||||
|
|
||||||
const browserProvider =
|
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 { loadBuiltinCommands } from "../features/builtin-commands";
|
||||||
import {
|
import {
|
||||||
discoverConfigSourceSkills,
|
discoverConfigSourceSkills,
|
||||||
|
loadGlobalAgentsSkills,
|
||||||
|
loadProjectAgentsSkills,
|
||||||
loadUserSkills,
|
loadUserSkills,
|
||||||
loadProjectSkills,
|
loadProjectSkills,
|
||||||
loadOpencodeGlobalSkills,
|
loadOpencodeGlobalSkills,
|
||||||
@@ -36,7 +38,9 @@ export async function applyCommandConfig(params: {
|
|||||||
opencodeGlobalCommands,
|
opencodeGlobalCommands,
|
||||||
opencodeProjectCommands,
|
opencodeProjectCommands,
|
||||||
userSkills,
|
userSkills,
|
||||||
|
globalAgentsSkills,
|
||||||
projectSkills,
|
projectSkills,
|
||||||
|
projectAgentsSkills,
|
||||||
opencodeGlobalSkills,
|
opencodeGlobalSkills,
|
||||||
opencodeProjectSkills,
|
opencodeProjectSkills,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
@@ -49,7 +53,9 @@ export async function applyCommandConfig(params: {
|
|||||||
loadOpencodeGlobalCommands(),
|
loadOpencodeGlobalCommands(),
|
||||||
loadOpencodeProjectCommands(params.ctx.directory),
|
loadOpencodeProjectCommands(params.ctx.directory),
|
||||||
includeClaudeSkills ? loadUserSkills() : Promise.resolve({}),
|
includeClaudeSkills ? loadUserSkills() : Promise.resolve({}),
|
||||||
|
includeClaudeSkills ? loadGlobalAgentsSkills() : Promise.resolve({}),
|
||||||
includeClaudeSkills ? loadProjectSkills(params.ctx.directory) : Promise.resolve({}),
|
includeClaudeSkills ? loadProjectSkills(params.ctx.directory) : Promise.resolve({}),
|
||||||
|
includeClaudeSkills ? loadProjectAgentsSkills(params.ctx.directory) : Promise.resolve({}),
|
||||||
loadOpencodeGlobalSkills(),
|
loadOpencodeGlobalSkills(),
|
||||||
loadOpencodeProjectSkills(params.ctx.directory),
|
loadOpencodeProjectSkills(params.ctx.directory),
|
||||||
]);
|
]);
|
||||||
@@ -59,11 +65,13 @@ export async function applyCommandConfig(params: {
|
|||||||
...skillsToCommandDefinitionRecord(configSourceSkills),
|
...skillsToCommandDefinitionRecord(configSourceSkills),
|
||||||
...userCommands,
|
...userCommands,
|
||||||
...userSkills,
|
...userSkills,
|
||||||
|
...globalAgentsSkills,
|
||||||
...opencodeGlobalCommands,
|
...opencodeGlobalCommands,
|
||||||
...opencodeGlobalSkills,
|
...opencodeGlobalSkills,
|
||||||
...systemCommands,
|
...systemCommands,
|
||||||
...projectCommands,
|
...projectCommands,
|
||||||
...projectSkills,
|
...projectSkills,
|
||||||
|
...projectAgentsSkills,
|
||||||
...opencodeProjectCommands,
|
...opencodeProjectCommands,
|
||||||
...opencodeProjectSkills,
|
...opencodeProjectSkills,
|
||||||
...params.pluginComponents.commands,
|
...params.pluginComponents.commands,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
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 { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
|
|
||||||
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
|
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
|
||||||
|
|
||||||
|
function canonicalPath(path: string): string {
|
||||||
|
return realpathSync(path)
|
||||||
|
}
|
||||||
|
|
||||||
describe("project-discovery-dirs", () => {
|
describe("project-discovery-dirs", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mkdirSync(TEST_DIR, { recursive: true })
|
mkdirSync(TEST_DIR, { recursive: true })
|
||||||
@@ -33,9 +37,9 @@ describe("project-discovery-dirs", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(directories).toEqual([
|
expect(directories).toEqual([
|
||||||
join(projectDir, ".opencode", "skills"),
|
canonicalPath(join(projectDir, ".opencode", "skills")),
|
||||||
join(projectDir, ".opencode", "skill"),
|
canonicalPath(join(projectDir, ".opencode", "skill")),
|
||||||
join(TEST_DIR, ".opencode", "skills"),
|
canonicalPath(join(TEST_DIR, ".opencode", "skills")),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,8 +55,8 @@ describe("project-discovery-dirs", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(directories).toEqual([
|
expect(directories).toEqual([
|
||||||
join(projectDir, ".opencode", "commands"),
|
canonicalPath(join(projectDir, ".opencode", "commands")),
|
||||||
join(TEST_DIR, ".opencode", "command"),
|
canonicalPath(join(TEST_DIR, ".opencode", "command")),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,7 +72,21 @@ describe("project-discovery-dirs", () => {
|
|||||||
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
|
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(claudeDirectories).toEqual([join(projectDir, ".claude", "skills")])
|
expect(claudeDirectories).toEqual([canonicalPath(join(projectDir, ".claude", "skills"))])
|
||||||
expect(agentsDirectories).toEqual([join(TEST_DIR, ".agents", "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"
|
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(
|
function findAncestorDirectories(
|
||||||
startDirectory: string,
|
startDirectory: string,
|
||||||
targetPaths: ReadonlyArray<ReadonlyArray<string>>,
|
targetPaths: ReadonlyArray<ReadonlyArray<string>>,
|
||||||
|
stopDirectory?: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
let currentDirectory = resolve(startDirectory)
|
let currentDirectory = normalizePath(startDirectory)
|
||||||
|
const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
for (const targetPath of targetPaths) {
|
for (const targetPath of targetPaths) {
|
||||||
@@ -20,33 +36,66 @@ function findAncestorDirectories(
|
|||||||
directories.push(candidateDirectory)
|
directories.push(candidateDirectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolvedStopDirectory === currentDirectory) {
|
||||||
|
return directories
|
||||||
|
}
|
||||||
|
|
||||||
const parentDirectory = dirname(currentDirectory)
|
const parentDirectory = dirname(currentDirectory)
|
||||||
if (parentDirectory === currentDirectory) {
|
if (parentDirectory === currentDirectory) {
|
||||||
return directories
|
return directories
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDirectory = parentDirectory
|
currentDirectory = normalizePath(parentDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findProjectClaudeSkillDirs(startDirectory: string): string[] {
|
function detectWorktreePath(directory: string): string | undefined {
|
||||||
return findAncestorDirectories(startDirectory, [[".claude", "skills"]])
|
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[] {
|
export function findProjectClaudeSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
|
||||||
return findAncestorDirectories(startDirectory, [[".agents", "skills"]])
|
return findAncestorDirectories(
|
||||||
|
startDirectory,
|
||||||
|
[[".claude", "skills"]],
|
||||||
|
stopDirectory ?? detectWorktreePath(startDirectory),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findProjectOpencodeSkillDirs(startDirectory: string): string[] {
|
export function findProjectAgentsSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
|
||||||
return findAncestorDirectories(startDirectory, [
|
return findAncestorDirectories(
|
||||||
[".opencode", "skills"],
|
startDirectory,
|
||||||
[".opencode", "skill"],
|
[[".agents", "skills"]],
|
||||||
])
|
stopDirectory ?? detectWorktreePath(startDirectory),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findProjectOpencodeCommandDirs(startDirectory: string): string[] {
|
export function findProjectOpencodeSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
|
||||||
return findAncestorDirectories(startDirectory, [
|
return findAncestorDirectories(
|
||||||
[".opencode", "commands"],
|
startDirectory,
|
||||||
[".opencode", "command"],
|
[
|
||||||
])
|
[".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).toHaveLength(1)
|
||||||
expect(duplicates[0]?.content).toContain("Use plural command.")
|
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>
|
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 []
|
if (!existsSync(commandsDir)) return []
|
||||||
|
|
||||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||||
const commands: CommandInfo[] = []
|
const commands: CommandInfo[] = []
|
||||||
|
|
||||||
for (const entry of entries) {
|
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
|
if (!isMarkdownFile(entry)) continue
|
||||||
|
|
||||||
const commandPath = join(commandsDir, entry.name)
|
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 {
|
try {
|
||||||
const content = readFileSync(commandPath, "utf-8")
|
const content = readFileSync(commandPath, "utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user