feat(skills): align discovery with Opencode API and improve skills editor UX (#441)
This commit is contained in:
committed by
GitHub
parent
7eba5141fa
commit
14737b6b28
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
||||
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiSaveLine, RiUser3Line } from '@remixicon/react';
|
||||
import { RiAddLine, RiBookOpenLine, RiDeleteBinLine, RiFileLine, RiFolderLine, RiRobot2Line, RiSaveLine, RiUser3Line } from '@remixicon/react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import {
|
||||
Select,
|
||||
@@ -23,8 +25,23 @@ import {
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from './skillLocations';
|
||||
|
||||
const LazyCodeMirrorEditor = React.lazy(async () => {
|
||||
const module = await import('@/components/ui/CodeMirrorEditor');
|
||||
return { default: module.CodeMirrorEditor };
|
||||
});
|
||||
|
||||
export const SkillsPage: React.FC = () => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const {
|
||||
selectedSkillName,
|
||||
getSkillByName,
|
||||
@@ -73,6 +90,7 @@ export const SkillsPage: React.FC = () => {
|
||||
|
||||
const [draftName, setDraftName] = React.useState('');
|
||||
const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
|
||||
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [instructions, setInstructions] = React.useState('');
|
||||
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
|
||||
@@ -93,6 +111,82 @@ export const SkillsPage: React.FC = () => {
|
||||
const [originalFileContent, setOriginalFileContent] = React.useState(''); // Track original for change detection
|
||||
const [deleteFilePath, setDeleteFilePath] = React.useState<string | null>(null);
|
||||
const [isDeletingFile, setIsDeletingFile] = React.useState(false);
|
||||
const [instructionsEditorHeight, setInstructionsEditorHeight] = React.useState(320);
|
||||
const [instructionsLanguage, setInstructionsLanguage] = React.useState<Extension | null>(null);
|
||||
const [supportingFileLanguage, setSupportingFileLanguage] = React.useState<Extension | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadInstructionsLanguage = async () => {
|
||||
const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
|
||||
if (cancelled) return;
|
||||
setInstructionsLanguage(languageByExtension('SKILL.md'));
|
||||
};
|
||||
|
||||
void loadInstructionsLanguage();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadSupportingFileLanguage = async () => {
|
||||
const targetPath = newFileName.trim();
|
||||
if (!targetPath) {
|
||||
setSupportingFileLanguage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { languageByExtension } = await import('@/lib/codemirror/languageByExtension');
|
||||
if (cancelled) return;
|
||||
setSupportingFileLanguage(languageByExtension(targetPath));
|
||||
};
|
||||
|
||||
void loadSupportingFileLanguage();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [newFileName]);
|
||||
|
||||
const instructionsEditorExtensions = React.useMemo(() => {
|
||||
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
|
||||
if (instructionsLanguage) {
|
||||
extensions.push(instructionsLanguage);
|
||||
}
|
||||
return extensions;
|
||||
}, [currentTheme, instructionsLanguage]);
|
||||
|
||||
const supportingFileEditorExtensions = React.useMemo(() => {
|
||||
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme), EditorView.lineWrapping];
|
||||
if (supportingFileLanguage) {
|
||||
extensions.push(supportingFileLanguage);
|
||||
}
|
||||
return extensions;
|
||||
}, [currentTheme, supportingFileLanguage]);
|
||||
|
||||
const handleStartInstructionsResize = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startY = event.clientY;
|
||||
const startHeight = instructionsEditorHeight;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
const viewportMax = typeof window !== 'undefined' ? Math.floor(window.innerHeight * 0.75) : 800;
|
||||
const nextHeight = Math.max(220, Math.min(viewportMax, startHeight + deltaY));
|
||||
setInstructionsEditorHeight(nextHeight);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}, [instructionsEditorHeight]);
|
||||
|
||||
// Detect if skill-level fields have changed
|
||||
const hasSkillChanges = isNewSkill
|
||||
@@ -115,6 +209,7 @@ export const SkillsPage: React.FC = () => {
|
||||
// Prefill from draft (for new or duplicated skills)
|
||||
setDraftName(skillDraft.name || '');
|
||||
setDraftScope(skillDraft.scope || 'user');
|
||||
setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode');
|
||||
setDescription(skillDraft.description || '');
|
||||
setInstructions(skillDraft.instructions || '');
|
||||
setOriginalDescription('');
|
||||
@@ -178,6 +273,7 @@ export const SkillsPage: React.FC = () => {
|
||||
description: description.trim(),
|
||||
instructions: instructions.trim() || undefined,
|
||||
scope: isNewSkill ? draftScope : undefined,
|
||||
source: isNewSkill ? draftSource : undefined,
|
||||
// Include pending files when creating new skill
|
||||
supportingFiles: isNewSkill && pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
};
|
||||
@@ -387,7 +483,7 @@ export const SkillsPage: React.FC = () => {
|
||||
</h1>
|
||||
{selectedSkill && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{selectedSkill.scope === 'project' ? 'Project' : 'User'} skill
|
||||
{locationLabel(selectedSkill.scope, selectedSkill.source)} skill
|
||||
{selectedSkill.source === 'claude' && ' (Claude-compatible)'}
|
||||
</p>
|
||||
)}
|
||||
@@ -405,7 +501,7 @@ export const SkillsPage: React.FC = () => {
|
||||
{isNewSkill && (
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Skill Name & Scope
|
||||
Skill Name & Location
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -414,34 +510,36 @@ export const SkillsPage: React.FC = () => {
|
||||
placeholder="skill-name"
|
||||
className="flex-1 text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Select value={draftScope} onValueChange={(v) => setDraftScope(v as SkillScope)}>
|
||||
<Select
|
||||
value={locationValueFrom(draftScope, draftSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setDraftScope(next.scope);
|
||||
setDraftSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="!h-9 w-auto gap-1.5">
|
||||
{draftScope === 'user' ? (
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
) : (
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
)}
|
||||
<span className="capitalize">{draftScope}</span>
|
||||
{draftSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(draftScope, draftSource)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
<span>User</span>
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
<span>Project</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -476,13 +574,43 @@ export const SkillsPage: React.FC = () => {
|
||||
Detailed instructions for the agent when this skill is loaded
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Step-by-step instructions, guidelines, or reference content..."
|
||||
rows={12}
|
||||
className="font-mono typography-meta max-h-80 resize-y"
|
||||
/>
|
||||
<div
|
||||
className="relative min-h-[220px] max-h-[75vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden flex flex-col"
|
||||
style={{ height: `${instructionsEditorHeight}px` }}
|
||||
>
|
||||
<div className="flex-1 min-h-0">
|
||||
<React.Suspense
|
||||
fallback={(
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Step-by-step instructions, guidelines, or reference content..."
|
||||
rows={12}
|
||||
className="h-full border-0 rounded-none font-mono typography-meta resize-none"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LazyCodeMirrorEditor
|
||||
value={instructions}
|
||||
onChange={setInstructions}
|
||||
extensions={instructionsEditorExtensions}
|
||||
className="h-full"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="Resize instructions editor"
|
||||
onMouseDown={handleStartInstructionsResize}
|
||||
className="absolute right-1.5 bottom-1.5 z-10 h-4 w-4 cursor-nwse-resize opacity-80 hover:opacity-100"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="h-4 w-4 text-[var(--surface-muted-foreground)]" aria-hidden="true">
|
||||
<path d="M6 14L14 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M10 14L14 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M2 14L14 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supporting Files */}
|
||||
@@ -636,12 +764,25 @@ export const SkillsPage: React.FC = () => {
|
||||
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
||||
Content
|
||||
</label>
|
||||
<Textarea
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="File content..."
|
||||
className="font-mono typography-meta flex-1 min-h-[200px] max-h-full resize-none"
|
||||
/>
|
||||
<div className="h-[45vh] min-h-[220px] max-h-[55vh] rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] overflow-hidden">
|
||||
<React.Suspense
|
||||
fallback={(
|
||||
<Textarea
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="File content..."
|
||||
className="h-full border-0 rounded-none font-mono typography-meta resize-none"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LazyCodeMirrorEditor
|
||||
value={newFileContent}
|
||||
onChange={setNewFileContent}
|
||||
extensions={supportingFileEditorExtensions}
|
||||
className="h-full"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
// Set draft and open the page for editing
|
||||
setSkillDraft({ name: newName, scope: 'user', description: '' });
|
||||
setSkillDraft({ name: newName, scope: 'user', source: 'opencode', description: '' });
|
||||
setSelectedSkill(newName);
|
||||
onItemSelect?.();
|
||||
|
||||
@@ -115,12 +115,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
// Set draft with prefilled values from source skill
|
||||
setSkillDraft({
|
||||
name: newName,
|
||||
scope: skill.scope || 'user',
|
||||
description: detail.sources.md.fields.includes('description') ? '' : '', // Will be populated from page
|
||||
instructions: '',
|
||||
});
|
||||
setSkillDraft({
|
||||
name: newName,
|
||||
scope: skill.scope || 'user',
|
||||
source: skill.source || 'opencode',
|
||||
description: detail.sources.md.fields.includes('description') ? '' : '', // Will be populated from page
|
||||
instructions: '',
|
||||
});
|
||||
setSelectedSkill(newName);
|
||||
|
||||
if (isMobile) {
|
||||
@@ -166,6 +167,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
name: sanitizedName,
|
||||
description: 'Renamed skill', // Will need proper description
|
||||
scope: renameDialogSkill.scope,
|
||||
source: renameDialogSkill.source,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
@@ -378,6 +380,11 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
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">
|
||||
agents
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
export type SkillConflict = {
|
||||
skillName: string;
|
||||
scope: 'user' | 'project';
|
||||
source?: 'opencode' | 'agents';
|
||||
};
|
||||
|
||||
export type ConflictDecision = 'skip' | 'overwrite';
|
||||
@@ -85,7 +86,9 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label truncate">{conflict.skillName}</div>
|
||||
<div className="typography-micro text-muted-foreground">Installed in {conflict.scope} scope</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Installed in {conflict.scope} / {conflict.source || 'opencode'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiGitRepositoryLine, RiUser3Line } from '@remixicon/react';
|
||||
import { RiFolderLine, RiGitRepositoryLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
||||
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
@@ -28,6 +28,13 @@ import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from '../skillLocations';
|
||||
|
||||
interface InstallFromRepoDialogProps {
|
||||
open: boolean;
|
||||
@@ -45,6 +52,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
const [source, setSource] = React.useState('');
|
||||
const [subpath, setSubpath] = React.useState('');
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
|
||||
const [items, setItems] = React.useState<SkillsCatalogItem[]>([]);
|
||||
const [selected, setSelected] = React.useState<Record<string, boolean>>({});
|
||||
@@ -59,6 +67,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource: 'opencode' | 'agents';
|
||||
selections: Array<{ skillDir: string }>;
|
||||
gitIdentityId?: string;
|
||||
} | null>(null);
|
||||
@@ -68,6 +77,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
setSource('');
|
||||
setSubpath('');
|
||||
setScope('user');
|
||||
setTargetSource('opencode');
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
setSearch('');
|
||||
@@ -82,9 +92,9 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
}, [open, loadDefaultGitIdentityId]);
|
||||
|
||||
const installedByName = React.useMemo(() => {
|
||||
const map = new Map<string, { scope: 'user' | 'project' }>();
|
||||
const map = new Map<string, { scope: 'user' | 'project'; source: 'opencode' | 'claude' | 'agents' }>();
|
||||
for (const s of installedSkills) {
|
||||
map.set(s.name, { scope: s.scope });
|
||||
map.set(s.name, { scope: s.scope, source: s.source });
|
||||
}
|
||||
return map;
|
||||
}, [installedSkills]);
|
||||
@@ -176,6 +186,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
source: source.trim(),
|
||||
subpath: subpath.trim() || undefined,
|
||||
scope,
|
||||
targetSource,
|
||||
selections: selectedDirs.map((dir) => ({ skillDir: dir })),
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
};
|
||||
@@ -272,31 +283,33 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Target scope</label>
|
||||
<Select value={scope} onValueChange={(v) => setScope(v as 'user' | 'project')}>
|
||||
<label className="typography-ui-label font-medium text-foreground">Target location</label>
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setScope(next.scope);
|
||||
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="!h-9 w-full gap-1.5">
|
||||
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
<span className="capitalize">{scope}</span>
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
<span>User</span>
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
<span>Project</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -378,7 +391,7 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
<div className="typography-ui-label truncate">{item.skillName}</div>
|
||||
{installed ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
installed ({installed.scope})
|
||||
installed ({installed.scope}/{installed.source})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,18 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiUser3Line } from '@remixicon/react';
|
||||
import { RiFolderLine, RiRobot2Line, RiUser3Line } from '@remixicon/react';
|
||||
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
locationLabel,
|
||||
locationPartsFrom,
|
||||
locationValueFrom,
|
||||
type SkillLocationValue,
|
||||
} from '../skillLocations';
|
||||
|
||||
interface InstallSkillDialogProps {
|
||||
open: boolean;
|
||||
@@ -31,18 +38,21 @@ interface InstallSkillDialogProps {
|
||||
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
|
||||
const { installSkills, isInstalling } = useSkillsCatalogStore();
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [targetSource, setTargetSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||
const [conflictsOpen, setConflictsOpen] = React.useState(false);
|
||||
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
|
||||
const [baseRequest, setBaseRequest] = React.useState<{
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource: 'opencode' | 'agents';
|
||||
skillDir: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScope('user');
|
||||
setTargetSource('opencode');
|
||||
setConflictsOpen(false);
|
||||
setConflicts([]);
|
||||
setBaseRequest(null);
|
||||
@@ -52,6 +62,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource: 'opencode' | 'agents';
|
||||
skillDir: string;
|
||||
conflictDecisions?: Record<string, ConflictDecision>;
|
||||
}) => {
|
||||
@@ -71,6 +82,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
subpath: request.subpath,
|
||||
gitIdentityId: item?.gitIdentityId,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
selections: [selection],
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: request.conflictDecisions,
|
||||
@@ -83,7 +95,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
setBaseRequest({ source: request.source, subpath: request.subpath, scope: request.scope, skillDir: request.skillDir });
|
||||
setBaseRequest({
|
||||
source: request.source,
|
||||
subpath: request.subpath,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
skillDir: request.skillDir,
|
||||
});
|
||||
setConflicts(result.error.conflicts);
|
||||
setConflictsOpen(true);
|
||||
return;
|
||||
@@ -108,7 +126,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Install <span className="font-semibold text-foreground">{item.skillName}</span> into user or project scope.
|
||||
Install <span className="font-semibold text-foreground">{item.skillName}</span> into one of four target locations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -127,26 +145,34 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
|
||||
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={scope} onValueChange={(v) => setScope(v as 'user' | 'project')}>
|
||||
<SelectTrigger className="!h-9 w-full sm:w-36 justify-between">
|
||||
<span className="flex flex-1 items-center gap-2 justify-start">
|
||||
<Select
|
||||
value={locationValueFrom(scope, targetSource)}
|
||||
onValueChange={(v) => {
|
||||
const next = locationPartsFrom(v as SkillLocationValue);
|
||||
setScope(next.scope);
|
||||
setTargetSource(next.source === 'agents' ? 'agents' : 'opencode');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="!h-9 w-full sm:w-auto">
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
<span className="capitalize">{scope}</span>
|
||||
{targetSource === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{locationLabel(scope, targetSource)}</span>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiUser3Line className="h-4 w-4" />
|
||||
<span>User</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
<span>Project</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{SKILL_LOCATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="pr-2 [&>span:first-child]:hidden">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
{option.source === 'agents' ? <RiRobot2Line className="h-4 w-4" /> : null}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<span className="typography-micro text-muted-foreground ml-6">{option.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -164,6 +190,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
source: item.repoSource,
|
||||
subpath: item.repoSubpath,
|
||||
scope,
|
||||
targetSource,
|
||||
skillDir: item.skillDir,
|
||||
})
|
||||
}
|
||||
@@ -185,6 +212,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
source: baseRequest.source,
|
||||
subpath: baseRequest.subpath,
|
||||
scope: baseRequest.scope,
|
||||
targetSource: baseRequest.targetSource,
|
||||
skillDir: baseRequest.skillDir,
|
||||
conflictDecisions: decisions,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SkillScope, SkillSource } from '@/stores/useSkillsStore';
|
||||
|
||||
export type SkillLocationValue = 'user-opencode' | 'project-opencode' | 'user-agents' | 'project-agents';
|
||||
|
||||
export const SKILL_LOCATION_OPTIONS: Array<{
|
||||
value: SkillLocationValue;
|
||||
scope: SkillScope;
|
||||
source: SkillSource;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'user-opencode',
|
||||
scope: 'user',
|
||||
source: 'opencode',
|
||||
label: 'User / OpenCode',
|
||||
description: 'Global OpenCode config location',
|
||||
},
|
||||
{
|
||||
value: 'project-opencode',
|
||||
scope: 'project',
|
||||
source: 'opencode',
|
||||
label: 'Project / OpenCode',
|
||||
description: 'Current project .opencode location',
|
||||
},
|
||||
{
|
||||
value: 'user-agents',
|
||||
scope: 'user',
|
||||
source: 'agents',
|
||||
label: 'User / Agents',
|
||||
description: 'Global .agents compatibility location',
|
||||
},
|
||||
{
|
||||
value: 'project-agents',
|
||||
scope: 'project',
|
||||
source: 'agents',
|
||||
label: 'Project / Agents',
|
||||
description: 'Current project .agents compatibility location',
|
||||
},
|
||||
];
|
||||
|
||||
export function locationValueFrom(scope: SkillScope, source: SkillSource): SkillLocationValue {
|
||||
if (scope === 'project' && source === 'agents') return 'project-agents';
|
||||
if (scope === 'project') return 'project-opencode';
|
||||
if (source === 'agents') return 'user-agents';
|
||||
return 'user-opencode';
|
||||
}
|
||||
|
||||
export function locationPartsFrom(value: SkillLocationValue): { scope: SkillScope; source: SkillSource } {
|
||||
const match = SKILL_LOCATION_OPTIONS.find((option) => option.value === value);
|
||||
if (!match) {
|
||||
return { scope: 'user', source: 'opencode' };
|
||||
}
|
||||
return { scope: match.scope, source: match.source };
|
||||
}
|
||||
|
||||
export function locationLabel(scope: SkillScope, source: SkillSource): string {
|
||||
const match = SKILL_LOCATION_OPTIONS.find((option) => option.scope === scope && option.source === source);
|
||||
return match?.label || `${scope} / ${source}`;
|
||||
}
|
||||
@@ -940,6 +940,7 @@ export interface SkillsCatalogSource {
|
||||
export interface SkillsCatalogItemInstalledBadge {
|
||||
isInstalled: boolean;
|
||||
scope?: 'user' | 'project';
|
||||
source?: 'opencode' | 'agents' | 'claude';
|
||||
}
|
||||
|
||||
export interface ClawdHubSkillMetadata {
|
||||
@@ -1018,6 +1019,7 @@ export interface SkillsInstallRequest {
|
||||
subpath?: string;
|
||||
gitIdentityId?: string;
|
||||
scope: 'user' | 'project';
|
||||
targetSource?: 'opencode' | 'agents';
|
||||
selections: SkillsInstallSelection[];
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
@@ -1026,12 +1028,12 @@ export interface SkillsInstallRequest {
|
||||
export type SkillsInstallError = SkillsRepoScanError | {
|
||||
kind: 'conflicts';
|
||||
message: string;
|
||||
conflicts: Array<{ skillName: string; scope: 'user' | 'project' }>;
|
||||
conflicts: Array<{ skillName: string; scope: 'user' | 'project'; source?: 'opencode' | 'agents' }>;
|
||||
};
|
||||
|
||||
export interface SkillsInstallResponse {
|
||||
ok: boolean;
|
||||
installed?: Array<{ skillName: string; scope: 'user' | 'project' }>;
|
||||
installed?: Array<{ skillName: string; scope: 'user' | 'project'; source?: 'opencode' | 'agents' }>;
|
||||
skipped?: Array<{ skillName: string; reason: string }>;
|
||||
error?: SkillsInstallError;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const getCurrentDirectory = (): string | null => {
|
||||
};
|
||||
|
||||
export type SkillScope = 'user' | 'project';
|
||||
export type SkillSource = 'opencode' | 'claude';
|
||||
export type SkillSource = 'opencode' | 'claude' | 'agents';
|
||||
|
||||
export interface SupportingFile {
|
||||
name: string;
|
||||
@@ -83,6 +83,7 @@ export interface SkillConfig {
|
||||
description: string;
|
||||
instructions?: string;
|
||||
scope?: SkillScope;
|
||||
source?: SkillSource;
|
||||
supportingFiles?: Array<{ path: string; content: string }>;
|
||||
}
|
||||
|
||||
@@ -94,6 +95,7 @@ export interface PendingFile {
|
||||
export interface SkillDraft {
|
||||
name: string;
|
||||
scope: SkillScope;
|
||||
source?: SkillSource;
|
||||
description: string;
|
||||
instructions?: string;
|
||||
pendingFiles?: PendingFile[];
|
||||
@@ -217,6 +219,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
|
||||
if (config.instructions) skillConfig.instructions = config.instructions;
|
||||
if (config.scope) skillConfig.scope = config.scope;
|
||||
if (config.source) skillConfig.source = config.source;
|
||||
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
|
||||
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as fs from 'fs';
|
||||
import { spawn, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { type OpenCodeManager } from './opencode';
|
||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE, getProviderSources, removeProviderConfig } from './opencodeConfig';
|
||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, type SkillSource, type DiscoveredSkill, SKILL_SCOPE, getProviderSources, removeProviderConfig } from './opencodeConfig';
|
||||
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
||||
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
|
||||
import * as gitService from './gitService';
|
||||
@@ -102,6 +102,132 @@ const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/b
|
||||
|
||||
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
||||
|
||||
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
|
||||
const normalizedCandidate = path.resolve(candidatePath);
|
||||
const normalizedParent = path.resolve(parentPath);
|
||||
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
|
||||
};
|
||||
|
||||
const findWorktreeRootForSkills = (workingDirectory?: string): string | null => {
|
||||
if (!workingDirectory) return null;
|
||||
let current = path.resolve(workingDirectory);
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, '.git'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectAncestors = (workingDirectory?: string): string[] => {
|
||||
if (!workingDirectory) return [];
|
||||
const result: string[] = [];
|
||||
let current = path.resolve(workingDirectory);
|
||||
const stop = findWorktreeRootForSkills(workingDirectory) || current;
|
||||
while (true) {
|
||||
result.push(current);
|
||||
if (current === stop) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory?: string): { scope: SkillScope; source: SkillSource } => {
|
||||
const resolvedPath = path.resolve(location);
|
||||
const source: SkillSource = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`)
|
||||
? 'agents'
|
||||
: resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`)
|
||||
? 'claude'
|
||||
: 'opencode';
|
||||
|
||||
const projectAncestors = getProjectAncestors(workingDirectory);
|
||||
const isProjectScoped = projectAncestors.some((ancestor) => {
|
||||
const candidates = [
|
||||
path.join(ancestor, '.opencode'),
|
||||
path.join(ancestor, '.claude', 'skills'),
|
||||
path.join(ancestor, '.agents', 'skills'),
|
||||
];
|
||||
return candidates.some((candidate) => isPathInside(resolvedPath, candidate));
|
||||
});
|
||||
|
||||
if (isProjectScoped) {
|
||||
return { scope: 'project', source };
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
const userRoots = [
|
||||
path.join(home, '.config', 'opencode'),
|
||||
path.join(home, '.opencode'),
|
||||
path.join(home, '.claude', 'skills'),
|
||||
path.join(home, '.agents', 'skills'),
|
||||
process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
|
||||
if (userRoots.some((root) => isPathInside(resolvedPath, root))) {
|
||||
return { scope: 'user', source };
|
||||
}
|
||||
|
||||
return { scope: 'user', source };
|
||||
};
|
||||
|
||||
const fetchOpenCodeSkillsFromApi = async (ctx: BridgeContext | undefined, workingDirectory?: string): Promise<DiscoveredSkill[] | null> => {
|
||||
const apiUrl = ctx?.manager?.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`;
|
||||
const url = new URL('skill', base);
|
||||
if (workingDirectory) {
|
||||
url.searchParams.set('directory', workingDirectory);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(ctx?.manager?.getOpenCodeAuthHeaders() || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload
|
||||
.map((item) => {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const inferred = inferSkillScopeAndSourceFromLocation(location, workingDirectory);
|
||||
return {
|
||||
name,
|
||||
path: location,
|
||||
scope: inferred.scope,
|
||||
source: inferred.source,
|
||||
description,
|
||||
} as DiscoveredSkill;
|
||||
})
|
||||
.filter((item): item is DiscoveredSkill => item !== null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readSharedSettingsFromDisk = (): Record<string, unknown> => {
|
||||
try {
|
||||
const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8');
|
||||
@@ -1851,7 +1977,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
|
||||
// LIST all skills (no name provided)
|
||||
if (!name && normalizedMethod === 'GET') {
|
||||
const skills = discoverSkills(workingDirectory);
|
||||
const skills = (await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory);
|
||||
return { id, type, success: true, data: { skills } };
|
||||
}
|
||||
|
||||
@@ -1861,7 +1987,9 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'GET') {
|
||||
const sources = getSkillSources(skillName, workingDirectory);
|
||||
const discoveredSkill = ((await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
|
||||
.find((skill) => skill.name === skillName);
|
||||
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
@@ -1872,8 +2000,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const sourceValue = body?.source as string | undefined;
|
||||
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
||||
createSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
||||
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
return {
|
||||
id,
|
||||
@@ -1949,7 +2079,8 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
.filter((v) => v !== null) as SkillsCatalogSourceConfig[])
|
||||
: [];
|
||||
|
||||
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources);
|
||||
const installedSkills = (await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined;
|
||||
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills);
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
@@ -1967,6 +2098,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
source?: string;
|
||||
subpath?: string;
|
||||
scope?: 'user' | 'project';
|
||||
targetSource?: 'opencode' | 'agents';
|
||||
selections?: Array<{ skillDir: string }>;
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
@@ -1978,6 +2110,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
source: String(body.source || ''),
|
||||
subpath: body.subpath,
|
||||
scope: body.scope === 'project' ? 'project' : 'user',
|
||||
targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode',
|
||||
workingDirectory: body.scope === 'project' ? workingDirectory : undefined,
|
||||
selections: Array.isArray(body.selections) ? body.selections : [],
|
||||
conflictPolicy: body.conflictPolicy,
|
||||
@@ -2006,7 +2139,9 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: false, error: 'File path is required' };
|
||||
}
|
||||
|
||||
const sources = getSkillSources(skillName, workingDirectory);
|
||||
const discoveredSkill = ((await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
|
||||
.find((skill) => skill.name === skillName);
|
||||
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
||||
if (!sources.md.dir) {
|
||||
return { id, type, success: false, error: `Skill "${skillName}" not found` };
|
||||
}
|
||||
|
||||
@@ -275,10 +275,93 @@ const readConfigLayers = (workingDirectory?: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for potential future use or debugging
|
||||
const readConfig = (workingDirectory?: string): Record<string, unknown> =>
|
||||
readConfigLayers(workingDirectory).mergedConfig;
|
||||
|
||||
const getAncestors = (startDir?: string, stopDir?: string): string[] => {
|
||||
if (!startDir) return [];
|
||||
const result: string[] = [];
|
||||
let current = path.resolve(startDir);
|
||||
const resolvedStop = stopDir ? path.resolve(stopDir) : null;
|
||||
|
||||
while (true) {
|
||||
result.push(current);
|
||||
if (resolvedStop && current === resolvedStop) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const findWorktreeRoot = (startDir?: string): string | null => {
|
||||
if (!startDir) return null;
|
||||
let current = path.resolve(startDir);
|
||||
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, '.git'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
const walkSkillMdFiles = (rootDir?: string | null): string[] => {
|
||||
if (!rootDir || !fs.existsSync(rootDir)) return [];
|
||||
|
||||
const results: string[] = [];
|
||||
const walkDir = (dir: string) => {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name === 'SKILL.md') {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walkDir(rootDir);
|
||||
return results;
|
||||
};
|
||||
|
||||
const resolveSkillSearchDirectories = (workingDirectory?: string): string[] => {
|
||||
const directories: string[] = [];
|
||||
const pushDir = (dir?: string | null) => {
|
||||
if (!dir) return;
|
||||
const resolved = path.resolve(dir);
|
||||
if (!directories.includes(resolved)) {
|
||||
directories.push(resolved);
|
||||
}
|
||||
};
|
||||
|
||||
pushDir(OPENCODE_CONFIG_DIR);
|
||||
|
||||
if (workingDirectory) {
|
||||
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||
const projectDirs = getAncestors(workingDirectory, worktreeRoot)
|
||||
.map((dir) => path.join(dir, '.opencode'));
|
||||
projectDirs.forEach(pushDir);
|
||||
}
|
||||
|
||||
pushDir(path.join(os.homedir(), '.opencode'));
|
||||
pushDir(process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null);
|
||||
|
||||
return directories;
|
||||
};
|
||||
|
||||
const getConfigForPath = (layers: ReturnType<typeof readConfigLayers>, targetPath?: string | null) => {
|
||||
if (!targetPath) return layers.userConfig;
|
||||
if (layers.paths.customPath && targetPath === layers.paths.customPath) return layers.customConfig;
|
||||
@@ -898,7 +981,7 @@ export const SKILL_SCOPE = {
|
||||
} as const;
|
||||
|
||||
export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE];
|
||||
export type SkillSource = 'opencode' | 'claude';
|
||||
export type SkillSource = 'opencode' | 'claude' | 'agents';
|
||||
|
||||
export type SupportingFile = {
|
||||
name: string;
|
||||
@@ -926,6 +1009,38 @@ export type DiscoveredSkill = {
|
||||
path: string;
|
||||
scope: SkillScope;
|
||||
source: SkillSource;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const addSkillFromMdFile = (
|
||||
skillsMap: Map<string, DiscoveredSkill>,
|
||||
skillMdPath: string,
|
||||
scope: SkillScope,
|
||||
source: SkillSource
|
||||
) => {
|
||||
try {
|
||||
const parsed = parseMdFile(skillMdPath);
|
||||
const name = typeof parsed.frontmatter?.name === 'string'
|
||||
? parsed.frontmatter.name.trim()
|
||||
: '';
|
||||
const description = typeof parsed.frontmatter?.description === 'string'
|
||||
? parsed.frontmatter.description
|
||||
: '';
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
skillsMap.set(name, {
|
||||
name,
|
||||
path: skillMdPath,
|
||||
scope,
|
||||
source,
|
||||
description,
|
||||
});
|
||||
} catch {
|
||||
// Ignore invalid SKILL.md entries.
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSkillDirs = () => {
|
||||
@@ -970,11 +1085,24 @@ const getClaudeSkillPath = (workingDirectory: string, skillName: string): string
|
||||
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||
};
|
||||
|
||||
const getUserAgentsSkillDir = (skillName: string): string => {
|
||||
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
||||
};
|
||||
|
||||
const getProjectAgentsSkillDir = (workingDirectory: string, skillName: string): string => {
|
||||
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
||||
};
|
||||
|
||||
export const getSkillScope = (skillName: string, workingDirectory?: string): {
|
||||
scope: SkillScope | null;
|
||||
path: string | null;
|
||||
source: SkillSource | null;
|
||||
} => {
|
||||
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
if (discovered?.path) {
|
||||
return { scope: discovered.scope, path: discovered.path, source: discovered.source };
|
||||
}
|
||||
|
||||
if (workingDirectory) {
|
||||
// Check .opencode/skill first
|
||||
const projectPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
@@ -1026,86 +1154,100 @@ const listSupportingFiles = (skillDir: string): SupportingFile[] => {
|
||||
|
||||
export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => {
|
||||
const skills = new Map<string, DiscoveredSkill>();
|
||||
|
||||
const addSkill = (name: string, skillPath: string, scope: SkillScope, source: SkillSource) => {
|
||||
if (!skills.has(name)) {
|
||||
skills.set(name, { name, path: skillPath, scope, source });
|
||||
|
||||
// 1) External global (.claude, .agents)
|
||||
for (const externalRootName of ['.claude', '.agents']) {
|
||||
const source: SkillSource = externalRootName === '.agents' ? 'agents' : 'claude';
|
||||
const homeRoot = path.join(os.homedir(), externalRootName, 'skills');
|
||||
for (const skillMdPath of walkSkillMdFiles(homeRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, source);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Project level .opencode/skills/ (highest priority)
|
||||
}
|
||||
|
||||
// 2) External project ancestors (.claude, .agents)
|
||||
if (workingDirectory) {
|
||||
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
|
||||
if (fs.existsSync(projectSkillDir)) {
|
||||
const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(projectSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
|
||||
if (fs.existsSync(legacyProjectSkillDir)) {
|
||||
const entries = fs.readdirSync(legacyProjectSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(legacyProjectSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Claude-compatible .claude/skills/
|
||||
const claudeSkillDir = path.join(workingDirectory, '.claude', 'skills');
|
||||
if (fs.existsSync(claudeSkillDir)) {
|
||||
const entries = fs.readdirSync(claudeSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(claudeSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'claude');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User level ~/.config/opencode/skills/
|
||||
if (fs.existsSync(SKILL_DIR)) {
|
||||
const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(SKILL_DIR, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||
const ancestors = getAncestors(workingDirectory, worktreeRoot);
|
||||
for (const ancestor of ancestors) {
|
||||
for (const externalRootName of ['.claude', '.agents']) {
|
||||
const source: SkillSource = externalRootName === '.agents' ? 'agents' : 'claude';
|
||||
const externalSkillsRoot = path.join(ancestor, externalRootName, 'skills');
|
||||
for (const skillMdPath of walkSkillMdFiles(externalSkillsRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyUserSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill');
|
||||
if (fs.existsSync(legacyUserSkillDir)) {
|
||||
const entries = fs.readdirSync(legacyUserSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(legacyUserSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
}
|
||||
// 3) Config directories: {skill,skills}/**/SKILL.md
|
||||
const configDirectories = resolveSkillSearchDirectories(workingDirectory);
|
||||
const homeOpencodeDir = path.resolve(path.join(os.homedir(), '.opencode'));
|
||||
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||
: null;
|
||||
for (const dir of configDirectories) {
|
||||
for (const subDir of ['skill', 'skills']) {
|
||||
const root = path.join(dir, subDir);
|
||||
for (const skillMdPath of walkSkillMdFiles(root)) {
|
||||
const isUserConfigDir = dir === OPENCODE_CONFIG_DIR
|
||||
|| dir === homeOpencodeDir
|
||||
|| (customConfigDir && dir === customConfigDir);
|
||||
const scope = isUserConfigDir ? SKILL_SCOPE.USER : SKILL_SCOPE.PROJECT;
|
||||
addSkillFromMdFile(skills, skillMdPath, scope, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4) Additional config.skills.paths
|
||||
let configuredPaths: unknown[] = [];
|
||||
try {
|
||||
const config = readConfig(workingDirectory);
|
||||
const skillsConfig = isPlainObject(config.skills) ? config.skills : null;
|
||||
configuredPaths = Array.isArray(skillsConfig?.paths) ? skillsConfig.paths : [];
|
||||
} catch {
|
||||
configuredPaths = [];
|
||||
}
|
||||
for (const skillPath of configuredPaths) {
|
||||
if (typeof skillPath !== 'string' || !skillPath.trim()) continue;
|
||||
const expanded = skillPath.startsWith('~/')
|
||||
? path.join(os.homedir(), skillPath.slice(2))
|
||||
: skillPath;
|
||||
const resolved = path.isAbsolute(expanded)
|
||||
? path.resolve(expanded)
|
||||
: path.resolve(workingDirectory || process.cwd(), expanded);
|
||||
for (const skillMdPath of walkSkillMdFiles(resolved)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Cached skills from config.skills.urls pulls (best-effort, no network)
|
||||
const cacheCandidates: string[] = [];
|
||||
if (process.env.XDG_CACHE_HOME) {
|
||||
cacheCandidates.push(path.join(process.env.XDG_CACHE_HOME, 'opencode', 'skills'));
|
||||
}
|
||||
cacheCandidates.push(path.join(os.homedir(), '.cache', 'opencode', 'skills'));
|
||||
cacheCandidates.push(path.join(os.homedir(), 'Library', 'Caches', 'opencode', 'skills'));
|
||||
|
||||
for (const cacheRoot of cacheCandidates) {
|
||||
if (!fs.existsSync(cacheRoot)) continue;
|
||||
const entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillRoot = path.join(cacheRoot, entry.name);
|
||||
for (const skillMdPath of walkSkillMdFiles(skillRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skills.values());
|
||||
};
|
||||
|
||||
export const getSkillSources = (skillName: string, workingDirectory?: string): SkillConfigSources => {
|
||||
export const getSkillSources = (
|
||||
skillName: string,
|
||||
workingDirectory?: string,
|
||||
discoveredSkill?: DiscoveredSkill | null
|
||||
): SkillConfigSources => {
|
||||
ensureSkillDirs();
|
||||
|
||||
// Check all possible locations
|
||||
@@ -1120,6 +1262,10 @@ export const getSkillSources = (skillName: string, workingDirectory?: string): S
|
||||
const userPath = getUserSkillPath(skillName);
|
||||
const userExists = fs.existsSync(userPath);
|
||||
const userDir = userExists ? getUserSkillDir(skillName) : null;
|
||||
|
||||
const matchedDiscovered = discoveredSkill?.name === skillName
|
||||
? discoveredSkill
|
||||
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
|
||||
// Determine which md file to use (priority: project > claude > user)
|
||||
let mdPath: string | null = null;
|
||||
@@ -1142,6 +1288,11 @@ export const getSkillSources = (skillName: string, workingDirectory?: string): S
|
||||
mdScope = SKILL_SCOPE.USER;
|
||||
mdSource = 'opencode';
|
||||
mdDir = userDir;
|
||||
} else if (matchedDiscovered?.path) {
|
||||
mdPath = matchedDiscovered.path;
|
||||
mdScope = matchedDiscovered.scope;
|
||||
mdSource = matchedDiscovered.source;
|
||||
mdDir = path.dirname(matchedDiscovered.path);
|
||||
}
|
||||
|
||||
const mdExists = !!mdPath;
|
||||
@@ -1226,22 +1377,31 @@ export const createSkill = (skillName: string, config: Record<string, unknown>,
|
||||
// Determine target directory
|
||||
let targetDir: string;
|
||||
|
||||
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||
targetDir = getProjectSkillDir(workingDirectory, skillName);
|
||||
const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER;
|
||||
const requestedSource: SkillSource = config.source === 'agents' ? 'agents' : 'opencode';
|
||||
|
||||
if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||
targetDir = requestedSource === 'agents'
|
||||
? getProjectAgentsSkillDir(workingDirectory, skillName)
|
||||
: getProjectSkillDir(workingDirectory, skillName);
|
||||
} else {
|
||||
targetDir = getUserSkillDir(skillName);
|
||||
targetDir = requestedSource === 'agents'
|
||||
? getUserAgentsSkillDir(skillName)
|
||||
: getUserSkillDir(skillName);
|
||||
}
|
||||
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
const targetPath = path.join(targetDir, 'SKILL.md');
|
||||
|
||||
// Extract fields
|
||||
const { instructions, scope: _ignored, supportingFiles: supportingFilesData, ...frontmatter } = config as Record<string, unknown> & {
|
||||
const { instructions, scope: _ignored, source: _sourceIgnored, supportingFiles: supportingFilesData, ...frontmatter } = config as Record<string, unknown> & {
|
||||
instructions?: unknown;
|
||||
scope?: unknown;
|
||||
source?: unknown;
|
||||
supportingFiles?: Array<{ path: string; content: string }>;
|
||||
};
|
||||
void _ignored;
|
||||
void _sourceIgnored;
|
||||
|
||||
// Ensure required fields
|
||||
if (!frontmatter.name) {
|
||||
@@ -1322,6 +1482,12 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
|
||||
fs.rmSync(claudeDir, { recursive: true, force: true });
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
||||
if (fs.existsSync(projectAgentsDir)) {
|
||||
fs.rmSync(projectAgentsDir, { recursive: true, force: true });
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// User level
|
||||
@@ -1330,6 +1496,12 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
|
||||
fs.rmSync(userDir, { recursive: true, force: true });
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const userAgentsDir = getUserAgentsSkillDir(skillName);
|
||||
if (fs.existsSync(userAgentsDir)) {
|
||||
fs.rmSync(userAgentsDir, { recursive: true, force: true });
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024;
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
type SkillScope = 'user' | 'project';
|
||||
type SkillInstallSource = 'opencode' | 'agents';
|
||||
|
||||
export type SkillsCatalogSourceConfig = {
|
||||
id: string;
|
||||
@@ -56,7 +57,7 @@ export type SkillsCatalogItem = {
|
||||
|
||||
type SkillsCatalogItemWithBadge = SkillsCatalogItem & {
|
||||
sourceId: string;
|
||||
installed: { isInstalled: boolean; scope?: SkillScope };
|
||||
installed: { isInstalled: boolean; scope?: SkillScope; source?: SkillInstallSource };
|
||||
};
|
||||
|
||||
type SkillsRepoError =
|
||||
@@ -65,14 +66,14 @@ type SkillsRepoError =
|
||||
| { kind: 'gitUnavailable'; message: string }
|
||||
| { kind: 'networkError'; message: string }
|
||||
| { kind: 'unknown'; message: string }
|
||||
| { kind: 'conflicts'; message: string; conflicts: Array<{ skillName: string; scope: SkillScope }> };
|
||||
| { kind: 'conflicts'; message: string; conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> };
|
||||
|
||||
type SkillsRepoScanResult =
|
||||
| { ok: true; items: SkillsCatalogItem[] }
|
||||
| { ok: false; error: SkillsRepoError };
|
||||
|
||||
type SkillsInstallResult =
|
||||
| { ok: true; installed: Array<{ skillName: string; scope: SkillScope }>; skipped: Array<{ skillName: string; reason: string }> }
|
||||
| { ok: true; installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }>; skipped: Array<{ skillName: string; reason: string }> }
|
||||
| { ok: false; error: SkillsRepoError };
|
||||
|
||||
export const CURATED_SOURCES: CuratedSource[] = [
|
||||
@@ -251,6 +252,7 @@ async function fetchClawdHubSkillInfo(slug: string): Promise<ClawdHubSkillInfoRe
|
||||
|
||||
export async function installSkillsFromClawdHub(options: {
|
||||
scope: SkillScope;
|
||||
targetSource?: SkillInstallSource;
|
||||
workingDirectory?: string;
|
||||
selections: Array<{ skillDir: string; clawdhub?: { slug: string; version: string } }>;
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
@@ -261,6 +263,7 @@ export async function installSkillsFromClawdHub(options: {
|
||||
}
|
||||
|
||||
const userSkillDir = getUserSkillBaseDir();
|
||||
const targetSource: SkillInstallSource = options.targetSource === 'agents' ? 'agents' : 'opencode';
|
||||
const requestedSkills = options.selections || [];
|
||||
|
||||
if (requestedSkills.length === 0) {
|
||||
@@ -268,20 +271,24 @@ export async function installSkillsFromClawdHub(options: {
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts: Array<{ skillName: string; scope: SkillScope }> = [];
|
||||
const conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
||||
for (const sel of requestedSkills) {
|
||||
const slug = sel.clawdhub?.slug || sel.skillDir;
|
||||
if (!validateSkillName(slug)) continue;
|
||||
|
||||
const targetDir = options.scope === 'user'
|
||||
? path.join(userSkillDir, slug)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug);
|
||||
? (targetSource === 'agents'
|
||||
? path.join(os.homedir(), '.agents', 'skills', slug)
|
||||
: path.join(userSkillDir, slug))
|
||||
: (targetSource === 'agents'
|
||||
? path.join(options.workingDirectory as string, '.agents', 'skills', slug)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug));
|
||||
|
||||
if (fs.existsSync(targetDir)) {
|
||||
const decision = options.conflictDecisions?.[slug];
|
||||
const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll';
|
||||
if (!decision && !hasAutoPolicy) {
|
||||
conflicts.push({ skillName: slug, scope: options.scope });
|
||||
conflicts.push({ skillName: slug, scope: options.scope, source: targetSource });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,7 +297,7 @@ export async function installSkillsFromClawdHub(options: {
|
||||
return { ok: false, error: { kind: 'conflicts', message: 'Some skills already exist in the selected scope', conflicts } };
|
||||
}
|
||||
|
||||
const installed: Array<{ skillName: string; scope: SkillScope }> = [];
|
||||
const installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
||||
const skipped: Array<{ skillName: string; reason: string }> = [];
|
||||
|
||||
for (const sel of requestedSkills) {
|
||||
@@ -322,8 +329,12 @@ export async function installSkillsFromClawdHub(options: {
|
||||
}
|
||||
|
||||
const targetDir = options.scope === 'user'
|
||||
? path.join(userSkillDir, slug)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug);
|
||||
? (targetSource === 'agents'
|
||||
? path.join(os.homedir(), '.agents', 'skills', slug)
|
||||
: path.join(userSkillDir, slug))
|
||||
: (targetSource === 'agents'
|
||||
? path.join(options.workingDirectory as string, '.agents', 'skills', slug)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug));
|
||||
|
||||
const exists = fs.existsSync(targetDir);
|
||||
let decision = options.conflictDecisions?.[slug] || null;
|
||||
@@ -361,7 +372,7 @@ export async function installSkillsFromClawdHub(options: {
|
||||
await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
|
||||
await fs.promises.rename(tempDir, targetDir);
|
||||
|
||||
installed.push({ skillName: slug, scope: options.scope });
|
||||
installed.push({ skillName: slug, scope: options.scope, source: targetSource });
|
||||
} catch (extractError) {
|
||||
await safeRm(tempDir);
|
||||
throw extractError;
|
||||
@@ -705,6 +716,7 @@ export async function installSkillsFromRepository(options: {
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: SkillScope;
|
||||
targetSource?: SkillInstallSource;
|
||||
workingDirectory?: string;
|
||||
selections: Array<{ skillDir: string }>;
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
@@ -730,24 +742,29 @@ export async function installSkillsFromRepository(options: {
|
||||
}
|
||||
|
||||
const userSkillDir = getUserSkillBaseDir();
|
||||
const targetSource: SkillInstallSource = options.targetSource === 'agents' ? 'agents' : 'opencode';
|
||||
|
||||
const skillPlans = requestedDirs.map((dir) => {
|
||||
const skillName = path.posix.basename(dir);
|
||||
return { skillDirPosix: dir, skillName, installable: validateSkillName(skillName) };
|
||||
});
|
||||
|
||||
const conflicts: Array<{ skillName: string; scope: SkillScope }> = [];
|
||||
const conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
||||
for (const plan of skillPlans) {
|
||||
if (!plan.installable) continue;
|
||||
const targetDir = options.scope === 'user'
|
||||
? path.join(userSkillDir, plan.skillName)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName);
|
||||
? (targetSource === 'agents'
|
||||
? path.join(os.homedir(), '.agents', 'skills', plan.skillName)
|
||||
: path.join(userSkillDir, plan.skillName))
|
||||
: (targetSource === 'agents'
|
||||
? path.join(options.workingDirectory as string, '.agents', 'skills', plan.skillName)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName));
|
||||
|
||||
if (fs.existsSync(targetDir)) {
|
||||
const decision = options.conflictDecisions?.[plan.skillName];
|
||||
const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll';
|
||||
if (!decision && !hasAutoPolicy) {
|
||||
conflicts.push({ skillName: plan.skillName, scope: options.scope });
|
||||
conflicts.push({ skillName: plan.skillName, scope: options.scope, source: targetSource });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -778,7 +795,7 @@ export async function installSkillsFromRepository(options: {
|
||||
return { ok: false as const, error: { kind: 'unknown' as const, message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } };
|
||||
}
|
||||
|
||||
const installed: Array<{ skillName: string; scope: SkillScope }> = [];
|
||||
const installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
||||
const skipped: Array<{ skillName: string; reason: string }> = [];
|
||||
|
||||
for (const plan of skillPlans) {
|
||||
@@ -795,8 +812,12 @@ export async function installSkillsFromRepository(options: {
|
||||
}
|
||||
|
||||
const targetDir = options.scope === 'user'
|
||||
? path.join(userSkillDir, plan.skillName)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName);
|
||||
? (targetSource === 'agents'
|
||||
? path.join(os.homedir(), '.agents', 'skills', plan.skillName)
|
||||
: path.join(userSkillDir, plan.skillName))
|
||||
: (targetSource === 'agents'
|
||||
? path.join(options.workingDirectory as string, '.agents', 'skills', plan.skillName)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName));
|
||||
|
||||
const exists = fs.existsSync(targetDir);
|
||||
let decision = options.conflictDecisions?.[plan.skillName] || null;
|
||||
@@ -819,7 +840,7 @@ export async function installSkillsFromRepository(options: {
|
||||
|
||||
try {
|
||||
await copyDirectoryNoSymlinks(srcDir, targetDir);
|
||||
installed.push({ skillName: plan.skillName, scope: options.scope });
|
||||
installed.push({ skillName: plan.skillName, scope: options.scope, source: targetSource });
|
||||
} catch (error) {
|
||||
await safeRm(targetDir);
|
||||
skipped.push({
|
||||
@@ -841,10 +862,11 @@ const CATALOG_TTL_MS = 30 * 60 * 1000;
|
||||
export async function getSkillsCatalog(
|
||||
workingDirectory?: string,
|
||||
refresh?: boolean,
|
||||
additionalSources?: SkillsCatalogSourceConfig[]
|
||||
additionalSources?: SkillsCatalogSourceConfig[],
|
||||
installedSkills?: Array<{ name: string; scope: SkillScope; source?: 'opencode' | 'agents' | 'claude' }>
|
||||
) {
|
||||
const sources = [...CURATED_SOURCES, ...(Array.isArray(additionalSources) ? additionalSources : [])];
|
||||
const discovered = discoverSkills(workingDirectory);
|
||||
const discovered = Array.isArray(installedSkills) ? installedSkills : discoverSkills(workingDirectory);
|
||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||
|
||||
const itemsBySource: Record<string, SkillsCatalogItemWithBadge[]> = {};
|
||||
@@ -877,7 +899,7 @@ export async function getSkillsCatalog(
|
||||
return {
|
||||
sourceId: src.id,
|
||||
...item,
|
||||
installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false },
|
||||
installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false },
|
||||
};
|
||||
});
|
||||
continue;
|
||||
@@ -917,7 +939,7 @@ export async function getSkillsCatalog(
|
||||
return {
|
||||
sourceId: src.id,
|
||||
...item,
|
||||
installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false },
|
||||
installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+151
-11
@@ -6893,6 +6893,133 @@ async function main(options = {}) {
|
||||
SKILL_DIR,
|
||||
} = await import('./lib/opencode-config.js');
|
||||
|
||||
const findWorktreeRootForSkills = (workingDirectory) => {
|
||||
if (!workingDirectory) return null;
|
||||
let current = path.resolve(workingDirectory);
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, '.git'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
const getSkillProjectAncestors = (workingDirectory) => {
|
||||
if (!workingDirectory) return [];
|
||||
const result = [];
|
||||
let current = path.resolve(workingDirectory);
|
||||
const stop = findWorktreeRootForSkills(workingDirectory) || current;
|
||||
while (true) {
|
||||
result.push(current);
|
||||
if (current === stop) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const isPathInside = (candidatePath, parentPath) => {
|
||||
if (!candidatePath || !parentPath) return false;
|
||||
const normalizedCandidate = path.resolve(candidatePath);
|
||||
const normalizedParent = path.resolve(parentPath);
|
||||
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
|
||||
};
|
||||
|
||||
const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => {
|
||||
const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : '';
|
||||
const home = os.homedir();
|
||||
const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`)
|
||||
? 'agents'
|
||||
: resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`)
|
||||
? 'claude'
|
||||
: 'opencode';
|
||||
|
||||
const projectAncestors = getSkillProjectAncestors(workingDirectory);
|
||||
const isProjectScoped = projectAncestors.some((ancestor) => {
|
||||
const candidates = [
|
||||
path.join(ancestor, '.opencode'),
|
||||
path.join(ancestor, '.claude', 'skills'),
|
||||
path.join(ancestor, '.agents', 'skills'),
|
||||
];
|
||||
return candidates.some((candidate) => isPathInside(resolvedPath, candidate));
|
||||
});
|
||||
|
||||
if (isProjectScoped) {
|
||||
return { scope: SKILL_SCOPE.PROJECT, source };
|
||||
}
|
||||
|
||||
const userRoots = [
|
||||
path.join(home, '.config', 'opencode'),
|
||||
path.join(home, '.opencode'),
|
||||
path.join(home, '.claude', 'skills'),
|
||||
path.join(home, '.agents', 'skills'),
|
||||
process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (userRoots.some((root) => isPathInside(resolvedPath, root))) {
|
||||
return { scope: SKILL_SCOPE.USER, source };
|
||||
}
|
||||
|
||||
return { scope: SKILL_SCOPE.USER, source };
|
||||
};
|
||||
|
||||
const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
|
||||
if (!openCodePort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload
|
||||
.map((item) => {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
||||
return {
|
||||
name,
|
||||
path: location,
|
||||
scope: inferred.scope,
|
||||
source: inferred.source,
|
||||
description,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// List all discovered skills
|
||||
app.get('/api/config/skills', async (req, res) => {
|
||||
try {
|
||||
@@ -6900,11 +7027,11 @@ async function main(options = {}) {
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const skills = discoverSkills(directory);
|
||||
const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory);
|
||||
|
||||
// Enrich with full sources info
|
||||
const enrichedSkills = skills.map(skill => {
|
||||
const sources = getSkillSources(skill.name, directory);
|
||||
const sources = getSkillSources(skill.name, directory, skill);
|
||||
return {
|
||||
...skill,
|
||||
sources
|
||||
@@ -7018,7 +7145,9 @@ async function main(options = {}) {
|
||||
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
|
||||
}
|
||||
|
||||
const discovered = directory ? discoverSkills(directory) : [];
|
||||
const discovered = directory
|
||||
? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory))
|
||||
: [];
|
||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||
|
||||
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
||||
@@ -7033,7 +7162,7 @@ async function main(options = {}) {
|
||||
...item,
|
||||
sourceId: src.id,
|
||||
installed: installed
|
||||
? { isInstalled: true, scope: installed.scope }
|
||||
? { isInstalled: true, scope: installed.scope, source: installed.source }
|
||||
: { isInstalled: false },
|
||||
};
|
||||
});
|
||||
@@ -7077,7 +7206,7 @@ async function main(options = {}) {
|
||||
...item,
|
||||
gitIdentityId: src.gitIdentityId,
|
||||
installed: installed
|
||||
? { isInstalled: true, scope: installed.scope }
|
||||
? { isInstalled: true, scope: installed.scope, source: installed.source }
|
||||
: { isInstalled: false },
|
||||
};
|
||||
});
|
||||
@@ -7131,6 +7260,7 @@ async function main(options = {}) {
|
||||
subpath,
|
||||
gitIdentityId,
|
||||
scope,
|
||||
targetSource,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
@@ -7152,6 +7282,7 @@ async function main(options = {}) {
|
||||
if (isClawdHubSource(source)) {
|
||||
const result = await installSkillsFromClawdHub({
|
||||
scope,
|
||||
targetSource,
|
||||
workingDirectory,
|
||||
userSkillDir: SKILL_DIR,
|
||||
selections,
|
||||
@@ -7177,6 +7308,7 @@ async function main(options = {}) {
|
||||
subpath,
|
||||
identity,
|
||||
scope,
|
||||
targetSource,
|
||||
workingDirectory,
|
||||
userSkillDir: SKILL_DIR,
|
||||
selections,
|
||||
@@ -7217,7 +7349,9 @@ async function main(options = {}) {
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const sources = getSkillSources(skillName, directory);
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
|
||||
res.json({
|
||||
name: skillName,
|
||||
@@ -7242,7 +7376,9 @@ async function main(options = {}) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const sources = getSkillSources(skillName, 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) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
@@ -7263,7 +7399,7 @@ async function main(options = {}) {
|
||||
app.post('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { scope, ...config } = req.body;
|
||||
const { scope, source: skillSource, ...config } = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
@@ -7272,7 +7408,7 @@ async function main(options = {}) {
|
||||
console.log('[Server] Creating skill:', skillName);
|
||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||
|
||||
createSkill(skillName, config, directory, scope);
|
||||
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
|
||||
res.json({
|
||||
@@ -7324,7 +7460,9 @@ async function main(options = {}) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const sources = getSkillSources(skillName, 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) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
@@ -7351,7 +7489,9 @@ async function main(options = {}) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const sources = getSkillSources(skillName, 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) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
|
||||
@@ -330,11 +330,32 @@ function getClaudeSkillPath(workingDirectory, skillName) {
|
||||
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
function getUserAgentsSkillDir(skillName) {
|
||||
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
||||
}
|
||||
|
||||
function getUserAgentsSkillPath(skillName) {
|
||||
return path.join(getUserAgentsSkillDir(skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
function getProjectAgentsSkillDir(workingDirectory, skillName) {
|
||||
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
||||
}
|
||||
|
||||
function getProjectAgentsSkillPath(workingDirectory, skillName) {
|
||||
return path.join(getProjectAgentsSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine skill scope based on where the SKILL.md file exists
|
||||
* Priority: project level (.opencode) > user level > claude-compat (.claude/skills)
|
||||
*/
|
||||
function getSkillScope(skillName, workingDirectory) {
|
||||
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
if (discovered?.path) {
|
||||
return { scope: discovered.scope || null, path: discovered.path, source: discovered.source || null };
|
||||
}
|
||||
|
||||
if (workingDirectory) {
|
||||
// Check .opencode/skill first
|
||||
const projectPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
@@ -689,6 +710,129 @@ function readConfig(workingDirectory) {
|
||||
return readConfigLayers(workingDirectory).mergedConfig;
|
||||
}
|
||||
|
||||
function getAncestors(startDir, stopDir) {
|
||||
if (!startDir) return [];
|
||||
const result = [];
|
||||
let current = path.resolve(startDir);
|
||||
const resolvedStop = stopDir ? path.resolve(stopDir) : null;
|
||||
|
||||
while (true) {
|
||||
result.push(current);
|
||||
if (resolvedStop && current === resolvedStop) {
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findWorktreeRoot(startDir) {
|
||||
if (!startDir) return null;
|
||||
let current = path.resolve(startDir);
|
||||
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, '.git'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function walkSkillMdFiles(rootDir) {
|
||||
if (!rootDir || !fs.existsSync(rootDir)) return [];
|
||||
|
||||
const results = [];
|
||||
const walk = (dir) => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name === 'SKILL.md') {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootDir);
|
||||
return results;
|
||||
}
|
||||
|
||||
function addSkillFromMdFile(skillsMap, skillMdPath, scope, source) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseMdFile(skillMdPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = typeof parsed.frontmatter?.name === 'string'
|
||||
? parsed.frontmatter.name.trim()
|
||||
: '';
|
||||
const description = typeof parsed.frontmatter?.description === 'string'
|
||||
? parsed.frontmatter.description
|
||||
: '';
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
skillsMap.set(name, {
|
||||
name,
|
||||
path: skillMdPath,
|
||||
scope,
|
||||
source,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSkillSearchDirectories(workingDirectory) {
|
||||
const directories = [];
|
||||
const pushDir = (dir) => {
|
||||
if (!dir) return;
|
||||
const resolved = path.resolve(dir);
|
||||
if (!directories.includes(resolved)) {
|
||||
directories.push(resolved);
|
||||
}
|
||||
};
|
||||
|
||||
// Equivalent to Opencode Config.directories order.
|
||||
pushDir(OPENCODE_CONFIG_DIR);
|
||||
|
||||
if (workingDirectory) {
|
||||
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||
const projectDirs = getAncestors(workingDirectory, worktreeRoot)
|
||||
.map((dir) => path.join(dir, '.opencode'));
|
||||
projectDirs.forEach(pushDir);
|
||||
}
|
||||
|
||||
pushDir(path.join(os.homedir(), '.opencode'));
|
||||
|
||||
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||
: null;
|
||||
pushDir(customConfigDir);
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
function getConfigForPath(layers, targetPath) {
|
||||
if (!targetPath) {
|
||||
return layers.userConfig;
|
||||
@@ -1491,87 +1635,95 @@ function deleteCommand(commandName, workingDirectory) {
|
||||
*/
|
||||
function discoverSkills(workingDirectory) {
|
||||
const skills = new Map();
|
||||
|
||||
// Helper to add skill if not already found (first found wins by priority)
|
||||
const addSkill = (name, skillPath, scope, source) => {
|
||||
if (!skills.has(name)) {
|
||||
skills.set(name, { name, path: skillPath, scope, source });
|
||||
|
||||
// 1) External global (.claude, .agents)
|
||||
for (const externalRootName of ['.claude', '.agents']) {
|
||||
const homeRoot = path.join(os.homedir(), externalRootName, 'skills');
|
||||
const source = externalRootName === '.agents' ? 'agents' : 'claude';
|
||||
for (const skillMdPath of walkSkillMdFiles(homeRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, source);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Project level .opencode/skills/ (highest priority)
|
||||
}
|
||||
|
||||
// 2) External project ancestors (.claude, .agents)
|
||||
if (workingDirectory) {
|
||||
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
|
||||
if (fs.existsSync(projectSkillDir)) {
|
||||
const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(projectSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
|
||||
if (fs.existsSync(legacyProjectSkillDir)) {
|
||||
const entries = fs.readdirSync(legacyProjectSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(legacyProjectSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Claude-compatible .claude/skills/
|
||||
const claudeSkillDir = path.join(workingDirectory, '.claude', 'skills');
|
||||
if (fs.existsSync(claudeSkillDir)) {
|
||||
const entries = fs.readdirSync(claudeSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(claudeSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'claude');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User level ~/.config/opencode/skills/
|
||||
if (fs.existsSync(SKILL_DIR)) {
|
||||
const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(SKILL_DIR, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||
const ancestors = getAncestors(workingDirectory, worktreeRoot);
|
||||
for (const ancestor of ancestors) {
|
||||
for (const externalRootName of ['.claude', '.agents']) {
|
||||
const source = externalRootName === '.agents' ? 'agents' : 'claude';
|
||||
const externalSkillsRoot = path.join(ancestor, externalRootName, 'skills');
|
||||
for (const skillMdPath of walkSkillMdFiles(externalSkillsRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyUserSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill');
|
||||
if (fs.existsSync(legacyUserSkillDir)) {
|
||||
const entries = fs.readdirSync(legacyUserSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(legacyUserSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
}
|
||||
// 3) Config directories: {skill,skills}/**/SKILL.md
|
||||
const configDirectories = resolveSkillSearchDirectories(workingDirectory);
|
||||
const homeOpencodeDir = path.resolve(path.join(os.homedir(), '.opencode'));
|
||||
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||
: null;
|
||||
for (const dir of configDirectories) {
|
||||
for (const subDir of ['skill', 'skills']) {
|
||||
const root = path.join(dir, subDir);
|
||||
for (const skillMdPath of walkSkillMdFiles(root)) {
|
||||
const isUserConfigDir = dir === OPENCODE_CONFIG_DIR
|
||||
|| dir === homeOpencodeDir
|
||||
|| (customConfigDir && dir === customConfigDir);
|
||||
const scope = isUserConfigDir ? SKILL_SCOPE.USER : SKILL_SCOPE.PROJECT;
|
||||
addSkillFromMdFile(skills, skillMdPath, scope, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4) Additional config.skills.paths
|
||||
let configuredPaths = [];
|
||||
try {
|
||||
const config = readConfig(workingDirectory);
|
||||
configuredPaths = Array.isArray(config?.skills?.paths) ? config.skills.paths : [];
|
||||
} catch {
|
||||
configuredPaths = [];
|
||||
}
|
||||
for (const skillPath of configuredPaths) {
|
||||
if (typeof skillPath !== 'string' || !skillPath.trim()) continue;
|
||||
const expanded = skillPath.startsWith('~/')
|
||||
? path.join(os.homedir(), skillPath.slice(2))
|
||||
: skillPath;
|
||||
const resolved = path.isAbsolute(expanded)
|
||||
? path.resolve(expanded)
|
||||
: path.resolve(workingDirectory || process.cwd(), expanded);
|
||||
for (const skillMdPath of walkSkillMdFiles(resolved)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Cached skills from config.skills.urls pulls (best-effort, no network)
|
||||
const cacheCandidates = [];
|
||||
if (process.env.XDG_CACHE_HOME) {
|
||||
cacheCandidates.push(path.join(process.env.XDG_CACHE_HOME, 'opencode', 'skills'));
|
||||
}
|
||||
cacheCandidates.push(path.join(os.homedir(), '.cache', 'opencode', 'skills'));
|
||||
cacheCandidates.push(path.join(os.homedir(), 'Library', 'Caches', 'opencode', 'skills'));
|
||||
|
||||
for (const cacheRoot of cacheCandidates) {
|
||||
if (!fs.existsSync(cacheRoot)) continue;
|
||||
const entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillRoot = path.join(cacheRoot, entry.name);
|
||||
for (const skillMdPath of walkSkillMdFiles(skillRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skills.values());
|
||||
}
|
||||
|
||||
function getSkillSources(skillName, workingDirectory) {
|
||||
function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||
// Check all possible locations
|
||||
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
||||
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||
@@ -1584,6 +1736,10 @@ function getSkillSources(skillName, workingDirectory) {
|
||||
const userPath = getUserSkillPath(skillName);
|
||||
const userExists = fs.existsSync(userPath);
|
||||
const userDir = userExists ? path.dirname(userPath) : null;
|
||||
|
||||
const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName
|
||||
? discoveredSkill
|
||||
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
|
||||
// Determine which md file to use (priority: project > claude > user)
|
||||
let mdPath = null;
|
||||
@@ -1606,6 +1762,11 @@ function getSkillSources(skillName, workingDirectory) {
|
||||
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);
|
||||
}
|
||||
|
||||
const mdExists = !!mdPath;
|
||||
@@ -1675,14 +1836,27 @@ function createSkill(skillName, config, workingDirectory, scope) {
|
||||
let targetPath;
|
||||
let targetScope;
|
||||
|
||||
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||
const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER;
|
||||
const requestedSource = config?.source === 'agents' ? 'agents' : 'opencode';
|
||||
|
||||
if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||
ensureProjectSkillDir(workingDirectory);
|
||||
targetDir = getProjectSkillDir(workingDirectory, skillName);
|
||||
targetPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
if (requestedSource === 'agents') {
|
||||
targetDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
||||
targetPath = getProjectAgentsSkillPath(workingDirectory, skillName);
|
||||
} else {
|
||||
targetDir = getProjectSkillDir(workingDirectory, skillName);
|
||||
targetPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
}
|
||||
targetScope = SKILL_SCOPE.PROJECT;
|
||||
} else {
|
||||
targetDir = getUserSkillDir(skillName);
|
||||
targetPath = getUserSkillPath(skillName);
|
||||
if (requestedSource === 'agents') {
|
||||
targetDir = getUserAgentsSkillDir(skillName);
|
||||
targetPath = getUserAgentsSkillPath(skillName);
|
||||
} else {
|
||||
targetDir = getUserSkillDir(skillName);
|
||||
targetPath = getUserSkillPath(skillName);
|
||||
}
|
||||
targetScope = SKILL_SCOPE.USER;
|
||||
}
|
||||
|
||||
@@ -1690,7 +1864,9 @@ function createSkill(skillName, config, workingDirectory, scope) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Extract fields - scope is only for path determination
|
||||
const { instructions, scope: _scopeFromConfig, supportingFiles, ...frontmatter } = config;
|
||||
const { instructions, scope: _scopeFromConfig, source: _sourceFromConfig, supportingFiles, ...frontmatter } = config;
|
||||
void _scopeFromConfig;
|
||||
void _sourceFromConfig;
|
||||
|
||||
// Ensure required fields
|
||||
if (!frontmatter.name) {
|
||||
@@ -1789,6 +1965,13 @@ function deleteSkill(skillName, workingDirectory) {
|
||||
console.log(`Deleted claude-compat skill directory: ${claudeDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
||||
if (fs.existsSync(projectAgentsDir)) {
|
||||
fs.rmSync(projectAgentsDir, { recursive: true, force: true });
|
||||
console.log(`Deleted project-level agents skill directory: ${projectAgentsDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// User level
|
||||
@@ -1799,6 +1982,13 @@ function deleteSkill(skillName, workingDirectory) {
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const userAgentsDir = getUserAgentsSkillDir(skillName);
|
||||
if (fs.existsSync(userAgentsDir)) {
|
||||
fs.rmSync(userAgentsDir, { recursive: true, force: true });
|
||||
console.log(`Deleted user-level agents skill directory: ${userAgentsDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
}
|
||||
|
||||
@@ -43,8 +43,13 @@ async function ensureDir(dirPath) {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) {
|
||||
function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
|
||||
const source = targetSource === 'agents' ? 'agents' : 'opencode';
|
||||
|
||||
if (scope === 'user') {
|
||||
if (source === 'agents') {
|
||||
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
||||
}
|
||||
return path.join(userSkillDir, skillName);
|
||||
}
|
||||
|
||||
@@ -52,6 +57,10 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
|
||||
throw new Error('workingDirectory is required for project installs');
|
||||
}
|
||||
|
||||
if (source === 'agents') {
|
||||
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
||||
}
|
||||
|
||||
return path.join(workingDirectory, '.opencode', 'skills', skillName);
|
||||
}
|
||||
|
||||
@@ -59,6 +68,7 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
|
||||
* Install skills from ClawdHub registry
|
||||
* @param {Object} options
|
||||
* @param {string} options.scope - 'user' or 'project'
|
||||
* @param {string} [options.targetSource] - 'opencode' or 'agents'
|
||||
* @param {string} [options.workingDirectory] - Required for project scope
|
||||
* @param {string} options.userSkillDir - User skills directory
|
||||
* @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } }
|
||||
@@ -68,6 +78,7 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
|
||||
*/
|
||||
export async function installSkillsFromClawdHub({
|
||||
scope,
|
||||
targetSource,
|
||||
workingDirectory,
|
||||
userSkillDir,
|
||||
selections,
|
||||
@@ -78,6 +89,10 @@ export async function installSkillsFromClawdHub({
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
|
||||
}
|
||||
|
||||
if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
|
||||
}
|
||||
|
||||
if (!userSkillDir) {
|
||||
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
|
||||
}
|
||||
@@ -114,12 +129,12 @@ export async function installSkillsFromClawdHub({
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
|
||||
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug });
|
||||
if (fs.existsSync(targetDir)) {
|
||||
const decision = conflictDecisions?.[plan.slug];
|
||||
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
|
||||
if (!decision && !hasAutoPolicy) {
|
||||
conflicts.push({ skillName: plan.slug, scope });
|
||||
conflicts.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +179,7 @@ export async function installSkillsFromClawdHub({
|
||||
}
|
||||
}
|
||||
|
||||
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
|
||||
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug });
|
||||
const exists = fs.existsSync(targetDir);
|
||||
|
||||
// Determine conflict resolution
|
||||
@@ -205,7 +220,7 @@ export async function installSkillsFromClawdHub({
|
||||
await ensureDir(path.dirname(targetDir));
|
||||
await fs.promises.rename(tempDir, targetDir);
|
||||
|
||||
installed.push({ skillName: plan.slug, scope });
|
||||
installed.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
|
||||
} catch (extractError) {
|
||||
await safeRm(tempDir);
|
||||
throw extractError;
|
||||
|
||||
@@ -105,8 +105,13 @@ async function cloneRepo({ cloneUrl, identity, tempDir }) {
|
||||
};
|
||||
}
|
||||
|
||||
function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) {
|
||||
function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
|
||||
const source = targetSource === 'agents' ? 'agents' : 'opencode';
|
||||
|
||||
if (scope === 'user') {
|
||||
if (source === 'agents') {
|
||||
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
||||
}
|
||||
return path.join(userSkillDir, skillName);
|
||||
}
|
||||
|
||||
@@ -114,6 +119,10 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
|
||||
throw new Error('workingDirectory is required for project installs');
|
||||
}
|
||||
|
||||
if (source === 'agents') {
|
||||
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
||||
}
|
||||
|
||||
return path.join(workingDirectory, '.opencode', 'skills', skillName);
|
||||
}
|
||||
|
||||
@@ -123,6 +132,7 @@ export async function installSkillsFromRepository({
|
||||
defaultSubpath,
|
||||
identity,
|
||||
scope,
|
||||
targetSource,
|
||||
workingDirectory,
|
||||
userSkillDir,
|
||||
selections,
|
||||
@@ -147,6 +157,10 @@ export async function installSkillsFromRepository({
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
|
||||
}
|
||||
|
||||
if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
|
||||
}
|
||||
|
||||
if (scope === 'project' && !workingDirectory) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
|
||||
}
|
||||
@@ -178,12 +192,12 @@ export async function installSkillsFromRepository({
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.skillName });
|
||||
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
|
||||
if (fs.existsSync(targetDir)) {
|
||||
const decision = conflictDecisions?.[plan.skillName];
|
||||
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
|
||||
if (!decision && !hasAutoPolicy) {
|
||||
conflicts.push({ skillName: plan.skillName, scope });
|
||||
conflicts.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,7 +253,7 @@ export async function installSkillsFromRepository({
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.skillName });
|
||||
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
|
||||
const exists = fs.existsSync(targetDir);
|
||||
|
||||
let decision = conflictDecisions?.[plan.skillName] || null;
|
||||
@@ -263,7 +277,7 @@ export async function installSkillsFromRepository({
|
||||
|
||||
try {
|
||||
await copyDirectoryNoSymlinks(srcDir, targetDir);
|
||||
installed.push({ skillName: plan.skillName, scope });
|
||||
installed.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
|
||||
} catch (error) {
|
||||
await safeRm(targetDir);
|
||||
skipped.push({
|
||||
|
||||
Reference in New Issue
Block a user