diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index f571479c..7c436d52 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -54,6 +54,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useSkillsStore } from '@/stores/useSkillsStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; @@ -71,6 +72,7 @@ const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; const EMPTY_MESSAGES: Message[] = []; const FILE_MENTION_TOKEN = /^@[^\s]+$/; +const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g; const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500; const VS_CODE_DROP_DATA_TYPES = [ 'CodeFiles', @@ -81,6 +83,28 @@ const VS_CODE_DROP_DATA_TYPES = [ 'text/plain', ]; +const collectInlineSkillMentions = (text: string, skillNames: Set): string[] => { + const mentions: string[] = []; + INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = INLINE_SKILL_TOKEN_PATTERN.exec(text)) !== null) { + const prefix = match[1] || ''; + const name = match[2] || ''; + const slashIndex = match.index + prefix.length; + if (slashIndex === 0 || !skillNames.has(name) || mentions.includes(name)) { + continue; + } + mentions.push(name); + } + return mentions; +}; + +const buildSkillMentionInstruction = (skillNames: string[]): string | null => { + if (skillNames.length === 0) return null; + const formatted = skillNames.map((name) => `/${name}`).join(', '); + return `The user explicitly mentioned these skills in their message: ${formatted}. Use the corresponding skill tool when it is relevant to accomplishing the user's request.`; +}; + const hasUserMessages = (sessionId: string, directory?: string) => { return getSyncMessages(sessionId, directory).some((message) => message.role === 'user'); }; @@ -1508,6 +1532,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo let primaryAttachments: AttachedFile[] = []; let agentMentionName: string | undefined; const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = []; + const availableSkillNames = new Set(useSkillsStore.getState().skills.map((skill) => skill.name)); + const mentionedSkillNames: string[] = []; + const addMentionedSkills = (text: string) => { + for (const name of collectInlineSkillMentions(text, availableSkillNames)) { + if (!mentionedSkillNames.includes(name)) mentionedSkillNames.push(name); + } + }; // Consume any pending synthetic parts (from conflict resolution, etc.) const syntheticParts = consumePendingSyntheticParts(); @@ -1517,6 +1548,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const queuedMsg = queuedMessages[i]; const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents); const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); + addMentionedSkills(queuedText); // Use agent mention from first message that has one if (!agentMentionName && mention?.name) { @@ -1546,6 +1578,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles); + addMentionedSkills(messageText); if (!agentMentionName && mention?.name) { agentMentionName = mention.name; @@ -1611,6 +1644,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }); } + const skillMentionInstruction = buildSkillMentionInstruction(mentionedSkillNames); + if (skillMentionInstruction) { + additionalParts.push({ + text: skillMentionInstruction, + synthetic: true, + }); + } + if (!primaryText && additionalParts.length === 0) return; // Clear queue and input diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx index d3f7e7e2..24e23cfe 100644 --- a/packages/ui/src/components/chat/SkillAutocomplete.tsx +++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx @@ -6,6 +6,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface SkillInfo { name: string; scope: string; + source?: string; description?: string; } @@ -111,6 +112,7 @@ export const SkillAutocomplete = React.forwardRef { const isProject = skill.scope === 'project'; + const source = skill.source || 'opencode'; return (
{skill.scope} + + {source} +
{skill.description && (
diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index 6bfacdb8..0f17bc99 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -18,6 +18,7 @@ import { isExpandableTool, isStandaloneTool, isStaticTool } from './toolRenderUt import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useUIStore } from '@/stores/useUIStore'; +import { useSkillsStore } from '@/stores/useSkillsStore'; import ReasoningPart from './ReasoningPart'; import JustificationBlock from './JustificationBlock'; import { areRenderRelevantPartsEqual } from '../renderCompare'; @@ -599,7 +600,9 @@ const StaticToolRowInner: React.FC<{ const isReadGroup = toolName.toLowerCase() === 'read'; const runtime = React.useContext(RuntimeAPIContext); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const skills = useSkillsStore((state) => state.skills); const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]); + const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]); const descriptions = React.useMemo(() => { const descs: string[] = []; @@ -648,6 +651,15 @@ const StaticToolRowInner: React.FC<{ uiStore.openContextFile(contextDirectory, absolutePath); }, [currentDirectory, runtime]); + const handleSkillClick = React.useCallback((skillName: string) => { + const skill = skillByName.get(skillName); + if (!skill?.path) { + return; + } + const uiStore = useUIStore.getState(); + uiStore.openContextFile(currentDirectory || getContextDirectoryForPath('', skill.path), skill.path); + }, [currentDirectory, skillByName]); + const normalizedToolName = toolName.toLowerCase(); const isSearchGroup = normalizedToolName === 'grep' || normalizedToolName === 'search' @@ -655,6 +667,7 @@ const StaticToolRowInner: React.FC<{ || normalizedToolName === 'ripgrep' || normalizedToolName === 'glob'; const isFetchGroup = normalizedToolName === 'webfetch' || normalizedToolName === 'fetch' || normalizedToolName === 'curl' || normalizedToolName === 'wget'; + const isSkillGroup = normalizedToolName === 'skill'; return (
)) : null} - {!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? ( + {isSkillGroup && descriptions.length > 0 + ? descriptions.map((skillName, index) => ( + + )) + : null} + {!isReadGroup && !isSearchGroup && !isFetchGroup && !isSkillGroup && descriptions.length > 0 ? ( { return `https://opencode.ai/docs/agents/#${encoded}`; }; +const SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g; + const escapeHtml = (text: string): string => { return text .replace(/&/g, '&') @@ -41,8 +45,18 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti const [isExpanded, setIsExpanded] = React.useState(false); const [isTruncated, setIsTruncated] = React.useState(false); const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode); + const skills = useSkillsStore((state) => state.skills); + const openContextFile = useUIStore((state) => state.openContextFile); + const effectiveDirectory = useEffectiveDirectory(); const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode); const textRef = React.useRef(null); + const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]); + + const openSkill = React.useCallback((name: string) => { + const skill = skillByName.get(name); + if (!skill?.path) return; + openContextFile(effectiveDirectory || skill.path.replace(/\/[^/]*$/, '') || '/', skill.path); + }, [effectiveDirectory, openContextFile, skillByName]); const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => { if (typeof window === 'undefined') { @@ -76,7 +90,17 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti return () => resizeObserver.disconnect(); }, [textContent, isExpanded]); - const handleClick = React.useCallback(() => { + const handleClick = React.useCallback((event: React.MouseEvent) => { + const target = event.target as HTMLElement | null; + const skillLink = target?.closest('[data-skill-name]'); + const skillName = skillLink?.dataset.skillName; + if (skillName) { + event.preventDefault(); + event.stopPropagation(); + openSkill(skillName); + return; + } + const element = textRef.current; if (!element) { return; @@ -89,7 +113,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti if (!isExpanded && isTruncated) { setIsExpanded(true); } - }, [hasActiveSelectionInElement, isExpanded, isTruncated]); + }, [hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]); const handleCollapse = React.useCallback((event: React.MouseEvent) => { event.stopPropagation(); @@ -108,21 +132,62 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti content = content.replace(agentMention.token, mentionHtml); } + content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string, offset: number) => { + const slashIndex = offset + prefix.length; + if (slashIndex === 0 || !skillByName.has(skillName)) return match; + return `${prefix}/${skillName}`; + }); + return content; - }, [agentMention, textContent]); + }, [agentMention, skillByName, textContent]); const plainTextContent = React.useMemo(() => { - if (!agentMention?.token || !textContent.includes(agentMention.token)) { - return textContent; + const nodes: React.ReactNode[] = []; + let cursor = 0; + let agentMentionUsed = false; + let match: RegExpExecArray | null; + SKILL_TOKEN_PATTERN.lastIndex = 0; + + while ((match = SKILL_TOKEN_PATTERN.exec(textContent)) !== null) { + const prefix = match[1] || ''; + const skillName = match[2]; + const slashIndex = match.index + prefix.length; + if (slashIndex === 0 || !skillByName.has(skillName)) continue; + + if (match.index > cursor) nodes.push(textContent.slice(cursor, match.index)); + if (prefix) nodes.push(prefix); + nodes.push( + + ); + cursor = slashIndex + skillName.length + 1; } - const idx = textContent.indexOf(agentMention.token); - const before = textContent.slice(0, idx); - const after = textContent.slice(idx + agentMention.token.length); - return ( - <> - {before} + if (cursor < textContent.length) nodes.push(textContent.slice(cursor)); + + const withSkills = nodes.length > 0 ? nodes : [textContent]; + if (!agentMention?.token || !textContent.includes(agentMention.token)) { + return withSkills; + } + + return withSkills.flatMap((node, index) => { + if (agentMentionUsed || typeof node !== 'string') return node; + const idx = node.indexOf(agentMention.token); + if (idx === -1) return node; + agentMentionUsed = true; + return [ + node.slice(0, idx), = ({ part, messageId, agentMenti onClick={(event) => event.stopPropagation()} > {agentMention.token} - - {after} - - ); - }, [agentMention, textContent]); + , + node.slice(idx + agentMention.token.length), + ]; + }); + }, [agentMention, openSkill, skillByName, textContent]); if (!textContent || textContent.trim().length === 0) { return null; diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx index 443a5f63..bf346d51 100644 --- a/packages/ui/src/components/sections/skills/SkillsPage.tsx +++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx @@ -108,6 +108,10 @@ const SkillsInstalledPage: React.FC = () => { switch (value) { case 'project-opencode': return t('settings.skills.location.option.projectOpencode.label'); + case 'user-claude': + return t('settings.skills.location.option.userClaude.label'); + case 'project-claude': + return t('settings.skills.location.option.projectClaude.label'); case 'user-agents': return t('settings.skills.location.option.userAgents.label'); case 'project-agents': @@ -121,6 +125,10 @@ const SkillsInstalledPage: React.FC = () => { switch (value) { case 'project-opencode': return t('settings.skills.location.option.projectOpencode.description'); + case 'user-claude': + return t('settings.skills.location.option.userClaude.description'); + case 'project-claude': + return t('settings.skills.location.option.projectClaude.description'); case 'user-agents': return t('settings.skills.location.option.userAgents.description'); case 'project-agents': @@ -197,6 +205,7 @@ const SkillsInstalledPage: React.FC = () => { instructions: instructions.trim() || undefined, scope: isNewSkill ? draftScope : undefined, source: isNewSkill ? draftSource : undefined, + targetPath: !isNewSkill ? selectedSkill?.path : undefined, supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined, }; @@ -386,11 +395,6 @@ const SkillsInstalledPage: React.FC = () => {

