diff --git a/src/create-managers.ts b/src/create-managers.ts index d40896343a..94d08ac56e 100644 --- a/src/create-managers.ts +++ b/src/create-managers.ts @@ -10,6 +10,7 @@ import { TmuxSessionManager } from "./features/tmux-subagent" import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch" import { registerManagerForCleanup } from "./features/background-agent/process-cleanup" import { createConfigHandler } from "./plugin-handlers" +import { createHostSkillConfigStore } from "./shared/host-skill-config" import { log } from "./shared" import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health" @@ -38,6 +39,7 @@ export type Managers = { backgroundManager: BackgroundManager skillMcpManager: SkillMcpManager configHandler: ReturnType + hostSkillConfigStore: ReturnType } export function createManagers(args: { @@ -113,11 +115,13 @@ export function createManagers(args: { deps.initTaskToastManagerFn(ctx.client) const skillMcpManager = new deps.SkillMcpManagerClass() + const hostSkillConfigStore = createHostSkillConfigStore() const configHandler = deps.createConfigHandlerFn({ ctx: { directory: ctx.directory, client: ctx.client }, pluginConfig, modelCacheState, + hostSkillConfigStore, }) return { @@ -125,5 +129,6 @@ export function createManagers(args: { backgroundManager, skillMcpManager, configHandler, + hostSkillConfigStore, } } diff --git a/src/create-tools.ts b/src/create-tools.ts index 5ac5a7e2fc..3f63864a67 100644 --- a/src/create-tools.ts +++ b/src/create-tools.ts @@ -22,7 +22,7 @@ type CreateToolsResult = { export async function createTools(args: { ctx: PluginContext pluginConfig: OhMyOpenCodeConfig - managers: Pick + managers: Pick }): Promise { const { ctx, pluginConfig, managers } = args diff --git a/src/plugin-handlers/agent-config-handler-agents-skills.test.ts b/src/plugin-handlers/agent-config-handler-agents-skills.test.ts index 593f22d9e4..2a02a62626 100644 --- a/src/plugin-handlers/agent-config-handler-agents-skills.test.ts +++ b/src/plugin-handlers/agent-config-handler-agents-skills.test.ts @@ -122,4 +122,35 @@ describe("applyAgentConfig .agents skills", () => { expect(discoveredSkills.map(skill => skill.name)).toContain("project-agent-skill") expect(discoveredSkills.map(skill => skill.name)).toContain("global-agent-skill") }) + + test("passes host config skills declared in opencode config.skills.paths to builtin agent creation", async () => { + // given + discoverConfigSourceSkillsSpy + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "host-config-skill", + definition: { name: "host-config-skill", template: "host-template" }, + scope: "config", + }, + ]) + + // when + await applyAgentConfig({ + config: { + model: "anthropic/claude-opus-4-6", + agent: {}, + skills: { + paths: ["/host/skills"], + }, + }, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp/project" }, + pluginComponents: createPluginComponents(), + }) + + // then + const discoveredSkills = createBuiltinAgentsSpy.mock.calls[0]?.[6] as Array<{ name: string }> + expect(discoveredSkills.map(skill => skill.name)).toContain("host-config-skill") + }) }) diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index b8c7a9ee60..d4afbe0929 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -24,6 +24,7 @@ import { } from "./agent-override-protection"; import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; import { buildPlanDemoteConfig } from "./plan-model-inheritance"; +import { adaptHostSkillConfig } from "../shared/host-skill-config"; type AgentConfigRecord = Record | undefined> & { build?: Record; @@ -51,8 +52,10 @@ export async function applyAgentConfig(params: { ) as typeof params.pluginConfig.disabled_agents; const includeClaudeSkillsForAwareness = params.pluginConfig.claude_code?.skills ?? true; + const hostSkillConfig = adaptHostSkillConfig(params.config.skills) const [ discoveredConfigSourceSkills, + discoveredHostConfigSourceSkills, discoveredUserSkills, discoveredProjectSkills, discoveredProjectAgentsSkills, @@ -64,6 +67,10 @@ export async function applyAgentConfig(params: { config: params.pluginConfig.skills, configDir: params.ctx.directory, }), + discoverConfigSourceSkills({ + config: hostSkillConfig, + configDir: params.ctx.directory, + }), includeClaudeSkillsForAwareness ? discoverUserClaudeSkills() : Promise.resolve([]), includeClaudeSkillsForAwareness ? discoverProjectClaudeSkills(params.ctx.directory) @@ -78,6 +85,7 @@ export async function applyAgentConfig(params: { const allDiscoveredSkills = [ ...discoveredConfigSourceSkills, + ...discoveredHostConfigSourceSkills, ...discoveredOpencodeProjectSkills, ...discoveredProjectSkills, ...discoveredProjectAgentsSkills, diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index fa39cea1b3..9fec509a84 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -157,4 +157,39 @@ describe("applyCommandConfig", () => { const commandConfig = config.command as Record; expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); }); + + test("includes host config skills declared in opencode config.skills.paths", async () => { + // given + discoverConfigSourceSkillsSpy + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "host-config-skill", + definition: { + name: "host-config-skill", + description: "Host config skill", + template: "template", + }, + scope: "config", + }, + ]); + const config: Record = { + command: {}, + skills: { + paths: ["/host/skills"], + }, + }; + + // when + await applyCommandConfig({ + config, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }); + + // then + const commandConfig = config.command as Record; + expect(commandConfig["host-config-skill"]?.description).toContain("Host config skill"); + }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 471e4df522..cac3699bae 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -26,6 +26,7 @@ import { log, } from "../shared"; import type { PluginComponents } from "./plugin-components-loader"; +import { adaptHostSkillConfig } from "../shared/host-skill-config"; export async function applyCommandConfig(params: { config: Record; @@ -40,6 +41,7 @@ export async function applyCommandConfig(params: { const includeClaudeCommands = params.pluginConfig.claude_code?.commands ?? true; const includeClaudeSkills = params.pluginConfig.claude_code?.skills ?? true; + const hostSkillConfig = adaptHostSkillConfig(params.config.skills); const externalSkillPlugin = detectExternalSkillPlugin(params.ctx.directory); if (includeClaudeSkills && externalSkillPlugin.detected) { @@ -48,6 +50,7 @@ export async function applyCommandConfig(params: { const [ configSourceSkills, + hostConfigSourceSkills, userCommands, projectCommands, opencodeGlobalCommands, @@ -63,6 +66,10 @@ export async function applyCommandConfig(params: { config: params.pluginConfig.skills, configDir: params.ctx.directory, }), + discoverConfigSourceSkills({ + config: hostSkillConfig, + configDir: params.ctx.directory, + }), includeClaudeCommands ? loadUserCommands() : Promise.resolve({}), includeClaudeCommands ? loadProjectCommands(params.ctx.directory) : Promise.resolve({}), loadOpencodeGlobalCommands(), @@ -78,6 +85,7 @@ export async function applyCommandConfig(params: { params.config.command = { ...builtinCommands, ...skillsToCommandDefinitionRecord(configSourceSkills), + ...skillsToCommandDefinitionRecord(hostConfigSourceSkills), ...userCommands, ...userSkills, ...globalAgentsSkills, diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index e75d95ab1a..a41a89aa42 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -9,6 +9,7 @@ import { applyProviderConfig } from "./provider-config-handler"; import { loadPluginComponents } from "./plugin-components-loader"; import { applyToolConfig } from "./tool-config-handler"; import { clearFormatterCache } from "../tools/hashline-edit/formatter-trigger" +import type { createHostSkillConfigStore } from "../shared/host-skill-config" export { resolveCategoryConfig } from "./category-config-resolver"; @@ -16,14 +17,16 @@ export interface ConfigHandlerDeps { ctx: { directory: string; client?: any }; pluginConfig: OhMyOpenCodeConfig; modelCacheState: ModelCacheState; + hostSkillConfigStore: ReturnType; } export function createConfigHandler(deps: ConfigHandlerDeps) { - const { ctx, pluginConfig, modelCacheState } = deps; + const { ctx, pluginConfig, modelCacheState, hostSkillConfigStore } = deps; return async (config: Record) => { const formatterConfig = config.formatter; + hostSkillConfigStore.set(config.skills) setAdditionalAllowedMcpEnvVars(pluginConfig.mcp_env_allowlist ?? []) applyProviderConfig({ config, modelCacheState }); clearFormatterCache() diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 6d04e7e1c9..bd15d93c48 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -144,7 +144,7 @@ export function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): vo export function createToolRegistry(args: { ctx: PluginContext pluginConfig: OhMyOpenCodeConfig - managers: Pick + managers: Pick skillContext: SkillContext availableCategories: AvailableCategory[] interactiveBashEnabled?: boolean @@ -241,6 +241,8 @@ export function createToolRegistry(args: { getSessionID: getSessionIDForMcp, gitMasterConfig: pluginConfig.git_master, browserProvider: skillContext.browserProvider, + directory: ctx.directory, + hostConfigSkills: () => managers.hostSkillConfigStore.get(), nativeSkills: "skills" in ctx ? (ctx as { skills: SkillLoadOptions["nativeSkills"] }).skills : undefined, }) diff --git a/src/shared/host-skill-config.test.ts b/src/shared/host-skill-config.test.ts new file mode 100644 index 0000000000..a0a7665b0f --- /dev/null +++ b/src/shared/host-skill-config.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" + +import { adaptHostSkillConfig } from "./host-skill-config" + +describe("adaptHostSkillConfig", () => { + test("filters blank and whitespace-only paths and urls", () => { + const result = adaptHostSkillConfig({ + paths: ["", " ", "/real/skills"], + urls: ["\n", "https://example.com/.well-known/skills/"], + }) + + expect(result).toEqual({ + sources: [ + "/real/skills", + "https://example.com/.well-known/skills/", + ], + }) + }) + + test("returns undefined when no usable sources remain", () => { + const result = adaptHostSkillConfig({ + paths: ["", " "], + urls: ["\t"], + }) + + expect(result).toBeUndefined() + }) +}) diff --git a/src/shared/host-skill-config.ts b/src/shared/host-skill-config.ts new file mode 100644 index 0000000000..7c710c6deb --- /dev/null +++ b/src/shared/host-skill-config.ts @@ -0,0 +1,41 @@ +import type { SkillsConfig } from "../config/schema/skills" + +type HostSkillConfig = { + paths?: unknown + urls?: unknown +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0) +} + +export function adaptHostSkillConfig(value: unknown): SkillsConfig | undefined { + if (!value || typeof value !== "object") return undefined + + const hostSkillConfig = value as HostSkillConfig + const sources = [ + ...toStringArray(hostSkillConfig.paths), + ...toStringArray(hostSkillConfig.urls), + ] + + if (sources.length === 0) return undefined + + return { sources } as SkillsConfig +} + +export function createHostSkillConfigStore() { + let current: SkillsConfig | undefined + + return { + get(): SkillsConfig | undefined { + return current + }, + set(value: unknown): void { + current = adaptHostSkillConfig(value) + }, + } +} diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 0fada5607e..42455cf3e3 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -6,6 +6,7 @@ import type { SkillArgs, SkillLoadOptions } from "./types" import type { LoadedSkill } from "../../features/opencode-skill-loader" import { getAllSkills, clearSkillCache } from "../../features/opencode-skill-loader/skill-content" import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content" +import { discoverConfigSourceSkills } from "../../features/opencode-skill-loader" import { discoverCommandsSync } from "../slashcommand/command-discovery" import type { CommandInfo } from "../slashcommand/types" import { formatLoadedCommand } from "../slashcommand/command-output-formatter" @@ -29,16 +30,29 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition const getSkills = async (): Promise => { clearSkillCache() - const discovered = await getAllSkills({ - disabledSkills: options?.disabledSkills, - browserProvider: options?.browserProvider, - }) + const [discovered, hostConfigSourceSkills] = await Promise.all([ + getAllSkills({ + disabledSkills: options?.disabledSkills, + browserProvider: options?.browserProvider, + directory: options?.directory, + }), + discoverConfigSourceSkills({ + config: options.hostConfigSkills?.(), + configDir: options?.directory ?? process.cwd(), + }), + ]) + const discoveredWithHostConfig = [ + ...discovered, + ...hostConfigSourceSkills.filter( + (skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name), + ), + ] const allSkills = !options.skills - ? discovered + ? discoveredWithHostConfig : [ - ...discovered, + ...discoveredWithHostConfig, ...options.skills.filter( - (skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name) + (skill) => !new Set(discoveredWithHostConfig.map((discoveredSkill) => discoveredSkill.name)).has(skill.name) ), ] diff --git a/src/tools/skill/types.ts b/src/tools/skill/types.ts index c5ae02540d..f7488d861c 100644 --- a/src/tools/skill/types.ts +++ b/src/tools/skill/types.ts @@ -1,6 +1,6 @@ import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types" import type { SkillMcpManager } from "../../features/skill-mcp-manager" -import type { BrowserAutomationProvider, GitMasterConfig } from "../../config/schema" +import type { BrowserAutomationProvider, GitMasterConfig, SkillsConfig } from "../../config/schema" import type { CommandInfo } from "../slashcommand/types" export interface SkillArgs { @@ -32,9 +32,13 @@ export interface SkillLoadOptions { getSessionID?: () => string | undefined /** Git master configuration for watermark/co-author settings */ gitMasterConfig?: GitMasterConfig + /** Project directory used when discovering config-based skill sources */ + directory?: string disabledSkills?: Set /** Browser automation provider for provider-gated skill filtering */ browserProvider?: BrowserAutomationProvider + /** Host opencode config skill sources mirrored from config.skills.{paths,urls} */ + hostConfigSkills?: () => SkillsConfig | undefined /** Include Claude marketplace plugin commands in discovery (default: true) */ pluginsEnabled?: boolean /** Override plugin enablement from Claude settings by plugin key */ diff --git a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts index 5dac1e7d99..e32bca18b0 100644 --- a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts +++ b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:tes import type { ToolContext } from "@opencode-ai/plugin/tool" import * as fs from "node:fs" import { SkillMcpManager } from "../../../features/skill-mcp-manager" +import * as skillLoader from "../../../features/opencode-skill-loader" import type { LoadedSkill } from "../../../features/opencode-skill-loader/types" import type { CommandInfo } from "../../slashcommand/types" import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js" @@ -730,6 +731,46 @@ describe("skill tool - nativeSkills integration", () => { expect(result).toContain("external-plugin-skill") expect(result).toContain("External plugin skill body") }) + + it("merges host config skill sources exposed via config.skills.paths", async () => { + //#given + const discoverConfigSourceSkillsSpy = spyOn( + skillLoader, + "discoverConfigSourceSkills", + ).mockResolvedValue([ + { + name: "host-config-skill", + path: "/host/skills/host-config-skill/SKILL.md", + resolvedPath: "/host/skills/host-config-skill", + definition: { + name: "host-config-skill", + description: "Host config skill", + template: "Host config skill body", + }, + scope: "config", + }, + ]) + + try { + const tool = createSkillTool({ + skills: [], + directory: "/workspace/project", + hostConfigSkills: () => ({ sources: ["/host/skills"] }), + }) + + //#when + const result = await tool.execute({ name: "host-config-skill" }, mockContext) + + //#then + expect(discoverConfigSourceSkillsSpy).toHaveBeenCalledWith({ + config: { sources: ["/host/skills"] }, + configDir: "/workspace/project", + }) + expect(result).toContain("host-config-skill") + } finally { + discoverConfigSourceSkillsSpy.mockRestore() + } + }) }) describe("skill tool - short name resolution", () => {