fix: use opencode skills as source of truth
This commit is contained in:
@@ -54,6 +54,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
|||||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||||
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||||
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
|
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
|
||||||
@@ -71,6 +72,7 @@ const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
|||||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||||
const EMPTY_MESSAGES: Message[] = [];
|
const EMPTY_MESSAGES: Message[] = [];
|
||||||
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
|
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 CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
||||||
const VS_CODE_DROP_DATA_TYPES = [
|
const VS_CODE_DROP_DATA_TYPES = [
|
||||||
'CodeFiles',
|
'CodeFiles',
|
||||||
@@ -81,6 +83,28 @@ const VS_CODE_DROP_DATA_TYPES = [
|
|||||||
'text/plain',
|
'text/plain',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const collectInlineSkillMentions = (text: string, skillNames: Set<string>): 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) => {
|
const hasUserMessages = (sessionId: string, directory?: string) => {
|
||||||
return getSyncMessages(sessionId, directory).some((message) => message.role === 'user');
|
return getSyncMessages(sessionId, directory).some((message) => message.role === 'user');
|
||||||
};
|
};
|
||||||
@@ -1508,6 +1532,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
let primaryAttachments: AttachedFile[] = [];
|
let primaryAttachments: AttachedFile[] = [];
|
||||||
let agentMentionName: string | undefined;
|
let agentMentionName: string | undefined;
|
||||||
const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = [];
|
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.)
|
// Consume any pending synthetic parts (from conflict resolution, etc.)
|
||||||
const syntheticParts = consumePendingSyntheticParts();
|
const syntheticParts = consumePendingSyntheticParts();
|
||||||
@@ -1517,6 +1548,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const queuedMsg = queuedMessages[i];
|
const queuedMsg = queuedMessages[i];
|
||||||
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
||||||
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
||||||
|
addMentionedSkills(queuedText);
|
||||||
|
|
||||||
// Use agent mention from first message that has one
|
// Use agent mention from first message that has one
|
||||||
if (!agentMentionName && mention?.name) {
|
if (!agentMentionName && mention?.name) {
|
||||||
@@ -1546,6 +1578,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||||
const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
||||||
const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
||||||
|
addMentionedSkills(messageText);
|
||||||
|
|
||||||
if (!agentMentionName && mention?.name) {
|
if (!agentMentionName && mention?.name) {
|
||||||
agentMentionName = mention.name;
|
agentMentionName = mention.name;
|
||||||
@@ -1611,6 +1644,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skillMentionInstruction = buildSkillMentionInstruction(mentionedSkillNames);
|
||||||
|
if (skillMentionInstruction) {
|
||||||
|
additionalParts.push({
|
||||||
|
text: skillMentionInstruction,
|
||||||
|
synthetic: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!primaryText && additionalParts.length === 0) return;
|
if (!primaryText && additionalParts.length === 0) return;
|
||||||
|
|
||||||
// Clear queue and input
|
// Clear queue and input
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|||||||
interface SkillInfo {
|
interface SkillInfo {
|
||||||
name: string;
|
name: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
|
source?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
|
|
||||||
const renderSkill = (skill: SkillInfo, index: number) => {
|
const renderSkill = (skill: SkillInfo, index: number) => {
|
||||||
const isProject = skill.scope === 'project';
|
const isProject = skill.scope === 'project';
|
||||||
|
const source = skill.source || 'opencode';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${skill.name}-${skill.scope}`}
|
key={`${skill.name}-${skill.scope}`}
|
||||||
@@ -135,6 +137,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
)}>
|
)}>
|
||||||
{skill.scope}
|
{skill.scope}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0 bg-[var(--surface-muted)] text-muted-foreground border-[var(--interactive-border)]/60">
|
||||||
|
{source}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{skill.description && (
|
{skill.description && (
|
||||||
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { isExpandableTool, isStandaloneTool, isStaticTool } from './toolRenderUt
|
|||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import ReasoningPart from './ReasoningPart';
|
import ReasoningPart from './ReasoningPart';
|
||||||
import JustificationBlock from './JustificationBlock';
|
import JustificationBlock from './JustificationBlock';
|
||||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||||
@@ -599,7 +600,9 @@ const StaticToolRowInner: React.FC<{
|
|||||||
const isReadGroup = toolName.toLowerCase() === 'read';
|
const isReadGroup = toolName.toLowerCase() === 'read';
|
||||||
const runtime = React.useContext(RuntimeAPIContext);
|
const runtime = React.useContext(RuntimeAPIContext);
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
const skills = useSkillsStore((state) => state.skills);
|
||||||
const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]);
|
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 descriptions = React.useMemo(() => {
|
||||||
const descs: string[] = [];
|
const descs: string[] = [];
|
||||||
@@ -648,6 +651,15 @@ const StaticToolRowInner: React.FC<{
|
|||||||
uiStore.openContextFile(contextDirectory, absolutePath);
|
uiStore.openContextFile(contextDirectory, absolutePath);
|
||||||
}, [currentDirectory, runtime]);
|
}, [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 normalizedToolName = toolName.toLowerCase();
|
||||||
const isSearchGroup = normalizedToolName === 'grep'
|
const isSearchGroup = normalizedToolName === 'grep'
|
||||||
|| normalizedToolName === 'search'
|
|| normalizedToolName === 'search'
|
||||||
@@ -655,6 +667,7 @@ const StaticToolRowInner: React.FC<{
|
|||||||
|| normalizedToolName === 'ripgrep'
|
|| normalizedToolName === 'ripgrep'
|
||||||
|| normalizedToolName === 'glob';
|
|| normalizedToolName === 'glob';
|
||||||
const isFetchGroup = normalizedToolName === 'webfetch' || normalizedToolName === 'fetch' || normalizedToolName === 'curl' || normalizedToolName === 'wget';
|
const isFetchGroup = normalizedToolName === 'webfetch' || normalizedToolName === 'fetch' || normalizedToolName === 'curl' || normalizedToolName === 'wget';
|
||||||
|
const isSkillGroup = normalizedToolName === 'skill';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -725,7 +738,25 @@ const StaticToolRowInner: React.FC<{
|
|||||||
</a>
|
</a>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
{!isReadGroup && !isSearchGroup && !isFetchGroup && descriptions.length > 0 ? (
|
{isSkillGroup && descriptions.length > 0
|
||||||
|
? descriptions.map((skillName, index) => (
|
||||||
|
<button
|
||||||
|
key={`${skillName}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleSkillClick(skillName);
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5 text-left hover:opacity-90"
|
||||||
|
style={{ color: 'var(--tools-description)' }}
|
||||||
|
title={skillName}
|
||||||
|
>
|
||||||
|
{skillName}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{!isReadGroup && !isSearchGroup && !isFetchGroup && !isSkillGroup && descriptions.length > 0 ? (
|
||||||
<Text
|
<Text
|
||||||
variant={animateTailText ? 'generate-effect' : 'static'}
|
variant={animateTailText ? 'generate-effect' : 'static'}
|
||||||
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
|
className="min-w-0 flex-1 truncate whitespace-nowrap typography-meta leading-5"
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
|||||||
import type { AgentMentionInfo } from '../types';
|
import type { AgentMentionInfo } from '../types';
|
||||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ const buildMentionUrl = (name: string): string => {
|
|||||||
return `https://opencode.ai/docs/agents/#${encoded}`;
|
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 => {
|
const escapeHtml = (text: string): string => {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -41,8 +45,18 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||||
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
|
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 normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
||||||
const textRef = React.useRef<HTMLDivElement>(null);
|
const textRef = React.useRef<HTMLDivElement>(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 => {
|
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -76,7 +90,17 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
return () => resizeObserver.disconnect();
|
return () => resizeObserver.disconnect();
|
||||||
}, [textContent, isExpanded]);
|
}, [textContent, isExpanded]);
|
||||||
|
|
||||||
const handleClick = React.useCallback(() => {
|
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const skillLink = target?.closest<HTMLElement>('[data-skill-name]');
|
||||||
|
const skillName = skillLink?.dataset.skillName;
|
||||||
|
if (skillName) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openSkill(skillName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const element = textRef.current;
|
const element = textRef.current;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
@@ -89,7 +113,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
if (!isExpanded && isTruncated) {
|
if (!isExpanded && isTruncated) {
|
||||||
setIsExpanded(true);
|
setIsExpanded(true);
|
||||||
}
|
}
|
||||||
}, [hasActiveSelectionInElement, isExpanded, isTruncated]);
|
}, [hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]);
|
||||||
|
|
||||||
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
|
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -108,21 +132,62 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
content = content.replace(agentMention.token, mentionHtml);
|
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}<a href="#" data-skill-name="${skillName}" class="text-primary hover:underline">/${skillName}</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
}, [agentMention, textContent]);
|
}, [agentMention, skillByName, textContent]);
|
||||||
|
|
||||||
const plainTextContent = React.useMemo(() => {
|
const plainTextContent = React.useMemo(() => {
|
||||||
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
const nodes: React.ReactNode[] = [];
|
||||||
return textContent;
|
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(
|
||||||
|
<button
|
||||||
|
key={`skill-${slashIndex}-${skillName}`}
|
||||||
|
type="button"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openSkill(skillName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/{skillName}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
cursor = slashIndex + skillName.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const idx = textContent.indexOf(agentMention.token);
|
if (cursor < textContent.length) nodes.push(textContent.slice(cursor));
|
||||||
const before = textContent.slice(0, idx);
|
|
||||||
const after = textContent.slice(idx + agentMention.token.length);
|
const withSkills = nodes.length > 0 ? nodes : [textContent];
|
||||||
return (
|
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
||||||
<>
|
return withSkills;
|
||||||
{before}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
<a
|
<a
|
||||||
|
key={`agent-${index}`}
|
||||||
href={buildMentionUrl(agentMention.name)}
|
href={buildMentionUrl(agentMention.name)}
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -130,11 +195,11 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
{agentMention.token}
|
{agentMention.token}
|
||||||
</a>
|
</a>,
|
||||||
{after}
|
node.slice(idx + agentMention.token.length),
|
||||||
</>
|
];
|
||||||
);
|
});
|
||||||
}, [agentMention, textContent]);
|
}, [agentMention, openSkill, skillByName, textContent]);
|
||||||
|
|
||||||
if (!textContent || textContent.trim().length === 0) {
|
if (!textContent || textContent.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
switch (value) {
|
switch (value) {
|
||||||
case 'project-opencode':
|
case 'project-opencode':
|
||||||
return t('settings.skills.location.option.projectOpencode.label');
|
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':
|
case 'user-agents':
|
||||||
return t('settings.skills.location.option.userAgents.label');
|
return t('settings.skills.location.option.userAgents.label');
|
||||||
case 'project-agents':
|
case 'project-agents':
|
||||||
@@ -121,6 +125,10 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
switch (value) {
|
switch (value) {
|
||||||
case 'project-opencode':
|
case 'project-opencode':
|
||||||
return t('settings.skills.location.option.projectOpencode.description');
|
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':
|
case 'user-agents':
|
||||||
return t('settings.skills.location.option.userAgents.description');
|
return t('settings.skills.location.option.userAgents.description');
|
||||||
case 'project-agents':
|
case 'project-agents':
|
||||||
@@ -197,6 +205,7 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
instructions: instructions.trim() || undefined,
|
instructions: instructions.trim() || undefined,
|
||||||
scope: isNewSkill ? draftScope : undefined,
|
scope: isNewSkill ? draftScope : undefined,
|
||||||
source: isNewSkill ? draftSource : undefined,
|
source: isNewSkill ? draftSource : undefined,
|
||||||
|
targetPath: !isNewSkill ? selectedSkill?.path : undefined,
|
||||||
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
|
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -386,11 +395,6 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
|
<h2 className="typography-ui-header font-semibold text-foreground truncate flex items-center gap-2">
|
||||||
{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
|
{isNewSkill ? t('settings.skills.page.title.newSkill') : selectedSkillName}
|
||||||
{selectedSkill?.source === 'claude' && (
|
|
||||||
<span className="typography-micro font-normal bg-[var(--surface-muted)] text-muted-foreground px-1.5 py-0.5 rounded">
|
|
||||||
{t('settings.skills.page.badge.claudeCompatible')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="typography-meta text-muted-foreground truncate">
|
<p className="typography-meta text-muted-foreground truncate">
|
||||||
{selectedSkill
|
{selectedSkill
|
||||||
|
|||||||
@@ -437,6 +437,12 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isMobile = isMobileDeviceViaCSS();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -458,19 +464,10 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
|||||||
<span className="typography-ui-label font-normal truncate text-foreground">
|
<span className="typography-ui-label font-normal truncate text-foreground">
|
||||||
{skill.name}
|
{skill.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
<span className={badgeClassName}>
|
||||||
{skill.scope}
|
{skill.scope}
|
||||||
</span>
|
</span>
|
||||||
{skill.source === 'claude' && (
|
<span className={badgeClassName}>{sourceLabel}</span>
|
||||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
|
||||||
{t('settings.skills.sidebar.badge.claude')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{skill.source === 'agents' && (
|
|
||||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
|
||||||
{t('settings.skills.sidebar.badge.agents')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SkillScope, SkillSource } from '@/stores/useSkillsStore';
|
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<{
|
export const SKILL_LOCATION_OPTIONS: Array<{
|
||||||
value: SkillLocationValue;
|
value: SkillLocationValue;
|
||||||
@@ -40,13 +40,17 @@ export const SKILL_LOCATION_OPTIONS: Array<{
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function locationValueFrom(scope: SkillScope, source: SkillSource): SkillLocationValue {
|
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 (scope === 'project' && source === 'agents') return 'project-agents';
|
||||||
|
if (source === 'claude') return 'user-claude';
|
||||||
if (scope === 'project') return 'project-opencode';
|
if (scope === 'project') return 'project-opencode';
|
||||||
if (source === 'agents') return 'user-agents';
|
if (source === 'agents') return 'user-agents';
|
||||||
return 'user-opencode';
|
return 'user-opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScope; source: SkillSource } {
|
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);
|
const match = SKILL_LOCATION_OPTIONS.find((option) => option.value === value);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return { scope: 'user', source: 'opencode' };
|
return { scope: 'user', source: 'opencode' };
|
||||||
@@ -55,6 +59,8 @@ export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScop
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function locationLabel(scope: SkillScope, source: SkillSource): string {
|
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);
|
const match = SKILL_LOCATION_OPTIONS.find((option) => option.scope === scope && option.source === source);
|
||||||
return match?.label || `${scope} / ${source}`;
|
return match?.label || `${scope} / ${source}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
|
|
||||||
// Load stores when project changes or when a page becomes active.
|
// Load stores when project changes or when a page becomes active.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode) {
|
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
void useSkillsStore.getState().loadSkills();
|
void useSkillsStore.getState().loadSkills();
|
||||||
void useSkillsCatalogStore.getState().loadCatalog();
|
void useSkillsCatalogStore.getState().loadCatalog();
|
||||||
}
|
}
|
||||||
}, [activeProjectId, isSettingsDialogOpen, runtimeCtx.isVSCode, settingsSlug]);
|
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]);
|
||||||
|
|
||||||
const openPage = React.useCallback((slug: SettingsPageSlug) => {
|
const openPage = React.useCallback((slug: SettingsPageSlug) => {
|
||||||
setSettingsPage(slug);
|
setSettingsPage(slug);
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const settingsDict = {
|
|||||||
'settings.skills.sidebar.empty.description': 'Use the + button above to create one',
|
'settings.skills.sidebar.empty.description': 'Use the + button above to create one',
|
||||||
'settings.skills.sidebar.badge.claude': 'claude',
|
'settings.skills.sidebar.badge.claude': 'claude',
|
||||||
'settings.skills.sidebar.badge.agents': 'agents',
|
'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.skillDeleted': 'Skill "{name}" deleted successfully',
|
||||||
'settings.skills.sidebar.toast.deleteSkillFailed': 'Failed to delete skill',
|
'settings.skills.sidebar.toast.deleteSkillFailed': 'Failed to delete skill',
|
||||||
'settings.skills.sidebar.toast.duplicateLoadFailed': 'Failed to load skill details for duplication',
|
'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.userOpencode.description': 'Global OpenCode config location',
|
||||||
'settings.skills.location.option.projectOpencode.label': 'Project / OpenCode',
|
'settings.skills.location.option.projectOpencode.label': 'Project / OpenCode',
|
||||||
'settings.skills.location.option.projectOpencode.description': 'Current project .opencode location',
|
'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.label': 'User / Agents',
|
||||||
'settings.skills.location.option.userAgents.description': 'Global .agents compatibility location',
|
'settings.skills.location.option.userAgents.description': 'Global .agents compatibility location',
|
||||||
'settings.skills.location.option.projectAgents.label': 'Project / Agents',
|
'settings.skills.location.option.projectAgents.label': 'Project / Agents',
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const settingsDict = {
|
|||||||
"settings.skills.sidebar.empty.description": "Usa el botón + arriba para crear una",
|
"settings.skills.sidebar.empty.description": "Usa el botón + arriba para crear una",
|
||||||
"settings.skills.sidebar.badge.claude": "claude",
|
"settings.skills.sidebar.badge.claude": "claude",
|
||||||
"settings.skills.sidebar.badge.agents": "agentes",
|
"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.skillDeleted": "Habilidad \"{name}\" eliminada con éxito",
|
||||||
"settings.skills.sidebar.toast.deleteSkillFailed": "No se pudo eliminar la habilidad",
|
"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",
|
"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.userOpencode.description": "Ubicación global de la configuración de OpenCode",
|
||||||
"settings.skills.location.option.projectOpencode.label": "Proyecto / 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.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.label": "Usuario / Agentes",
|
||||||
"settings.skills.location.option.userAgents.description": "Ubicación global de compatibilidad con .agents",
|
"settings.skills.location.option.userAgents.description": "Ubicación global de compatibilidad con .agents",
|
||||||
"settings.skills.location.option.projectAgents.label": "Proyecto / Agentes",
|
"settings.skills.location.option.projectAgents.label": "Proyecto / Agentes",
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const settingsDict = {
|
|||||||
'settings.skills.sidebar.empty.description': '위의 + 버튼으로 새로 생성하세요',
|
'settings.skills.sidebar.empty.description': '위의 + 버튼으로 새로 생성하세요',
|
||||||
'settings.skills.sidebar.badge.claude': 'claude',
|
'settings.skills.sidebar.badge.claude': 'claude',
|
||||||
'settings.skills.sidebar.badge.agents': 'agents',
|
'settings.skills.sidebar.badge.agents': 'agents',
|
||||||
|
'settings.skills.sidebar.badge.opencode': 'opencode',
|
||||||
'settings.skills.sidebar.toast.skillDeleted': '스킬 "{name}"을 삭제했습니다',
|
'settings.skills.sidebar.toast.skillDeleted': '스킬 "{name}"을 삭제했습니다',
|
||||||
'settings.skills.sidebar.toast.deleteSkillFailed': '스킬을 삭제하지 못했습니다',
|
'settings.skills.sidebar.toast.deleteSkillFailed': '스킬을 삭제하지 못했습니다',
|
||||||
'settings.skills.sidebar.toast.duplicateLoadFailed': '복제를 위한 스킬 세부 정보를 로드하지 못했습니다',
|
'settings.skills.sidebar.toast.duplicateLoadFailed': '복제를 위한 스킬 세부 정보를 로드하지 못했습니다',
|
||||||
@@ -537,6 +538,10 @@ export const settingsDict = {
|
|||||||
'settings.skills.location.option.userOpencode.description': '전역 OpenCode 설정 위치',
|
'settings.skills.location.option.userOpencode.description': '전역 OpenCode 설정 위치',
|
||||||
'settings.skills.location.option.projectOpencode.label': '프로젝트 / OpenCode',
|
'settings.skills.location.option.projectOpencode.label': '프로젝트 / OpenCode',
|
||||||
'settings.skills.location.option.projectOpencode.description': '현재 프로젝트 .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.label': '사용자 / 에이전트',
|
||||||
'settings.skills.location.option.userAgents.description': '전역 .agents 호환 위치',
|
'settings.skills.location.option.userAgents.description': '전역 .agents 호환 위치',
|
||||||
'settings.skills.location.option.projectAgents.label': '프로젝트 / 에이전트',
|
'settings.skills.location.option.projectAgents.label': '프로젝트 / 에이전트',
|
||||||
|
|||||||
@@ -1352,6 +1352,10 @@ export const settingsDict = {
|
|||||||
'settings.skills.location.option.projectAgents.label': 'Projekt / Agents',
|
'settings.skills.location.option.projectAgents.label': 'Projekt / Agents',
|
||||||
'settings.skills.location.option.projectOpencode.description': 'Bieżąca lokalizacja .opencode projektu',
|
'settings.skills.location.option.projectOpencode.description': 'Bieżąca lokalizacja .opencode projektu',
|
||||||
'settings.skills.location.option.projectOpencode.label': 'Projekt / OpenCode',
|
'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.description': 'Globalna lokalizacja zgodna z .agents',
|
||||||
'settings.skills.location.option.userAgents.label': 'Użytkownik / Agents',
|
'settings.skills.location.option.userAgents.label': 'Użytkownik / Agents',
|
||||||
'settings.skills.location.option.userOpencode.description': 'Globalna lokalizacja konfiguracji OpenCode',
|
'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.page.toast.updateSkillFailed': 'Nie udało się zaktualizować umiejętności',
|
||||||
'settings.skills.sidebar.badge.agents': 'agents',
|
'settings.skills.sidebar.badge.agents': 'agents',
|
||||||
'settings.skills.sidebar.badge.claude': 'claude',
|
'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.description': 'Czy na pewno chcesz usunąć umiejętność „{name}”?',
|
||||||
'settings.skills.sidebar.deleteDialog.title': 'Usuń umiejętność',
|
'settings.skills.sidebar.deleteDialog.title': 'Usuń umiejętność',
|
||||||
'settings.skills.sidebar.empty.description': 'Użyj przycisku + powyżej, aby utworzyć nową',
|
'settings.skills.sidebar.empty.description': 'Użyj przycisku + powyżej, aby utworzyć nową',
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const settingsDict = {
|
|||||||
"settings.skills.sidebar.empty.description": "Use o botão + acima para criar uma",
|
"settings.skills.sidebar.empty.description": "Use o botão + acima para criar uma",
|
||||||
"settings.skills.sidebar.badge.claude": "claude",
|
"settings.skills.sidebar.badge.claude": "claude",
|
||||||
"settings.skills.sidebar.badge.agents": "agentes",
|
"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.skillDeleted": "Habilidade \"{name}\" excluída com sucesso",
|
||||||
"settings.skills.sidebar.toast.deleteSkillFailed": "Não foi possível excluir a habilidade",
|
"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",
|
"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.userOpencode.description": "Localização global das configurações do OpenCode",
|
||||||
"settings.skills.location.option.projectOpencode.label": "Projeto / 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.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.label": "Usuário / Agentes",
|
||||||
"settings.skills.location.option.userAgents.description": "Localização global compatível com .agents",
|
"settings.skills.location.option.userAgents.description": "Localização global compatível com .agents",
|
||||||
"settings.skills.location.option.projectAgents.label": "Projeto / Agentes",
|
"settings.skills.location.option.projectAgents.label": "Projeto / Agentes",
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const settingsDict = {
|
|||||||
"settings.skills.sidebar.empty.description": "Скористайтеся кнопкою + вище, щоб створити його",
|
"settings.skills.sidebar.empty.description": "Скористайтеся кнопкою + вище, щоб створити його",
|
||||||
"settings.skills.sidebar.badge.claude": "Claude",
|
"settings.skills.sidebar.badge.claude": "Claude",
|
||||||
"settings.skills.sidebar.badge.agents": "агентів",
|
"settings.skills.sidebar.badge.agents": "агентів",
|
||||||
|
"settings.skills.sidebar.badge.opencode": "opencode",
|
||||||
"settings.skills.sidebar.toast.skillDeleted": "Навичку \"{name}\" успішно видалено",
|
"settings.skills.sidebar.toast.skillDeleted": "Навичку \"{name}\" успішно видалено",
|
||||||
"settings.skills.sidebar.toast.deleteSkillFailed": "Не вдалося видалити навичку",
|
"settings.skills.sidebar.toast.deleteSkillFailed": "Не вдалося видалити навичку",
|
||||||
"settings.skills.sidebar.toast.duplicateLoadFailed": "Не вдалося завантажити деталі навичок для дублювання",
|
"settings.skills.sidebar.toast.duplicateLoadFailed": "Не вдалося завантажити деталі навичок для дублювання",
|
||||||
@@ -537,6 +538,10 @@ export const settingsDict = {
|
|||||||
"settings.skills.location.option.userOpencode.description": "Розташування глобальної конфігурації OpenCode",
|
"settings.skills.location.option.userOpencode.description": "Розташування глобальної конфігурації OpenCode",
|
||||||
"settings.skills.location.option.projectOpencode.label": "Проєкт / OpenCode",
|
"settings.skills.location.option.projectOpencode.label": "Проєкт / OpenCode",
|
||||||
"settings.skills.location.option.projectOpencode.description": "Розташування поточного проєкту .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.label": "Користувач / Агенти",
|
||||||
"settings.skills.location.option.userAgents.description": "Глобальне розташування сумісності .agents",
|
"settings.skills.location.option.userAgents.description": "Глобальне розташування сумісності .agents",
|
||||||
"settings.skills.location.option.projectAgents.label": "Проєкт / Агенти",
|
"settings.skills.location.option.projectAgents.label": "Проєкт / Агенти",
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export const settingsDict = {
|
|||||||
'settings.skills.sidebar.empty.description': '使用上方 + 按钮创建一个',
|
'settings.skills.sidebar.empty.description': '使用上方 + 按钮创建一个',
|
||||||
'settings.skills.sidebar.badge.claude': 'claude',
|
'settings.skills.sidebar.badge.claude': 'claude',
|
||||||
'settings.skills.sidebar.badge.agents': 'agents',
|
'settings.skills.sidebar.badge.agents': 'agents',
|
||||||
|
'settings.skills.sidebar.badge.opencode': 'opencode',
|
||||||
'settings.skills.sidebar.toast.skillDeleted': '技能“{name}”已删除',
|
'settings.skills.sidebar.toast.skillDeleted': '技能“{name}”已删除',
|
||||||
'settings.skills.sidebar.toast.deleteSkillFailed': '删除技能失败',
|
'settings.skills.sidebar.toast.deleteSkillFailed': '删除技能失败',
|
||||||
'settings.skills.sidebar.toast.duplicateLoadFailed': '加载技能详情以复制失败',
|
'settings.skills.sidebar.toast.duplicateLoadFailed': '加载技能详情以复制失败',
|
||||||
@@ -537,6 +538,10 @@ export const settingsDict = {
|
|||||||
'settings.skills.location.option.userOpencode.description': '全局 OpenCode 配置位置',
|
'settings.skills.location.option.userOpencode.description': '全局 OpenCode 配置位置',
|
||||||
'settings.skills.location.option.projectOpencode.label': '项目 / OpenCode',
|
'settings.skills.location.option.projectOpencode.label': '项目 / OpenCode',
|
||||||
'settings.skills.location.option.projectOpencode.description': '当前项目 .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.label': '用户 / Agents',
|
||||||
'settings.skills.location.option.userAgents.description': '全局 .agents 兼容位置',
|
'settings.skills.location.option.userAgents.description': '全局 .agents 兼容位置',
|
||||||
'settings.skills.location.option.projectAgents.label': '项目 / Agents',
|
'settings.skills.location.option.projectAgents.label': '项目 / Agents',
|
||||||
|
|||||||
@@ -1323,7 +1323,7 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Command Management
|
// Command Management
|
||||||
async listCommands(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string }>> {
|
async listCommands(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; source?: string }>> {
|
||||||
try {
|
try {
|
||||||
const response = await this.client.command.list(
|
const response = await this.client.command.list(
|
||||||
this.currentDirectory ? { directory: this.currentDirectory } : undefined
|
this.currentDirectory ? { directory: this.currentDirectory } : undefined
|
||||||
@@ -1333,7 +1333,8 @@ class OpencodeService {
|
|||||||
name: cmd.name as string,
|
name: cmd.name as string,
|
||||||
description: cmd.description as string | undefined,
|
description: cmd.description as string | undefined,
|
||||||
agent: cmd.agent 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
|
// Intentionally excluding template to keep memory usage low
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1341,7 +1342,7 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; template?: string }>> {
|
async listCommandsWithDetails(): Promise<Array<{ name: string; description?: string; agent?: string; model?: string; source?: string; template?: string }>> {
|
||||||
try {
|
try {
|
||||||
const response = await this.client.command.list(
|
const response = await this.client.command.list(
|
||||||
this.currentDirectory ? { directory: this.currentDirectory } : undefined
|
this.currentDirectory ? { directory: this.currentDirectory } : undefined
|
||||||
@@ -1352,6 +1353,7 @@ class OpencodeService {
|
|||||||
description: cmd.description as string | undefined,
|
description: cmd.description as string | undefined,
|
||||||
agent: cmd.agent 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,
|
||||||
template: cmd.template as string | undefined,
|
template: cmd.template as string | undefined,
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1359,6 +1361,34 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSkillsWithDetails(): Promise<Array<{ name: string; description?: string; location: string; content?: string }>> {
|
||||||
|
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<Record<string, unknown>>) {
|
||||||
|
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
||||||
|
const location = typeof item.location === 'string' ? item.location : '';
|
||||||
|
if (!name || !location || location === '<built-in>') {
|
||||||
|
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> {
|
async getCommandDetails(name: string): Promise<{ name: string; template: string; description?: string; agent?: string; model?: string } | null> {
|
||||||
try {
|
try {
|
||||||
const response = await this.client.command.list(
|
const response = await this.client.command.list(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface CommandConfig {
|
|||||||
description?: string;
|
description?: string;
|
||||||
agent?: string | null;
|
agent?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
|
source?: string;
|
||||||
template?: string;
|
template?: string;
|
||||||
scope?: CommandScope;
|
scope?: CommandScope;
|
||||||
}
|
}
|
||||||
@@ -168,8 +169,9 @@ export const useCommandsStore = create<CommandsStore>()(
|
|||||||
() => opencodeClient.listCommandsWithDetails()
|
() => opencodeClient.listCommandsWithDetails()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const configurableCommands = commands.filter((cmd) => cmd.source !== 'skill');
|
||||||
const commandsWithScope = await Promise.all(
|
const commandsWithScope = await Promise.all(
|
||||||
commands.map(async (cmd) => {
|
configurableCommands.map(async (cmd) => {
|
||||||
try {
|
try {
|
||||||
// Force no-cache
|
// Force no-cache
|
||||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
|
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export interface SkillSources {
|
|||||||
projectMd?: { exists: boolean; path: string | null };
|
projectMd?: { exists: boolean; path: string | null };
|
||||||
claudeMd?: { exists: boolean; path: string | null };
|
claudeMd?: { exists: boolean; path: string | null };
|
||||||
userMd?: { 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 {
|
export interface DiscoveredSkill {
|
||||||
@@ -102,6 +104,7 @@ export interface SkillConfig {
|
|||||||
instructions?: string;
|
instructions?: string;
|
||||||
scope?: SkillScope;
|
scope?: SkillScope;
|
||||||
source?: SkillSource;
|
source?: SkillSource;
|
||||||
|
targetPath?: string;
|
||||||
supportingFiles?: Array<{ path: string; content: string }>;
|
supportingFiles?: Array<{ path: string; content: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +166,7 @@ const skillsLoadInFlight = new Map<string, Promise<boolean>>();
|
|||||||
const getSkillsCacheKey = (directory: string | null): string => {
|
const getSkillsCacheKey = (directory: string | null): string => {
|
||||||
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
|
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_HEALTH_WAIT_MS = 20000;
|
const MAX_HEALTH_WAIT_MS = 20000;
|
||||||
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
|
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
|
||||||
const FAST_HEALTH_POLL_ATTEMPTS = 4;
|
const FAST_HEALTH_POLL_ATTEMPTS = 4;
|
||||||
@@ -219,7 +223,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const rawSkills: RawSkillResponse[] = data.skills || [];
|
const rawSkills: RawSkillResponse[] = data.skills || [];
|
||||||
const skills: DiscoveredSkill[] = rawSkills.map((s) => ({
|
const configSkills: DiscoveredSkill[] = rawSkills.map((s) => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
path: s.path,
|
path: s.path,
|
||||||
scope: s.scope ?? 'user',
|
scope: s.scope ?? 'user',
|
||||||
@@ -228,7 +232,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
group: parseSkillGroup(s.path),
|
group: parseSkillGroup(s.path),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
set({ skills, isLoading: false });
|
set({ skills: configSkills, isLoading: false });
|
||||||
skillsLastLoadedAt.set(cacheKey, Date.now());
|
skillsLastLoadedAt.set(cacheKey, Date.now());
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -329,6 +333,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
if (config.description !== undefined) skillConfig.description = config.description;
|
if (config.description !== undefined) skillConfig.description = config.description;
|
||||||
if (config.instructions !== undefined) skillConfig.instructions = config.instructions;
|
if (config.instructions !== undefined) skillConfig.instructions = config.instructions;
|
||||||
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
|
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
|
||||||
|
if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath;
|
||||||
|
|
||||||
const currentDirectory = getCurrentDirectory();
|
const currentDirectory = getCurrentDirectory();
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
export const registerSkillRoutes = (app, dependencies) => {
|
export const registerSkillRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
fs,
|
fs,
|
||||||
@@ -14,7 +16,6 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
getOpenCodePort,
|
getOpenCodePort,
|
||||||
getSkillSources,
|
getSkillSources,
|
||||||
discoverSkills,
|
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
@@ -114,31 +115,23 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
|
|
||||||
const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
|
const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
|
||||||
if (!getOpenCodePort()) {
|
if (!getOpenCodePort()) {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(buildOpenCodeUrl('/skill', ''));
|
const client = createOpencodeClient({
|
||||||
if (workingDirectory) {
|
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
||||||
url.searchParams.set('directory', workingDirectory);
|
directory: workingDirectory || undefined,
|
||||||
}
|
headers: getOpenCodeAuthHeaders(),
|
||||||
|
fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...getOpenCodeAuthHeaders(),
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(8_000),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const response = await client.app.skills(
|
||||||
return null;
|
workingDirectory ? { directory: workingDirectory } : undefined,
|
||||||
}
|
);
|
||||||
|
const payload = response?.data;
|
||||||
const payload = await response.json();
|
|
||||||
if (!Array.isArray(payload)) {
|
if (!Array.isArray(payload)) {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
@@ -146,7 +139,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||||
const location = typeof item?.location === 'string' ? item.location : '';
|
const location = typeof item?.location === 'string' ? item.location : '';
|
||||||
const description = typeof item?.description === 'string' ? item.description : '';
|
const description = typeof item?.description === 'string' ? item.description : '';
|
||||||
if (!name || !location) {
|
if (!name || !location || location === '<built-in>') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
||||||
@@ -159,8 +152,9 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
} catch {
|
} catch (error) {
|
||||||
return null;
|
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) => {
|
app.get('/api/config/skills', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { directory, error } = await resolveProjectDirectory(req);
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||||
if (!directory) {
|
if (error) {
|
||||||
return res.status(400).json({ 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 enrichedSkills = skills.map((skill) => {
|
||||||
const sources = getSkillSources(skill.name, directory, 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' } });
|
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const discovered = directory
|
const discovered = await fetchOpenCodeDiscoveredSkills(directory);
|
||||||
? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory))
|
|
||||||
: [];
|
|
||||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||||
|
|
||||||
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
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) => {
|
app.get('/api/config/skills/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const { directory, error } = await resolveProjectDirectory(req);
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||||
if (!directory) {
|
if (error) {
|
||||||
return res.status(400).json({ error });
|
return res.status(400).json({ error });
|
||||||
}
|
}
|
||||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
|
||||||
.find((skill) => skill.name === skillName) || null;
|
.find((skill) => skill.name === skillName) || null;
|
||||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||||
|
|
||||||
@@ -532,12 +524,12 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
if (isUnsafeSkillRelativePath(filePath)) {
|
if (isUnsafeSkillRelativePath(filePath)) {
|
||||||
return res.status(400).json({ error: 'Invalid file path' });
|
return res.status(400).json({ error: 'Invalid file path' });
|
||||||
}
|
}
|
||||||
const { directory, error } = await resolveProjectDirectory(req);
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||||
if (!directory) {
|
if (error) {
|
||||||
return res.status(400).json({ error });
|
return res.status(400).json({ error });
|
||||||
}
|
}
|
||||||
|
|
||||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
|
||||||
.find((skill) => skill.name === skillName) || null;
|
.find((skill) => skill.name === skillName) || null;
|
||||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||||
if (!sources.md.exists || !sources.md.dir) {
|
if (!sources.md.exists || !sources.md.dir) {
|
||||||
@@ -563,9 +555,11 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const { scope, source: skillSource, ...config } = req.body;
|
const { scope, source: skillSource, ...config } = req.body;
|
||||||
const { directory, error } = await resolveProjectDirectory(req);
|
const { directory, error } = scope === SKILL_SCOPE.PROJECT
|
||||||
if (!directory) {
|
? await resolveProjectDirectory(req)
|
||||||
return res.status(400).json({ error });
|
: 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);
|
console.log('[Server] Creating skill:', skillName);
|
||||||
@@ -590,15 +584,15 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
const { directory, error } = await resolveProjectDirectory(req);
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||||
if (!directory) {
|
if (error) {
|
||||||
return res.status(400).json({ error });
|
return res.status(400).json({ error });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Server] Updating skill: ${skillName}`);
|
console.log(`[Server] Updating skill: ${skillName}`);
|
||||||
console.log('[Server] Working directory:', directory);
|
console.log('[Server] Working directory:', directory);
|
||||||
|
|
||||||
updateSkill(skillName, updates, directory);
|
updateSkill(skillName, updates, directory, updates?.targetPath);
|
||||||
await refreshOpenCodeAfterConfigChange('skill update');
|
await refreshOpenCodeAfterConfigChange('skill update');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -621,12 +615,12 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
return res.status(400).json({ error: 'Invalid file path' });
|
return res.status(400).json({ error: 'Invalid file path' });
|
||||||
}
|
}
|
||||||
const { content } = req.body;
|
const { content } = req.body;
|
||||||
const { directory, error } = await resolveProjectDirectory(req);
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||||
if (!directory) {
|
if (error) {
|
||||||
return res.status(400).json({ error });
|
return res.status(400).json({ error });
|
||||||
}
|
}
|
||||||
|
|
||||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
|
||||||
.find((skill) => skill.name === skillName) || null;
|
.find((skill) => skill.name === skillName) || null;
|
||||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||||
if (!sources.md.exists || !sources.md.dir) {
|
if (!sources.md.exists || !sources.md.dir) {
|
||||||
@@ -655,12 +649,12 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
if (isUnsafeSkillRelativePath(filePath)) {
|
if (isUnsafeSkillRelativePath(filePath)) {
|
||||||
return res.status(400).json({ error: 'Invalid file path' });
|
return res.status(400).json({ error: 'Invalid file path' });
|
||||||
}
|
}
|
||||||
const { directory, error } = await resolveProjectDirectory(req);
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||||
if (!directory) {
|
if (error) {
|
||||||
return res.status(400).json({ error });
|
return res.status(400).json({ error });
|
||||||
}
|
}
|
||||||
|
|
||||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
|
||||||
.find((skill) => skill.name === skillName) || null;
|
.find((skill) => skill.name === skillName) || null;
|
||||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||||
if (!sources.md.exists || !sources.md.dir) {
|
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) => {
|
app.delete('/api/config/skills/:name', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const skillName = req.params.name;
|
const skillName = req.params.name;
|
||||||
const { directory, error } = await resolveProjectDirectory(req);
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||||
if (!directory) {
|
if (error) {
|
||||||
return res.status(400).json({ error });
|
return res.status(400).json({ error });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ function getClaudeSkillPath(workingDirectory, skillName) {
|
|||||||
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
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) {
|
function getUserAgentsSkillDir(skillName) {
|
||||||
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
||||||
}
|
}
|
||||||
@@ -107,6 +115,16 @@ function getSkillScope(skillName, workingDirectory) {
|
|||||||
if (fs.existsSync(userPath)) {
|
if (fs.existsSync(userPath)) {
|
||||||
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
|
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 };
|
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 claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
|
||||||
const claudeExists = claudePath && fs.existsSync(claudePath);
|
const claudeExists = claudePath && fs.existsSync(claudePath);
|
||||||
const claudeDir = claudeExists ? path.dirname(claudePath) : null;
|
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 userPath = getUserSkillPath(skillName);
|
||||||
const userExists = fs.existsSync(userPath);
|
const userExists = fs.existsSync(userPath);
|
||||||
const userDir = userExists ? path.dirname(userPath) : null;
|
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
|
const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName
|
||||||
? discoveredSkill
|
? discoveredSkill
|
||||||
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||||
@@ -240,7 +265,12 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
|||||||
let mdSource = null;
|
let mdSource = null;
|
||||||
let mdDir = 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;
|
mdPath = projectPath;
|
||||||
mdScope = SKILL_SCOPE.PROJECT;
|
mdScope = SKILL_SCOPE.PROJECT;
|
||||||
mdSource = 'opencode';
|
mdSource = 'opencode';
|
||||||
@@ -255,14 +285,23 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
|||||||
mdScope = SKILL_SCOPE.USER;
|
mdScope = SKILL_SCOPE.USER;
|
||||||
mdSource = 'opencode';
|
mdSource = 'opencode';
|
||||||
mdDir = userDir;
|
mdDir = userDir;
|
||||||
} else if (matchedDiscovered?.path) {
|
} else if (userClaudeExists) {
|
||||||
mdPath = matchedDiscovered.path;
|
mdPath = userClaudePath;
|
||||||
mdScope = matchedDiscovered.scope || null;
|
mdScope = SKILL_SCOPE.USER;
|
||||||
mdSource = matchedDiscovered.source || null;
|
mdSource = 'claude';
|
||||||
mdDir = path.dirname(matchedDiscovered.path);
|
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 = {
|
const sources = {
|
||||||
md: {
|
md: {
|
||||||
@@ -288,6 +327,16 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
|||||||
exists: userExists,
|
exists: userExists,
|
||||||
path: userPath,
|
path: userPath,
|
||||||
dir: userDir
|
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})`);
|
console.log(`Created new skill: ${skillName} (scope: ${targetScope}, path: ${targetPath})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSkill(skillName, updates, workingDirectory) {
|
function updateSkill(skillName, updates, workingDirectory, targetPath = null) {
|
||||||
ensureDirs();
|
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) {
|
if (!existing.path) {
|
||||||
throw new Error(`Skill "${skillName}" not found`);
|
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 mdPath = existing.path;
|
||||||
const mdDir = path.dirname(mdPath);
|
const mdDir = path.dirname(mdPath);
|
||||||
const mdData = parseMdFile(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;
|
let mdModified = false;
|
||||||
|
|
||||||
for (const [field, value] of Object.entries(updates)) {
|
for (const [field, value] of Object.entries(updates)) {
|
||||||
if (field === 'scope') {
|
if (field === 'scope' || field === 'source' || field === 'targetPath') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +525,13 @@ function deleteSkill(skillName, workingDirectory) {
|
|||||||
deleted = true;
|
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) {
|
if (!deleted) {
|
||||||
throw new Error(`Skill "${skillName}" not found`);
|
throw new Error(`Skill "${skillName}" not found`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user