Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/create-managers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -38,6 +39,7 @@ export type Managers = {
backgroundManager: BackgroundManager
skillMcpManager: SkillMcpManager
configHandler: ReturnType<typeof createConfigHandler>
hostSkillConfigStore: ReturnType<typeof createHostSkillConfigStore>
}

export function createManagers(args: {
Expand Down Expand Up @@ -113,17 +115,20 @@ 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 {
tmuxSessionManager,
backgroundManager,
skillMcpManager,
configHandler,
hostSkillConfigStore,
}
}
2 changes: 1 addition & 1 deletion src/create-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type CreateToolsResult = {
export async function createTools(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "hostSkillConfigStore">
}): Promise<CreateToolsResult> {
const { ctx, pluginConfig, managers } = args

Expand Down
31 changes: 31 additions & 0 deletions src/plugin-handlers/agent-config-handler-agents-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
8 changes: 8 additions & 0 deletions src/plugin-handlers/agent-config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown> | undefined> & {
build?: Record<string, unknown>;
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -78,6 +85,7 @@ export async function applyAgentConfig(params: {

const allDiscoveredSkills = [
...discoveredConfigSourceSkills,
...discoveredHostConfigSourceSkills,
...discoveredOpencodeProjectSkills,
...discoveredProjectSkills,
...discoveredProjectAgentsSkills,
Expand Down
35 changes: 35 additions & 0 deletions src/plugin-handlers/command-config-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,39 @@ describe("applyCommandConfig", () => {
const commandConfig = config.command as Record<string, { agent?: string }>;
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<string, unknown> = {
command: {},
skills: {
paths: ["/host/skills"],
},
};

// when
await applyCommandConfig({
config,
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
});

// then
const commandConfig = config.command as Record<string, { description?: string }>;
expect(commandConfig["host-config-skill"]?.description).toContain("Host config skill");
});
});
8 changes: 8 additions & 0 deletions src/plugin-handlers/command-config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand All @@ -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) {
Expand All @@ -48,6 +50,7 @@ export async function applyCommandConfig(params: {

const [
configSourceSkills,
hostConfigSourceSkills,
userCommands,
projectCommands,
opencodeGlobalCommands,
Expand All @@ -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(),
Expand All @@ -78,6 +85,7 @@ export async function applyCommandConfig(params: {
params.config.command = {
...builtinCommands,
...skillsToCommandDefinitionRecord(configSourceSkills),
...skillsToCommandDefinitionRecord(hostConfigSourceSkills),
...userCommands,
...userSkills,
...globalAgentsSkills,
Expand Down
5 changes: 4 additions & 1 deletion src/plugin-handlers/config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,24 @@ 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";

export interface ConfigHandlerDeps {
ctx: { directory: string; client?: any };
pluginConfig: OhMyOpenCodeConfig;
modelCacheState: ModelCacheState;
hostSkillConfigStore: ReturnType<typeof createHostSkillConfigStore>;
}

export function createConfigHandler(deps: ConfigHandlerDeps) {
const { ctx, pluginConfig, modelCacheState } = deps;
const { ctx, pluginConfig, modelCacheState, hostSkillConfigStore } = deps;

return async (config: Record<string, unknown>) => {
const formatterConfig = config.formatter;

hostSkillConfigStore.set(config.skills)
setAdditionalAllowedMcpEnvVars(pluginConfig.mcp_env_allowlist ?? [])
applyProviderConfig({ config, modelCacheState });
clearFormatterCache()
Expand Down
4 changes: 3 additions & 1 deletion src/plugin/tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): vo
export function createToolRegistry(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "hostSkillConfigStore">
skillContext: SkillContext
availableCategories: AvailableCategory[]
interactiveBashEnabled?: boolean
Expand Down Expand Up @@ -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,
})

Expand Down
28 changes: 28 additions & 0 deletions src/shared/host-skill-config.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
41 changes: 41 additions & 0 deletions src/shared/host-skill-config.ts
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
28 changes: 21 additions & 7 deletions src/tools/skill/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -29,16 +30,29 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition

const getSkills = async (): Promise<LoadedSkill[]> => {
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)
),
]

Expand Down
6 changes: 5 additions & 1 deletion src/tools/skill/types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string>
/** 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 */
Expand Down
Loading
Loading