feat(skills-catalog): implement caching, curated sources, git operations, and skill installation
This commit is contained in:
@@ -191,6 +191,48 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
result_obj.insert("typographySizes".to_string(), sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
// Skill catalogs (array of objects)
|
||||
if let Some(Value::Array(arr)) = obj.get("skillCatalogs") {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut catalogs: Vec<Value> = vec![];
|
||||
|
||||
for entry in arr {
|
||||
let Some(obj) = entry.as_object() else { continue };
|
||||
|
||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
|
||||
if id.is_empty() || label.is_empty() || source.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if seen.contains(id) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(id.to_string());
|
||||
|
||||
let mut catalog = serde_json::Map::new();
|
||||
catalog.insert("id".to_string(), json!(id));
|
||||
catalog.insert("label".to_string(), json!(label));
|
||||
catalog.insert("source".to_string(), json!(source));
|
||||
if !subpath.is_empty() {
|
||||
catalog.insert("subpath".to_string(), json!(subpath));
|
||||
}
|
||||
if !git_identity_id.is_empty() {
|
||||
catalog.insert("gitIdentityId".to_string(), json!(git_identity_id));
|
||||
}
|
||||
|
||||
catalogs.push(Value::Object(catalog));
|
||||
}
|
||||
|
||||
if !catalogs.is_empty() {
|
||||
result_obj.insert("skillCatalogs".to_string(), Value::Array(catalogs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
|
||||
@@ -9,6 +9,7 @@ mod opencode_config;
|
||||
mod opencode_manager;
|
||||
mod window_state;
|
||||
mod path_utils;
|
||||
mod skills_catalog;
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}};
|
||||
|
||||
@@ -1797,6 +1798,102 @@ async fn handle_config_routes(
|
||||
return handle_command_route(&state, method, req, trimmed.to_string()).await;
|
||||
}
|
||||
|
||||
// Skills catalog routes (must be checked before /api/config/skills/:name)
|
||||
if path == "/api/config/skills/catalog" && method == Method::GET {
|
||||
let refresh = req
|
||||
.uri()
|
||||
.query()
|
||||
.map(|q| q.contains("refresh=true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
let payload = skills_catalog::get_catalog(&working_directory, refresh).await;
|
||||
return Ok(json_response(StatusCode::OK, payload));
|
||||
}
|
||||
|
||||
if path == "/api/config/skills/scan" && method == Method::POST {
|
||||
let payload_map = match parse_request_payload(req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
|
||||
let scan_request = match serde_json::from_value::<skills_catalog::SkillsScanRequest>(payload_value) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return Ok(json_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
skills_catalog::SkillsRepoScanResponse {
|
||||
ok: false,
|
||||
items: None,
|
||||
error: Some(skills_catalog::SkillsRepoError {
|
||||
kind: "invalidSource".to_string(),
|
||||
message: "Malformed scan request".to_string(),
|
||||
ssh_only: None,
|
||||
identities: None,
|
||||
conflicts: None,
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let response = skills_catalog::scan_repository(scan_request).await;
|
||||
let status = if response.ok {
|
||||
StatusCode::OK
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") {
|
||||
StatusCode::UNAUTHORIZED
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
|
||||
return Ok(json_response(status, response));
|
||||
}
|
||||
|
||||
if path == "/api/config/skills/install" && method == Method::POST {
|
||||
let payload_map = match parse_request_payload(req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
|
||||
let install_request = match serde_json::from_value::<skills_catalog::SkillsInstallRequest>(payload_value) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return Ok(json_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
skills_catalog::SkillsInstallResponse {
|
||||
ok: false,
|
||||
installed: None,
|
||||
skipped: None,
|
||||
error: Some(skills_catalog::SkillsRepoError {
|
||||
kind: "invalidSource".to_string(),
|
||||
message: "Malformed install request".to_string(),
|
||||
ssh_only: None,
|
||||
identities: None,
|
||||
conflicts: None,
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
let response = skills_catalog::install_skills(&working_directory, install_request).await;
|
||||
|
||||
let status = if response.ok {
|
||||
StatusCode::OK
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") {
|
||||
StatusCode::CONFLICT
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") {
|
||||
StatusCode::UNAUTHORIZED
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
|
||||
return Ok(json_response(status, response));
|
||||
}
|
||||
|
||||
// Handle skill routes: /api/config/skills and /api/config/skills/:name
|
||||
if path == "/api/config/skills" && method == Method::GET {
|
||||
return handle_skill_list_route(&state).await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
// Set draft and open the page for editing
|
||||
setAgentDraft({ name: newName, scope: 'user' });
|
||||
setSelectedAgent(newName);
|
||||
onItemSelect?.();
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
|
||||
@@ -81,6 +81,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
// Set draft and open the page for editing
|
||||
setCommandDraft({ name: newName, scope: 'user' });
|
||||
setSelectedCommand(newName);
|
||||
onItemSelect?.();
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||
|
||||
export const SkillsPage: React.FC = () => {
|
||||
const {
|
||||
@@ -37,6 +39,37 @@ export const SkillsPage: React.FC = () => {
|
||||
|
||||
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
|
||||
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
|
||||
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
|
||||
|
||||
type SkillsMode = 'manual' | 'external';
|
||||
const [mode, setMode] = React.useState<SkillsMode>('manual');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isNewSkill && mode !== 'manual') {
|
||||
setMode('manual');
|
||||
}
|
||||
}, [isNewSkill, mode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasStaleSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear persisted selection if it points to a non-existent skill.
|
||||
setSelectedSkill(null);
|
||||
}, [hasStaleSelection, setSelectedSkill]);
|
||||
|
||||
const modeTabs = isNewSkill ? (
|
||||
<AnimatedTabs
|
||||
tabs={[
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'external', label: 'External' },
|
||||
]}
|
||||
value={mode}
|
||||
onValueChange={setMode}
|
||||
animate={false}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const [draftName, setDraftName] = React.useState('');
|
||||
const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
|
||||
@@ -71,6 +104,10 @@ export const SkillsPage: React.FC = () => {
|
||||
|
||||
// Load skill details when selection changes
|
||||
React.useEffect(() => {
|
||||
if (mode === 'external') {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadSkillDetails = async () => {
|
||||
if (isNewSkill && skillDraft) {
|
||||
// Prefill from draft (for new or duplicated skills)
|
||||
@@ -104,7 +141,7 @@ export const SkillsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
loadSkillDetails();
|
||||
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
|
||||
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail, mode]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
|
||||
@@ -297,8 +334,13 @@ export const SkillsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Show empty state only when nothing is selected AND no draft
|
||||
if (!selectedSkillName && !skillDraft) {
|
||||
if (isNewSkill && mode === 'external') {
|
||||
return <SkillsCatalogPage mode={mode} onModeChange={setMode} />;
|
||||
}
|
||||
|
||||
|
||||
// Show empty state when nothing is selected or selection is stale
|
||||
if ((!selectedSkillName && !skillDraft) || hasStaleSelection) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
@@ -322,6 +364,8 @@ export const SkillsPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{isNewSkill ? modeTabs : null}
|
||||
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-lg">
|
||||
|
||||
@@ -82,6 +82,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
// Set draft and open the page for editing
|
||||
setSkillDraft({ name: newName, scope: 'user', description: '' });
|
||||
setSelectedSkill(newName);
|
||||
onItemSelect?.();
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
import { RiGitRepositoryLine } from '@remixicon/react';
|
||||
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
|
||||
const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const guessLabelFromSource = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
const ssh = trimmed.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i);
|
||||
if (ssh) {
|
||||
return `${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`;
|
||||
}
|
||||
const https = trimmed.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i);
|
||||
if (https) {
|
||||
return `${https[1]}/${https[2].replace(/\.git$/i, '')}`;
|
||||
}
|
||||
const shorthand = trimmed.match(/^([^/\s]+)\/([^/\s]+)(?:\/.+)?$/);
|
||||
if (shorthand) {
|
||||
return `${shorthand[1]}/${shorthand[2].replace(/\.git$/i, '')}`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
type IdentityOption = { id: string; name: string };
|
||||
|
||||
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
try {
|
||||
if (isDesktopRuntime()) {
|
||||
return await getDesktopSettings();
|
||||
}
|
||||
|
||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||
if (runtimeSettings) {
|
||||
const result = await runtimeSettings.load();
|
||||
return (result?.settings || {}) as DesktopSettings;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json().catch(() => null)) as DesktopSettings | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface AddCatalogDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
|
||||
|
||||
const [label, setLabel] = React.useState('');
|
||||
const [source, setSource] = React.useState('');
|
||||
const [subpath, setSubpath] = React.useState('');
|
||||
|
||||
const [existingCatalogs, setExistingCatalogs] = React.useState<SkillCatalogConfig[]>([]);
|
||||
|
||||
const [scanCount, setScanCount] = React.useState<number | null>(null);
|
||||
const [scanOk, setScanOk] = React.useState(false);
|
||||
|
||||
const [identityOptions, setIdentityOptions] = React.useState<IdentityOption[]>([]);
|
||||
const [gitIdentityId, setGitIdentityId] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setLabel('');
|
||||
setSource('');
|
||||
setSubpath('');
|
||||
setScanCount(null);
|
||||
setScanOk(false);
|
||||
setIdentityOptions([]);
|
||||
setGitIdentityId(null);
|
||||
|
||||
void (async () => {
|
||||
const settings = await loadSettings();
|
||||
const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : [];
|
||||
setExistingCatalogs(catalogs || []);
|
||||
})();
|
||||
}, [open]);
|
||||
|
||||
const isDuplicate = React.useMemo(() => {
|
||||
const normalizedSource = source.trim();
|
||||
const normalizedSubpath = subpath.trim();
|
||||
|
||||
return existingCatalogs.some((c) => {
|
||||
const s = (c.source || '').trim();
|
||||
const sp = (c.subpath || '').trim();
|
||||
return s === normalizedSource && sp === normalizedSubpath;
|
||||
});
|
||||
}, [existingCatalogs, source, subpath]);
|
||||
|
||||
const handleScan = async () => {
|
||||
const trimmedSource = source.trim();
|
||||
if (!trimmedSource) {
|
||||
toast.error('Repository source is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!label.trim()) {
|
||||
setLabel(guessLabelFromSource(trimmedSource));
|
||||
}
|
||||
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
|
||||
const result = await scanRepo({
|
||||
source: trimmedSource,
|
||||
subpath: subpath.trim() || undefined,
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
setIdentityOptions(ids);
|
||||
if (!gitIdentityId && ids.length > 0) {
|
||||
setGitIdentityId(ids[0].id);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and scan again.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to scan repository');
|
||||
return;
|
||||
}
|
||||
|
||||
const count = result.items?.length || 0;
|
||||
setScanCount(count);
|
||||
if (count === 0) {
|
||||
toast.error('No skills found in this repository');
|
||||
setScanOk(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIdentityOptions([]);
|
||||
setScanOk(true);
|
||||
toast.success(`Found ${count} skill(s)`);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
const trimmedLabel = label.trim();
|
||||
const trimmedSource = source.trim();
|
||||
const trimmedSubpath = subpath.trim();
|
||||
|
||||
if (!trimmedLabel) {
|
||||
toast.error('Catalog name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedSource) {
|
||||
toast.error('Repository source is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scanOk) {
|
||||
toast.error('Scan the repository before adding this catalog');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
toast.error('This catalog already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
const next: SkillCatalogConfig = {
|
||||
id: generateCatalogId(),
|
||||
label: trimmedLabel,
|
||||
source: trimmedSource,
|
||||
...(trimmedSubpath ? { subpath: trimmedSubpath } : {}),
|
||||
...(gitIdentityId ? { gitIdentityId } : {}),
|
||||
};
|
||||
|
||||
const updated = [...existingCatalogs, next];
|
||||
|
||||
try {
|
||||
await updateDesktopSettings({ skillCatalogs: updated });
|
||||
setExistingCatalogs(updated);
|
||||
toast.success('Catalog added');
|
||||
await loadCatalog({ refresh: true });
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save catalog');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add skills catalog</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a Git repository as a new catalog source. OpenChamber will scan it for folders containing <code className="font-mono">SKILL.md</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Catalog name</label>
|
||||
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Repository</label>
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
}}
|
||||
placeholder="owner/repo or git@github.com:owner/repo.git"
|
||||
/>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Public repos work everywhere. Private repos require SSH identity (Desktop/Web only).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => {
|
||||
setSubpath(e.target.value);
|
||||
setScanOk(false);
|
||||
setScanCount(null);
|
||||
}}
|
||||
placeholder="e.g. skills"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
|
||||
<div className="typography-meta text-muted-foreground mt-1">
|
||||
Select a Git identity (SSH key) that can access this repository.
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
|
||||
<SelectTrigger className="!h-9 w-full justify-between">
|
||||
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identityOptions.map((id) => (
|
||||
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden">
|
||||
{id.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground mt-2">
|
||||
Configure identities in Settings → Git Identities.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{scanCount !== null ? (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Scan result: {scanCount} skill(s) found
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isDuplicate ? (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
This catalog is already added.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleScan()}
|
||||
disabled={isScanning || !source.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning…' : 'Scan'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
|
||||
>
|
||||
Add catalog
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export type SkillConflict = {
|
||||
skillName: string;
|
||||
scope: 'user' | 'project';
|
||||
};
|
||||
|
||||
export type ConflictDecision = 'skip' | 'overwrite';
|
||||
|
||||
interface InstallConflictsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
conflicts: SkillConflict[];
|
||||
onConfirm: (decisions: Record<string, ConflictDecision>) => void;
|
||||
}
|
||||
|
||||
export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
conflicts,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [decisions, setDecisions] = React.useState<Record<string, ConflictDecision>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const initial: Record<string, ConflictDecision> = {};
|
||||
for (const conflict of conflicts) {
|
||||
initial[conflict.skillName] = 'skip';
|
||||
}
|
||||
setDecisions(initial);
|
||||
}, [open, conflicts]);
|
||||
|
||||
const setAll = (decision: ConflictDecision) => {
|
||||
const next: Record<string, ConflictDecision> = {};
|
||||
for (const conflict of conflicts) {
|
||||
next[conflict.skillName] = decision;
|
||||
}
|
||||
setDecisions(next);
|
||||
};
|
||||
|
||||
const canConfirm = conflicts.length > 0 && conflicts.every((c) => decisions[c.skillName]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skills already exist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Some selected skills are already installed in this scope. Choose whether to skip or overwrite them.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setAll('skip')}>Skip all</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setAll('overwrite')}>Overwrite all</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{conflicts.map((conflict) => (
|
||||
<div
|
||||
key={conflict.skillName}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<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>
|
||||
|
||||
<Select
|
||||
value={decisions[conflict.skillName] || 'skip'}
|
||||
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
|
||||
>
|
||||
<SelectTrigger className="!h-9 w-36 justify-between">
|
||||
<span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="skip" className="pr-2 [&>span:first-child]:hidden">
|
||||
Skip
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite" className="pr-2 [&>span:first-child]:hidden">
|
||||
Overwrite
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge
|
||||
onClick={() => onConfirm(decisions)}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
Continue
|
||||
</ButtonLarge>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,418 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiGitRepositoryLine, RiUser3Line } from '@remixicon/react';
|
||||
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
|
||||
interface InstallFromRepoDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type IdentityOption = { id: string; name: string };
|
||||
|
||||
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
|
||||
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
|
||||
const installedSkills = useSkillsStore((s) => s.skills);
|
||||
|
||||
const [source, setSource] = React.useState('');
|
||||
const [subpath, setSubpath] = React.useState('');
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
|
||||
const [items, setItems] = React.useState<SkillsCatalogItem[]>([]);
|
||||
const [selected, setSelected] = React.useState<Record<string, boolean>>({});
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const [identities, setIdentities] = React.useState<IdentityOption[]>([]);
|
||||
const [gitIdentityId, setGitIdentityId] = React.useState<string | null>(null);
|
||||
|
||||
const [conflictsOpen, setConflictsOpen] = React.useState(false);
|
||||
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
|
||||
const [baseInstallRequest, setBaseInstallRequest] = React.useState<{
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
selections: Array<{ skillDir: string }>;
|
||||
gitIdentityId?: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setSource('');
|
||||
setSubpath('');
|
||||
setScope('user');
|
||||
setItems([]);
|
||||
setSelected({});
|
||||
setSearch('');
|
||||
setIdentities([]);
|
||||
setGitIdentityId(null);
|
||||
setConflictsOpen(false);
|
||||
setConflicts([]);
|
||||
setBaseInstallRequest(null);
|
||||
}, [open]);
|
||||
|
||||
const installedByName = React.useMemo(() => {
|
||||
const map = new Map<string, { scope: 'user' | 'project' }>();
|
||||
for (const s of installedSkills) {
|
||||
map.set(s.name, { scope: s.scope });
|
||||
}
|
||||
return map;
|
||||
}, [installedSkills]);
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const name = item.skillName.toLowerCase();
|
||||
const desc = (item.description || '').toLowerCase();
|
||||
const fm = (item.frontmatterName || '').toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || fm.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
|
||||
const selectedDirs = React.useMemo(() => Object.keys(selected).filter((k) => selected[k]), [selected]);
|
||||
|
||||
const toggleAll = (value: boolean) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const item of items) {
|
||||
if (!item.installable) continue;
|
||||
next[item.skillDir] = value;
|
||||
}
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) {
|
||||
toast.error('Repository source is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await scanRepo({
|
||||
source: trimmed,
|
||||
subpath: subpath.trim() || undefined,
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
setIdentities(ids);
|
||||
if (!gitIdentityId && ids.length > 0) {
|
||||
setGitIdentityId(ids[0].id);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and try scanning again.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to scan repository');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = result.items || [];
|
||||
setItems(nextItems);
|
||||
|
||||
// Auto-select all installable items when scanning returns a small set.
|
||||
const nextSelected: Record<string, boolean> = {};
|
||||
for (const item of nextItems) {
|
||||
if (item.installable) {
|
||||
nextSelected[item.skillDir] = true;
|
||||
}
|
||||
}
|
||||
setSelected(nextSelected);
|
||||
|
||||
setIdentities([]);
|
||||
toast.success(`Found ${nextItems.length} skill(s)`);
|
||||
};
|
||||
|
||||
const doInstall = async (opts: { conflictDecisions?: Record<string, ConflictDecision> }) => {
|
||||
if (selectedDirs.length === 0) {
|
||||
toast.error('Select at least one skill to install');
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
source: source.trim(),
|
||||
subpath: subpath.trim() || undefined,
|
||||
scope,
|
||||
selections: selectedDirs.map((dir) => ({ skillDir: dir })),
|
||||
gitIdentityId: gitIdentityId || undefined,
|
||||
};
|
||||
|
||||
const result = await installSkills({
|
||||
...request,
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: opts.conflictDecisions,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
const installedCount = result.installed?.length || 0;
|
||||
toast.success(installedCount > 0 ? `Installed ${installedCount} skill(s)` : 'Installation completed');
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
setBaseInstallRequest(request);
|
||||
setConflicts(result.error.conflicts);
|
||||
setConflictsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
if (isVSCodeRuntime()) {
|
||||
toast.error('Private repositories are not supported in VS Code yet');
|
||||
return;
|
||||
}
|
||||
const ids = (result.error.identities || []) as IdentityOption[];
|
||||
setIdentities(ids);
|
||||
if (!gitIdentityId && ids.length > 0) {
|
||||
setGitIdentityId(ids[0].id);
|
||||
}
|
||||
toast.error('Authentication required. Select a Git identity and try installing again.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to install skills');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Install from Git repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan a repository for folders containing <code className="font-mono">SKILL.md</code>, then install selected skills.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Repository</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder="owner/repo or git@github.com:owner/repo.git"
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleScan()}
|
||||
disabled={isScanning || !source.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning…' : 'Scan'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
For GitHub shorthand, you can add a subpath like <code className="font-mono">owner/repo/skills</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
|
||||
<Input
|
||||
value={subpath}
|
||||
onChange={(e) => setSubpath(e.target.value)}
|
||||
placeholder="e.g. skills"
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</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')}>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{identities.length > 0 && !isVSCodeRuntime() ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
|
||||
<div className="typography-meta text-muted-foreground mt-1">
|
||||
Select a Git identity (SSH key) that can access this repository.
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
|
||||
<SelectTrigger className="!h-9 w-full justify-between">
|
||||
<span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{identities.map((id) => (
|
||||
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden">
|
||||
{id.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground mt-2">
|
||||
Configure identities in Settings → Git Identities.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="typography-body">No scan results yet</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Scan a repository to discover skills</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills…"
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>Select all</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>Select none</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-2">
|
||||
{filteredItems.map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
const checked = Boolean(selected[item.skillDir]);
|
||||
const disabled = !item.installable;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={item.skillDir}
|
||||
className={
|
||||
'flex items-start gap-3 rounded-lg border bg-muted/10 px-3 py-2 cursor-pointer transition-colors ' +
|
||||
(disabled ? 'opacity-60 cursor-not-allowed' : 'hover:bg-muted/20')
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setSelected((prev) => ({ ...prev, [item.skillDir]: e.target.checked }))}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<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})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
)}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">
|
||||
{item.warnings.join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Selected: {selectedDirs.length} / {items.filter((i) => i.installable).length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge
|
||||
disabled={isInstalling || selectedDirs.length === 0 || !source.trim()}
|
||||
onClick={() => void doInstall({})}
|
||||
>
|
||||
{isInstalling ? 'Installing…' : 'Install selected'}
|
||||
</ButtonLarge>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InstallConflictsDialog
|
||||
open={conflictsOpen}
|
||||
onOpenChange={setConflictsOpen}
|
||||
conflicts={conflicts}
|
||||
onConfirm={(decisions) => {
|
||||
if (!baseInstallRequest) {
|
||||
setConflictsOpen(false);
|
||||
return;
|
||||
}
|
||||
void doInstall({ conflictDecisions: decisions });
|
||||
setConflictsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { RiFolderLine, RiUser3Line } from '@remixicon/react';
|
||||
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
|
||||
|
||||
interface InstallSkillDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: SkillsCatalogItem | null;
|
||||
}
|
||||
|
||||
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
|
||||
const { installSkills, isInstalling } = useSkillsCatalogStore();
|
||||
const [scope, setScope] = React.useState<'user' | 'project'>('user');
|
||||
const [conflictsOpen, setConflictsOpen] = React.useState(false);
|
||||
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
|
||||
const [baseRequest, setBaseRequest] = React.useState<{
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
skillDir: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScope('user');
|
||||
setConflictsOpen(false);
|
||||
setConflicts([]);
|
||||
setBaseRequest(null);
|
||||
}, [open]);
|
||||
|
||||
const doInstall = async (request: {
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: 'user' | 'project';
|
||||
skillDir: string;
|
||||
conflictDecisions?: Record<string, ConflictDecision>;
|
||||
}) => {
|
||||
const result = await installSkills({
|
||||
source: request.source,
|
||||
subpath: request.subpath,
|
||||
gitIdentityId: item?.gitIdentityId,
|
||||
scope: request.scope,
|
||||
selections: [{ skillDir: request.skillDir }],
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: request.conflictDecisions,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
toast.success('Skill installed successfully');
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
setBaseRequest({ source: request.source, subpath: request.subpath, scope: request.scope, skillDir: request.skillDir });
|
||||
setConflicts(result.error.conflicts);
|
||||
setConflictsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
toast.error(result.error.message || 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error?.message || 'Failed to install skill');
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Install <span className="font-semibold text-foreground">{item.skillName}</span> into user or project scope.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{item.warnings?.length ? (
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<div className="typography-micro text-muted-foreground">Warnings</div>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{item.warnings.map((w) => (
|
||||
<li key={w} className="typography-meta text-muted-foreground">{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
|
||||
<span className="capitalize">{scope}</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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button className="w-full sm:w-auto" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
variant="default"
|
||||
disabled={isInstalling || !item.installable}
|
||||
onClick={() =>
|
||||
void doInstall({
|
||||
source: item.repoSource,
|
||||
subpath: item.repoSubpath,
|
||||
scope,
|
||||
skillDir: item.skillDir,
|
||||
})
|
||||
}
|
||||
>
|
||||
{isInstalling ? 'Installing…' : 'Install'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InstallConflictsDialog
|
||||
open={conflictsOpen}
|
||||
onOpenChange={setConflictsOpen}
|
||||
conflicts={conflicts}
|
||||
onConfirm={(decisions) => {
|
||||
if (!baseRequest) return;
|
||||
void doInstall({
|
||||
source: baseRequest.source,
|
||||
subpath: baseRequest.subpath,
|
||||
scope: baseRequest.scope,
|
||||
skillDir: baseRequest.skillDir,
|
||||
conflictDecisions: decisions,
|
||||
});
|
||||
setConflictsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,285 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
import { RiAddLine, RiDeleteBinLine, RiRefreshLine } from '@remixicon/react';
|
||||
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
|
||||
import { AddCatalogDialog } from './AddCatalogDialog';
|
||||
import { InstallSkillDialog } from './InstallSkillDialog';
|
||||
|
||||
type SkillsMode = 'manual' | 'external';
|
||||
|
||||
interface SkillsCatalogPageProps {
|
||||
mode: SkillsMode;
|
||||
onModeChange: (mode: SkillsMode) => void;
|
||||
}
|
||||
|
||||
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
try {
|
||||
if (isDesktopRuntime()) {
|
||||
return await getDesktopSettings();
|
||||
}
|
||||
|
||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||
if (runtimeSettings) {
|
||||
const result = await runtimeSettings.load();
|
||||
return (result?.settings || {}) as DesktopSettings;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json().catch(() => null)) as DesktopSettings | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange }) => {
|
||||
const {
|
||||
sources,
|
||||
itemsBySource,
|
||||
selectedSourceId,
|
||||
setSelectedSource,
|
||||
loadCatalog,
|
||||
isLoadingCatalog,
|
||||
lastCatalogError,
|
||||
} = useSkillsCatalogStore();
|
||||
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [addCatalogOpen, setAddCatalogOpen] = React.useState(false);
|
||||
const [installDialogOpen, setInstallDialogOpen] = React.useState(false);
|
||||
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
|
||||
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (!selectedSourceId) return [];
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
}, [itemsBySource, selectedSourceId]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const name = item.skillName.toLowerCase();
|
||||
const desc = (item.description || '').toLowerCase();
|
||||
const fm = (item.frontmatterName || '').toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || fm.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
|
||||
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
|
||||
|
||||
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
|
||||
|
||||
const removeSelectedCatalog = async () => {
|
||||
if (!selectedSourceId || !isCustomSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('Remove this catalog?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRemovingCatalog(true);
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const catalogs = (Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : []) as SkillCatalogConfig[];
|
||||
const updated = catalogs.filter((c) => c.id !== selectedSourceId);
|
||||
await updateDesktopSettings({ skillCatalogs: updated });
|
||||
await loadCatalog({ refresh: true });
|
||||
} finally {
|
||||
setIsRemovingCatalog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div className="space-y-3">
|
||||
<AnimatedTabs
|
||||
tabs={[
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'external', label: 'External' },
|
||||
]}
|
||||
value={mode}
|
||||
onValueChange={onModeChange}
|
||||
animate={false}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-lg">Skills Catalog</h1>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Browse curated repositories and install skills into your OpenCode configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">Source</label>
|
||||
<Select
|
||||
value={selectedSourceId || ''}
|
||||
onValueChange={(v) => setSelectedSource(v)}
|
||||
>
|
||||
<SelectTrigger className="!h-9 w-full justify-between">
|
||||
<span>{selectedSource?.label || 'Select source'}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{sources.map((src) => (
|
||||
<SelectItem key={src.id} value={src.id} className="pr-2 [&>span:first-child]:hidden">
|
||||
{src.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadCatalog({ refresh: true })}
|
||||
disabled={isLoadingCatalog}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiRefreshLine className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
{isCustomSource ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void removeSelectedCatalog()}
|
||||
disabled={isRemovingCatalog}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
Add catalog
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills…"
|
||||
className="max-w-md"
|
||||
/>
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{isLoadingCatalog ? 'Loading…' : `${filtered.length} skill(s)`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastCatalogError ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">Catalog error</div>
|
||||
<div className="typography-meta text-muted-foreground mt-1">{lastCatalogError.message}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
<p className="typography-body">No skills found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((item) => {
|
||||
const installed = item.installed?.isInstalled;
|
||||
const installedScope = item.installed?.scope;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.sourceId}:${item.skillDir}`}
|
||||
className="rounded-lg border bg-muted/10 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 ({installedScope || 'unknown'})
|
||||
</span>
|
||||
) : null}
|
||||
{!item.installable ? (
|
||||
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
|
||||
not installable
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
|
||||
) : (
|
||||
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
|
||||
)}
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
|
||||
<InstallSkillDialog open={installDialogOpen} onOpenChange={setInstallDialogOpen} item={installItem} />
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ interface AnimatedTabsProps<T extends string> {
|
||||
onValueChange: (value: T) => void;
|
||||
className?: string;
|
||||
isInteractive?: boolean;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export function AnimatedTabs<T extends string>({
|
||||
@@ -21,6 +22,7 @@ export function AnimatedTabs<T extends string>({
|
||||
onValueChange,
|
||||
className,
|
||||
isInteractive = true,
|
||||
animate = true,
|
||||
}: AnimatedTabsProps<T>) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
||||
@@ -41,7 +43,7 @@ export function AnimatedTabs<T extends string>({
|
||||
container.style.clipPath = `inset(0 ${Number(100 - rightPercent).toFixed(2)}% 0 ${Number(leftPercent).toFixed(2)}% round 8px)`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
updateClipPath();
|
||||
}, [updateClipPath, value, tabs.length]);
|
||||
|
||||
@@ -60,7 +62,10 @@ export function AnimatedTabs<T extends string>({
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)] [transition:clip-path_200ms_ease]"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)]',
|
||||
animate ? '[transition:clip-path_200ms_ease]' : null
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 items-center gap-1 rounded-lg bg-accent px-1.5 text-accent-foreground">
|
||||
{tabs.map((tab) => {
|
||||
|
||||
@@ -412,3 +412,87 @@ export interface RuntimeAPIs {
|
||||
}
|
||||
|
||||
export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
|
||||
|
||||
// ============== Skills Catalog Types ==============
|
||||
|
||||
export type SkillsCatalogSourceId = string;
|
||||
|
||||
export interface SkillsCatalogSource {
|
||||
id: SkillsCatalogSourceId;
|
||||
label: string;
|
||||
description?: string;
|
||||
source: string;
|
||||
defaultSubpath?: string;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogItemInstalledBadge {
|
||||
isInstalled: boolean;
|
||||
scope?: 'user' | 'project';
|
||||
}
|
||||
|
||||
export interface SkillsCatalogItem {
|
||||
sourceId: SkillsCatalogSourceId;
|
||||
repoSource: string;
|
||||
repoSubpath?: string;
|
||||
gitIdentityId?: string;
|
||||
skillDir: string;
|
||||
skillName: string;
|
||||
frontmatterName?: string;
|
||||
description?: string;
|
||||
installable: boolean;
|
||||
warnings?: string[];
|
||||
installed?: SkillsCatalogItemInstalledBadge;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogResponse {
|
||||
ok: boolean;
|
||||
sources?: SkillsCatalogSource[];
|
||||
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
export interface SkillsRepoScanRequest {
|
||||
source: string;
|
||||
subpath?: string;
|
||||
gitIdentityId?: string;
|
||||
}
|
||||
|
||||
export type SkillsRepoScanError =
|
||||
| { kind: 'authRequired'; message: string; sshOnly: true; identities?: Array<{ id: string; name: string }> }
|
||||
| { kind: 'invalidSource'; message: string }
|
||||
| { kind: 'gitUnavailable'; message: string }
|
||||
| { kind: 'networkError'; message: string }
|
||||
| { kind: 'unknown'; message: string };
|
||||
|
||||
export interface SkillsRepoScanResponse {
|
||||
ok: boolean;
|
||||
items?: SkillsCatalogItem[];
|
||||
error?: SkillsRepoScanError;
|
||||
}
|
||||
|
||||
export interface SkillsInstallSelection {
|
||||
skillDir: string;
|
||||
}
|
||||
|
||||
export interface SkillsInstallRequest {
|
||||
source: string;
|
||||
subpath?: string;
|
||||
gitIdentityId?: string;
|
||||
scope: 'user' | 'project';
|
||||
selections: SkillsInstallSelection[];
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
}
|
||||
|
||||
export type SkillsInstallError = SkillsRepoScanError | {
|
||||
kind: 'conflicts';
|
||||
message: string;
|
||||
conflicts: Array<{ skillName: string; scope: 'user' | 'project' }>;
|
||||
};
|
||||
|
||||
export interface SkillsInstallResponse {
|
||||
ok: boolean;
|
||||
installed?: Array<{ skillName: string; scope: 'user' | 'project' }>;
|
||||
skipped?: Array<{ skillName: string; reason: string }>;
|
||||
error?: SkillsInstallError;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@ export type DesktopServerInfo = {
|
||||
cliAvailable: boolean;
|
||||
};
|
||||
|
||||
export type SkillCatalogConfig = {
|
||||
id: string;
|
||||
label: string;
|
||||
source: string;
|
||||
subpath?: string;
|
||||
gitIdentityId?: string;
|
||||
};
|
||||
|
||||
export type DesktopSettings = {
|
||||
themeId?: string;
|
||||
useSystemTheme?: boolean;
|
||||
@@ -44,6 +52,9 @@ export type DesktopSettings = {
|
||||
defaultModel?: string; // format: "provider/model"
|
||||
defaultAgent?: string;
|
||||
queueModeEnabled?: boolean;
|
||||
|
||||
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
|
||||
skillCatalogs?: SkillCatalogConfig[];
|
||||
};
|
||||
|
||||
export type DesktopSettingsApi = {
|
||||
|
||||
@@ -44,6 +44,40 @@ type PersistApi = {
|
||||
onFinishHydration?: (callback: () => void) => (() => void) | void;
|
||||
};
|
||||
|
||||
const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs'] | undefined => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: NonNullable<DesktopSettings['skillCatalogs']> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||
const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
|
||||
const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
|
||||
const gitIdentityId = typeof candidate.gitIdentityId === 'string' ? candidate.gitIdentityId.trim() : '';
|
||||
|
||||
if (!id || !label || !source) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
result.push({
|
||||
id,
|
||||
label,
|
||||
source,
|
||||
...(subpath ? { subpath } : {}),
|
||||
...(gitIdentityId ? { gitIdentityId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getPersistApi = (): PersistApi | undefined => {
|
||||
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
|
||||
if (candidate && typeof candidate === 'object') {
|
||||
@@ -140,6 +174,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||
}
|
||||
|
||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||
if (skillCatalogs) {
|
||||
result.skillCatalogs = skillCatalogs;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
import type {
|
||||
SkillsCatalogResponse,
|
||||
SkillsCatalogSource,
|
||||
SkillsCatalogItem,
|
||||
SkillsRepoScanRequest,
|
||||
SkillsRepoScanResponse,
|
||||
SkillsInstallRequest,
|
||||
SkillsInstallResponse,
|
||||
SkillsInstallError,
|
||||
} from '@/lib/api/types';
|
||||
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
|
||||
const getCurrentDirectory = (): string | null => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const store = (window as any).__zustand_directory_store__;
|
||||
if (store) {
|
||||
return store.getState().currentDirectory;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface SkillsCatalogState {
|
||||
sources: SkillsCatalogSource[];
|
||||
itemsBySource: Record<string, SkillsCatalogItem[]>;
|
||||
selectedSourceId: string | null;
|
||||
|
||||
isLoadingCatalog: boolean;
|
||||
isScanning: boolean;
|
||||
isInstalling: boolean;
|
||||
|
||||
lastCatalogError: SkillsCatalogResponse['error'] | null;
|
||||
lastScanError: SkillsRepoScanResponse['error'] | null;
|
||||
lastInstallError: SkillsInstallError | null;
|
||||
|
||||
scanResults: SkillsCatalogItem[] | null;
|
||||
|
||||
setSelectedSource: (id: string | null) => void;
|
||||
|
||||
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
|
||||
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
|
||||
installSkills: (request: SkillsInstallRequest) => Promise<SkillsInstallResponse>;
|
||||
}
|
||||
|
||||
export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
sources: [],
|
||||
itemsBySource: {},
|
||||
selectedSourceId: null,
|
||||
|
||||
isLoadingCatalog: false,
|
||||
isScanning: false,
|
||||
isInstalling: false,
|
||||
|
||||
lastCatalogError: null,
|
||||
lastScanError: null,
|
||||
lastInstallError: null,
|
||||
|
||||
scanResults: null,
|
||||
|
||||
setSelectedSource: (id) => set({ selectedSourceId: id }),
|
||||
|
||||
loadCatalog: async (options) => {
|
||||
set({ isLoadingCatalog: true, lastCatalogError: null });
|
||||
|
||||
const previous = {
|
||||
sources: get().sources,
|
||||
itemsBySource: get().itemsBySource,
|
||||
};
|
||||
|
||||
let lastError: SkillsCatalogResponse['error'] | null = null;
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const refresh = options?.refresh ? '&refresh=true' : '';
|
||||
const queryParams = currentDirectory
|
||||
? `?directory=${encodeURIComponent(currentDirectory)}${refresh}`
|
||||
: refresh
|
||||
? `?refresh=true`
|
||||
: '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null;
|
||||
if (!response.ok || !payload?.ok) {
|
||||
lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` };
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
const sources = payload.sources || [];
|
||||
const itemsBySource = payload.itemsBySource || {};
|
||||
const currentSelected = get().selectedSourceId;
|
||||
const selectedSourceId =
|
||||
(currentSelected && sources.some((s) => s.id === currentSelected))
|
||||
? currentSelected
|
||||
: (sources[0]?.id ?? null);
|
||||
|
||||
set({ sources, itemsBySource, selectedSourceId });
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
lastError = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) };
|
||||
const waitMs = 200 * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
sources: previous.sources,
|
||||
itemsBySource: previous.itemsBySource,
|
||||
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
set({ isLoadingCatalog: false });
|
||||
}
|
||||
},
|
||||
|
||||
scanRepo: async (request) => {
|
||||
set({ isScanning: true, lastScanError: null, scanResults: null });
|
||||
try {
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/scan${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsRepoScanResponse | null;
|
||||
if (!response.ok || !payload) {
|
||||
const error = payload?.error || { kind: 'unknown', message: 'Failed to scan repository' };
|
||||
set({ lastScanError: error });
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
if (!payload.ok) {
|
||||
set({ lastScanError: payload.error || { kind: 'unknown', message: 'Failed to scan repository' } });
|
||||
return payload;
|
||||
}
|
||||
|
||||
set({ scanResults: payload.items || [] });
|
||||
return payload;
|
||||
} finally {
|
||||
set({ isScanning: false });
|
||||
}
|
||||
},
|
||||
|
||||
installSkills: async (request) => {
|
||||
set({ isInstalling: true, lastInstallError: null });
|
||||
try {
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/install${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as SkillsInstallResponse | null;
|
||||
if (!payload) {
|
||||
const error = { kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError;
|
||||
set({ lastInstallError: error });
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
const error = payload.error || ({ kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError);
|
||||
set({ lastInstallError: error });
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
// Refresh installed skills list.
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
const err = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) } as SkillsInstallError;
|
||||
set({ lastInstallError: err });
|
||||
return { ok: false, error: err };
|
||||
} finally {
|
||||
set({ isInstalling: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'skills-catalog-store' }
|
||||
)
|
||||
);
|
||||
@@ -136,6 +136,7 @@
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.0.209",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
"react-dom": "^19.1.1",
|
||||
"yaml": "^2.8.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import * as path from 'path';
|
||||
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 } from './opencodeConfig';
|
||||
import { removeProviderAuth } from './opencodeAuth';
|
||||
import {
|
||||
getSkillsCatalog,
|
||||
scanSkillsRepository as scanSkillsRepositoryFromGit,
|
||||
installSkillsFromRepository as installSkillsFromGit,
|
||||
type SkillsCatalogSourceConfig,
|
||||
} from './skillsCatalog';
|
||||
|
||||
export interface BridgeRequest {
|
||||
id: string;
|
||||
@@ -836,6 +842,73 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/skills:catalog': {
|
||||
const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh);
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
|
||||
const settings = readSettings(ctx);
|
||||
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
|
||||
|
||||
const additionalSources: SkillsCatalogSourceConfig[] = Array.isArray(rawCatalogs)
|
||||
? (rawCatalogs
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const candidate = entry as Record<string, unknown>;
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||
const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
|
||||
const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
|
||||
if (!id || !label || !source) return null;
|
||||
const normalized: SkillsCatalogSourceConfig = {
|
||||
id,
|
||||
label,
|
||||
description: source,
|
||||
source,
|
||||
...(subpath ? { defaultSubpath: subpath } : {}),
|
||||
};
|
||||
return normalized;
|
||||
})
|
||||
.filter((v) => v !== null) as SkillsCatalogSourceConfig[])
|
||||
: [];
|
||||
|
||||
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources);
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
case 'api:config/skills:scan': {
|
||||
const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string };
|
||||
const data = await scanSkillsRepositoryFromGit({
|
||||
source: String(body.source || ''),
|
||||
subpath: body.subpath,
|
||||
});
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
case 'api:config/skills:install': {
|
||||
const body = (payload || {}) as {
|
||||
source?: string;
|
||||
subpath?: string;
|
||||
scope?: 'user' | 'project';
|
||||
selections?: Array<{ skillDir: string }>;
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
};
|
||||
|
||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
|
||||
const data = await installSkillsFromGit({
|
||||
source: String(body.source || ''),
|
||||
subpath: body.subpath,
|
||||
scope: body.scope === 'project' ? 'project' : 'user',
|
||||
workingDirectory: body.scope === 'project' ? workingDirectory : undefined,
|
||||
selections: Array.isArray(body.selections) ? body.selections : [],
|
||||
conflictPolicy: body.conflictPolicy,
|
||||
conflictDecisions: body.conflictDecisions,
|
||||
});
|
||||
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
case 'api:config/skills/files': {
|
||||
const { method, name, filePath, content } = (payload || {}) as {
|
||||
method?: string;
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { discoverSkills } from './opencodeConfig';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
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';
|
||||
|
||||
export type SkillsCatalogSourceConfig = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
source: string;
|
||||
defaultSubpath?: string;
|
||||
};
|
||||
|
||||
type CuratedSource = SkillsCatalogSourceConfig;
|
||||
|
||||
type SkillFrontmatter = {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SkillsCatalogItem = {
|
||||
repoSource: string;
|
||||
repoSubpath?: string;
|
||||
skillDir: string;
|
||||
skillName: string;
|
||||
frontmatterName?: string;
|
||||
description?: string;
|
||||
installable: boolean;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
type SkillsCatalogItemWithBadge = SkillsCatalogItem & {
|
||||
sourceId: string;
|
||||
installed: { isInstalled: boolean; scope?: SkillScope };
|
||||
};
|
||||
|
||||
type SkillsRepoError =
|
||||
| { kind: 'authRequired'; message: string; sshOnly: boolean }
|
||||
| { kind: 'invalidSource'; message: string }
|
||||
| { kind: 'gitUnavailable'; message: string }
|
||||
| { kind: 'networkError'; message: string }
|
||||
| { kind: 'unknown'; message: string }
|
||||
| { kind: 'conflicts'; message: string; conflicts: Array<{ skillName: string; scope: SkillScope }> };
|
||||
|
||||
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: false; error: SkillsRepoError };
|
||||
|
||||
export const CURATED_SOURCES: CuratedSource[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
description: "Anthropic’s public skills repository",
|
||||
source: 'anthropics/skills',
|
||||
defaultSubpath: 'skills',
|
||||
},
|
||||
];
|
||||
|
||||
function validateSkillName(skillName: string): boolean {
|
||||
if (skillName.length < 1 || skillName.length > 64) return false;
|
||||
return SKILL_NAME_PATTERN.test(skillName);
|
||||
}
|
||||
|
||||
function looksLikeAuthError(message: string): boolean {
|
||||
return (
|
||||
/permission denied/i.test(message) ||
|
||||
/publickey/i.test(message) ||
|
||||
/could not read from remote repository/i.test(message) ||
|
||||
/authentication failed/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
async function runGit(args: string[], options?: { cwd?: string; timeoutMs?: number }) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd: options?.cwd,
|
||||
timeout: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: DEFAULT_MAX_BUFFER,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
},
|
||||
});
|
||||
return { ok: true as const, stdout: stdout || '', stderr: stderr || '' };
|
||||
} catch (error) {
|
||||
const err = error as { stdout?: string; stderr?: string; message?: string };
|
||||
return {
|
||||
ok: false as const,
|
||||
stdout: typeof err.stdout === 'string' ? err.stdout : '',
|
||||
stderr: typeof err.stderr === 'string' ? err.stderr : '',
|
||||
message: typeof err.message === 'string' ? err.message : 'Git command failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function assertGitAvailable() {
|
||||
const result = await runGit(['--version'], { timeoutMs: 5_000 });
|
||||
if (!result.ok) {
|
||||
return { ok: false as const, error: { kind: 'gitUnavailable' as const, message: 'Git is not available in PATH' } };
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
function parseSkillRepoSource(input: string, subpath?: string) {
|
||||
const raw = (input || '').trim();
|
||||
if (!raw) {
|
||||
return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Repository source is required' } };
|
||||
}
|
||||
|
||||
const explicitSubpath = subpath?.trim() ? subpath.trim() : null;
|
||||
|
||||
const sshMatch = raw.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i);
|
||||
if (sshMatch) {
|
||||
const owner = sshMatch[1];
|
||||
const repo = sshMatch[2].replace(/\.git$/i, '');
|
||||
return {
|
||||
ok: true as const,
|
||||
normalizedRepo: `${owner}/${repo}`,
|
||||
cloneUrlHttps: `https://github.com/${owner}/${repo}.git`,
|
||||
cloneUrlSsh: `git@github.com:${owner}/${repo}.git`,
|
||||
effectiveSubpath: explicitSubpath,
|
||||
};
|
||||
}
|
||||
|
||||
const httpsMatch = raw.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i);
|
||||
if (httpsMatch) {
|
||||
const owner = httpsMatch[1];
|
||||
const repo = httpsMatch[2].replace(/\.git$/i, '');
|
||||
return {
|
||||
ok: true as const,
|
||||
normalizedRepo: `${owner}/${repo}`,
|
||||
cloneUrlHttps: `https://github.com/${owner}/${repo}.git`,
|
||||
cloneUrlSsh: `git@github.com:${owner}/${repo}.git`,
|
||||
effectiveSubpath: explicitSubpath,
|
||||
};
|
||||
}
|
||||
|
||||
const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
|
||||
if (shorthandMatch) {
|
||||
const owner = shorthandMatch[1];
|
||||
const repo = shorthandMatch[2].replace(/\.git$/i, '');
|
||||
const shorthandSubpath = shorthandMatch[3]?.trim() || null;
|
||||
return {
|
||||
ok: true as const,
|
||||
normalizedRepo: `${owner}/${repo}`,
|
||||
cloneUrlHttps: `https://github.com/${owner}/${repo}.git`,
|
||||
cloneUrlSsh: `git@github.com:${owner}/${repo}.git`,
|
||||
effectiveSubpath: explicitSubpath || shorthandSubpath,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Unsupported repository source format' } };
|
||||
}
|
||||
|
||||
function parseSkillMd(content: string): { frontmatter: SkillFrontmatter; warnings: string[] } {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) {
|
||||
return {
|
||||
frontmatter: {},
|
||||
warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = yaml.parse(match[1]);
|
||||
const frontmatter = parsed && typeof parsed === 'object' ? (parsed as SkillFrontmatter) : {};
|
||||
return { frontmatter, warnings: [] };
|
||||
} catch {
|
||||
return {
|
||||
frontmatter: {},
|
||||
warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function safeRm(dir: string) {
|
||||
try {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneRepo(cloneUrl: string, targetDir: string) {
|
||||
const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, targetDir];
|
||||
const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, targetDir];
|
||||
|
||||
const result = await runGit(preferred, { timeoutMs: 60_000 });
|
||||
if (result.ok) return { ok: true as const };
|
||||
|
||||
const fallbackResult = await runGit(fallback, { timeoutMs: 60_000 });
|
||||
if (fallbackResult.ok) return { ok: true as const };
|
||||
|
||||
const combined = `${fallbackResult.stderr}\n${fallbackResult.message}`.trim();
|
||||
if (looksLikeAuthError(combined)) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: {
|
||||
kind: 'authRequired' as const,
|
||||
message: 'Private repositories are not supported in VS Code yet. Use Desktop/Web.',
|
||||
sshOnly: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false as const, error: { kind: 'networkError' as const, message: combined || 'Failed to clone repository' } };
|
||||
}
|
||||
|
||||
export async function scanSkillsRepository(options: { source: string; subpath?: string; defaultSubpath?: string }): Promise<SkillsRepoScanResult> {
|
||||
const gitCheck = await assertGitAvailable();
|
||||
if (!gitCheck.ok) {
|
||||
return { ok: false as const, error: gitCheck.error };
|
||||
}
|
||||
|
||||
const parsed = parseSkillRepoSource(options.source, options.subpath);
|
||||
if (!parsed.ok) {
|
||||
return { ok: false as const, error: parsed.error };
|
||||
}
|
||||
|
||||
const effectiveSubpath = parsed.effectiveSubpath || options.defaultSubpath || null;
|
||||
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-vscode-skills-scan-'));
|
||||
|
||||
try {
|
||||
const cloned = await cloneRepo(parsed.cloneUrlHttps, tempBase);
|
||||
if (!cloned.ok) {
|
||||
return { ok: false as const, error: cloned.error };
|
||||
}
|
||||
|
||||
const toFsPath = (posixPath: string) => path.join(tempBase, ...posixPath.split('/').filter(Boolean));
|
||||
|
||||
const patterns = effectiveSubpath
|
||||
? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`]
|
||||
: ['SKILL.md', '**/SKILL.md'];
|
||||
|
||||
let skillMdPaths: string[] | null = null;
|
||||
|
||||
const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { timeoutMs: 15_000 });
|
||||
if (sparseInit.ok) {
|
||||
const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { timeoutMs: 30_000 });
|
||||
if (sparseSet.ok) {
|
||||
const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { timeoutMs: 60_000 });
|
||||
if (checkout.ok) {
|
||||
const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { timeoutMs: 15_000 });
|
||||
if (lsFiles.ok) {
|
||||
skillMdPaths = lsFiles.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(skillMdPaths)) {
|
||||
const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD'];
|
||||
if (effectiveSubpath) {
|
||||
listArgs.push('--', effectiveSubpath);
|
||||
}
|
||||
|
||||
const list = await runGit(listArgs, { timeoutMs: 30_000 });
|
||||
if (!list.ok) {
|
||||
return { ok: true as const, items: [] as SkillsCatalogItem[] };
|
||||
}
|
||||
|
||||
skillMdPaths = list.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
|
||||
}
|
||||
|
||||
const skillDirs = Array.from(new Set(skillMdPaths.filter((p) => p !== 'SKILL.md').map((p) => path.posix.dirname(p))));
|
||||
|
||||
const items: SkillsCatalogItem[] = [];
|
||||
|
||||
for (const skillDir of skillDirs) {
|
||||
const skillName = path.posix.basename(skillDir);
|
||||
const skillMdPath = path.posix.join(skillDir, 'SKILL.md');
|
||||
|
||||
const warnings: string[] = [];
|
||||
let content = '';
|
||||
|
||||
try {
|
||||
content = await fs.promises.readFile(toFsPath(skillMdPath), 'utf8');
|
||||
} catch {
|
||||
const show = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { timeoutMs: 15_000 });
|
||||
if (!show.ok) {
|
||||
warnings.push('Failed to read SKILL.md');
|
||||
} else {
|
||||
content = show.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
const parsedMd = parseSkillMd(content);
|
||||
warnings.push(...parsedMd.warnings);
|
||||
|
||||
const description = typeof parsedMd.frontmatter.description === 'string' ? parsedMd.frontmatter.description : undefined;
|
||||
const frontmatterName = typeof parsedMd.frontmatter.name === 'string' ? parsedMd.frontmatter.name : undefined;
|
||||
|
||||
const installable = validateSkillName(skillName);
|
||||
if (!installable) {
|
||||
warnings.push('Skill directory name is not a valid OpenCode skill name');
|
||||
}
|
||||
|
||||
items.push({
|
||||
repoSource: options.source,
|
||||
repoSubpath: effectiveSubpath || undefined,
|
||||
skillDir,
|
||||
skillName,
|
||||
frontmatterName,
|
||||
description,
|
||||
installable,
|
||||
warnings: warnings.length ? warnings : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
items.sort((a, b) => String(a.skillName).localeCompare(String(b.skillName)));
|
||||
|
||||
return { ok: true as const, items };
|
||||
} finally {
|
||||
await safeRm(tempBase);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDirectoryNoSymlinks(srcDir: string, dstDir: string) {
|
||||
const srcReal = await fs.promises.realpath(srcDir);
|
||||
|
||||
const ensureDir = async (dirPath: string) => {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
};
|
||||
|
||||
const walk = async (currentSrc: string, currentDst: string) => {
|
||||
const entries = await fs.promises.readdir(currentSrc, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const nextSrc = path.join(currentSrc, entry.name);
|
||||
const nextDst = path.join(currentDst, entry.name);
|
||||
|
||||
const stat = await fs.promises.lstat(nextSrc);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error('Symlinks are not supported in skills');
|
||||
}
|
||||
|
||||
const nextRealParent = await fs.promises.realpath(path.dirname(nextSrc));
|
||||
if (!nextRealParent.startsWith(srcReal)) {
|
||||
throw new Error('Invalid source path traversal detected');
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await ensureDir(nextDst);
|
||||
await walk(nextSrc, nextDst);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isFile()) {
|
||||
await ensureDir(path.dirname(nextDst));
|
||||
await fs.promises.copyFile(nextSrc, nextDst);
|
||||
try {
|
||||
await fs.promises.chmod(nextDst, stat.mode & 0o777);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await ensureDir(dstDir);
|
||||
await walk(srcDir, dstDir);
|
||||
}
|
||||
|
||||
function getUserSkillBaseDir() {
|
||||
return path.join(os.homedir(), '.config', 'opencode', 'skill');
|
||||
}
|
||||
|
||||
function toFsPath(repoDir: string, repoRelPosixPath: string) {
|
||||
const parts = repoRelPosixPath.split('/').filter(Boolean);
|
||||
return path.join(repoDir, ...parts);
|
||||
}
|
||||
|
||||
export async function installSkillsFromRepository(options: {
|
||||
source: string;
|
||||
subpath?: string;
|
||||
scope: SkillScope;
|
||||
workingDirectory?: string;
|
||||
selections: Array<{ skillDir: string }>;
|
||||
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
||||
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
||||
}): Promise<SkillsInstallResult> {
|
||||
const gitCheck = await assertGitAvailable();
|
||||
if (!gitCheck.ok) {
|
||||
return { ok: false as const, error: gitCheck.error };
|
||||
}
|
||||
|
||||
if (options.scope === 'project' && !options.workingDirectory) {
|
||||
return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Project installs require a directory parameter' } };
|
||||
}
|
||||
|
||||
const parsed = parseSkillRepoSource(options.source, options.subpath);
|
||||
if (!parsed.ok) {
|
||||
return { ok: false as const, error: parsed.error };
|
||||
}
|
||||
|
||||
const requestedDirs = options.selections.map((s) => String(s.skillDir || '').trim()).filter(Boolean);
|
||||
if (requestedDirs.length === 0) {
|
||||
return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'No skills selected for installation' } };
|
||||
}
|
||||
|
||||
const userSkillDir = getUserSkillBaseDir();
|
||||
|
||||
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 }> = [];
|
||||
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', 'skill', 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: { kind: 'conflicts' as const, message: 'Some skills already exist in the selected scope', conflicts },
|
||||
};
|
||||
}
|
||||
|
||||
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-vscode-skills-install-'));
|
||||
|
||||
try {
|
||||
const cloned = await cloneRepo(parsed.cloneUrlHttps, tempBase);
|
||||
if (!cloned.ok) {
|
||||
return { ok: false as const, error: cloned.error };
|
||||
}
|
||||
|
||||
await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { timeoutMs: 15_000 });
|
||||
const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { timeoutMs: 30_000 });
|
||||
if (!setResult.ok) {
|
||||
return { ok: false as const, error: { kind: 'unknown' as const, message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } };
|
||||
}
|
||||
|
||||
const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { timeoutMs: 60_000 });
|
||||
if (!checkoutResult.ok) {
|
||||
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 skipped: Array<{ skillName: string; reason: string }> = [];
|
||||
|
||||
for (const plan of skillPlans) {
|
||||
if (!plan.installable) {
|
||||
skipped.push({ skillName: plan.skillName, reason: 'Invalid skill name (directory basename)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const srcDir = toFsPath(tempBase, plan.skillDirPosix);
|
||||
const skillMdPath = path.join(srcDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) {
|
||||
skipped.push({ skillName: plan.skillName, reason: 'SKILL.md not found in selected directory' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = options.scope === 'user'
|
||||
? path.join(userSkillDir, plan.skillName)
|
||||
: path.join(options.workingDirectory as string, '.opencode', 'skill', plan.skillName);
|
||||
|
||||
const exists = fs.existsSync(targetDir);
|
||||
let decision = options.conflictDecisions?.[plan.skillName] || null;
|
||||
if (!decision) {
|
||||
if (exists && options.conflictPolicy === 'skipAll') decision = 'skip';
|
||||
if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite';
|
||||
if (!exists) decision = 'overwrite';
|
||||
}
|
||||
|
||||
if (exists && decision === 'skip') {
|
||||
skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exists && decision === 'overwrite') {
|
||||
await safeRm(targetDir);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
|
||||
|
||||
try {
|
||||
await copyDirectoryNoSymlinks(srcDir, targetDir);
|
||||
installed.push({ skillName: plan.skillName, scope: options.scope });
|
||||
} catch (error) {
|
||||
await safeRm(targetDir);
|
||||
skipped.push({
|
||||
skillName: plan.skillName,
|
||||
reason: error instanceof Error ? error.message : 'Failed to copy skill files',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true as const, installed, skipped };
|
||||
} finally {
|
||||
await safeRm(tempBase);
|
||||
}
|
||||
}
|
||||
|
||||
const catalogCache = new Map<string, { expiresAt: number; items: SkillsCatalogItem[] }>();
|
||||
const CATALOG_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export async function getSkillsCatalog(
|
||||
workingDirectory?: string,
|
||||
refresh?: boolean,
|
||||
additionalSources?: SkillsCatalogSourceConfig[]
|
||||
) {
|
||||
const sources = [...CURATED_SOURCES, ...(Array.isArray(additionalSources) ? additionalSources : [])];
|
||||
const discovered = discoverSkills(workingDirectory);
|
||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||
|
||||
const itemsBySource: Record<string, SkillsCatalogItemWithBadge[]> = {};
|
||||
|
||||
for (const src of sources) {
|
||||
const parsed = parseSkillRepoSource(src.source);
|
||||
if (!parsed.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || '';
|
||||
const cacheKey = `${parsed.normalizedRepo}::${effectiveSubpath}`;
|
||||
|
||||
let cached = !refresh ? catalogCache.get(cacheKey) : null;
|
||||
if (cached && Date.now() >= cached.expiresAt) {
|
||||
catalogCache.delete(cacheKey);
|
||||
cached = null;
|
||||
}
|
||||
|
||||
let items: SkillsCatalogItem[] = [];
|
||||
if (cached) {
|
||||
items = cached.items;
|
||||
} else {
|
||||
const scanned = await scanSkillsRepository({ source: src.source, defaultSubpath: src.defaultSubpath });
|
||||
if (!scanned.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
continue;
|
||||
}
|
||||
items = scanned.items || [];
|
||||
catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items });
|
||||
}
|
||||
|
||||
itemsBySource[src.id] = items.map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
return {
|
||||
sourceId: src.id,
|
||||
...item,
|
||||
installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, sources, itemsBySource };
|
||||
}
|
||||
@@ -408,6 +408,54 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
}
|
||||
}
|
||||
|
||||
const skillsCatalogStatusFromPayload = (payload: unknown): number => {
|
||||
if (!payload || typeof payload !== 'object') return 200;
|
||||
const data = payload as { ok?: boolean; error?: { kind?: string } };
|
||||
if (data.ok === false) {
|
||||
const kind = data.error?.kind;
|
||||
if (kind === 'conflicts') return 409;
|
||||
if (kind === 'authRequired') return 401;
|
||||
return 400;
|
||||
}
|
||||
return 200;
|
||||
};
|
||||
|
||||
// Skills catalog: /api/config/skills/catalog
|
||||
if (pathname === '/api/config/skills/catalog') {
|
||||
const refresh = url.searchParams.get('refresh') === 'true';
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:config/skills:catalog', { refresh });
|
||||
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
// Skills scan: /api/config/skills/scan
|
||||
if (pathname === '/api/config/skills/scan') {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:config/skills:scan', body);
|
||||
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
// Skills install: /api/config/skills/install
|
||||
if (pathname === '/api/config/skills/install') {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:config/skills:install', body);
|
||||
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
// Skills CRUD: /api/config/skills/:name or /api/config/skills
|
||||
if (pathname === '/api/config/skills') {
|
||||
try {
|
||||
|
||||
@@ -350,6 +350,39 @@ const normalizeStringArray = (input) => {
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeSkillCatalogs = (input) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const entry of input) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
|
||||
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
||||
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
|
||||
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
|
||||
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
|
||||
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
|
||||
|
||||
if (!id || !label || !source) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
result.push({
|
||||
id,
|
||||
label,
|
||||
source,
|
||||
...(subpath ? { subpath } : {}),
|
||||
...(gitIdentityId ? { gitIdentityId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeSettingsUpdate = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {};
|
||||
@@ -425,6 +458,11 @@ const sanitizeSettingsUpdate = (payload) => {
|
||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||
}
|
||||
|
||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||
if (skillCatalogs) {
|
||||
result.skillCatalogs = skillCatalogs;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2376,7 +2414,8 @@ async function main(options = {}) {
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
SKILL_SCOPE
|
||||
SKILL_SCOPE,
|
||||
SKILL_DIR,
|
||||
} = await import('./lib/opencode-config.js');
|
||||
|
||||
// List all discovered skills
|
||||
@@ -2401,6 +2440,210 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// ============== SKILLS CATALOG + INSTALL ENDPOINTS ==============
|
||||
|
||||
const { getCuratedSkillsSources } = await import('./lib/skills-catalog/curated-sources.js');
|
||||
const { getCacheKey, getCachedScan, setCachedScan } = await import('./lib/skills-catalog/cache.js');
|
||||
const { parseSkillRepoSource } = await import('./lib/skills-catalog/source.js');
|
||||
const { scanSkillsRepository } = await import('./lib/skills-catalog/scan.js');
|
||||
const { installSkillsFromRepository } = await import('./lib/skills-catalog/install.js');
|
||||
const { getProfiles, getProfile } = await import('./lib/git-identity-storage.js');
|
||||
|
||||
const listGitIdentitiesForResponse = () => {
|
||||
try {
|
||||
const profiles = getProfiles();
|
||||
return profiles.map((p) => ({ id: p.id, name: p.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const resolveGitIdentity = (profileId) => {
|
||||
if (!profileId) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const profile = getProfile(profileId);
|
||||
const sshKey = profile?.sshKey;
|
||||
if (typeof sshKey === 'string' && sshKey.trim()) {
|
||||
return { sshKey: sshKey.trim() };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
app.get('/api/config/skills/catalog', async (req, res) => {
|
||||
try {
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
||||
|
||||
const curatedSources = getCuratedSkillsSources();
|
||||
const settings = await readSettingsFromDisk();
|
||||
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
|
||||
|
||||
const customSources = customSourcesRaw.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
description: entry.source,
|
||||
source: entry.source,
|
||||
defaultSubpath: entry.subpath,
|
||||
gitIdentityId: entry.gitIdentityId,
|
||||
}));
|
||||
|
||||
const sources = [...curatedSources, ...customSources];
|
||||
|
||||
const discovered = discoverSkills(workingDirectory);
|
||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||
|
||||
const itemsBySource = {};
|
||||
|
||||
for (const src of sources) {
|
||||
const parsed = parseSkillRepoSource(src.source);
|
||||
if (!parsed.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null;
|
||||
const cacheKey = getCacheKey({
|
||||
normalizedRepo: parsed.normalizedRepo,
|
||||
subpath: effectiveSubpath || '',
|
||||
identityId: src.gitIdentityId || '',
|
||||
});
|
||||
|
||||
let scanResult = !refresh ? getCachedScan(cacheKey) : null;
|
||||
if (!scanResult) {
|
||||
const scanned = await scanSkillsRepository({
|
||||
source: src.source,
|
||||
subpath: src.defaultSubpath,
|
||||
defaultSubpath: src.defaultSubpath,
|
||||
identity: resolveGitIdentity(src.gitIdentityId),
|
||||
});
|
||||
|
||||
if (!scanned.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
scanResult = scanned;
|
||||
setCachedScan(cacheKey, scanResult);
|
||||
}
|
||||
|
||||
const items = (scanResult.items || []).map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
return {
|
||||
sourceId: src.id,
|
||||
...item,
|
||||
gitIdentityId: src.gitIdentityId,
|
||||
installed: installed
|
||||
? { isInstalled: true, scope: installed.scope }
|
||||
: { isInstalled: false },
|
||||
};
|
||||
});
|
||||
|
||||
itemsBySource[src.id] = items;
|
||||
}
|
||||
|
||||
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest);
|
||||
res.json({ ok: true, sources: sourcesForUi, itemsBySource });
|
||||
} catch (error) {
|
||||
console.error('Failed to load skills catalog:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/skills/scan', async (req, res) => {
|
||||
try {
|
||||
const { source, subpath, gitIdentityId } = req.body || {};
|
||||
const identity = resolveGitIdentity(gitIdentityId);
|
||||
|
||||
const result = await scanSkillsRepository({
|
||||
source,
|
||||
subpath,
|
||||
identity,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: {
|
||||
...result.error,
|
||||
identities: listGitIdentitiesForResponse(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(400).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
res.json({ ok: true, items: result.items });
|
||||
} catch (error) {
|
||||
console.error('Failed to scan skills repository:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/skills/install', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
source,
|
||||
subpath,
|
||||
gitIdentityId,
|
||||
scope,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
} = req.body || {};
|
||||
|
||||
const workingDirectory = req.query.directory;
|
||||
if (scope === 'project' && !workingDirectory) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' },
|
||||
});
|
||||
}
|
||||
const identity = resolveGitIdentity(gitIdentityId);
|
||||
|
||||
const result = await installSkillsFromRepository({
|
||||
source,
|
||||
subpath,
|
||||
identity,
|
||||
scope,
|
||||
workingDirectory,
|
||||
userSkillDir: SKILL_DIR,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
return res.status(409).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: {
|
||||
...result.error,
|
||||
identities: listGitIdentitiesForResponse(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(400).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] });
|
||||
} catch (error) {
|
||||
console.error('Failed to install skills:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single skill sources
|
||||
app.get('/api/config/skills/:name', (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
export function getCacheKey({ normalizedRepo, subpath, identityId }) {
|
||||
const safeRepo = String(normalizedRepo || '').trim();
|
||||
const safeSubpath = String(subpath || '').trim();
|
||||
const safeIdentity = String(identityId || '').trim();
|
||||
return `${safeRepo}::${safeSubpath}::${safeIdentity}`;
|
||||
}
|
||||
|
||||
export function getCachedScan(key) {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) {
|
||||
const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS;
|
||||
cache.set(key, { expiresAt: Date.now() + ttl, value });
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const CURATED_SKILLS_SOURCES = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
description: "Anthropic’s public skills repository",
|
||||
source: 'anthropics/skills',
|
||||
defaultSubpath: 'skills',
|
||||
},
|
||||
];
|
||||
|
||||
export function getCuratedSkillsSources() {
|
||||
return CURATED_SKILLS_SOURCES.slice();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024;
|
||||
|
||||
export function looksLikeAuthError(message) {
|
||||
const text = String(message || '');
|
||||
return (
|
||||
/permission denied/i.test(text) ||
|
||||
/publickey/i.test(text) ||
|
||||
/could not read from remote repository/i.test(text) ||
|
||||
/authentication failed/i.test(text) ||
|
||||
/fatal: could not/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
export async function runGit(args, options = {}) {
|
||||
const cwd = options.cwd;
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
|
||||
const maxBuffer = Number.isFinite(options.maxBuffer) ? options.maxBuffer : DEFAULT_MAX_BUFFER;
|
||||
|
||||
const identity = options.identity || null;
|
||||
const normalizedArgs = Array.isArray(args) ? args.slice() : [];
|
||||
|
||||
// Non-interactive git (avoid prompts / hangs)
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
|
||||
if (identity?.sshKey) {
|
||||
const sshKeyPath = String(identity.sshKey).trim();
|
||||
if (sshKeyPath) {
|
||||
// Avoid interactive host key prompts; still safe against changed keys.
|
||||
const sshCommand = `ssh -i ${sshKeyPath} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
|
||||
normalizedArgs.unshift(`core.sshCommand=${sshCommand}`);
|
||||
normalizedArgs.unshift('-c');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('git', normalizedArgs, {
|
||||
cwd,
|
||||
env,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer,
|
||||
});
|
||||
|
||||
return { ok: true, stdout: stdout || '', stderr: stderr || '' };
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
const stdout = typeof err?.stdout === 'string' ? err.stdout : '';
|
||||
const stderr = typeof err?.stderr === 'string' ? err.stderr : '';
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
stdout,
|
||||
stderr,
|
||||
message,
|
||||
code: typeof err?.code === 'number' ? err.code : null,
|
||||
signal: typeof err?.signal === 'string' ? err.signal : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertGitAvailable() {
|
||||
const result = await runGit(['--version'], { timeoutMs: 5_000 });
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: { kind: 'gitUnavailable', message: 'Git is not available in PATH' } };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
|
||||
import { parseSkillRepoSource } from './source.js';
|
||||
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
function validateSkillName(skillName) {
|
||||
if (typeof skillName !== 'string') return false;
|
||||
if (skillName.length < 1 || skillName.length > 64) return false;
|
||||
return SKILL_NAME_PATTERN.test(skillName);
|
||||
}
|
||||
|
||||
async function safeRm(dir) {
|
||||
try {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toFsPath(repoDir, repoRelPosixPath) {
|
||||
const parts = String(repoRelPosixPath || '')
|
||||
.split('/')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
return path.join(repoDir, ...parts);
|
||||
}
|
||||
|
||||
async function ensureDir(dirPath) {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyDirectoryNoSymlinks(srcDir, dstDir) {
|
||||
const srcReal = await fs.promises.realpath(srcDir);
|
||||
await ensureDir(dstDir);
|
||||
|
||||
const walk = async (currentSrc, currentDst) => {
|
||||
const entries = await fs.promises.readdir(currentSrc, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const nextSrc = path.join(currentSrc, entry.name);
|
||||
const nextDst = path.join(currentDst, entry.name);
|
||||
|
||||
const stat = await fs.promises.lstat(nextSrc);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error('Symlinks are not supported in skills');
|
||||
}
|
||||
|
||||
// Guard against traversal: ensure source is still under srcReal
|
||||
const nextRealParent = await fs.promises.realpath(path.dirname(nextSrc));
|
||||
if (!nextRealParent.startsWith(srcReal)) {
|
||||
throw new Error('Invalid source path traversal detected');
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await ensureDir(nextDst);
|
||||
await walk(nextSrc, nextDst);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isFile()) {
|
||||
await ensureDir(path.dirname(nextDst));
|
||||
await fs.promises.copyFile(nextSrc, nextDst);
|
||||
try {
|
||||
await fs.promises.chmod(nextDst, stat.mode & 0o777);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip other types (sockets, devices, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
await walk(srcDir, dstDir);
|
||||
}
|
||||
|
||||
async function cloneRepo({ cloneUrl, identity, tempDir }) {
|
||||
const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
|
||||
const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
|
||||
|
||||
const result = await runGit(preferred, { identity, timeoutMs: 90_000 });
|
||||
if (result.ok) return { ok: true };
|
||||
|
||||
const fallbackResult = await runGit(fallback, { identity, timeoutMs: 90_000 });
|
||||
if (fallbackResult.ok) return { ok: true };
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: fallbackResult,
|
||||
};
|
||||
}
|
||||
|
||||
function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) {
|
||||
if (scope === 'user') {
|
||||
return path.join(userSkillDir, skillName);
|
||||
}
|
||||
|
||||
if (!workingDirectory) {
|
||||
throw new Error('workingDirectory is required for project installs');
|
||||
}
|
||||
|
||||
return path.join(workingDirectory, '.opencode', 'skill', skillName);
|
||||
}
|
||||
|
||||
export async function installSkillsFromRepository({
|
||||
source,
|
||||
subpath,
|
||||
defaultSubpath,
|
||||
identity,
|
||||
scope,
|
||||
workingDirectory,
|
||||
userSkillDir,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
} = {}) {
|
||||
const gitCheck = await assertGitAvailable();
|
||||
if (!gitCheck.ok) {
|
||||
return { ok: false, error: gitCheck.error };
|
||||
}
|
||||
|
||||
if (scope !== 'user' && scope !== 'project') {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
|
||||
}
|
||||
|
||||
if (!userSkillDir) {
|
||||
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
|
||||
}
|
||||
|
||||
if (scope === 'project' && !workingDirectory) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
|
||||
}
|
||||
|
||||
const parsed = parseSkillRepoSource(source, { subpath });
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, error: parsed.error };
|
||||
}
|
||||
|
||||
const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
|
||||
void effectiveSubpath;
|
||||
|
||||
const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
|
||||
|
||||
const requestedDirs = Array.isArray(selections) ? selections.map((s) => String(s?.skillDir || '').trim()).filter(Boolean) : [];
|
||||
if (requestedDirs.length === 0) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
|
||||
}
|
||||
|
||||
// Validate names early and compute conflicts without mutating.
|
||||
const skillPlans = requestedDirs.map((skillDirPosix) => {
|
||||
const skillName = path.posix.basename(skillDirPosix);
|
||||
return { skillDirPosix, skillName, installable: validateSkillName(skillName) };
|
||||
});
|
||||
|
||||
const conflicts = [];
|
||||
for (const plan of skillPlans) {
|
||||
if (!plan.installable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = getTargetSkillDir({ scope, 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: 'conflicts',
|
||||
message: 'Some skills already exist in the selected scope',
|
||||
conflicts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-skills-install-'));
|
||||
|
||||
try {
|
||||
const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
|
||||
if (!cloned.ok) {
|
||||
const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
|
||||
if (looksLikeAuthError(msg)) {
|
||||
return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
|
||||
}
|
||||
return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
|
||||
}
|
||||
|
||||
// Selective checkout for only requested skill dirs.
|
||||
await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { identity, timeoutMs: 15_000 });
|
||||
const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { identity, timeoutMs: 30_000 });
|
||||
if (!setResult.ok) {
|
||||
return { ok: false, error: { kind: 'unknown', message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } };
|
||||
}
|
||||
|
||||
const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
|
||||
if (!checkoutResult.ok) {
|
||||
return { ok: false, error: { kind: 'unknown', message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } };
|
||||
}
|
||||
|
||||
const installed = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const plan of skillPlans) {
|
||||
if (!plan.installable) {
|
||||
skipped.push({ skillName: plan.skillName, reason: 'Invalid skill name (directory basename)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const srcDir = toFsPath(tempBase, plan.skillDirPosix);
|
||||
const skillMdPath = path.join(srcDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) {
|
||||
skipped.push({ skillName: plan.skillName, reason: 'SKILL.md not found in selected directory' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.skillName });
|
||||
const exists = fs.existsSync(targetDir);
|
||||
|
||||
let decision = conflictDecisions?.[plan.skillName] || null;
|
||||
if (!decision) {
|
||||
if (exists && conflictPolicy === 'skipAll') decision = 'skip';
|
||||
if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
|
||||
if (!exists) decision = 'overwrite'; // no conflict, proceed
|
||||
}
|
||||
|
||||
if (exists && decision === 'skip') {
|
||||
skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exists && decision === 'overwrite') {
|
||||
await safeRm(targetDir);
|
||||
}
|
||||
|
||||
// Ensure project parent directories exist
|
||||
await ensureDir(path.dirname(targetDir));
|
||||
|
||||
try {
|
||||
await copyDirectoryNoSymlinks(srcDir, targetDir);
|
||||
installed.push({ skillName: plan.skillName, scope });
|
||||
} catch (error) {
|
||||
await safeRm(targetDir);
|
||||
skipped.push({
|
||||
skillName: plan.skillName,
|
||||
reason: error instanceof Error ? error.message : 'Failed to copy skill files',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, installed, skipped };
|
||||
} finally {
|
||||
await safeRm(tempBase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
|
||||
import { parseSkillRepoSource } from './source.js';
|
||||
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
function validateSkillName(skillName) {
|
||||
if (typeof skillName !== 'string') return false;
|
||||
if (skillName.length < 1 || skillName.length > 64) return false;
|
||||
return SKILL_NAME_PATTERN.test(skillName);
|
||||
}
|
||||
|
||||
function parseSkillMd(content) {
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) {
|
||||
return {
|
||||
ok: true,
|
||||
frontmatter: {},
|
||||
warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const frontmatter = yaml.parse(match[1]) || {};
|
||||
return { ok: true, frontmatter, warnings: [] };
|
||||
} catch {
|
||||
return {
|
||||
ok: true,
|
||||
frontmatter: {},
|
||||
warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function safeRm(dir) {
|
||||
try {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneRepo({ cloneUrl, identity, tempDir }) {
|
||||
const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
|
||||
const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
|
||||
|
||||
const result = await runGit(preferred, { identity, timeoutMs: 60_000 });
|
||||
if (result.ok) return { ok: true };
|
||||
|
||||
const fallbackResult = await runGit(fallback, { identity, timeoutMs: 60_000 });
|
||||
if (fallbackResult.ok) return { ok: true };
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: fallbackResult,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanSkillsRepository({
|
||||
source,
|
||||
subpath,
|
||||
defaultSubpath,
|
||||
identity,
|
||||
} = {}) {
|
||||
const gitCheck = await assertGitAvailable();
|
||||
if (!gitCheck.ok) {
|
||||
return { ok: false, error: gitCheck.error };
|
||||
}
|
||||
|
||||
const parsed = parseSkillRepoSource(source, { subpath });
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, error: parsed.error };
|
||||
}
|
||||
|
||||
const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
|
||||
const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
|
||||
|
||||
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-skills-scan-'));
|
||||
|
||||
try {
|
||||
const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
|
||||
if (!cloned.ok) {
|
||||
const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
|
||||
if (looksLikeAuthError(msg)) {
|
||||
return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
|
||||
}
|
||||
return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
|
||||
}
|
||||
|
||||
const toFsPath = (posixPath) => path.join(tempBase, ...String(posixPath || '').split('/').filter(Boolean));
|
||||
|
||||
const patterns = effectiveSubpath
|
||||
? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`]
|
||||
: ['SKILL.md', '**/SKILL.md'];
|
||||
|
||||
let skillMdPaths = null;
|
||||
|
||||
// Fast path: sparse checkout only SKILL.md files, then parse from disk.
|
||||
// This avoids one `git show` per skill.
|
||||
const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { identity, timeoutMs: 15_000 });
|
||||
if (sparseInit.ok) {
|
||||
const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { identity, timeoutMs: 30_000 });
|
||||
if (sparseSet.ok) {
|
||||
const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
|
||||
if (checkout.ok) {
|
||||
const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { identity, timeoutMs: 15_000 });
|
||||
if (lsFiles.ok) {
|
||||
skillMdPaths = lsFiles.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: list tree and read SKILL.md blobs via git.
|
||||
if (!Array.isArray(skillMdPaths)) {
|
||||
const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD'];
|
||||
if (effectiveSubpath) {
|
||||
listArgs.push('--', effectiveSubpath);
|
||||
}
|
||||
|
||||
const listResult = await runGit(listArgs, { identity, timeoutMs: 30_000 });
|
||||
if (!listResult.ok) {
|
||||
// If subpath doesn't exist, treat as empty scan.
|
||||
return {
|
||||
ok: true,
|
||||
normalizedRepo: parsed.normalizedRepo,
|
||||
effectiveSubpath,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
skillMdPaths = listResult.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
|
||||
}
|
||||
|
||||
// Root-level SKILL.md doesn't map cleanly to OpenCode's "skill name == folder name" convention.
|
||||
const uniqueSkillDirs = Array.from(
|
||||
new Set(
|
||||
skillMdPaths
|
||||
.filter((p) => p !== 'SKILL.md')
|
||||
.map((p) => path.posix.dirname(p))
|
||||
)
|
||||
);
|
||||
|
||||
const items = [];
|
||||
const maxParallel = 10;
|
||||
let idx = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (idx < uniqueSkillDirs.length) {
|
||||
const skillDir = uniqueSkillDirs[idx++];
|
||||
const skillName = path.posix.basename(skillDir);
|
||||
const skillMdPath = path.posix.join(skillDir, 'SKILL.md');
|
||||
|
||||
const warnings = [];
|
||||
let skillMdContent = '';
|
||||
|
||||
// Prefer filesystem reads when sparse checkout succeeded.
|
||||
const filePath = toFsPath(skillMdPath);
|
||||
try {
|
||||
skillMdContent = await fs.promises.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
const showResult = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { identity, timeoutMs: 15_000 });
|
||||
if (!showResult.ok) {
|
||||
warnings.push('Failed to read SKILL.md');
|
||||
} else {
|
||||
skillMdContent = showResult.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
const parsedMd = parseSkillMd(skillMdContent);
|
||||
warnings.push(...(parsedMd.warnings || []));
|
||||
|
||||
const description = typeof parsedMd.frontmatter?.description === 'string' ? parsedMd.frontmatter.description : undefined;
|
||||
const frontmatterName = typeof parsedMd.frontmatter?.name === 'string' ? parsedMd.frontmatter.name : undefined;
|
||||
|
||||
const installable = validateSkillName(skillName);
|
||||
if (!installable) {
|
||||
warnings.push('Skill directory name is not a valid OpenCode skill name');
|
||||
}
|
||||
|
||||
items.push({
|
||||
repoSource: source,
|
||||
repoSubpath: effectiveSubpath || undefined,
|
||||
skillDir,
|
||||
skillName,
|
||||
frontmatterName,
|
||||
description,
|
||||
installable,
|
||||
warnings: warnings.length ? warnings : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(maxParallel, uniqueSkillDirs.length || 1) }, () => worker()));
|
||||
|
||||
// Stable ordering for UX
|
||||
items.sort((a, b) => a.skillName.localeCompare(b.skillName));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
normalizedRepo: parsed.normalizedRepo,
|
||||
effectiveSubpath,
|
||||
items,
|
||||
};
|
||||
} finally {
|
||||
await safeRm(tempBase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
const GITHUB_HOST = 'github.com';
|
||||
|
||||
function normalizeGitHubOwnerRepo(owner, repo) {
|
||||
const normalizedOwner = String(owner || '').trim();
|
||||
const normalizedRepo = String(repo || '').trim().replace(/\.git$/i, '');
|
||||
if (!normalizedOwner || !normalizedRepo) {
|
||||
return null;
|
||||
}
|
||||
return { owner: normalizedOwner, repo: normalizedRepo };
|
||||
}
|
||||
|
||||
export function parseSkillRepoSource(input, options = {}) {
|
||||
const raw = typeof input === 'string' ? input.trim() : '';
|
||||
if (!raw) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } };
|
||||
}
|
||||
|
||||
const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null;
|
||||
|
||||
// SSH URL: git@github.com:owner/repo(.git)
|
||||
const sshMatch = raw.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i);
|
||||
if (sshMatch) {
|
||||
const parsed = normalizeGitHubOwnerRepo(sshMatch[1], sshMatch[2]);
|
||||
if (!parsed) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid SSH repository URL' } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
host: GITHUB_HOST,
|
||||
owner: parsed.owner,
|
||||
repo: parsed.repo,
|
||||
cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
|
||||
cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
|
||||
// For SSH URLs, subpath is only accepted via options.subpath
|
||||
effectiveSubpath: explicitSubpath,
|
||||
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
// HTTPS URL: https://github.com/owner/repo(.git)
|
||||
const httpsMatch = raw.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i);
|
||||
if (httpsMatch) {
|
||||
const parsed = normalizeGitHubOwnerRepo(httpsMatch[1], httpsMatch[2]);
|
||||
if (!parsed) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid HTTPS repository URL' } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
host: GITHUB_HOST,
|
||||
owner: parsed.owner,
|
||||
repo: parsed.repo,
|
||||
cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
|
||||
cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
|
||||
effectiveSubpath: explicitSubpath,
|
||||
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Shorthand: owner/repo[/subpath...]
|
||||
const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
|
||||
if (shorthandMatch) {
|
||||
const parsed = normalizeGitHubOwnerRepo(shorthandMatch[1], shorthandMatch[2]);
|
||||
if (!parsed) {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository source' } };
|
||||
}
|
||||
|
||||
const shorthandSubpath = typeof shorthandMatch[3] === 'string' && shorthandMatch[3].trim() ? shorthandMatch[3].trim() : null;
|
||||
const effectiveSubpath = explicitSubpath || shorthandSubpath;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
host: GITHUB_HOST,
|
||||
owner: parsed.owner,
|
||||
repo: parsed.repo,
|
||||
cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
|
||||
cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
|
||||
effectiveSubpath,
|
||||
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } };
|
||||
}
|
||||
Reference in New Issue
Block a user