From 8406f41f421daece75b9d5e7b48c4b4081935f7a Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:09:12 +0800 Subject: [PATCH 1/5] Skills diagnostics, global master toggle, and ID-matching fix (step 5) Fix per-agent skill binding: OpenClaw filters agents.list[*].skills against each skill's frontmatter name, not its slug. Add SKILL_MATCH_NAMES + resolveSkillFilterNames and write resolved match-names to openclaw.json (seeding + set-agent-skills), while keeping the UI/IPC input slug-based. Round-trip reads recognize both slug- and name-form stored values via matchesSkill. Add dev-only diagnostics: skills:get-status IPC runs the OpenClaw CLI directly (skills list/check --json) to surface each skill's gating status and missing requirements, rendered as badges + reasons in SkillsDevPanel with a Model-visible summary and Refresh. Add a per-skill global master toggle: skills:set-global-enabled IPC manages both skills.entries[key].enabled and skills.allowBundled (bundled only), mirroring the set-agent-skills atomic-write + single managed-gateway restart with rollback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- desktop/renderer/env.d.ts | 29 ++ .../src/components/skills/SkillsDevPanel.vue | 247 +++++++++++++- desktop/renderer/src/i18n/en-US.ts | 20 ++ desktop/renderer/src/i18n/zh-CN.ts | 20 ++ desktop/src/agent-catalog.test.ts | 50 +++ desktop/src/agent-catalog.ts | 41 +++ desktop/src/agent-personas.test.ts | 32 +- desktop/src/agent-personas.ts | 9 +- desktop/src/constants.ts | 3 + desktop/src/main.ts | 313 +++++++++++++++++- desktop/src/preload.ts | 6 + 11 files changed, 752 insertions(+), 18 deletions(-) diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index a3d3a41..d705dd9 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; @@ -132,6 +159,8 @@ interface OpenClawAPI { 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 }>; integrityCheck(): Promise; pendingIntegrityResult(): Promise; acceptIntegrityChanges(): Promise; diff --git a/desktop/renderer/src/components/skills/SkillsDevPanel.vue b/desktop/renderer/src/components/skills/SkillsDevPanel.vue index 91d4472..8ed20cd 100644 --- a/desktop/renderer/src/components/skills/SkillsDevPanel.vue +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -29,18 +29,55 @@ + {{ t("skills.dev.enabledCount", { count: enabledCount, total: allSkillIds.length }) }} +
+ + {{ t("skills.dev.modelVisibleSummary", { + visible: status.summary.modelVisible, + total: status.summary.total, + }) }} + + + {{ t("skills.dev.statusFailed", { error: statusError }) }} + +
+
{{ t("skills.dev.loading") }}
  • - +
    + + +
    +
    + {{ badgeText(id) }} + {{ reasonText(id) }} + {{ missingText(id) }} +