{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName} - {selectedSkill?.source === 'claude' && ( - - {t('settings.skills.page.badge.claudeCompatible')} - - )}

{selectedSkill diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 48fd71ce..cf4b0296 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -437,6 +437,12 @@ const SkillListItem: React.FC = ({ }) => { const { t } = useI18n(); const isMobile = isMobileDeviceViaCSS(); + const sourceLabel = skill.source === 'claude' + ? t('settings.skills.sidebar.badge.claude') + : skill.source === 'agents' + ? t('settings.skills.sidebar.badge.agents') + : t('settings.skills.sidebar.badge.opencode'); + const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50'; return (

= ({ {skill.name} - + {skill.scope} - {skill.source === 'claude' && ( - - {t('settings.skills.sidebar.badge.claude')} - - )} - {skill.source === 'agents' && ( - - {t('settings.skills.sidebar.badge.agents')} - - )} + {sourceLabel}
diff --git a/packages/ui/src/components/sections/skills/skillLocations.ts b/packages/ui/src/components/sections/skills/skillLocations.ts index c0fbdea7..ba7219ad 100644 --- a/packages/ui/src/components/sections/skills/skillLocations.ts +++ b/packages/ui/src/components/sections/skills/skillLocations.ts @@ -1,6 +1,6 @@ import type { SkillScope, SkillSource } from '@/stores/useSkillsStore'; -export type SkillLocationValue = 'user-opencode' | 'project-opencode' | 'user-agents' | 'project-agents'; +export type SkillLocationValue = 'user-opencode' | 'project-opencode' | 'user-claude' | 'project-claude' | 'user-agents' | 'project-agents'; export const SKILL_LOCATION_OPTIONS: Array<{ value: SkillLocationValue; @@ -40,13 +40,17 @@ export const SKILL_LOCATION_OPTIONS: Array<{ ]; export function locationValueFrom(scope: SkillScope, source: SkillSource): SkillLocationValue { + if (scope === 'project' && source === 'claude') return 'project-claude'; if (scope === 'project' && source === 'agents') return 'project-agents'; + if (source === 'claude') return 'user-claude'; if (scope === 'project') return 'project-opencode'; if (source === 'agents') return 'user-agents'; return 'user-opencode'; } export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScope; source: SkillSource } { + if (value === 'user-claude') return { scope: 'user', source: 'claude' }; + if (value === 'project-claude') return { scope: 'project', source: 'claude' }; const match = SKILL_LOCATION_OPTIONS.find((option) => option.value === value); if (!match) { return { scope: 'user', source: 'opencode' }; @@ -55,6 +59,8 @@ export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScop } export function locationLabel(scope: SkillScope, source: SkillSource): string { + if (scope === 'user' && source === 'claude') return 'User / Claude'; + if (scope === 'project' && source === 'claude') return 'Project / Claude'; const match = SKILL_LOCATION_OPTIONS.find((option) => option.scope === scope && option.source === source); return match?.label || `${scope} / ${source}`; } diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index d7912bc9..7bc42a4c 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -337,7 +337,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile // Load stores when project changes or when a page becomes active. React.useEffect(() => { - if (!isSettingsDialogOpen && !runtimeCtx.isVSCode) { + if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) { return; } @@ -357,7 +357,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile void useSkillsStore.getState().loadSkills(); void useSkillsCatalogStore.getState().loadCatalog(); } - }, [activeProjectId, isSettingsDialogOpen, runtimeCtx.isVSCode, settingsSlug]); + }, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]); const openPage = React.useCallback((slug: SettingsPageSlug) => { setSettingsPage(slug); diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index a9c00025..b830659a 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -468,6 +468,7 @@ export const settingsDict = { 'settings.skills.sidebar.empty.description': 'Use the + button above to create one', 'settings.skills.sidebar.badge.claude': 'claude', 'settings.skills.sidebar.badge.agents': 'agents', + 'settings.skills.sidebar.badge.opencode': 'opencode', 'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" deleted successfully', 'settings.skills.sidebar.toast.deleteSkillFailed': 'Failed to delete skill', 'settings.skills.sidebar.toast.duplicateLoadFailed': 'Failed to load skill details for duplication', @@ -537,6 +538,10 @@ export const settingsDict = { 'settings.skills.location.option.userOpencode.description': 'Global OpenCode config location', 'settings.skills.location.option.projectOpencode.label': 'Project / OpenCode', 'settings.skills.location.option.projectOpencode.description': 'Current project .opencode location', + 'settings.skills.location.option.userClaude.label': 'User / Claude', + 'settings.skills.location.option.userClaude.description': 'Global Claude skills location', + 'settings.skills.location.option.projectClaude.label': 'Project / Claude', + 'settings.skills.location.option.projectClaude.description': 'Current project .claude location', 'settings.skills.location.option.userAgents.label': 'User / Agents', 'settings.skills.location.option.userAgents.description': 'Global .agents compatibility location', 'settings.skills.location.option.projectAgents.label': 'Project / Agents', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 6e8785d7..6e6846e6 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -468,6 +468,7 @@ export const settingsDict = { "settings.skills.sidebar.empty.description": "Usa el botón + arriba para crear una", "settings.skills.sidebar.badge.claude": "claude", "settings.skills.sidebar.badge.agents": "agentes", + "settings.skills.sidebar.badge.opencode": "opencode", "settings.skills.sidebar.toast.skillDeleted": "Habilidad \"{name}\" eliminada con éxito", "settings.skills.sidebar.toast.deleteSkillFailed": "No se pudo eliminar la habilidad", "settings.skills.sidebar.toast.duplicateLoadFailed": "No se pudo cargar la información de la habilidad para duplicarla", @@ -537,6 +538,10 @@ export const settingsDict = { "settings.skills.location.option.userOpencode.description": "Ubicación global de la configuración de OpenCode", "settings.skills.location.option.projectOpencode.label": "Proyecto / OpenCode", "settings.skills.location.option.projectOpencode.description": "Ubicación del .opencode del proyecto actual", + "settings.skills.location.option.userClaude.label": "Usuario / Claude", + "settings.skills.location.option.userClaude.description": "Ubicación global de skills de Claude", + "settings.skills.location.option.projectClaude.label": "Proyecto / Claude", + "settings.skills.location.option.projectClaude.description": "Ubicación .claude del proyecto actual", "settings.skills.location.option.userAgents.label": "Usuario / Agentes", "settings.skills.location.option.userAgents.description": "Ubicación global de compatibilidad con .agents", "settings.skills.location.option.projectAgents.label": "Proyecto / Agentes", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 23ac2c79..e435eabe 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -468,6 +468,7 @@ export const settingsDict = { 'settings.skills.sidebar.empty.description': '위의 + 버튼으로 새로 생성하세요', 'settings.skills.sidebar.badge.claude': 'claude', 'settings.skills.sidebar.badge.agents': 'agents', + 'settings.skills.sidebar.badge.opencode': 'opencode', 'settings.skills.sidebar.toast.skillDeleted': '스킬 "{name}"을 삭제했습니다', 'settings.skills.sidebar.toast.deleteSkillFailed': '스킬을 삭제하지 못했습니다', 'settings.skills.sidebar.toast.duplicateLoadFailed': '복제를 위한 스킬 세부 정보를 로드하지 못했습니다', @@ -537,6 +538,10 @@ export const settingsDict = { 'settings.skills.location.option.userOpencode.description': '전역 OpenCode 설정 위치', 'settings.skills.location.option.projectOpencode.label': '프로젝트 / OpenCode', 'settings.skills.location.option.projectOpencode.description': '현재 프로젝트 .opencode 위치', + 'settings.skills.location.option.userClaude.label': '사용자 / Claude', + 'settings.skills.location.option.userClaude.description': '전역 Claude skills 위치', + 'settings.skills.location.option.projectClaude.label': '프로젝트 / Claude', + 'settings.skills.location.option.projectClaude.description': '현재 프로젝트 .claude 위치', 'settings.skills.location.option.userAgents.label': '사용자 / 에이전트', 'settings.skills.location.option.userAgents.description': '전역 .agents 호환 위치', 'settings.skills.location.option.projectAgents.label': '프로젝트 / 에이전트', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index f0ee66b2..885c3a41 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1352,6 +1352,10 @@ export const settingsDict = { 'settings.skills.location.option.projectAgents.label': 'Projekt / Agents', 'settings.skills.location.option.projectOpencode.description': 'Bieżąca lokalizacja .opencode projektu', 'settings.skills.location.option.projectOpencode.label': 'Projekt / OpenCode', + 'settings.skills.location.option.projectClaude.description': 'Bieżąca lokalizacja .claude projektu', + 'settings.skills.location.option.projectClaude.label': 'Projekt / Claude', + 'settings.skills.location.option.userClaude.description': 'Globalna lokalizacja Claude skills', + 'settings.skills.location.option.userClaude.label': 'Użytkownik / Claude', 'settings.skills.location.option.userAgents.description': 'Globalna lokalizacja zgodna z .agents', 'settings.skills.location.option.userAgents.label': 'Użytkownik / Agents', 'settings.skills.location.option.userOpencode.description': 'Globalna lokalizacja konfiguracji OpenCode', @@ -1412,6 +1416,7 @@ export const settingsDict = { 'settings.skills.page.toast.updateSkillFailed': 'Nie udało się zaktualizować umiejętności', 'settings.skills.sidebar.badge.agents': 'agents', 'settings.skills.sidebar.badge.claude': 'claude', + 'settings.skills.sidebar.badge.opencode': 'opencode', 'settings.skills.sidebar.deleteDialog.description': 'Czy na pewno chcesz usunąć umiejętność „{name}”?', 'settings.skills.sidebar.deleteDialog.title': 'Usuń umiejętność', 'settings.skills.sidebar.empty.description': 'Użyj przycisku + powyżej, aby utworzyć nową', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 15cac3be..66aacf56 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -468,6 +468,7 @@ export const settingsDict = { "settings.skills.sidebar.empty.description": "Use o botão + acima para criar uma", "settings.skills.sidebar.badge.claude": "claude", "settings.skills.sidebar.badge.agents": "agentes", + "settings.skills.sidebar.badge.opencode": "opencode", "settings.skills.sidebar.toast.skillDeleted": "Habilidade \"{name}\" excluída com sucesso", "settings.skills.sidebar.toast.deleteSkillFailed": "Não foi possível excluir a habilidade", "settings.skills.sidebar.toast.duplicateLoadFailed": "Não foi possível carregar as informações da habilidade para duplicá-la", @@ -537,6 +538,10 @@ export const settingsDict = { "settings.skills.location.option.userOpencode.description": "Localização global das configurações do OpenCode", "settings.skills.location.option.projectOpencode.label": "Projeto / OpenCode", "settings.skills.location.option.projectOpencode.description": "Localização .opencode do projeto atual", + "settings.skills.location.option.userClaude.label": "Usuário / Claude", + "settings.skills.location.option.userClaude.description": "Localização global de skills do Claude", + "settings.skills.location.option.projectClaude.label": "Projeto / Claude", + "settings.skills.location.option.projectClaude.description": "Localização .claude do projeto atual", "settings.skills.location.option.userAgents.label": "Usuário / Agentes", "settings.skills.location.option.userAgents.description": "Localização global compatível com .agents", "settings.skills.location.option.projectAgents.label": "Projeto / Agentes", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8c395087..78800b80 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -468,6 +468,7 @@ export const settingsDict = { "settings.skills.sidebar.empty.description": "Скористайтеся кнопкою + вище, щоб створити його", "settings.skills.sidebar.badge.claude": "Claude", "settings.skills.sidebar.badge.agents": "агентів", + "settings.skills.sidebar.badge.opencode": "opencode", "settings.skills.sidebar.toast.skillDeleted": "Навичку \"{name}\" успішно видалено", "settings.skills.sidebar.toast.deleteSkillFailed": "Не вдалося видалити навичку", "settings.skills.sidebar.toast.duplicateLoadFailed": "Не вдалося завантажити деталі навичок для дублювання", @@ -537,6 +538,10 @@ export const settingsDict = { "settings.skills.location.option.userOpencode.description": "Розташування глобальної конфігурації OpenCode", "settings.skills.location.option.projectOpencode.label": "Проєкт / OpenCode", "settings.skills.location.option.projectOpencode.description": "Розташування поточного проєкту .opencode", + "settings.skills.location.option.userClaude.label": "Користувач / Claude", + "settings.skills.location.option.userClaude.description": "Глобальне розташування Claude skills", + "settings.skills.location.option.projectClaude.label": "Проєкт / Claude", + "settings.skills.location.option.projectClaude.description": "Розташування поточного проєкту .claude", "settings.skills.location.option.userAgents.label": "Користувач / Агенти", "settings.skills.location.option.userAgents.description": "Глобальне розташування сумісності .agents", "settings.skills.location.option.projectAgents.label": "Проєкт / Агенти", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 2d5c0b7b..2a6c8bc8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -468,6 +468,7 @@ export const settingsDict = { 'settings.skills.sidebar.empty.description': '使用上方 + 按钮创建一个', 'settings.skills.sidebar.badge.claude': 'claude', 'settings.skills.sidebar.badge.agents': 'agents', + 'settings.skills.sidebar.badge.opencode': 'opencode', 'settings.skills.sidebar.toast.skillDeleted': '技能“{name}”已删除', 'settings.skills.sidebar.toast.deleteSkillFailed': '删除技能失败', 'settings.skills.sidebar.toast.duplicateLoadFailed': '加载技能详情以复制失败', @@ -537,6 +538,10 @@ export const settingsDict = { 'settings.skills.location.option.userOpencode.description': '全局 OpenCode 配置位置', 'settings.skills.location.option.projectOpencode.label': '项目 / OpenCode', 'settings.skills.location.option.projectOpencode.description': '当前项目 .opencode 位置', + 'settings.skills.location.option.userClaude.label': '用户 / Claude', + 'settings.skills.location.option.userClaude.description': '全局 Claude skills 位置', + 'settings.skills.location.option.projectClaude.label': '项目 / Claude', + 'settings.skills.location.option.projectClaude.description': '当前项目 .claude 位置', 'settings.skills.location.option.userAgents.label': '用户 / Agents', 'settings.skills.location.option.userAgents.description': '全局 .agents 兼容位置', 'settings.skills.location.option.projectAgents.label': '项目 / Agents', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index ab8f03ad..c1dbfe8e 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -1323,7 +1323,7 @@ class OpencodeService { } // Command Management - async listCommands(): Promise> { + async listCommands(): Promise> { try { const response = await this.client.command.list( this.currentDirectory ? { directory: this.currentDirectory } : undefined @@ -1333,7 +1333,8 @@ class OpencodeService { name: cmd.name as string, description: cmd.description as string | undefined, agent: cmd.agent as string | undefined, - model: cmd.model as string | undefined + model: cmd.model as string | undefined, + source: cmd.source as string | undefined, // Intentionally excluding template to keep memory usage low })); } catch { @@ -1341,7 +1342,7 @@ class OpencodeService { } } - async listCommandsWithDetails(): Promise> { + async listCommandsWithDetails(): Promise> { try { const response = await this.client.command.list( this.currentDirectory ? { directory: this.currentDirectory } : undefined @@ -1352,6 +1353,7 @@ class OpencodeService { description: cmd.description as string | undefined, agent: cmd.agent as string | undefined, model: cmd.model as string | undefined, + source: cmd.source as string | undefined, template: cmd.template as string | undefined, })); } catch { @@ -1359,6 +1361,34 @@ class OpencodeService { } } + async listSkillsWithDetails(): Promise> { + try { + const response = await this.client.app.skills( + this.currentDirectory ? { directory: this.currentDirectory } : undefined, + ); + const data = response.data; + if (!Array.isArray(data)) { + return []; + } + + const skills: Array<{ name: string; description?: string; location: string; content?: string }> = []; + for (const item of data as Array>) { + const name = typeof item.name === 'string' ? item.name.trim() : ''; + const location = typeof item.location === 'string' ? item.location : ''; + if (!name || !location || location === '') { + continue; + } + const skill: { name: string; description?: string; location: string; content?: string } = { name, location }; + if (typeof item.description === 'string') skill.description = item.description; + if (typeof item.content === 'string') skill.content = item.content; + skills.push(skill); + } + return skills; + } catch { + return []; + } + } + async getCommandDetails(name: string): Promise<{ name: string; template: string; description?: string; agent?: string; model?: string } | null> { try { const response = await this.client.command.list( diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index 86a45e2b..0c08851e 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -19,6 +19,7 @@ export interface CommandConfig { description?: string; agent?: string | null; model?: string | null; + source?: string; template?: string; scope?: CommandScope; } @@ -168,8 +169,9 @@ export const useCommandsStore = create()( () => opencodeClient.listCommandsWithDetails() ); + const configurableCommands = commands.filter((cmd) => cmd.source !== 'skill'); const commandsWithScope = await Promise.all( - commands.map(async (cmd) => { + configurableCommands.map(async (cmd) => { try { // Force no-cache const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, { diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index e9630113..ed06fa11 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -56,6 +56,8 @@ export interface SkillSources { 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 { @@ -102,6 +104,7 @@ export interface SkillConfig { instructions?: string; scope?: SkillScope; source?: SkillSource; + targetPath?: string; supportingFiles?: Array<{ path: string; content: string }>; } @@ -163,6 +166,7 @@ const skillsLoadInFlight = new Map>(); const getSkillsCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY; }; + const MAX_HEALTH_WAIT_MS = 20000; const FAST_HEALTH_POLL_INTERVAL_MS = 300; const FAST_HEALTH_POLL_ATTEMPTS = 4; @@ -219,7 +223,7 @@ export const useSkillsStore = create()( const data = await response.json(); const rawSkills: RawSkillResponse[] = data.skills || []; - const skills: DiscoveredSkill[] = rawSkills.map((s) => ({ + const configSkills: DiscoveredSkill[] = rawSkills.map((s) => ({ name: s.name, path: s.path, scope: s.scope ?? 'user', @@ -228,7 +232,7 @@ export const useSkillsStore = create()( group: parseSkillGroup(s.path), })); - set({ skills, isLoading: false }); + set({ skills: configSkills, isLoading: false }); skillsLastLoadedAt.set(cacheKey, Date.now()); return true; } catch (error) { @@ -329,6 +333,7 @@ export const useSkillsStore = create()( 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 currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index 69ef2727..3490f77a 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -1,3 +1,5 @@ +import { createOpencodeClient } from '@opencode-ai/sdk/v2'; + export const registerSkillRoutes = (app, dependencies) => { const { fs, @@ -14,7 +16,6 @@ export const registerSkillRoutes = (app, dependencies) => { getOpenCodeAuthHeaders, getOpenCodePort, getSkillSources, - discoverSkills, createSkill, updateSkill, deleteSkill, @@ -114,31 +115,23 @@ export const registerSkillRoutes = (app, dependencies) => { const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => { if (!getOpenCodePort()) { - return null; + return []; } try { - const url = new URL(buildOpenCodeUrl('/skill', '')); - if (workingDirectory) { - url.searchParams.set('directory', workingDirectory); - } - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Accept: 'application/json', - ...getOpenCodeAuthHeaders(), - }, - signal: AbortSignal.timeout(8_000), + const client = createOpencodeClient({ + baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''), + directory: workingDirectory || undefined, + headers: getOpenCodeAuthHeaders(), + fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }), }); - if (!response.ok) { - return null; - } - - const payload = await response.json(); + const response = await client.app.skills( + workingDirectory ? { directory: workingDirectory } : undefined, + ); + const payload = response?.data; if (!Array.isArray(payload)) { - return null; + return []; } return payload @@ -146,7 +139,7 @@ export const registerSkillRoutes = (app, dependencies) => { const name = typeof item?.name === 'string' ? item.name.trim() : ''; const location = typeof item?.location === 'string' ? item.location : ''; const description = typeof item?.description === 'string' ? item.description : ''; - if (!name || !location) { + if (!name || !location || location === '') { return null; } const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory); @@ -159,8 +152,9 @@ export const registerSkillRoutes = (app, dependencies) => { }; }) .filter(Boolean); - } catch { - return null; + } catch (error) { + console.error('Failed to list OpenCode skills:', error); + return []; } }; @@ -191,11 +185,11 @@ export const registerSkillRoutes = (app, dependencies) => { app.get('/api/config/skills', async (req, res) => { try { - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { return res.status(400).json({ error }); } - const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory); + const skills = await fetchOpenCodeDiscoveredSkills(directory); const enrichedSkills = skills.map((skill) => { const sources = getSkillSources(skill.name, directory, skill); @@ -277,9 +271,7 @@ export const registerSkillRoutes = (app, dependencies) => { return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } }); } - const discovered = directory - ? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory)) - : []; + const discovered = await fetchOpenCodeDiscoveredSkills(directory); const installedByName = new Map(discovered.map((s) => [s.name, s])); if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) { @@ -504,11 +496,11 @@ export const registerSkillRoutes = (app, dependencies) => { app.get('/api/config/skills/:name', async (req, res) => { try { const skillName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { return res.status(400).json({ error }); } - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory)) .find((skill) => skill.name === skillName) || null; const sources = getSkillSources(skillName, directory, discoveredSkill); @@ -532,12 +524,12 @@ export const registerSkillRoutes = (app, dependencies) => { if (isUnsafeSkillRelativePath(filePath)) { return res.status(400).json({ error: 'Invalid file path' }); } - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { return res.status(400).json({ error }); } - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory)) .find((skill) => skill.name === skillName) || null; const sources = getSkillSources(skillName, directory, discoveredSkill); if (!sources.md.exists || !sources.md.dir) { @@ -563,9 +555,11 @@ export const registerSkillRoutes = (app, dependencies) => { try { const skillName = req.params.name; const { scope, source: skillSource, ...config } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { - return res.status(400).json({ error }); + const { directory, error } = scope === SKILL_SCOPE.PROJECT + ? await resolveProjectDirectory(req) + : await resolveOptionalProjectDirectory(req); + if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) { + return res.status(400).json({ error: error || 'Project skill creation requires a directory' }); } console.log('[Server] Creating skill:', skillName); @@ -590,15 +584,15 @@ export const registerSkillRoutes = (app, dependencies) => { try { const skillName = req.params.name; const updates = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { return res.status(400).json({ error }); } console.log(`[Server] Updating skill: ${skillName}`); console.log('[Server] Working directory:', directory); - updateSkill(skillName, updates, directory); + updateSkill(skillName, updates, directory, updates?.targetPath); await refreshOpenCodeAfterConfigChange('skill update'); res.json({ @@ -621,12 +615,12 @@ export const registerSkillRoutes = (app, dependencies) => { return res.status(400).json({ error: 'Invalid file path' }); } const { content } = req.body; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { return res.status(400).json({ error }); } - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory)) .find((skill) => skill.name === skillName) || null; const sources = getSkillSources(skillName, directory, discoveredSkill); if (!sources.md.exists || !sources.md.dir) { @@ -655,12 +649,12 @@ export const registerSkillRoutes = (app, dependencies) => { if (isUnsafeSkillRelativePath(filePath)) { return res.status(400).json({ error: 'Invalid file path' }); } - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { return res.status(400).json({ error }); } - const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || []) + const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory)) .find((skill) => skill.name === skillName) || null; const sources = getSkillSources(skillName, directory, discoveredSkill); if (!sources.md.exists || !sources.md.dir) { @@ -685,8 +679,8 @@ export const registerSkillRoutes = (app, dependencies) => { app.delete('/api/config/skills/:name', async (req, res) => { try { const skillName = req.params.name; - const { directory, error } = await resolveProjectDirectory(req); - if (!directory) { + const { directory, error } = await resolveOptionalProjectDirectory(req); + if (error) { return res.status(400).json({ error }); } diff --git a/packages/web/server/lib/opencode/skills.js b/packages/web/server/lib/opencode/skills.js index 5db96de7..23161b2e 100644 --- a/packages/web/server/lib/opencode/skills.js +++ b/packages/web/server/lib/opencode/skills.js @@ -69,6 +69,14 @@ function getClaudeSkillPath(workingDirectory, skillName) { return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md'); } +function getUserClaudeSkillDir(skillName) { + return path.join(os.homedir(), '.claude', 'skills', skillName); +} + +function getUserClaudeSkillPath(skillName) { + return path.join(getUserClaudeSkillDir(skillName), 'SKILL.md'); +} + function getUserAgentsSkillDir(skillName) { return path.join(os.homedir(), '.agents', 'skills', skillName); } @@ -107,6 +115,16 @@ function getSkillScope(skillName, workingDirectory) { if (fs.existsSync(userPath)) { return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' }; } + + const userClaudePath = getUserClaudeSkillPath(skillName); + if (fs.existsSync(userClaudePath)) { + return { scope: SKILL_SCOPE.USER, path: userClaudePath, source: 'claude' }; + } + + const userAgentsPath = getUserAgentsSkillPath(skillName); + if (fs.existsSync(userAgentsPath)) { + return { scope: SKILL_SCOPE.USER, path: userAgentsPath, source: 'agents' }; + } return { scope: null, path: null, source: null }; } @@ -226,11 +244,18 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) { const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null; const claudeExists = claudePath && fs.existsSync(claudePath); const claudeDir = claudeExists ? path.dirname(claudePath) : null; + const userClaudePath = getUserClaudeSkillPath(skillName); + const userClaudeExists = fs.existsSync(userClaudePath); + const userClaudeDir = userClaudeExists ? path.dirname(userClaudePath) : null; const userPath = getUserSkillPath(skillName); const userExists = fs.existsSync(userPath); const userDir = userExists ? path.dirname(userPath) : null; + const userAgentsPath = getUserAgentsSkillPath(skillName); + const userAgentsExists = fs.existsSync(userAgentsPath); + const userAgentsDir = userAgentsExists ? path.dirname(userAgentsPath) : null; + const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName ? discoveredSkill : discoverSkills(workingDirectory).find((skill) => skill.name === skillName); @@ -240,7 +265,12 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) { let mdSource = null; let mdDir = null; - if (projectExists) { + if (matchedDiscovered?.path) { + mdPath = matchedDiscovered.path; + mdScope = matchedDiscovered.scope || null; + mdSource = matchedDiscovered.source || null; + mdDir = path.dirname(matchedDiscovered.path); + } else if (projectExists) { mdPath = projectPath; mdScope = SKILL_SCOPE.PROJECT; mdSource = 'opencode'; @@ -255,14 +285,23 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) { mdScope = SKILL_SCOPE.USER; mdSource = 'opencode'; mdDir = userDir; - } else if (matchedDiscovered?.path) { - mdPath = matchedDiscovered.path; - mdScope = matchedDiscovered.scope || null; - mdSource = matchedDiscovered.source || null; - mdDir = path.dirname(matchedDiscovered.path); + } else if (userClaudeExists) { + mdPath = userClaudePath; + mdScope = SKILL_SCOPE.USER; + mdSource = 'claude'; + mdDir = userClaudeDir; + } else if (userAgentsExists) { + mdPath = userAgentsPath; + mdScope = SKILL_SCOPE.USER; + mdSource = 'agents'; + mdDir = userAgentsDir; } - const mdExists = !!mdPath; + const mdExists = !!mdPath && fs.existsSync(mdPath); + if (!mdExists) { + mdPath = null; + mdDir = null; + } const sources = { md: { @@ -288,6 +327,16 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) { exists: userExists, path: userPath, dir: userDir + }, + userClaudeMd: { + exists: userClaudeExists, + path: userClaudePath, + dir: userClaudeDir + }, + userAgentsMd: { + exists: userAgentsExists, + path: userAgentsPath, + dir: userAgentsDir } }; @@ -374,22 +423,34 @@ function createSkill(skillName, config, workingDirectory, scope) { console.log(`Created new skill: ${skillName} (scope: ${targetScope}, path: ${targetPath})`); } -function updateSkill(skillName, updates, workingDirectory) { +function updateSkill(skillName, updates, workingDirectory, targetPath = null) { ensureDirs(); - const existing = getSkillScope(skillName, workingDirectory); + const requestedPath = typeof targetPath === 'string' && targetPath.trim() + ? path.resolve(targetPath.trim()) + : null; + const existing = requestedPath && fs.existsSync(requestedPath) + ? { scope: null, path: requestedPath, source: null } + : getSkillScope(skillName, workingDirectory); if (!existing.path) { throw new Error(`Skill "${skillName}" not found`); } + if (path.basename(existing.path) !== 'SKILL.md') { + throw new Error(`Skill "${skillName}" target must be a SKILL.md file`); + } const mdPath = existing.path; const mdDir = path.dirname(mdPath); const mdData = parseMdFile(mdPath); + const frontmatterName = typeof mdData.frontmatter?.name === 'string' ? mdData.frontmatter.name : skillName; + if (frontmatterName !== skillName) { + throw new Error(`Skill "${skillName}" does not match ${mdPath}`); + } let mdModified = false; for (const [field, value] of Object.entries(updates)) { - if (field === 'scope') { + if (field === 'scope' || field === 'source' || field === 'targetPath') { continue; } @@ -464,6 +525,13 @@ function deleteSkill(skillName, workingDirectory) { deleted = true; } + const userClaudeDir = getUserClaudeSkillDir(skillName); + if (fs.existsSync(userClaudeDir)) { + fs.rmSync(userClaudeDir, { recursive: true, force: true }); + console.log(`Deleted user-level claude skill directory: ${userClaudeDir}`); + deleted = true; + } + if (!deleted) { throw new Error(`Skill "${skillName}" not found`); }