mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 11:17:44 +02:00
[eric] merge #113 (aidan): skills agent-discovery + multi-file install + versioning
This commit is contained in:
@@ -47,6 +47,9 @@ import {
|
||||
fetchAllRegistrySkills,
|
||||
fetchSkillRegistryStats,
|
||||
fetchSkillDetail,
|
||||
fetchSkillUpdates,
|
||||
installCuratedSkill,
|
||||
updateInstalledSkill,
|
||||
RegistrySkill,
|
||||
RegistrySkillDetail,
|
||||
} from '@/shared/state/skillRegistrySlice';
|
||||
@@ -85,6 +88,7 @@ const Skills: React.FC = () => {
|
||||
stats: regStats,
|
||||
detail: regDetail,
|
||||
detailLoading: regDetailLoading,
|
||||
outdated: regOutdated,
|
||||
} = useAppSelector((s) => s.skillRegistry);
|
||||
const localSkills = Object.values(items);
|
||||
|
||||
@@ -119,6 +123,7 @@ const Skills: React.FC = () => {
|
||||
dispatch(fetchSkills());
|
||||
dispatch(fetchSkillRegistryStats());
|
||||
dispatch(fetchAllRegistrySkills());
|
||||
dispatch(fetchSkillUpdates());
|
||||
}, [dispatch]);
|
||||
|
||||
const regGrouped = useMemo(() => {
|
||||
@@ -186,16 +191,39 @@ const Skills: React.FC = () => {
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!selectedReg) return;
|
||||
await dispatch(createSkill({
|
||||
name: selectedReg.name,
|
||||
description: selectedReg.description,
|
||||
content: selectedReg.content,
|
||||
command: selectedReg.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
}));
|
||||
try {
|
||||
await dispatch(installCuratedSkill(selectedReg.folder)).unwrap();
|
||||
} catch (e) {
|
||||
// unwrap() rejects with a plain serialized object, not an Error instance, so read .message off it directly.
|
||||
const msg = (e as { message?: string })?.message || 'unknown error';
|
||||
setSnackbar({ open: true, message: `Install failed: ${msg}` });
|
||||
return;
|
||||
}
|
||||
await dispatch(fetchSkills());
|
||||
onboardingBus.emit('skill:installed');
|
||||
setSnackbar({ open: true, message: `Installed "${selectedReg.name}" as a local skill` });
|
||||
};
|
||||
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const handleUpdate = async (skill: Skill) => {
|
||||
setUpdatingId(skill.id);
|
||||
let result: { secret_findings: string[] } | null = null;
|
||||
try {
|
||||
result = await dispatch(updateInstalledSkill(skill.id)).unwrap();
|
||||
} catch (e) {
|
||||
const msg = (e as { message?: string })?.message || 'unknown error';
|
||||
setSnackbar({ open: true, message: `Update failed: ${msg}` });
|
||||
setUpdatingId(null);
|
||||
return;
|
||||
}
|
||||
await Promise.all([dispatch(fetchSkills()), dispatch(fetchSkillUpdates())]);
|
||||
setUpdatingId(null);
|
||||
const flagged = result?.secret_findings?.length
|
||||
? ` (heads up: the update ships ${result.secret_findings.length} file(s) with secret-shaped content)`
|
||||
: '';
|
||||
setSnackbar({ open: true, message: `Updated "${skill.name}" to the latest version${flagged}` });
|
||||
};
|
||||
|
||||
const handleEditInstall = () => {
|
||||
if (!selectedReg) return;
|
||||
setEditingId(null);
|
||||
@@ -285,7 +313,8 @@ const Skills: React.FC = () => {
|
||||
onClick: () => void;
|
||||
icon?: React.ReactNode;
|
||||
onboardingId?: string;
|
||||
}> = ({ label, selected, onClick, icon, onboardingId }) => (
|
||||
trailing?: React.ReactNode;
|
||||
}> = ({ label, selected, onClick, icon, onboardingId, trailing }) => (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
data-onboarding={onboardingId}
|
||||
@@ -306,6 +335,7 @@ const Skills: React.FC = () => {
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{trailing && <Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', flexShrink: 0 }}>{trailing}</Box>}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -438,6 +468,9 @@ const Skills: React.FC = () => {
|
||||
selected={isSelected('local', sk.id)}
|
||||
onClick={() => selectLocal(sk.id)}
|
||||
icon={<FolderIcon sx={{ fontSize: 15, color: c.text.tertiary, flexShrink: 0 }} />}
|
||||
trailing={regOutdated.includes(sk.id)
|
||||
? <Tooltip title="Update available"><Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: c.status.warning }} /></Tooltip>
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -641,8 +674,27 @@ const Skills: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{regOutdated.includes(selectedLocal.id) && (
|
||||
<Chip
|
||||
label="Update available"
|
||||
size="small"
|
||||
sx={{ bgcolor: `${c.status.warning}22`, color: c.status.warning, fontWeight: 600, fontSize: '0.7rem', height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
{regOutdated.includes(selectedLocal.id) && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon sx={{ fontSize: 16 }} />}
|
||||
disabled={updatingId === selectedLocal.id}
|
||||
onClick={() => handleUpdate(selectedLocal)}
|
||||
sx={{ textTransform: 'none', fontSize: '0.78rem', py: 0.3, bgcolor: c.status.warning, '&:hover': { bgcolor: c.status.warning } }}
|
||||
>
|
||||
{updatingId === selectedLocal.id ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
)}
|
||||
<ShareButton target={{ kind: 'skill', id: selectedLocal.id, name: selectedLocal.name }} />
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>
|
||||
|
||||
@@ -20,6 +20,7 @@ import BlockIcon from '@mui/icons-material/Block';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import PanToolIcon from '@mui/icons-material/PanTool';
|
||||
import CallSplitIcon from '@mui/icons-material/CallSplit';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import { BuiltinTool } from '@/shared/state/toolsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { CATEGORY_ORDER } from '../toolsHelpers';
|
||||
@@ -60,6 +61,7 @@ const ToolSection: React.FC<ToolSectionProps> = ({
|
||||
planning: { label: 'Planning', color: '#ec4899', icon: <MapIcon sx={{ fontSize: 16 }} /> },
|
||||
scheduling: { label: 'Scheduling', color: '#14b8a6', icon: <ScheduleIcon sx={{ fontSize: 16 }} /> },
|
||||
agents: { label: 'Agents', color: '#f97316', icon: <CallSplitIcon sx={{ fontSize: 16 }} /> },
|
||||
skills: { label: 'Skills', color: '#7B61BD', icon: <AutoAwesomeIcon sx={{ fontSize: 16 }} /> },
|
||||
};
|
||||
|
||||
const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { McpServer } from '@/shared/state/mcpRegistrySlice';
|
||||
|
||||
export const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling'];
|
||||
export const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'skills', 'planning', 'scheduling'];
|
||||
|
||||
export interface ToolForm {
|
||||
name: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { Skill } from '@/shared/state/skillsSlice';
|
||||
|
||||
const SKILL_REGISTRY_API = `${API_BASE}/skill-registry`;
|
||||
|
||||
@@ -24,6 +25,7 @@ interface SkillRegistryState {
|
||||
stats: { total: number; categories: Record<string, number>; lastUpdated: number } | null;
|
||||
detail: RegistrySkillDetail | null;
|
||||
detailLoading: boolean;
|
||||
outdated: string[];
|
||||
}
|
||||
|
||||
const initialState: SkillRegistryState = {
|
||||
@@ -35,6 +37,7 @@ const initialState: SkillRegistryState = {
|
||||
stats: null,
|
||||
detail: null,
|
||||
detailLoading: false,
|
||||
outdated: [],
|
||||
};
|
||||
|
||||
export const searchSkillRegistry = createAsyncThunk(
|
||||
@@ -69,6 +72,60 @@ export const fetchSkillDetail = createAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
export interface CuratedInstallResult {
|
||||
installed: boolean;
|
||||
skill: Skill;
|
||||
files: string[];
|
||||
scripts: string[];
|
||||
}
|
||||
|
||||
// Curated install fetches the WHOLE skill folder (scripts/assets), not just SKILL.md, so multi-file skills land complete. Caller refreshes the local skills list after.
|
||||
export const installCuratedSkill = createAsyncThunk(
|
||||
'skillRegistry/installCurated',
|
||||
async (folder: string) => {
|
||||
const res = await fetch(`${SKILL_REGISTRY_API}/install-curated`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.detail || `install failed (${res.status})`);
|
||||
}
|
||||
return (await res.json()) as CuratedInstallResult;
|
||||
},
|
||||
);
|
||||
|
||||
export interface SkillUpdatesResult {
|
||||
outdated: string[];
|
||||
checked: string[];
|
||||
unknown: string[];
|
||||
}
|
||||
|
||||
// Which installed skills have a newer version upstream. Curated checks are free (cached tree); community checks are best-effort.
|
||||
export const fetchSkillUpdates = createAsyncThunk('skillRegistry/updates', async () => {
|
||||
const res = await fetch(`${SKILL_REGISTRY_API}/updates`);
|
||||
if (!res.ok) throw new Error(`updates check failed (${res.status})`);
|
||||
return (await res.json()) as SkillUpdatesResult;
|
||||
});
|
||||
|
||||
// Re-fetch an installed skill from its recorded source and overwrite it in place, bumping its version. Caller refreshes the local skills list + updates after.
|
||||
export const updateInstalledSkill = createAsyncThunk(
|
||||
'skillRegistry/updateInstalled',
|
||||
async (skillId: string) => {
|
||||
const res = await fetch(`${SKILL_REGISTRY_API}/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ skill_id: skillId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.detail || `update failed (${res.status})`);
|
||||
}
|
||||
return (await res.json()) as { updated: boolean; skill: Skill; scripts: string[]; secret_findings: string[] };
|
||||
},
|
||||
);
|
||||
|
||||
const skillRegistrySlice = createSlice({
|
||||
name: 'skillRegistry',
|
||||
initialState,
|
||||
@@ -119,6 +176,9 @@ const skillRegistrySlice = createSlice({
|
||||
})
|
||||
.addCase(fetchSkillDetail.rejected, (state) => {
|
||||
state.detailLoading = false;
|
||||
})
|
||||
.addCase(fetchSkillUpdates.fulfilled, (state, action) => {
|
||||
state.outdated = action.payload.outdated;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface Skill {
|
||||
command: string;
|
||||
/** Platform-shipped skill; UI hides delete, backend DELETE returns 409. Content still editable. */
|
||||
built_in?: boolean;
|
||||
/** Provenance for registry-installed skills (used for update detection). Empty for user-created skills. */
|
||||
source?: string;
|
||||
folder?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface SkillsState {
|
||||
|
||||
Reference in New Issue
Block a user