import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage, } from "@/lib/configUpdate"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { runBackgroundNetworkTask } from "@/lib/background-network"; import { noteDeferredRestartFromPayload } from "@/lib/opencode/deferredRestart"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { opencodeClient } from '@/lib/opencode/client'; import { filterSkillsByRuntimeFlags } from './skillVisibility'; // Prefer the active project path so Settings/Skills discovery matches the // project selector (and Commands/Agents). Falling back only to the session // directory misses repository-local `.agents/skills` when the client directory // is unset or points elsewhere while an active project exists. /** * Directory a call operates on. Settings can browse another project without * moving the app, so every entry point takes one; omitting it means the project * the app is currently on. */ const resolveDirectory = (directory?: string | null): string | null => { if (directory !== undefined) { const trimmed = directory?.trim(); return trimmed ? trimmed : null; } return getRequestDirectory(); }; const getRequestDirectory = (): string | null => { try { const projectsStore = useProjectsStore.getState(); const activeProject = projectsStore.getActiveProject?.(); if (activeProject?.path?.trim()) { return activeProject.path.trim(); } const clientDir = opencodeClient.getDirectory(); if (clientDir?.trim()) { return clientDir.trim(); } } catch (err) { console.warn('[SkillsStore] Error resolving config directory:', err); } return null; }; export type SkillScope = 'user' | 'project'; export type SkillSource = 'opencode' | 'claude' | 'agents'; export interface SupportingFile { name: string; path: string; fullPath: string; } interface SkillSources { md: { exists: boolean; path: string | null; dir: string | null; fields: string[]; scope?: SkillScope | null; source?: SkillSource | null; supportingFiles: SupportingFile[]; // Actual content values name?: string; description?: string; instructions?: string; }; projectMd?: { exists: boolean; path: string | null }; claudeMd?: { exists: boolean; path: string | null }; userMd?: { exists: boolean; path: string | null }; userClaudeMd?: { exists: boolean; path: string | null }; userAgentsMd?: { exists: boolean; path: string | null }; } export interface DiscoveredSkill { name: string; path: string; scope: SkillScope; source: SkillSource; description?: string; /** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */ group?: string; /** Authoritative server flag: skill lives under a managed root and can be renamed in place. */ renamable?: boolean; } /** Parse the domain group folder from a skill file path. * e.g. "~/.config/opencode/skills/automation-ai/ai-production/SKILL.md" → "automation-ai" * e.g. "~/.config/opencode/skills/theme-system/SKILL.md" → undefined (flat) */ function parseSkillGroup(path: string): string | undefined { const normalizedPath = path.replace(/\\/g, '/'); const idx = normalizedPath.lastIndexOf('/skills/'); if (idx === -1) return undefined; const relative = normalizedPath.substring(idx + '/skills/'.length); const parts = relative.split('/'); // Grouped layout: //SKILL.md → parts.length >= 3 // Flat layout: /SKILL.md → parts.length == 2 return parts.length >= 3 ? parts[0] : undefined; } // Raw skill response from API before transformation interface RawSkillResponse { name: string; path: string; scope?: SkillScope; source?: SkillSource; renamable?: boolean; sources?: { md?: { description?: string; }; }; } export interface SkillConfig { name: string; description: string; instructions?: string; scope?: SkillScope; source?: SkillSource; targetPath?: string; supportingFiles?: Array<{ path: string; content: string }>; } export interface PendingFile { path: string; content: string; } export interface SkillDraft { name: string; scope: SkillScope; source?: SkillSource; description: string; instructions?: string; pendingFiles?: PendingFile[]; } interface SkillDetail { name: string; sources: SkillSources; scope?: SkillScope | null; source?: SkillSource | null; } interface SkillsStore { selectedSkillName: string | null; /** Skills of the project the app is on. Chat and autocompletes read this one. */ skills: DiscoveredSkill[]; /** Every directory loaded so far, including the ambient one. */ skillsByDirectory: Record; isLoading: boolean; skillDraft: SkillDraft | null; setSelectedSkill: (name: string | null) => void; setSkillDraft: (draft: SkillDraft | null) => void; loadSkills: (directory?: string | null) => Promise; getSkillDetail: (name: string, directory?: string | null) => Promise; createSkill: (config: SkillConfig, directory?: string | null) => Promise; updateSkill: (name: string, config: Partial, directory?: string | null) => Promise; renameSkill: (name: string, newName: string, directory?: string | null) => Promise; deleteSkill: (name: string, directory?: string | null) => Promise; getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined; /** * Skills are discovered on the connected instance and cached by directory, * which two instances can share — so a switch must drop the caches rather * than report the previous instance's skills for the new one. */ resetForRuntimeSwitch: () => void; // Supporting files readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise; writeSupportingFile: (skillName: string, filePath: string, content: string, directory?: string | null) => Promise; deleteSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise; } declare global { interface Window { __zustand_skills_store__?: UseBoundStore>; } } const CONFIG_EVENT_SOURCE = "useSkillsStore"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const SKILLS_LOAD_CACHE_TTL_MS = 5000; const DEFAULT_SKILLS_CACHE_KEY = '__default__'; const skillsLastLoadedAt = new Map(); const skillsLoadInFlight = new Map>(); const getSkillsCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY; }; export const invalidateSkillsLoadCache = (directory: string | null = getRequestDirectory()) => { skillsLastLoadedAt.delete(getSkillsCacheKey(directory)); }; const upsertSkillLocal = ( set: (updater: (state: SkillsStore) => Partial) => void, get: () => SkillsStore, name: string, config: Partial, directory: string | null, ) => { const cacheKey = getSkillsCacheKey(directory); const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory()); const current = get().skillsByDirectory[cacheKey] ?? []; const existing = current.find((skill) => skill.name === name); const path = config.targetPath ?? existing?.path ?? ''; const nextSkill: DiscoveredSkill = { ...existing, name, path, scope: config.scope ?? existing?.scope ?? 'user', source: config.source ?? existing?.source ?? 'opencode', description: config.description ?? existing?.description ?? '', group: parseSkillGroup(path), }; const nextSkills = current.some((skill) => skill.name === name) ? current.map((skill) => (skill.name === name ? nextSkill : skill)) : [...current, nextSkill]; set((state) => { const next: Partial = { skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: nextSkills }, }; if (isAmbient) next.skills = nextSkills; return next; }); }; const removeSkillLocal = ( set: (state: Partial) => void, get: () => SkillsStore, name: string, ) => { const nextState: Partial = { skills: get().skills.filter((skill) => skill.name !== name), }; if (get().selectedSkillName === name) { nextState.selectedSkillName = null; } set(nextState); }; const MAX_HEALTH_WAIT_MS = 20000; const FAST_HEALTH_POLL_INTERVAL_MS = 300; const FAST_HEALTH_POLL_ATTEMPTS = 4; const SLOW_HEALTH_POLL_BASE_MS = 800; const SLOW_HEALTH_POLL_INCREMENT_MS = 200; const SLOW_HEALTH_POLL_MAX_MS = 2000; const EMPTY_SKILLS: DiscoveredSkill[] = []; /** * Skills of one project. Returns a stored array so components can select it * directly; an omitted directory means the project the app is on. */ export const selectSkillsForDirectory = ( state: Pick, directory?: string | null, ): DiscoveredSkill[] => { const cacheKey = getSkillsCacheKey(resolveDirectory(directory)); return state.skillsByDirectory[cacheKey] ?? EMPTY_SKILLS; }; export const useSkillsStore = create()( devtools( persist( (set, get) => ({ selectedSkillName: null, skills: [], skillsByDirectory: {}, isLoading: false, skillDraft: null, resetForRuntimeSwitch: () => { skillsLastLoadedAt.clear(); skillsLoadInFlight.clear(); set({ skills: [], skillsByDirectory: {}, isLoading: false }); }, setSelectedSkill: (name: string | null) => { set({ selectedSkillName: name }); }, setSkillDraft: (draft: SkillDraft | null) => { set({ skillDraft: draft }); }, loadSkills: async (requestedDirectory?: string | null) => { const directory = resolveDirectory(requestedDirectory); const cacheKey = getSkillsCacheKey(directory); const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory()); const now = Date.now(); const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0; const hasCachedSkills = (get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : [])).length > 0; if (hasCachedSkills && now - loadedAt < SKILLS_LOAD_CACHE_TTL_MS) { return true; } const inFlight = skillsLoadInFlight.get(cacheKey); if (inFlight) { return inFlight; } const request = (async () => { set({ isLoading: true }); // Failure must never look like an empty project. The mirror is the // fallback so a directory loaded before this map existed still counts. const previousSkills = get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : []); let lastError: unknown = null; for (let attempt = 0; attempt < 3; attempt++) { try { const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, { priority: 'low', headers: directory ? { 'x-opencode-directory': directory } : undefined, })); if (!response.ok) { throw new Error(`Failed to list skills: ${response.status}`); } const data = await response.json(); const rawSkills: RawSkillResponse[] = data.skills || []; const configSkills: DiscoveredSkill[] = rawSkills.map((s) => ({ name: s.name, path: s.path, scope: s.scope ?? 'user', source: s.source ?? 'opencode', description: s.sources?.md?.description || '', group: parseSkillGroup(s.path), renamable: s.renamable === true, })); // OpenCode loads a narrower set than this scan finds, and the // rules live in server-side env flags the browser cannot read. // The route reports them; `filterSkillsByRuntimeFlags` mirrors // OpenCode's discovery, including the `.agents`-wins dedup that // matters when `.claude/skills` are symlinks back into it. // // Deliberately not OpenCode's own skill endpoint: measured // against 1.18.14 it lists only global and builtin skills and // omits the project skills the agent actually has. const visibleSkills = filterSkillsByRuntimeFlags( configSkills, data.externalSkills ?? null, ); set((state) => { const next: Partial = { skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills }, isLoading: false, }; if (isAmbient) next.skills = visibleSkills; return next; }); skillsLastLoadedAt.set(cacheKey, Date.now()); return true; } catch (error) { lastError = error; const waitMs = 200 * (attempt + 1); await new Promise((resolve) => setTimeout(resolve, waitMs)); } } console.error("Failed to load skills:", lastError); set((state) => { const next: Partial = { skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills }, isLoading: false, }; if (isAmbient) next.skills = previousSkills; return next; }); return false; })(); skillsLoadInFlight.set(cacheKey, request); try { return await request; } finally { skillsLoadInFlight.delete(cacheKey); } }, getSkillDetail: async (name: string, requestedDirectory?: string | null) => { try { const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { headers: directory ? { 'x-opencode-directory': directory } : undefined, }); if (!response.ok) { return null; } return await response.json() as SkillDetail; } catch { return null; } }, createSkill: async (config: SkillConfig, requestedDirectory?: string | null) => { try { const skillConfig: Record = { name: config.name, description: config.description, }; if (config.instructions) skillConfig.instructions = config.instructions; if (config.scope) skillConfig.scope = config.scope; if (config.source) skillConfig.source = config.source; if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles; const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}), }, body: JSON.stringify(skillConfig) }); const payload = await response.json().catch(() => null); if (!response.ok) { const message = payload?.error || 'Failed to create skill'; throw new Error(message); } invalidateSkillsLoadCache(directory); if (payload?.requiresManualRestart) { upsertSkillLocal(set, get, config.name, config, directory); return true; } if (noteDeferredRestartFromPayload(payload, 'skills', { id: config.name })) { upsertSkillLocal(set, get, config.name, config, directory); emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); return true; } if (payload?.requiresReload) { startConfigUpdate("Creating skill..."); await refreshSkillsAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, }); return true; } const loaded = await get().loadSkills(directory); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } return loaded; } catch { return false; } }, updateSkill: async (name: string, config: Partial, requestedDirectory?: string | null) => { try { const skillConfig: Record = {}; if (config.description !== undefined) skillConfig.description = config.description; if (config.instructions !== undefined) skillConfig.instructions = config.instructions; if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles; if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath; const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}), }, body: JSON.stringify(skillConfig) }); const payload = await response.json().catch(() => null); if (!response.ok) { const message = payload?.error || 'Failed to update skill'; throw new Error(message); } invalidateSkillsLoadCache(directory); if (payload?.requiresManualRestart) { upsertSkillLocal(set, get, name, config, directory); return true; } if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) { upsertSkillLocal(set, get, name, config, directory); emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); return true; } if (payload?.requiresReload) { startConfigUpdate("Updating skill..."); await refreshSkillsAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, }); return true; } const loaded = await get().loadSkills(directory); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } return loaded; } catch { return false; } }, renameSkill: async (name: string, newName: string, requestedDirectory?: string | null) => { startConfigUpdate("Renaming skill..."); let requiresReload = false; try { const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}), }, body: JSON.stringify({ renameTo: newName }), }); const payload = await response.json().catch(() => null); if (!response.ok) { const message = payload?.error || 'Failed to rename skill'; throw new Error(message); } const needsReload = payload?.requiresReload ?? false; invalidateSkillsLoadCache(directory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, }); return true; } const loaded = await get().loadSkills(directory); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } return loaded; } catch { return false; } finally { if (!requiresReload) { finishConfigUpdate(); } } }, deleteSkill: async (name: string, requestedDirectory?: string | null) => { try { const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { method: 'DELETE', headers: directory ? { 'x-opencode-directory': directory } : undefined, }); const payload = await response.json().catch(() => null); if (!response.ok) { const message = payload?.error || 'Failed to delete skill'; throw new Error(message); } invalidateSkillsLoadCache(directory); if (payload?.requiresManualRestart) { removeSkillLocal(set, get, name); return true; } if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) { removeSkillLocal(set, get, name); emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); return true; } if (payload?.requiresReload) { startConfigUpdate("Deleting skill..."); await refreshSkillsAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, }); return true; } const loaded = await get().loadSkills(directory); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } if (get().selectedSkillName === name) { set({ selectedSkillName: null }); } return loaded; } catch { return false; } }, getSkillByName: (name: string, requestedDirectory?: string | null) => { return selectSkillsForDirectory(get(), requestedDirectory).find((skill) => skill.name === name); }, readSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => { try { const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `&directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch( `/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`, { headers: directory ? { 'x-opencode-directory': directory } : undefined }, ); if (!response.ok) { return null; } const data = await response.json(); return data.content ?? null; } catch { return null; } }, writeSupportingFile: async (skillName: string, filePath: string, content: string, requestedDirectory?: string | null) => { try { const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch( `/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}), }, body: JSON.stringify({ content }) } ); return response.ok; } catch { return false; } }, deleteSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => { try { const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch( `/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`, { method: 'DELETE', headers: directory ? { 'x-opencode-directory': directory } : undefined, } ); return response.ok; } catch { return false; } }, }), { name: "skills-store", storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedSkillName: state.selectedSkillName, }), }, ), { name: "skills-store", }, ), ); if (typeof window !== "undefined") { window.__zustand_skills_store__ = useSkillsStore; } async function waitForOpenCodeConnection(delayMs?: number) { const initialPause = typeof delayMs === "number" && delayMs > 0 ? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS) : 0; if (initialPause > 0) { await sleep(initialPause); } const start = Date.now(); let attempt = 0; let lastError: unknown = null; while (Date.now() - start < MAX_HEALTH_WAIT_MS) { attempt += 1; updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`); try { const isHealthy = await opencodeClient.checkHealth(); if (isHealthy) { return; } lastError = new Error("OpenCode health check reported not ready"); } catch (error) { lastError = error; } const elapsed = Date.now() - start; const waitMs = attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200 ? FAST_HEALTH_POLL_INTERVAL_MS : Math.min( SLOW_HEALTH_POLL_BASE_MS + Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS, SLOW_HEALTH_POLL_MAX_MS, ); await sleep(waitMs); } throw lastError || new Error("OpenCode did not become ready in time"); } export async function refreshSkillsAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) { try { updateConfigUpdateMessage(options?.message || "Refreshing skills…"); } catch { // ignore } try { await waitForOpenCodeConnection(options?.delayMs); updateConfigUpdateMessage("Refreshing skills…"); const skillsStore = useSkillsStore.getState(); invalidateSkillsLoadCache(); const loaded = await skillsStore.loadSkills(); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } } catch (error) { updateConfigUpdateMessage("OpenCode refresh failed. Please retry."); await sleep(1500); throw error; } finally { finishConfigUpdate(); } } // Subscribe to config changes from other stores let unsubscribeSkillsConfigChanges: (() => void) | null = null; if (!unsubscribeSkillsConfigChanges) { unsubscribeSkillsConfigChanges = subscribeToConfigChanges((event) => { if (event.source === CONFIG_EVENT_SOURCE) { return; } if (scopeMatches(event, "skills")) { const { loadSkills } = useSkillsStore.getState(); void loadSkills(); } }); }