@@ -65,7 +102,7 @@ import { computed, onMounted, ref, watch } from "vue"; import { t } from "@/i18n"; import { useAgentStore } from "@/stores/agents"; -import { AGENT_CATALOG, ALL_SKILL_IDS } from "../../../../src/agent-catalog"; +import { AGENT_CATALOG, ALL_SKILL_IDS, matchesSkill } from "../../../../src/agent-catalog"; const agentStore = useAgentStore(); @@ -80,6 +117,11 @@ const applying = ref(false); const feedback = ref(""); const feedbackKind = ref<"info" | "success" | "error">("info"); +const status = ref(null); +const statusLoading = ref(false); +const statusError = ref(""); +const globalBusy = ref>(new Set()); + function resolveDefaultAgentId(): string { const current = agentStore.currentAgentId; return agents.some((a) => a.id === current) ? current : "main"; @@ -88,6 +130,61 @@ function resolveDefaultAgentId(): string { const enabledCount = computed(() => pending.value.size); const dirty = computed(() => !setsEqual(pending.value, persisted.value)); +// Match a status record (keyed by the CLI's frontmatter name or slug) back to a +// catalog slug, tolerating both the slug- and display-name forms. +function statusFor(slug: string): SkillStatusRecord | undefined { + const records = status.value?.skills; + if (!records) return undefined; + return records.find( + (record) => matchesSkill(record.skillKey, slug) || matchesSkill(record.name, slug), + ); +} + +function globalOn(slug: string): boolean { + const record = statusFor(slug); + // Unknown skills default to "on" so the switch isn't misleadingly off. + if (!record) return true; + return !record.disabled && !record.blockedByAllowlist; +} + +function badgeClass(slug: string): string { + const record = statusFor(slug); + if (!record) return "is-unknown"; + if (record.modelVisible) return "is-visible"; + return "is-hidden"; +} + +function badgeText(slug: string): string { + const record = statusFor(slug); + if (!record) return t("skills.dev.badge.unknown"); + return record.modelVisible ? t("skills.dev.badge.visible") : t("skills.dev.badge.hidden"); +} + +function reasonText(slug: string): string { + const record = statusFor(slug); + if (!record || record.modelVisible) return ""; + if (record.blockedByAllowlist) return t("skills.dev.reason.allowlist"); + if (record.blockedByAgentFilter) return t("skills.dev.reason.agentFilter"); + if (record.disabled) return t("skills.dev.reason.disabled"); + if (!record.eligible) return t("skills.dev.reason.missing"); + return ""; +} + +function missingText(slug: string): string { + const record = statusFor(slug); + if (!record || record.modelVisible || record.eligible) return ""; + const parts: string[] = []; + const bins = [...record.missing.bins, ...record.missing.anyBins]; + if (bins.length) parts.push(t("skills.dev.missing.bins", { list: bins.join(", ") })); + if (record.missing.env.length) + parts.push(t("skills.dev.missing.env", { list: record.missing.env.join(", ") })); + if (record.missing.config.length) + parts.push(t("skills.dev.missing.config", { list: record.missing.config.join(", ") })); + if (record.missing.os.length) + parts.push(t("skills.dev.missing.os", { list: record.missing.os.join(", ") })); + return parts.join(" · "); +} + function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const value of a) if (!b.has(value)) return false; @@ -106,11 +203,14 @@ async function loadAgentSkills(agentId: string): Promise { candidate !== null && (candidate as { id?: unknown }).id === agentId, ) as { skills?: unknown } | undefined; - const skills = Array.isArray(entry?.skills) - ? entry.skills.filter( - (id: unknown): id is string => typeof id === "string" && allSkillIds.includes(id), - ) + const stored = Array.isArray(entry?.skills) + ? entry.skills.filter((id: unknown): id is string => typeof id === "string") : []; + // A stored value counts as ON if it matches EITHER the slug or the mapped + // OpenClaw match-name, so both older slug-form and current name-form register. + const skills = allSkillIds.filter((slug) => + stored.some((value) => matchesSkill(value, slug)), + ); persisted.value = new Set(skills); pending.value = new Set(skills); } catch (error) { @@ -144,6 +244,53 @@ function clearAll(): void { pending.value = new Set(); } +async function loadStatus(agentId: string): Promise { + statusLoading.value = true; + statusError.value = ""; + try { + const result = await window.openclaw.skills.getStatus(agentId); + if (result.ok) { + status.value = result; + } else { + status.value = null; + statusError.value = result.error ?? "unknown error"; + } + } catch (error) { + status.value = null; + statusError.value = error instanceof Error ? error.message : String(error); + } finally { + statusLoading.value = false; + } +} + +function refreshStatus(): void { + void loadStatus(selectedAgentId.value); +} + +async function toggleGlobal(slug: string): Promise { + const next = !globalOn(slug); + const busy = new Set(globalBusy.value); + busy.add(slug); + globalBusy.value = busy; + feedbackKind.value = "info"; + feedback.value = t("skills.dev.globalUpdating", { skill: slug }); + try { + await window.openclaw.skills.setGlobalEnabled(slug, next); + feedbackKind.value = "success"; + feedback.value = t("skills.dev.globalUpdated", { skill: slug }); + await loadStatus(selectedAgentId.value); + } catch (error) { + feedbackKind.value = "error"; + feedback.value = t("skills.dev.globalFailed", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + const done = new Set(globalBusy.value); + done.delete(slug); + globalBusy.value = done; + } +} + async function apply(): Promise { applying.value = true; feedbackKind.value = "info"; @@ -155,6 +302,7 @@ async function apply(): Promise { persisted.value = new Set(skillIds); feedbackKind.value = "success"; feedback.value = t("skills.dev.applied"); + await loadStatus(agentId); } catch (error) { feedbackKind.value = "error"; feedback.value = t("skills.dev.applyFailed", { @@ -167,10 +315,12 @@ async function apply(): Promise { watch(selectedAgentId, (agentId) => { void loadAgentSkills(agentId); + void loadStatus(agentId); }); onMounted(() => { void loadAgentSkills(selectedAgentId.value); + void loadStatus(selectedAgentId.value); }); @@ -299,6 +449,85 @@ onMounted(() => { white-space: nowrap; } +.skills-dev-summary { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--ux-text-muted); + margin-bottom: 8px; + min-height: 16px; +} + +.skills-dev-status-error { + color: var(--ux-status-error, #c62828); +} + +.skills-dev-item-main { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; +} + +.skills-dev-toggle-hint { + font-size: 11px; + color: var(--ux-text-muted); +} + +.skills-dev-global { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.skills-dev-global-label { + font-size: 11px; + color: var(--ux-text-muted); +} + +.skills-dev-item-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 2px 8px 4px 8px; +} + +.skills-dev-badge { + font-size: 11px; + font-weight: 500; + padding: 1px 6px; + border-radius: 999px; + white-space: nowrap; +} + +.skills-dev-badge.is-visible { + background: var(--ux-status-success-bg, rgba(46, 125, 50, 0.14)); + color: var(--ux-status-success, #2e7d32); +} + +.skills-dev-badge.is-hidden { + background: var(--ux-status-warning-bg, rgba(198, 40, 40, 0.12)); + color: var(--ux-status-error, #c62828); +} + +.skills-dev-badge.is-unknown { + background: var(--ux-surface-hover); + color: var(--ux-text-muted); +} + +.skills-dev-reason { + font-size: 11px; + color: var(--ux-text-primary); +} + +.skills-dev-missing { + font-size: 11px; + color: var(--ux-text-muted); +} + .skills-dev-footer { display: flex; align-items: center; diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 43b2a48..38cb6a3 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -713,6 +713,26 @@ export default { "skills.dev.applying": "Applying…", "skills.dev.applied": "Applied. Gateway reloaded.", "skills.dev.applyFailed": "Failed to apply: {error}", + "skills.dev.refresh": "Refresh status", + "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.badge.visible": "Model-visible", + "skills.dev.badge.hidden": "Not injected", + "skills.dev.badge.unknown": "No status", + "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}", + "skills.dev.globalUpdating": "Updating global state for {skill}…", + "skills.dev.globalUpdated": "Updated global state for {skill}. Gateway reloaded.", + "skills.dev.globalFailed": "Failed to update global state: {error}", // ── Studio ── "studio.switchToStudio": "Studio", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index fbbdd41..5e9353c 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -686,6 +686,26 @@ export default { "skills.dev.applying": "正在应用……", "skills.dev.applied": "已应用,网关已重载。", "skills.dev.applyFailed": "应用失败:{error}", + "skills.dev.refresh": "刷新状态", + "skills.dev.refreshing": "刷新中…", + "skills.dev.thisAgent": "本智能体", + "skills.dev.global": "全局", + "skills.dev.modelVisibleSummary": "模型可见:{visible} / {total}", + "skills.dev.statusFailed": "无法获取状态:{error}", + "skills.dev.badge.visible": "模型可见", + "skills.dev.badge.hidden": "未注入", + "skills.dev.badge.unknown": "无状态", + "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}", + "skills.dev.globalUpdating": "正在更新 {skill} 的全局状态…", + "skills.dev.globalUpdated": "已更新 {skill} 的全局状态,网关已重载。", + "skills.dev.globalFailed": "更新全局状态失败:{error}", // ── Studio ── "studio.switchToStudio": "工作室", diff --git a/desktop/src/agent-catalog.test.ts b/desktop/src/agent-catalog.test.ts index 62e81de..f85f717 100644 --- a/desktop/src/agent-catalog.test.ts +++ b/desktop/src/agent-catalog.test.ts @@ -4,7 +4,10 @@ import { ALL_SKILL_IDS, getAgentSkills, isKnownSkillId, + matchesSkill, + resolveSkillFilterNames, sanitizeAgentSkillIds, + SKILL_MATCH_NAMES, } from "./agent-catalog"; const EXPECTED_SKILL_IDS = [ @@ -99,3 +102,50 @@ describe("agent catalog skills binding", () => { ); }); }); + +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 72f4188..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. diff --git a/desktop/src/agent-personas.test.ts b/desktop/src/agent-personas.test.ts index c024e17..7cdd22c 100644 --- a/desktop/src/agent-personas.test.ts +++ b/desktop/src/agent-personas.test.ts @@ -2,7 +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 } from "./agent-catalog"; +import { getAgentSkills, matchesSkill, resolveSkillFilterNames } from "./agent-catalog"; import { AGENT_PERSONAS, DEFAULT_AGENT_PERSONAS, @@ -229,7 +229,33 @@ describe("agent personas", () => { ensureAgentPersonasConfig(config, stateDir, [...DEFAULT_AGENT_PERSONAS, persona]); for (const entry of agentList(config)) { - expect(entry.skills).toEqual(getAgentSkills(entry.id)); + // 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); } }); @@ -285,7 +311,7 @@ describe("agent personas", () => { 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(getAgentSkills("main")); + expect(mainEntry?.skills).toEqual(resolveSkillFilterNames(getAgentSkills("main"))); }); it("reports changed when skills are added to a config that lacked them", () => { diff --git a/desktop/src/agent-personas.ts b/desktop/src/agent-personas.ts index c756746..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; @@ -152,7 +152,9 @@ function createAgentEntry(persona: AgentPersona, stateDir: string): Record = { name: persona.name }; const workspace = getAgentWorkspacePath(stateDir, persona); if (workspace) entry.workspace = workspace; - entry.skills = [...persona.skills]; + // 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; } @@ -264,7 +266,8 @@ export function ensureAgentPersonasConfig( // (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")) { - entry.skills = [...catalogSkills]; + // Persist match-names (not raw slugs) — see resolveSkillFilterNames. + entry.skills = resolveSkillFilterNames([...catalogSkills]); } } diff --git a/desktop/src/constants.ts b/desktop/src/constants.ts index c41eafe..ce80736 100644 --- a/desktop/src/constants.ts +++ b/desktop/src/constants.ts @@ -64,6 +64,9 @@ 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). */ +export const SKILLS_STATUS_TIMEOUT_MS = 30_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 1c31e19..906d11f 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,7 +91,7 @@ import { type AgentPersona, type AgentRosterConfig, } from "./agent-personas"; -import { sanitizeAgentSkillIds } from "./agent-catalog"; +import { resolveSkillFilterNames, sanitizeAgentSkillIds } from "./agent-catalog"; /** * Normalize a directory path for comparison/storage. @@ -1840,8 +1841,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; @@ -3164,7 +3281,10 @@ function registerIpcHandlers(): void { if (!entry) { throw new Error(`Unknown agent "${agentId}"`); } - entry.skills = skills; + // 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. + entry.skills = resolveSkillFilterNames(skills); const alreadyRunning = await checkExistingGateway(gatewayPort); const managedGateway = gatewaySpawnedByUs && gatewayProcess !== null; @@ -3203,6 +3323,193 @@ function registerIpcHandlers(): void { }, ); + // 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 { + const [listJson, checkJson] = await Promise.all([ + runCli(["skills", "list", "--agent", agentId, "--json"]), + runCli(["skills", "check", "--agent", agentId, "--json"]), + ]); + if (!listJson && !checkJson) { + return { + ok: false, + error: "OpenClaw skills CLI produced no parseable output", + summary: emptySummary, + skills: [], + }; + } + const skills = normalizeSkillsStatus(listJson, checkJson); + 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"); + + if (!config.skills) config.skills = {}; + if (!config.skills.entries) config.skills.entries = {}; + if (!config.skills.entries[skillKey]) config.skills.entries[skillKey] = {}; + + if (enabled) { + // Clear any global disable, and — for bundled skills gated by a non-empty + // allowlist — add the key so it isn't excluded. When allowBundled is absent + // or empty, bundled skills are already globally allowed, so leave it alone + // rather than creating a newly restrictive allowlist. + config.skills.entries[skillKey].enabled = true; + if (isBundled && Array.isArray(config.skills.allowBundled) && config.skills.allowBundled.length > 0) { + if (!config.skills.allowBundled.includes(skillKey)) { + config.skills.allowBundled.push(skillKey); + } + } + } else { + config.skills.entries[skillKey].enabled = false; + } + + 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; + } + }, + ); + // --- Chat (WebSocket gateway protocol) --- ipcMain.handle( "chat:send-message", diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 7809b37..453582f 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -62,6 +62,12 @@ contextBridge.exposeInMainWorld("openclaw", { 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; + }>, integrityCheck: () => ipcRenderer.invoke("skills:integrity-check"), pendingIntegrityResult: () => ipcRenderer.invoke("skills:pending-integrity-result"), acceptIntegrityChanges: () => ipcRenderer.invoke("skills:accept-integrity-changes"), From 648e354ea1ccc4feeea3473f292efa9642be7dea Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:37:19 +0800 Subject: [PATCH 2/5] Fix skills status timeout and panel layout Raise SKILLS_STATUS_TIMEOUT_MS to 120s so the cold OpenClaw CLI (52-81s to emit JSON) is not killed before producing output, and drop the redundant 'skills check' spawn since 'skills list --json' already carries every per-skill boolean and missing-requirement the panel uses. Add a visible 'checking status' loading line during the ~1 minute wait. Redesign SkillsDevPanel as a single-column vertical list: skill name + status/reason/missing on the left, per-agent 'This agent' and 'Global' toggles right-aligned, no horizontal overflow or clipped labels. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- .../src/components/skills/SkillsDevPanel.vue | 73 ++++++++++++------- desktop/renderer/src/i18n/en-US.ts | 1 + desktop/renderer/src/i18n/zh-CN.ts | 1 + desktop/src/constants.ts | 6 +- desktop/src/main.ts | 13 ++-- 5 files changed, 61 insertions(+), 33 deletions(-) diff --git a/desktop/renderer/src/components/skills/SkillsDevPanel.vue b/desktop/renderer/src/components/skills/SkillsDevPanel.vue index 8ed20cd..5c3805c 100644 --- a/desktop/renderer/src/components/skills/SkillsDevPanel.vue +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -43,7 +43,10 @@
- + + {{ t("skills.dev.statusChecking") }} + + {{ t("skills.dev.modelVisibleSummary", { visible: status.summary.modelVisible, total: status.summary.total, @@ -57,10 +60,17 @@
{{ t("skills.dev.loading") }}
  • -
    +
    + {{ id }} +
    + {{ badgeText(id) }} + {{ reasonText(id) }} + {{ missingText(id) }} +
    +
    +
    -
    - {{ badgeText(id) }} - {{ reasonText(id) }} - {{ missingText(id) }} -
@@ -419,34 +424,54 @@ onMounted(() => { list-style: none; margin: 0; padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 6px 16px; + display: flex; + flex-direction: column; + gap: 6px; } .skills-dev-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--ux-border); + border-radius: 8px; +} + +.skills-dev-item-left { + display: flex; + flex-direction: column; + gap: 4px; min-width: 0; + flex: 1; +} + +.skills-dev-item-controls { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; } .skills-dev-toggle { display: flex; align-items: center; - gap: 8px; - padding: 6px 8px; + gap: 6px; border-radius: 8px; cursor: pointer; -} - -.skills-dev-toggle:hover { - background: var(--ux-surface-hover); + white-space: nowrap; } .skills-dev-skill-id { font-size: 13px; + font-weight: 500; color: var(--ux-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; } .skills-dev-summary { @@ -458,15 +483,13 @@ onMounted(() => { min-height: 16px; } -.skills-dev-status-error { - color: var(--ux-status-error, #c62828); +.skills-dev-status-checking { + color: var(--ux-ctrl-brand-rest); + font-weight: 500; } -.skills-dev-item-main { - display: flex; - align-items: center; - gap: 8px; - justify-content: space-between; +.skills-dev-status-error { + color: var(--ux-status-error, #c62828); } .skills-dev-toggle-hint { @@ -480,6 +503,7 @@ onMounted(() => { gap: 4px; cursor: pointer; flex-shrink: 0; + white-space: nowrap; } .skills-dev-global-label { @@ -492,7 +516,6 @@ onMounted(() => { flex-wrap: wrap; align-items: center; gap: 6px; - padding: 2px 8px 4px 8px; } .skills-dev-badge { diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 38cb6a3..8eb6c6b 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -719,6 +719,7 @@ export default { "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.badge.visible": "Model-visible", "skills.dev.badge.hidden": "Not injected", "skills.dev.badge.unknown": "No status", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 5e9353c..3967185 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -692,6 +692,7 @@ export default { "skills.dev.global": "全局", "skills.dev.modelVisibleSummary": "模型可见:{visible} / {total}", "skills.dev.statusFailed": "无法获取状态:{error}", + "skills.dev.statusChecking": "正在检查技能状态…最长可能需要一分钟。", "skills.dev.badge.visible": "模型可见", "skills.dev.badge.hidden": "未注入", "skills.dev.badge.unknown": "无状态", diff --git a/desktop/src/constants.ts b/desktop/src/constants.ts index ce80736..aec4dc8 100644 --- a/desktop/src/constants.ts +++ b/desktop/src/constants.ts @@ -64,8 +64,10 @@ 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). */ -export const SKILLS_STATUS_TIMEOUT_MS = 30_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 906d11f..869fb14 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -3389,11 +3389,12 @@ function registerIpcHandlers(): void { }); try { - const [listJson, checkJson] = await Promise.all([ - runCli(["skills", "list", "--agent", agentId, "--json"]), - runCli(["skills", "check", "--agent", agentId, "--json"]), - ]); - if (!listJson && !checkJson) { + // `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", @@ -3401,7 +3402,7 @@ function registerIpcHandlers(): void { skills: [], }; } - const skills = normalizeSkillsStatus(listJson, checkJson); + const skills = normalizeSkillsStatus(listJson, null); return { ok: true, summary: { From 140920de65ea941de87f332f0958f5334ab0a2d8 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:13:14 +0800 Subject: [PATCH 3/5] Add Not installed badge and defer the global skill toggle Show a muted 'Not installed' badge for catalog slugs (e.g. canvas) that return no record once status has loaded successfully, instead of a bare disabled Global switch. Make the per-skill Global master toggle deferred like the per-agent toggle: flipping it only stages a pending change, and a single 'Apply & reload' now persists BOTH the per-agent allowlist and every global on/off change in ONE atomic config write + ONE gateway restart via the new skills:apply-agent-config IPC. Factor the config mutations into shared skill-config helpers (applyAgentSkillsToConfig / applyGlobalSkillChange) reused by the existing set-agent-skills and set-global-enabled handlers, and unit-test them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- desktop/renderer/env.d.ts | 9 ++ .../src/components/skills/SkillsDevPanel.vue | 96 +++++++++---- desktop/renderer/src/i18n/en-US.ts | 4 +- desktop/renderer/src/i18n/zh-CN.ts | 6 +- desktop/src/main.ts | 136 +++++++++++++----- desktop/src/preload.ts | 14 ++ desktop/src/skill-config.test.ts | 121 ++++++++++++++++ desktop/src/skill-config.ts | 90 ++++++++++++ 8 files changed, 406 insertions(+), 70 deletions(-) create mode 100644 desktop/src/skill-config.test.ts create mode 100644 desktop/src/skill-config.ts diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index d705dd9..95ba87e 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -161,6 +161,15 @@ interface OpenClawAPI { 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 index 5c3805c..5ad3196 100644 --- a/desktop/renderer/src/components/skills/SkillsDevPanel.vue +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -67,6 +67,11 @@ {{ reasonText(id) }} {{ missingText(id) }} +
+ + {{ t("skills.dev.badge.notInstalled") }} + +