diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 5e83b3f..95ba87e 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -63,6 +63,33 @@ interface SkillEntry { eligible: boolean; } +interface SkillStatusRecord { + skillKey: string; + name: string; + source: string; + bundled: boolean; + eligible: boolean; + disabled: boolean; + modelVisible: boolean; + commandVisible: boolean; + blockedByAllowlist: boolean; + blockedByAgentFilter: boolean; + missing: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; +} + +interface SkillsStatus { + ok: boolean; + error?: string; + summary: { total: number; modelVisible: number }; + skills: SkillStatusRecord[]; +} + interface IntegrityChange { skill: string; source: string; @@ -131,6 +158,18 @@ interface OpenClawAPI { refresh(): Promise<{ builtin: SkillEntry[]; custom: SkillEntry[]; managed: SkillEntry[] }>; updateAllowlist(allowBundled: string[]): Promise; updateManagedEntries(entries: Record): Promise; + setAgentSkills(agentId: string, skillIds: string[]): Promise<{ agentId: string; skills: string[] }>; + getStatus(agentId: string): Promise; + setGlobalEnabled(skillKey: string, enabled: boolean): Promise<{ skillKey: string; enabled: boolean }>; + applyAgentConfig( + agentId: string, + skillIds: string[], + globalChanges: { skillKey: string; enabled: boolean }[], + ): Promise<{ + agentId: string; + skills: string[]; + globalChanges: { skillKey: string; enabled: boolean }[]; + }>; integrityCheck(): Promise; pendingIntegrityResult(): Promise; acceptIntegrityChanges(): Promise; diff --git a/desktop/renderer/src/components/skills/SkillsDevPanel.vue b/desktop/renderer/src/components/skills/SkillsDevPanel.vue new file mode 100644 index 0000000..f919600 --- /dev/null +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -0,0 +1,697 @@ + + + + + + + diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 446bb5b..c601478 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -680,6 +680,42 @@ export default { "usage.noSessions": "No sessions in this time range", "usage.footer": "Data sourced from OpenClaw Gateway. Requires the gateway to be running.", + // ── Skills (dev-only panel) ── + "skills.dev.tab": "Skills", + "skills.dev.title": "Agent Skills (Dev)", + "skills.dev.subtitle": "Toggle which skills each agent can use, then apply and reload the gateway. Development builds only.", + "skills.dev.agent": "Agent", + "skills.dev.selectAll": "Select all", + "skills.dev.clearAll": "Clear all", + "skills.dev.enabledCount": "{count} of {total} enabled", + "skills.dev.loading": "Loading agent skills…", + "skills.dev.loadFailed": "Failed to load skills: {error}", + "skills.dev.apply": "Apply & reload", + "skills.dev.applying": "Applying…", + "skills.dev.applied": "Applied. Gateway reloaded.", + "skills.dev.applyFailed": "Failed to apply: {error}", + "skills.dev.refresh": "Refresh all agents", + "skills.dev.refreshing": "Refreshing…", + "skills.dev.thisAgent": "This agent", + "skills.dev.global": "Global", + "skills.dev.modelVisibleSummary": "Model-visible: {visible} / {total}", + "skills.dev.statusFailed": "Status unavailable: {error}", + "skills.dev.statusChecking": "Checking skill status… this can take up to a minute.", + "skills.dev.statusCheckingAll": "Checking all agents ({current}/{total})… this can take a few minutes.", + "skills.dev.statusNotChecked": "Status not checked for this agent — click “Refresh all agents” (takes a few minutes).", + "skills.dev.badge.visible": "Model-visible", + "skills.dev.badge.hidden": "Not injected", + "skills.dev.badge.unknown": "No status", + "skills.dev.badge.notInstalled": "Not installed", + "skills.dev.reason.allowlist": "Blocked by global allowlist", + "skills.dev.reason.agentFilter": "Excluded by agent allowlist", + "skills.dev.reason.disabled": "Disabled", + "skills.dev.reason.missing": "Missing requirements", + "skills.dev.missing.bins": "bins: {list}", + "skills.dev.missing.env": "env: {list}", + "skills.dev.missing.config": "config: {list}", + "skills.dev.missing.os": "os: {list}", + // ── Studio ── "studio.switchToStudio": "Studio", "studio.switchToChat": "Chat", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index d27aedd..4fa5f88 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -653,6 +653,42 @@ export default { "usage.noSessions": "此时间范围内暂无会话", "usage.footer": "数据来源于 OpenClaw Gateway,需要 Gateway 运行中。", + // ── Skills (dev-only panel) ── + "skills.dev.tab": "技能", + "skills.dev.title": "智能体技能(开发)", + "skills.dev.subtitle": "切换每个智能体可使用的技能,然后应用并重载网关。仅限开发版本。", + "skills.dev.agent": "智能体", + "skills.dev.selectAll": "全选", + "skills.dev.clearAll": "清空", + "skills.dev.enabledCount": "已启用 {count} / {total}", + "skills.dev.loading": "正在加载智能体技能……", + "skills.dev.loadFailed": "加载技能失败:{error}", + "skills.dev.apply": "应用并重载", + "skills.dev.applying": "正在应用……", + "skills.dev.applied": "已应用,网关已重载。", + "skills.dev.applyFailed": "应用失败:{error}", + "skills.dev.refresh": "刷新所有 agent 状态", + "skills.dev.refreshing": "刷新中…", + "skills.dev.thisAgent": "本智能体", + "skills.dev.global": "全局", + "skills.dev.modelVisibleSummary": "模型可见:{visible} / {total}", + "skills.dev.statusFailed": "无法获取状态:{error}", + "skills.dev.statusChecking": "正在检查技能状态…最长可能需要一分钟。", + "skills.dev.statusCheckingAll": "正在检查所有 agent({current}/{total})……可能需要几分钟。", + "skills.dev.statusNotChecked": "尚未检查该智能体的技能状态——请点击“刷新所有 agent 状态”(可能需要几分钟)。", + "skills.dev.badge.visible": "模型可见", + "skills.dev.badge.hidden": "未注入", + "skills.dev.badge.unknown": "无状态", + "skills.dev.badge.notInstalled": "未安装", + "skills.dev.reason.allowlist": "被全局允许列表拦截", + "skills.dev.reason.agentFilter": "被智能体允许列表排除", + "skills.dev.reason.disabled": "已禁用", + "skills.dev.reason.missing": "缺少依赖", + "skills.dev.missing.bins": "命令:{list}", + "skills.dev.missing.env": "环境变量:{list}", + "skills.dev.missing.config": "配置项:{list}", + "skills.dev.missing.os": "系统: {list}", + // ── Studio ── "studio.switchToStudio": "工作室", "studio.switchToChat": "对话", diff --git a/desktop/renderer/src/views/ChatView.vue b/desktop/renderer/src/views/ChatView.vue index 0f50b73..846afb0 100644 --- a/desktop/renderer/src/views/ChatView.vue +++ b/desktop/renderer/src/views/ChatView.vue @@ -16,6 +16,14 @@ > {{ t("studio.switchToStudio") }} + @@ -199,6 +207,11 @@ + + + @@ -215,6 +228,7 @@ import StudioGame from "@/components/studio/StudioGame.vue"; import StudioChatPanel from "@/components/studio/StudioChatPanel.vue"; import StudioStatusPanel from "@/components/studio/StudioStatusPanel.vue"; import ModelSetupDialog from "@/components/ModelSetupDialog.vue"; +import SkillsDevPanel from "@/components/skills/SkillsDevPanel.vue"; import { useRoute } from "vue-router"; const route = useRoute(); @@ -222,7 +236,8 @@ const chatStore = useChatStore(); const agentStore = useAgentStore(); const studioStore = useStudioStore(); -const viewMode = ref<"chat" | "studio">("chat"); +const viewMode = ref<"chat" | "studio" | "skills">("chat"); +const isDev = import.meta.env.DEV; const inputText = ref(""); const inputRef = ref(); const threadRef = ref(); diff --git a/desktop/src/agent-catalog.test.ts b/desktop/src/agent-catalog.test.ts index 33ae59b..f85f717 100644 --- a/desktop/src/agent-catalog.test.ts +++ b/desktop/src/agent-catalog.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "vitest"; -import { AGENT_CATALOG, ALL_SKILL_IDS, getAgentSkills } from "./agent-catalog"; +import { + AGENT_CATALOG, + ALL_SKILL_IDS, + getAgentSkills, + isKnownSkillId, + matchesSkill, + resolveSkillFilterNames, + sanitizeAgentSkillIds, + SKILL_MATCH_NAMES, +} from "./agent-catalog"; const EXPECTED_SKILL_IDS = [ "1password", @@ -71,4 +80,72 @@ describe("agent catalog skills binding", () => { expect(getAgentSkills("coder")).toEqual(ALL_SKILL_IDS); expect(getAgentSkills("does-not-exist")).toEqual([]); }); + + it("recognizes known skill ids and rejects unknown ones", () => { + expect(isKnownSkillId("canvas")).toBe(true); + expect(isKnownSkillId("not-a-skill")).toBe(false); + }); + + it("sanitizes requested skill ids into sorted, de-duplicated known ids", () => { + expect(sanitizeAgentSkillIds(["canvas", "1password", "canvas"])).toEqual([ + "1password", + "canvas", + ]); + expect(sanitizeAgentSkillIds([])).toEqual([]); + }); + + it("throws when a requested skill id is unknown or malformed", () => { + expect(() => sanitizeAgentSkillIds(["canvas", "bogus"])).toThrow(/Unknown skill id/); + expect(() => sanitizeAgentSkillIds(["", "canvas"])).toThrow(/non-empty strings/); + expect(() => sanitizeAgentSkillIds("canvas" as unknown as string[])).toThrow( + /must be an array/, + ); + }); +}); + +describe("OpenClaw skill match-name resolution", () => { + const EXCEPTIONS: Record = { + "desktop-organizer": "Desktop Organizer", + "excel-xlsx": "Excel / XLSX", + "powerpoint-pptx": "Powerpoint / PPTX", + "security-practice": "Security Practice", + "word-docx": "Word / DOCX", + }; + + it("maps exactly the five slug/name exceptions", () => { + expect(SKILL_MATCH_NAMES).toEqual(EXCEPTIONS); + }); + + it("only maps slugs that are real catalog ids", () => { + for (const slug of Object.keys(SKILL_MATCH_NAMES)) { + expect(isKnownSkillId(slug)).toBe(true); + } + }); + + it("resolveSkillFilterNames maps the exceptions and passes others through", () => { + expect(resolveSkillFilterNames(["excel-xlsx", "1password", "word-docx", "canvas"])).toEqual([ + "Excel / XLSX", + "1password", + "Word / DOCX", + "canvas", + ]); + }); + + it("resolves every catalog id, leaving non-exception slugs unchanged", () => { + const resolved = resolveSkillFilterNames(ALL_SKILL_IDS); + for (let i = 0; i < ALL_SKILL_IDS.length; i++) { + const slug = ALL_SKILL_IDS[i]; + expect(resolved[i]).toBe(EXCEPTIONS[slug] ?? slug); + } + }); + + it("matchesSkill recognizes both slug- and name-form stored values", () => { + // Exception: both slug and mapped name register. + expect(matchesSkill("excel-xlsx", "excel-xlsx")).toBe(true); + expect(matchesSkill("Excel / XLSX", "excel-xlsx")).toBe(true); + // Non-exception: slug matches, unrelated value does not. + expect(matchesSkill("1password", "1password")).toBe(true); + expect(matchesSkill("Excel / XLSX", "1password")).toBe(false); + expect(matchesSkill("word-docx", "excel-xlsx")).toBe(false); + }); }); diff --git a/desktop/src/agent-catalog.ts b/desktop/src/agent-catalog.ts index 49e994a..b0f3f9e 100644 --- a/desktop/src/agent-catalog.ts +++ b/desktop/src/agent-catalog.ts @@ -207,6 +207,47 @@ export const DEFAULT_AGENT_IDS = AGENT_CATALOG.filter( (agent) => agent.installedByDefault, ).map((agent) => agent.id); +// OpenClaw's runtime filters the per-agent allowlist (config.agents.list[*].skills) +// against each skill's FRONTMATTER `name:` field — NOT its directory slug/skillKey +// (see openclaw dist workspace bundle: `filtered.filter(entry => allowed.has(entry.skill.name))`). +// For most skills the frontmatter `name:` equals the slug, so they bind either way. The +// entries below are the ONLY skills whose installed frontmatter `name:` differs from the +// slug; without remapping them, they are silently excluded from an agent even when eligible. +// +// SYNC: each value here MUST match the exact `name:` field in the corresponding installed +// skill's SKILL.md frontmatter. Keep this map limited to the differing cases. +// +// NOTE: `canvas` is present in ALL_SKILL_IDS (kept in sync with deployer/skill_catalog.py) +// but resolves to no installed skill on any known host — it is a phantom that matches +// nothing. It is intentionally left in ALL_SKILL_IDS; removing it is out of scope. +export const SKILL_MATCH_NAMES: Readonly> = { + "desktop-organizer": "Desktop Organizer", + "excel-xlsx": "Excel / XLSX", + "powerpoint-pptx": "Powerpoint / PPTX", + "security-practice": "Security Practice", + "word-docx": "Word / DOCX", +}; + +/** + * Resolves catalog slugs to the match-names OpenClaw's runtime expects in the + * per-agent allowlist. Slugs without a mapping pass through unchanged. Call this + * ONLY at the moment of writing openclaw.json — the renderer/UI and IPC input stay + * slug-based. + */ +export function resolveSkillFilterNames(ids: readonly string[]): string[] { + return ids.map((id) => SKILL_MATCH_NAMES[id] ?? id); +} + +/** + * Returns true when a value stored in config.agents.list[*].skills corresponds to + * the given catalog slug. Matches EITHER the raw slug (older config written before + * the name remap) OR the mapped match-name (current config), so round-trip reads + * recognize both forms. + */ +export function matchesSkill(storedValue: string, slug: string): boolean { + return storedValue === slug || storedValue === SKILL_MATCH_NAMES[slug]; +} + /** * Returns the skill IDs bound to the given agent, or an empty array if the agent * id is not present in AGENT_CATALOG. @@ -215,3 +256,34 @@ export function getAgentSkills(agentId: string): readonly string[] { const agent = AGENT_CATALOG.find((entry) => entry.id === agentId); return agent ? agent.skills : []; } + +const ALL_SKILL_ID_SET: ReadonlySet = new Set(ALL_SKILL_IDS); + +/** + * Returns true when the given id is one of the known catalog skill ids. + */ +export function isKnownSkillId(skillId: string): boolean { + return ALL_SKILL_ID_SET.has(skillId); +} + +/** + * Validates and normalizes a requested list of skill ids into a sorted, + * de-duplicated array of known catalog skill ids. Throws when the input is not + * an array, contains non-string / empty entries, or references an unknown id. + */ +export function sanitizeAgentSkillIds(skillIds: readonly string[]): string[] { + if (!Array.isArray(skillIds)) { + throw new Error("skillIds must be an array"); + } + const unique = new Set(); + for (const skillId of skillIds) { + if (typeof skillId !== "string" || skillId.length === 0) { + throw new Error("skillIds must contain only non-empty strings"); + } + if (!ALL_SKILL_ID_SET.has(skillId)) { + throw new Error(`Unknown skill id: ${skillId}`); + } + unique.add(skillId); + } + return [...unique].sort(); +} diff --git a/desktop/src/agent-personas.test.ts b/desktop/src/agent-personas.test.ts index 928ada3..7cdd22c 100644 --- a/desktop/src/agent-personas.test.ts +++ b/desktop/src/agent-personas.test.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { afterEach, describe, expect, it } from "vitest"; +import { getAgentSkills, matchesSkill, resolveSkillFilterNames } from "./agent-catalog"; import { AGENT_PERSONAS, DEFAULT_AGENT_PERSONAS, @@ -27,6 +28,13 @@ afterEach(() => { } }); +type AgentListEntry = { id: string; skills?: readonly string[] } & Record; + +function agentList(config: { agents?: unknown }): AgentListEntry[] { + const agents = (config.agents ?? {}) as { list?: unknown }; + return (Array.isArray(agents.list) ? agents.list : []) as AgentListEntry[]; +} + describe("agent personas", () => { it("registers only main for a new installation", () => { const stateDir = createStateDir(); @@ -211,4 +219,110 @@ describe("agent personas", () => { ), ).toBe(path.join(openClawHome, "archive")); }); + + it("writes each generated agent's catalog skills into agents.list", () => { + const stateDir = createStateDir(); + const persona = getAgentPersona("master-archive"); + if (!persona) throw new Error("Master Archive persona is not registered"); + const config = { agents: {} }; + + ensureAgentPersonasConfig(config, stateDir, [...DEFAULT_AGENT_PERSONAS, persona]); + + for (const entry of agentList(config)) { + // Config stores OpenClaw match-names, not raw catalog slugs. + expect(entry.skills).toEqual(resolveSkillFilterNames(getAgentSkills(entry.id))); + } + }); + + it("writes OpenClaw match-names (not slugs) for remapped skills", () => { + const stateDir = createStateDir(); + const config = { agents: {} }; + + ensureAgentPersonasConfig(config, stateDir); + + const mainEntry = agentList(config).find((entry) => entry.id === "main"); + const skills = (mainEntry?.skills ?? []) as string[]; + // The five display-name exceptions must be stored by their frontmatter name. + expect(skills).toContain("Excel / XLSX"); + expect(skills).toContain("Word / DOCX"); + expect(skills).toContain("Powerpoint / PPTX"); + expect(skills).toContain("Desktop Organizer"); + expect(skills).toContain("Security Practice"); + // Their raw slugs must NOT appear. + expect(skills).not.toContain("excel-xlsx"); + expect(skills).not.toContain("word-docx"); + // Non-exception slugs pass through unchanged. + expect(skills).toContain("1password"); + // Every stored value round-trips back to a catalog slug via matchesSkill. + for (const slug of getAgentSkills("main")) { + expect(skills.some((value) => matchesSkill(value, slug))).toBe(true); + } + }); + + it("gives each agent a distinct skills array instance", () => { + const stateDir = createStateDir(); + const persona = getAgentPersona("master-archive"); + if (!persona) throw new Error("Master Archive persona is not registered"); + const config = { agents: {} }; + + ensureAgentPersonasConfig(config, stateDir, [...DEFAULT_AGENT_PERSONAS, persona]); + + const mainEntry = agentList(config).find((entry) => entry.id === "main"); + const archiveEntry = agentList(config).find( + (entry) => entry.id === "master-archive", + ); + if (!mainEntry || !archiveEntry) throw new Error("expected both agents"); + + expect(mainEntry.skills).not.toBe(archiveEntry.skills); + (mainEntry.skills as string[]).push("mutated"); + expect(archiveEntry.skills).not.toContain("mutated"); + expect(getAgentSkills("master-archive")).not.toContain("mutated"); + expect(getAgentSkills("main")).not.toContain("mutated"); + }); + + it("preserves an existing skills value for a catalog-known agent", () => { + const stateDir = createStateDir(); + const config = { + agents: { + list: [{ id: "main", name: "Assistant", default: true, skills: ["stale-skill"] }], + }, + }; + + const result = ensureAgentPersonasConfig(config, stateDir); + + const mainEntry = agentList(config).find((entry) => entry.id === "main"); + expect(mainEntry?.skills).toEqual(["stale-skill"]); + expect(result.changed).toBe(false); + }); + + it("leaves a custom non-catalog agent without an injected skills field", () => { + const stateDir = createStateDir(); + const config = { + agents: { + list: [ + { id: "main", name: "Assistant", default: true }, + { id: "custom-bot", name: "Custom Bot" }, + ], + }, + }; + + ensureAgentPersonasConfig(config, stateDir); + + const customEntry = agentList(config).find((entry) => entry.id === "custom-bot"); + expect(customEntry).not.toHaveProperty("skills"); + const mainEntry = agentList(config).find((entry) => entry.id === "main"); + expect(mainEntry?.skills).toEqual(resolveSkillFilterNames(getAgentSkills("main"))); + }); + + it("reports changed when skills are added to a config that lacked them", () => { + const stateDir = createStateDir(); + const config = { + agents: { + list: [{ id: "main", name: "Assistant", default: true }], + }, + }; + + expect(ensureAgentPersonasConfig(config, stateDir).changed).toBe(true); + expect(ensureAgentPersonasConfig(config, stateDir).changed).toBe(false); + }); }); diff --git a/desktop/src/agent-personas.ts b/desktop/src/agent-personas.ts index fd56851..a77dbc0 100644 --- a/desktop/src/agent-personas.ts +++ b/desktop/src/agent-personas.ts @@ -1,7 +1,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { AGENT_CATALOG, DEFAULT_AGENT_IDS } from "./agent-catalog"; +import { AGENT_CATALOG, DEFAULT_AGENT_IDS, resolveSkillFilterNames } from "./agent-catalog"; interface WorkspaceFiles { "AGENTS.md": string; @@ -12,6 +12,7 @@ interface WorkspaceFiles { export interface AgentPersona { id: string; name: string; + skills: readonly string[]; workspaceDirName?: string; workspaceFiles?: WorkspaceFiles; } @@ -112,6 +113,7 @@ const PERSONA_PROFILES: Record = { export const AGENT_PERSONAS: readonly AgentPersona[] = AGENT_CATALOG.map((agent) => ({ id: agent.id, name: agent.name, + skills: agent.skills, workspaceDirName: agent.workspaceDirName, workspaceFiles: agent.personaProfile ? PERSONA_PROFILES[agent.personaProfile] : undefined, })); @@ -150,6 +152,9 @@ function createAgentEntry(persona: AgentPersona, stateDir: string): Record = { name: persona.name }; const workspace = getAgentWorkspacePath(stateDir, persona); if (workspace) entry.workspace = workspace; + // Write the OpenClaw match-names (not raw slugs) so the runtime's frontmatter-name + // based allowlist filter binds every eligible skill. See resolveSkillFilterNames. + entry.skills = resolveSkillFilterNames([...persona.skills]); return entry; } @@ -252,6 +257,20 @@ export function ensureAgentPersonasConfig( } } + const catalogSkillsById = new Map( + AGENT_PERSONAS.map((persona) => [persona.id, persona.skills]), + ); + for (const entry of entries) { + const catalogSkills = catalogSkillsById.get(entry.id); + // Seed catalog skills ONLY when the entry has no skills key yet + // (fill-if-missing). Existing skills arrays — including dev-edited ones from + // the runtime Skills panel — are preserved across gateway reloads. + if (catalogSkills !== undefined && !Object.hasOwn(entry, "skills")) { + // Persist match-names (not raw slugs) — see resolveSkillFilterNames. + entry.skills = resolveSkillFilterNames([...catalogSkills]); + } + } + const changed = !hasList || Object.hasOwn(agents, "entries") || diff --git a/desktop/src/constants.ts b/desktop/src/constants.ts index c41eafe..aec4dc8 100644 --- a/desktop/src/constants.ts +++ b/desktop/src/constants.ts @@ -64,6 +64,11 @@ export const SANDBOX_PERMISSION_TIMEOUT_MS = 60_000; /** Timeout for the WeChat login flow (ms). */ export const WEIXIN_LOGIN_TIMEOUT_MS = 180_000; +/** Timeout for each OpenClaw `skills` diagnostics CLI invocation (ms). The CLI is + * spawned cold and can take ~60–90s to emit its JSON on slower machines, so this + * is generous headroom to avoid killing the child before it produces output. */ +export const SKILLS_STATUS_TIMEOUT_MS = 120_000; + /** Number of days of usage data to query from the gateway. */ export const USAGE_QUERY_DAYS = 30; diff --git a/desktop/src/main.ts b/desktop/src/main.ts index d7086f4..36ee7c0 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -54,6 +54,7 @@ import { MIN_WINDOW_HEIGHT, POST_SPAWN_RESTART_DELAY_MS, WEIXIN_LOGIN_TIMEOUT_MS, + SKILLS_STATUS_TIMEOUT_MS, USAGE_QUERY_DAYS, UPDATE_MANIFEST_URL, } from "./constants"; @@ -90,6 +91,12 @@ import { type AgentPersona, type AgentRosterConfig, } from "./agent-personas"; +import { AGENT_CATALOG, sanitizeAgentSkillIds } from "./agent-catalog"; +import { + applyAgentSkillsToConfig, + applyGlobalSkillChange, + type GlobalSkillChange, +} from "./skill-config"; /** * Normalize a directory path for comparison/storage. @@ -1839,8 +1846,124 @@ function notifyRemotePermissionNeeded(): void { sendWeixinNotification(cachedRemoteSource, mainT(lang, "perm.remoteNotify")); } +/** One skill's diagnostic status merged from `skills check` + `skills list --json`. */ +interface SkillStatusRecord { + skillKey: string; + name: string; + source: string; + bundled: boolean; + eligible: boolean; + disabled: boolean; + modelVisible: boolean; + commandVisible: boolean; + blockedByAllowlist: boolean; + blockedByAgentFilter: boolean; + missing: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; +} + +interface SkillsStatusResult { + ok: boolean; + error?: string; + summary: { + total: number; + modelVisible: number; + }; + skills: SkillStatusRecord[]; +} + +/** + * OpenClaw's CLI prints a config-warning banner before its JSON payload. Strip + * everything before the first top-level `{` so the remainder parses cleanly. + */ +function stripJsonBanner(raw: string): string { + const start = raw.indexOf("{"); + return start >= 0 ? raw.slice(start) : raw; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; +} + +/** + * Merge the outputs of `skills list --agent --json` (per-skill booleans + + * `missing`) and `skills check --agent --json` (aggregate buckets + + * detailed `missingRequirements`) into one per-skill record list. `list` is the + * primary source; `check` supplies `missingRequirements` detail and the model-visible + * bucket. Either input may be null when its CLI call failed. + */ +function normalizeSkillsStatus( + listJson: Record | null, + checkJson: Record | null, +): SkillStatusRecord[] { + // Detailed missing-requirement info keyed by skill name from `check`. + const missingByName = new Map< + string, + { bins: string[]; anyBins: string[]; env: string[]; config: string[]; os: string[] } + >(); + const missingReqs = Array.isArray(checkJson?.missingRequirements) + ? (checkJson!.missingRequirements as unknown[]) + : []; + for (const req of missingReqs) { + if (typeof req !== "object" || req === null) continue; + const r = req as Record; + const name = typeof r.name === "string" ? r.name : undefined; + if (!name) continue; + const m = (r.missing ?? {}) as Record; + missingByName.set(name, { + bins: asStringArray(m.bins), + anyBins: asStringArray(m.anyBins), + env: asStringArray(m.env), + config: asStringArray(m.config), + os: asStringArray(m.os), + }); + } + + const rawSkills = Array.isArray(listJson?.skills) ? (listJson!.skills as unknown[]) : []; + const records: SkillStatusRecord[] = []; + for (const raw of rawSkills) { + if (typeof raw !== "object" || raw === null) continue; + const s = raw as Record; + const name = typeof s.name === "string" ? s.name : ""; + // `skills list` keys skills by their frontmatter `name`; treat it as the skillKey + // the UI needs for global gating only when a distinct key isn't provided. + const skillKey = + typeof s.skillKey === "string" + ? s.skillKey + : typeof s.key === "string" + ? s.key + : name; + const detail = missingByName.get(name); + const perSkillMissing = (s.missing ?? {}) as Record; + records.push({ + skillKey, + name, + source: typeof s.source === "string" ? s.source : "", + bundled: Boolean(s.bundled), + eligible: Boolean(s.eligible), + disabled: Boolean(s.disabled), + modelVisible: Boolean(s.modelVisible), + commandVisible: Boolean(s.commandVisible), + blockedByAllowlist: Boolean(s.blockedByAllowlist), + blockedByAgentFilter: Boolean(s.blockedByAgentFilter), + missing: detail ?? { + bins: asStringArray(perSkillMissing.bins), + anyBins: asStringArray(perSkillMissing.anyBins), + env: asStringArray(perSkillMissing.env), + config: asStringArray(perSkillMissing.config), + os: asStringArray(perSkillMissing.os), + }, + }); + } + return records; +} + async function startGateway(): Promise { - // Prevent concurrent calls — only one startGateway at a time if (gatewayStartInProgress) { console.log("[gateway] startGateway() already in progress, skipping"); return; @@ -3133,6 +3256,331 @@ function registerIpcHandlers(): void { pendingIntegrityResult = null; }); + ipcMain.handle( + "skills:set-agent-skills", + async ( + _event, + agentId: string, + skillIds: string[], + ): Promise<{ agentId: string; skills: string[] }> => { + if (typeof agentId !== "string" || agentId.length === 0) { + throw new Error("A non-empty agentId is required"); + } + const skills = sanitizeAgentSkillIds(skillIds ?? []); + + const configPath = getConfigPath(); + const originalConfigText = fs.readFileSync(configPath, "utf-8"); + const config = readConfig(); + if (!config) throw new Error("OpenClaw configuration is unavailable"); + + // Persist match-names (not raw slugs) so OpenClaw's frontmatter-name based + // allowlist filter binds every eligible skill. The IPC input and return value + // stay slug-based; resolution happens only at the openclaw.json write. + applyAgentSkillsToConfig(config, agentId, skills); + + const alreadyRunning = await checkExistingGateway(gatewayPort); + const managedGateway = gatewaySpawnedByUs && gatewayProcess !== null; + if ( + requiresExternalGatewayStop( + alreadyRunning, + true, + gatewaySpawnedByUs, + gatewayProcess !== null, + ) + ) { + throw new Error( + "Cannot update agent skills while MicroClaw is connected to an externally managed Gateway", + ); + } + + let restartAttempted = false; + try { + writeConfigTextAtomically(JSON.stringify(config, null, 2)); + restartAttempted = true; + await restartManagedGateway(`Updating skills for agent ${agentId}`); + return { agentId, skills }; + } catch (error) { + try { + writeConfigTextAtomically(originalConfigText); + if (restartAttempted && managedGateway) { + await restartManagedGateway( + `Rolling back failed skills update for ${agentId}`, + ); + } + } catch { + // Preserve the original failure; rollback is best-effort. + } + throw error; + } + }, + ); + + // Dev-only diagnostics: run the OpenClaw CLI directly (reads config from disk; + // does NOT require the gateway to be up) to surface each skill's gating status + // for the given agent. Merges `skills list` (per-skill booleans + missing) with + // `skills check` (aggregate buckets + detailed missingRequirements). + ipcMain.handle( + "skills:get-status", + async (_event, agentId: string): Promise => { + const emptySummary = { total: 0, modelVisible: 0 }; + if (typeof agentId !== "string" || agentId.length === 0) { + return { ok: false, error: "A non-empty agentId is required", summary: emptySummary, skills: [] }; + } + + const nodePath = resolveNodePath(); + const entryPath = resolveOpenClawEntry(); + if (!fs.existsSync(nodePath) || !fs.existsSync(entryPath)) { + return { ok: false, error: "OpenClaw CLI not found", summary: emptySummary, skills: [] }; + } + + const stateDir = getOpenClawStateDir(); + const compileCacheDir = path.join(stateDir, COMPILE_CACHE_SUBDIR); + + const runCli = (subArgs: string[]): Promise | null> => + new Promise((resolve) => { + const spawnOpts: Record = { + cwd: path.dirname(entryPath), + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + NODE_COMPILE_CACHE: compileCacheDir, + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }; + if (process.platform === "win32") { + spawnOpts.creationFlags = CREATE_NO_WINDOW; + } + let stdout = ""; + let stderr = ""; + let settled = false; + const child = spawn(nodePath, [entryPath, ...subArgs], spawnOpts); + child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf-8"))); + child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString("utf-8"))); + const finish = (value: Record | null) => { + if (settled) return; + settled = true; + resolve(value); + }; + child.on("error", () => finish(null)); + child.on("close", () => { + try { + const parsed = JSON.parse(stripJsonBanner(stdout)); + finish(typeof parsed === "object" && parsed !== null ? parsed : null); + } catch { + if (stderr.trim()) console.error(`[skills:get-status] CLI stderr: ${stderr.trim()}`); + finish(null); + } + }); + setTimeout(() => { + if (!settled) { + child.kill(); + finish(null); + } + }, SKILLS_STATUS_TIMEOUT_MS); + }); + + try { + // `skills list --json` already carries every per-skill boolean and the + // per-skill `missing` requirements the panel renders, so we only spawn one + // CLI process (the cold CLI can take ~60–90s). normalizeSkillsStatus stays + // tolerant of a null `check` payload. + const listJson = await runCli(["skills", "list", "--agent", agentId, "--json"]); + if (!listJson) { + return { + ok: false, + error: "OpenClaw skills CLI produced no parseable output", + summary: emptySummary, + skills: [], + }; + } + const skills = normalizeSkillsStatus(listJson, null); + return { + ok: true, + summary: { + total: skills.length, + modelVisible: skills.filter((s) => s.modelVisible).length, + }, + skills, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + summary: emptySummary, + skills: [], + }; + } + }, + ); + + // Dev-only: global master toggle for a single skill. Controls OpenClaw's global + // gating (both layers): the per-skill `skills.entries[key].enabled` flag and, for + // bundled skills, membership in `skills.allowBundled`. Operates on skillKey (slug). + ipcMain.handle( + "skills:set-global-enabled", + async ( + _event, + params: { skillKey: string; enabled: boolean }, + ): Promise<{ skillKey: string; enabled: boolean }> => { + const skillKey = params?.skillKey; + const enabled = params?.enabled; + if (typeof skillKey !== "string" || skillKey.length === 0) { + throw new Error("A non-empty skillKey is required"); + } + if (typeof enabled !== "boolean") { + throw new Error("enabled must be a boolean"); + } + + // Validate against actually-discovered skills and learn whether it's bundled. + const payload = buildSkillsPayload(false); + const discovered = [...payload.builtin, ...payload.custom, ...payload.managed].find( + (s) => s.id === skillKey, + ); + if (!discovered) { + throw new Error(`Unknown skill "${skillKey}"`); + } + const isBundled = discovered.source === "builtin"; + + const configPath = getConfigPath(); + const originalConfigText = fs.readFileSync(configPath, "utf-8"); + const config = readConfig(); + if (!config) throw new Error("OpenClaw configuration is unavailable"); + + applyGlobalSkillChange(config, skillKey, enabled, isBundled); + + const alreadyRunning = await checkExistingGateway(gatewayPort); + const managedGateway = gatewaySpawnedByUs && gatewayProcess !== null; + if ( + requiresExternalGatewayStop( + alreadyRunning, + true, + gatewaySpawnedByUs, + gatewayProcess !== null, + ) + ) { + throw new Error( + "Cannot update skill state while MicroClaw is connected to an externally managed Gateway", + ); + } + + let restartAttempted = false; + try { + writeConfigTextAtomically(JSON.stringify(config, null, 2)); + restartAttempted = true; + await restartManagedGateway(`Toggling global state for skill ${skillKey}`); + return { skillKey, enabled }; + } catch (error) { + try { + writeConfigTextAtomically(originalConfigText); + if (restartAttempted && managedGateway) { + await restartManagedGateway( + `Rolling back failed global toggle for ${skillKey}`, + ); + } + } catch { + // Preserve the original failure; rollback is best-effort. + } + throw error; + } + }, + ); + + // Dev-only: batched apply of BOTH the per-agent allowlist and any number of global + // on/off changes in a SINGLE atomic config write + SINGLE gateway restart. The panel + // stages per-agent and global toggles locally and flushes them here on "Apply & + // reload", so the user waits for exactly one restart instead of one per flip. Mirrors + // the robustness of skills:set-agent-skills + skills:set-global-enabled (validation, + // atomic write, rollback-on-failure) using the shared skill-config helpers. + ipcMain.handle( + "skills:apply-agent-config", + async ( + _event, + params: { agentId: string; skillIds: string[]; globalChanges: GlobalSkillChange[] }, + ): Promise<{ agentId: string; skills: string[]; globalChanges: GlobalSkillChange[] }> => { + const agentId = params?.agentId; + if (typeof agentId !== "string" || agentId.length === 0) { + throw new Error("A non-empty agentId is required"); + } + if (!AGENT_CATALOG.some((agent) => agent.id === agentId)) { + throw new Error(`Unknown agent "${agentId}"`); + } + const skills = sanitizeAgentSkillIds(params?.skillIds ?? []); + + // Validate every global change against actually-discovered skills and learn + // whether each is bundled (only bundled skills are gated by allowBundled). + const rawGlobalChanges = Array.isArray(params?.globalChanges) ? params.globalChanges : []; + const payload = buildSkillsPayload(false); + const discovered = [...payload.builtin, ...payload.custom, ...payload.managed]; + const globalChanges: Array = []; + for (const change of rawGlobalChanges) { + const skillKey = change?.skillKey; + const enabled = change?.enabled; + if (typeof skillKey !== "string" || skillKey.length === 0) { + throw new Error("Each global change requires a non-empty skillKey"); + } + if (typeof enabled !== "boolean") { + throw new Error("Each global change requires a boolean enabled"); + } + const match = discovered.find((s) => s.id === skillKey); + if (!match) { + throw new Error(`Unknown skill "${skillKey}"`); + } + globalChanges.push({ skillKey, enabled, bundled: match.source === "builtin" }); + } + + const configPath = getConfigPath(); + const originalConfigText = fs.readFileSync(configPath, "utf-8"); + const config = readConfig(); + if (!config) throw new Error("OpenClaw configuration is unavailable"); + + // Stage every change onto the single config object before writing once. + applyAgentSkillsToConfig(config, agentId, skills); + for (const change of globalChanges) { + applyGlobalSkillChange(config, change.skillKey, change.enabled, change.bundled); + } + + const alreadyRunning = await checkExistingGateway(gatewayPort); + const managedGateway = gatewaySpawnedByUs && gatewayProcess !== null; + if ( + requiresExternalGatewayStop( + alreadyRunning, + true, + gatewaySpawnedByUs, + gatewayProcess !== null, + ) + ) { + throw new Error( + "Cannot update skill state while MicroClaw is connected to an externally managed Gateway", + ); + } + + let restartAttempted = false; + try { + writeConfigTextAtomically(JSON.stringify(config, null, 2)); + restartAttempted = true; + await restartManagedGateway(`Applying skill config for agent ${agentId}`); + return { + agentId, + skills, + globalChanges: globalChanges.map(({ skillKey, enabled }) => ({ skillKey, enabled })), + }; + } catch (error) { + try { + writeConfigTextAtomically(originalConfigText); + if (restartAttempted && managedGateway) { + await restartManagedGateway( + `Rolling back failed skill config apply for ${agentId}`, + ); + } + } catch { + // Preserve the original failure; rollback is best-effort. + } + throw error; + } + }, + ); + // --- Chat (WebSocket gateway protocol) --- ipcMain.handle( "chat:send-message", diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index c193cf3..7bcaaf3 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -57,6 +57,31 @@ contextBridge.exposeInMainWorld("openclaw", { ipcRenderer.invoke("skills:update-allowlist", allowBundled), updateManagedEntries: (entries: Record) => ipcRenderer.invoke("skills:update-managed-entries", entries), + setAgentSkills: (agentId: string, skillIds: string[]) => + ipcRenderer.invoke("skills:set-agent-skills", agentId, skillIds) as Promise<{ + agentId: string; + skills: string[]; + }>, + getStatus: (agentId: string) => ipcRenderer.invoke("skills:get-status", agentId), + setGlobalEnabled: (skillKey: string, enabled: boolean) => + ipcRenderer.invoke("skills:set-global-enabled", { skillKey, enabled }) as Promise<{ + skillKey: string; + enabled: boolean; + }>, + applyAgentConfig: ( + agentId: string, + skillIds: string[], + globalChanges: { skillKey: string; enabled: boolean }[], + ) => + ipcRenderer.invoke("skills:apply-agent-config", { + agentId, + skillIds, + globalChanges, + }) as Promise<{ + agentId: string; + skills: string[]; + globalChanges: { skillKey: string; enabled: boolean }[]; + }>, integrityCheck: () => ipcRenderer.invoke("skills:integrity-check"), pendingIntegrityResult: () => ipcRenderer.invoke("skills:pending-integrity-result"), acceptIntegrityChanges: () => ipcRenderer.invoke("skills:accept-integrity-changes"), diff --git a/desktop/src/skill-config.test.ts b/desktop/src/skill-config.test.ts new file mode 100644 index 0000000..0bc2df6 --- /dev/null +++ b/desktop/src/skill-config.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + applyAgentSkillsToConfig, + applyGlobalSkillChange, + type MutableSkillsConfig, +} from "./skill-config"; + +describe("applyAgentSkillsToConfig", () => { + it("writes OpenClaw match-names (not raw slugs) for the differing skills", () => { + const config: MutableSkillsConfig = { + agents: { list: [{ id: "main", skills: [] }] }, + }; + applyAgentSkillsToConfig(config, "main", [ + "excel-xlsx", + "word-docx", + "powerpoint-pptx", + "desktop-organizer", + "security-practice", + "healthcheck", + ]); + const entry = (config.agents!.list as Array<{ id: string; skills: string[] }>)[0]; + expect(entry.skills).toEqual([ + "Excel / XLSX", + "Word / DOCX", + "Powerpoint / PPTX", + "Desktop Organizer", + "Security Practice", + "healthcheck", + ]); + }); + + it("passes through slugs that equal their frontmatter name", () => { + const config: MutableSkillsConfig = { + agents: { list: [{ id: "coder", skills: ["stale"] }] }, + }; + applyAgentSkillsToConfig(config, "coder", ["1password", "healthcheck"]); + const entry = (config.agents!.list as Array<{ id: string; skills: string[] }>)[0]; + expect(entry.skills).toEqual(["1password", "healthcheck"]); + }); + + it("targets the correct agent when several are present", () => { + const config: MutableSkillsConfig = { + agents: { + list: [ + { id: "main", skills: ["keep"] }, + { id: "painter", skills: [] }, + ], + }, + }; + applyAgentSkillsToConfig(config, "painter", ["excel-xlsx"]); + const list = config.agents!.list as Array<{ id: string; skills: string[] }>; + expect(list[0].skills).toEqual(["keep"]); + expect(list[1].skills).toEqual(["Excel / XLSX"]); + }); + + it("throws when the agent list is missing", () => { + expect(() => applyAgentSkillsToConfig({}, "main", [])).toThrow( + /No configured agents/, + ); + }); + + it("throws when the agent id is not present", () => { + const config: MutableSkillsConfig = { agents: { list: [{ id: "main" }] } }; + expect(() => applyAgentSkillsToConfig(config, "ghost", [])).toThrow( + /Unknown agent "ghost"/, + ); + }); +}); + +describe("applyGlobalSkillChange", () => { + it("enables a skill and clears any global disable", () => { + const config: MutableSkillsConfig = { + skills: { entries: { blucli: { enabled: false } } }, + }; + applyGlobalSkillChange(config, "blucli", true, false); + expect(config.skills!.entries!.blucli.enabled).toBe(true); + }); + + it("adds a bundled skill to a non-empty allowBundled when enabling", () => { + const config: MutableSkillsConfig = { + skills: { allowBundled: ["oracle"] }, + }; + applyGlobalSkillChange(config, "healthcheck", true, true); + expect(config.skills!.allowBundled).toEqual(["oracle", "healthcheck"]); + expect(config.skills!.entries!.healthcheck.enabled).toBe(true); + }); + + it("does not duplicate an already-present allowBundled entry", () => { + const config: MutableSkillsConfig = { + skills: { allowBundled: ["healthcheck"] }, + }; + applyGlobalSkillChange(config, "healthcheck", true, true); + expect(config.skills!.allowBundled).toEqual(["healthcheck"]); + }); + + it("leaves an absent/empty allowBundled untouched when enabling a bundled skill", () => { + const empty: MutableSkillsConfig = { skills: { allowBundled: [] } }; + applyGlobalSkillChange(empty, "healthcheck", true, true); + expect(empty.skills!.allowBundled).toEqual([]); + + const absent: MutableSkillsConfig = {}; + applyGlobalSkillChange(absent, "healthcheck", true, true); + expect(absent.skills!.allowBundled).toBeUndefined(); + expect(absent.skills!.entries!.healthcheck.enabled).toBe(true); + }); + + it("does not touch allowBundled for a non-bundled (managed) skill", () => { + const config: MutableSkillsConfig = { + skills: { allowBundled: ["oracle"] }, + }; + applyGlobalSkillChange(config, "coding-agent", true, false); + expect(config.skills!.allowBundled).toEqual(["oracle"]); + expect(config.skills!.entries!["coding-agent"].enabled).toBe(true); + }); + + it("disables a skill by setting entries[key].enabled=false", () => { + const config: MutableSkillsConfig = {}; + applyGlobalSkillChange(config, "blucli", false, true); + expect(config.skills!.entries!.blucli.enabled).toBe(false); + }); +}); diff --git a/desktop/src/skill-config.ts b/desktop/src/skill-config.ts new file mode 100644 index 0000000..33707bd --- /dev/null +++ b/desktop/src/skill-config.ts @@ -0,0 +1,90 @@ +// Pure config-mutation helpers shared by the skills IPC handlers. They mutate an +// OpenClaw config object in place so a single caller can stage several changes and +// then perform ONE atomic write + ONE gateway restart. Keeping the mutation logic +// here (rather than inline in each handler) gives a single source of truth and lets +// the behaviour be unit-tested without the Electron/IPC harness. + +import { resolveSkillFilterNames } from "./agent-catalog"; + +/** A single per-agent entry inside `config.agents.list`. */ +export interface MutableAgentEntry { + id?: unknown; + skills?: unknown; + [key: string]: unknown; +} + +/** The subset of the OpenClaw config these helpers read/mutate. */ +export interface MutableSkillsConfig { + agents?: { list?: unknown[] } & Record; + skills?: { + entries?: Record>; + allowBundled?: string[]; + } & Record; + [key: string]: unknown; +} + +/** A staged global on/off change for one skill (keyed by skillKey/slug). */ +export interface GlobalSkillChange { + skillKey: string; + enabled: boolean; +} + +/** + * Writes the per-agent allowlist for `agentId` into `config.agents.list[*].skills`. + * Persists match-names (not raw slugs) so OpenClaw's frontmatter-name based filter + * binds every eligible skill. Throws when the agent list is missing or the agent id + * isn't present. Mutates `config` in place. + */ +export function applyAgentSkillsToConfig( + config: MutableSkillsConfig, + agentId: string, + skillIds: readonly string[], +): void { + const agents = config.agents; + if (!agents || !Array.isArray(agents.list)) { + throw new Error("No configured agents to update"); + } + const entry = agents.list.find( + (candidate: unknown): candidate is MutableAgentEntry => + typeof candidate === "object" && + candidate !== null && + (candidate as { id?: unknown }).id === agentId, + ); + if (!entry) { + throw new Error(`Unknown agent "${agentId}"`); + } + entry.skills = resolveSkillFilterNames(skillIds); +} + +/** + * Applies a global on/off change for a single skill, mirroring OpenClaw's two-layer + * gating. Turning ON clears any global disable and, for bundled skills gated by a + * non-empty allowlist, adds the key to `allowBundled` (when the allowlist is absent + * or empty, bundled skills are already allowed, so it's left untouched rather than + * creating a newly restrictive allowlist). Turning OFF sets `entries[key].enabled` + * to false. Mutates `config` in place. + */ +export function applyGlobalSkillChange( + config: MutableSkillsConfig, + skillKey: string, + enabled: boolean, + isBundled: boolean, +): void { + if (!config.skills) config.skills = {}; + if (!config.skills.entries) config.skills.entries = {}; + if (!config.skills.entries[skillKey]) config.skills.entries[skillKey] = {}; + + if (enabled) { + config.skills.entries[skillKey].enabled = true; + if ( + isBundled && + Array.isArray(config.skills.allowBundled) && + config.skills.allowBundled.length > 0 && + !config.skills.allowBundled.includes(skillKey) + ) { + config.skills.allowBundled.push(skillKey); + } + } else { + config.skills.entries[skillKey].enabled = false; + } +}