mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
3 Commits
v0.3.23
...
fix/skills
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d259431de4 | ||
|
|
1d8179a4f6 | ||
|
|
07f5326fc3 |
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HardDrive, Download, AlertCircle } from "lucide-react";
|
||||
import type { AgentRuntime, Skill } from "@multica/core/types";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
runtimeListOptions,
|
||||
runtimeLocalSkillsKeys,
|
||||
runtimeLocalSkillsOptions,
|
||||
resolveRuntimeLocalSkillImport,
|
||||
} from "@multica/core/runtimes";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Badge } from "@multica/ui/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { RuntimeLocalSkillRow } from "./runtime-local-skill-row";
|
||||
|
||||
function runtimeLabel(runtime: AgentRuntime): string {
|
||||
return `${runtime.name} (${runtime.provider})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the "import local runtime skill into workspace" flow, extracted
|
||||
* from RuntimeLocalSkillImportDialog so it can be reused inside the unified
|
||||
* Add-Workspace-Skill dialog as a tab. Owns its own state, runtime/skill
|
||||
* picker, and Import button so the parent only needs to render it inside a
|
||||
* scroll/dialog container — no slot juggling.
|
||||
*
|
||||
* `active` lets the parent (e.g. a Tabs panel) tell the panel when it is
|
||||
* the visible tab; the panel uses that to seed defaults the first time it
|
||||
* opens, mirroring how the standalone dialog reacts to `open` going true.
|
||||
*/
|
||||
export function RuntimeLocalSkillImportPanel({
|
||||
active,
|
||||
onImported,
|
||||
initialRuntimeId,
|
||||
initialSkillKey,
|
||||
fixedRuntimeId,
|
||||
}: {
|
||||
active: boolean;
|
||||
onImported?: (skill: Skill) => void;
|
||||
initialRuntimeId?: string | null;
|
||||
initialSkillKey?: string | null;
|
||||
fixedRuntimeId?: string | null;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Only the runtime owner can browse + import its local skills (server-side
|
||||
// ACL enforces this), so listing other people's runtimes here just sets
|
||||
// the user up for a permission error after the fact. Match the Runtimes
|
||||
// page's "Mine" tab default and only show the caller's own local runtimes.
|
||||
const localRuntimes = useMemo(
|
||||
() =>
|
||||
runtimes.filter(
|
||||
(runtime) =>
|
||||
runtime.runtime_mode === "local" &&
|
||||
(userId == null || runtime.owner_id === userId),
|
||||
),
|
||||
[runtimes, userId],
|
||||
);
|
||||
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState<string>("");
|
||||
const [selectedSkillKey, setSelectedSkillKey] = useState<string>("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const preferredRuntimeId =
|
||||
fixedRuntimeId ?? initialRuntimeId ?? localRuntimes[0]?.id ?? "";
|
||||
setSelectedRuntimeId(preferredRuntimeId);
|
||||
}, [fixedRuntimeId, initialRuntimeId, localRuntimes, active]);
|
||||
|
||||
const selectedRuntime = localRuntimes.find(
|
||||
(runtime) => runtime.id === selectedRuntimeId,
|
||||
);
|
||||
const canBrowseSkills = active && !!selectedRuntimeId && selectedRuntime?.status === "online";
|
||||
const skillsQuery = useQuery({
|
||||
...runtimeLocalSkillsOptions(selectedRuntimeId || null),
|
||||
enabled: canBrowseSkills,
|
||||
});
|
||||
|
||||
const runtimeSkills = skillsQuery.data?.skills ?? [];
|
||||
const selectedSkill = runtimeSkills.find((skill) => skill.key === selectedSkillKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const preferredSkill =
|
||||
(initialSkillKey
|
||||
? runtimeSkills.find((skill) => skill.key === initialSkillKey)
|
||||
: null) ?? runtimeSkills[0];
|
||||
if (!preferredSkill) {
|
||||
setSelectedSkillKey("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
return;
|
||||
}
|
||||
if (!runtimeSkills.some((skill) => skill.key === selectedSkillKey)) {
|
||||
setSelectedSkillKey(preferredSkill.key);
|
||||
setName(preferredSkill.name);
|
||||
setDescription(preferredSkill.description ?? "");
|
||||
}
|
||||
}, [initialSkillKey, active, runtimeSkills, selectedSkillKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkill) return;
|
||||
setName(selectedSkill.name);
|
||||
setDescription(selectedSkill.description ?? "");
|
||||
}, [selectedSkillKey, selectedSkill]);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedRuntimeId || !selectedSkill) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await resolveRuntimeLocalSkillImport(selectedRuntimeId, {
|
||||
skill_key: selectedSkill.key,
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: runtimeLocalSkillsKeys.forRuntime(selectedRuntimeId) }),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }),
|
||||
]);
|
||||
toast.success("Skill imported");
|
||||
onImported?.(result.skill);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to import skill");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSkillContent = () => {
|
||||
if (localRuntimes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No local runtimes available</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Connect a local runtime to browse and import its local skills.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!selectedRuntime) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">Choose a runtime to continue</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (selectedRuntime.status !== "online") {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md bg-warning/10 px-3 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
Runtime must be online to browse local skills.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (skillsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-lg border px-4 py-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-2 h-3 w-48" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (skillsQuery.error) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{skillsQuery.error instanceof Error
|
||||
? skillsQuery.error.message
|
||||
: "Failed to load runtime local skills"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!skillsQuery.data?.supported) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
This runtime provider does not expose local skill inventory yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (runtimeSkills.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No local skills found</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This runtime does not have any discoverable local skills yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{runtimeSkills.map((skill) => (
|
||||
<RuntimeLocalSkillRow
|
||||
key={skill.key}
|
||||
skill={skill}
|
||||
selected={selectedSkillKey === skill.key}
|
||||
onSelect={() => setSelectedSkillKey(skill.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSkill && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Workspace skill name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="Optional description override"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const canImport = !!selectedRuntime
|
||||
&& selectedRuntime.status === "online"
|
||||
&& !!selectedSkill
|
||||
&& !!name.trim()
|
||||
&& !importing;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!fixedRuntimeId && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
<Select value={selectedRuntimeId} onValueChange={(value) => value && setSelectedRuntimeId(value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a local runtime">
|
||||
{selectedRuntime ? runtimeLabel(selectedRuntime) : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localRuntimes.map((runtime) => (
|
||||
<SelectItem key={runtime.id} value={runtime.id}>
|
||||
{runtimeLabel(runtime)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRuntime && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{runtimeLabel(selectedRuntime)}</span>
|
||||
<Badge variant={selectedRuntime.status === "online" ? "secondary" : "outline"}>
|
||||
{selectedRuntime.status}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderSkillContent()}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Symlinks, unreadable files, oversized files, and very large bundles are ignored during import.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleImport} disabled={!canImport}>
|
||||
{importing ? (
|
||||
"Importing..."
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3" />
|
||||
Import to Workspace
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,18 @@ vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
// The runtime selector now filters to runtimes owned by the current user
|
||||
// to mirror the Runtimes page's "Mine" default. Stub useAuthStore so the
|
||||
// panel sees user-1 — the owner of the seeded runtime in beforeEach.
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const stateUser = { id: "user-1", email: "u@example.com", name: "User" };
|
||||
const useAuthStore = (selector?: any) => {
|
||||
const state = { user: stateUser };
|
||||
return selector ? selector(state) : state;
|
||||
};
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listSkills: (...args: unknown[]) => mockListSkills(...args),
|
||||
@@ -133,15 +145,20 @@ describe("SkillsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the runtime import dialog and imports a local skill", async () => {
|
||||
it("imports a local skill via the From Runtime tab in the Add Skill dialog", async () => {
|
||||
renderSkillsPage();
|
||||
|
||||
const importButtons = await screen.findAllByRole("button", {
|
||||
name: /Import From Runtime/i,
|
||||
});
|
||||
fireEvent.click(importButtons[0]!);
|
||||
// Old flow had a dedicated "Import From Runtime" button. The dialog
|
||||
// now has a single "+ Add skill" entry point with three tabs; the
|
||||
// empty-state row also surfaces the same "Add Skill" button. Either
|
||||
// opens the unified dialog.
|
||||
const addButtons = await screen.findAllByRole("button", { name: /Add Skill/i });
|
||||
fireEvent.click(addButtons[0]!);
|
||||
|
||||
expect(await screen.findByText("Add Workspace Skill")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: /From Runtime/i }));
|
||||
|
||||
expect(await screen.findByText("Import Runtime Skill")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Review Helper")).toBeInTheDocument();
|
||||
|
||||
const importButton = screen.getByRole("button", {
|
||||
|
||||
@@ -41,7 +41,7 @@ import { skillListOptions, workspaceKeys } from "@multica/core/workspace/queries
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { FileTree } from "./file-tree";
|
||||
import { FileViewer } from "./file-viewer";
|
||||
import { RuntimeLocalSkillImportDialog } from "./runtime-local-skill-import-dialog";
|
||||
import { RuntimeLocalSkillImportPanel } from "./runtime-local-skill-import-panel";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create Skill Dialog
|
||||
@@ -51,12 +51,14 @@ function CreateSkillDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
onImport,
|
||||
onRuntimeImported,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreate: (data: CreateSkillRequest) => Promise<void>;
|
||||
onImport: (url: string) => Promise<void>;
|
||||
onRuntimeImported?: (skill: Skill) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"create" | "import">("create");
|
||||
const [tab, setTab] = useState<"create" | "import" | "runtime">("create");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
@@ -96,15 +98,21 @@ function CreateSkillDialog({
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent
|
||||
className={`flex max-h-[85vh] flex-col ${tab === "runtime" ? "sm:max-w-2xl" : "sm:max-w-md"}`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Workspace Skill</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new skill or import from ClawHub / Skills.sh. Workspace skills are shared with your team and automatically injected into agent runs.
|
||||
Create a new skill, import from ClawHub / Skills.sh, or pull one in from a connected runtime. Workspace skills are shared with your team and automatically injected into agent runs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "create" | "import")}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as "create" | "import" | "runtime")}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="create" className="flex-1">
|
||||
<Plus className="mr-1.5 h-3 w-3" />
|
||||
@@ -112,11 +120,17 @@ function CreateSkillDialog({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import" className="flex-1">
|
||||
<Download className="mr-1.5 h-3 w-3" />
|
||||
Import
|
||||
Import URL
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="runtime" className="flex-1">
|
||||
<HardDrive className="mr-1.5 h-3 w-3" />
|
||||
From Runtime
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="create" className="space-y-4 mt-4 min-h-[180px]">
|
||||
<div className="-mx-6 mt-4 flex-1 overflow-y-auto px-6">
|
||||
|
||||
<TabsContent value="create" className="space-y-4 min-h-[180px]">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
@@ -141,7 +155,7 @@ function CreateSkillDialog({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="import" className="space-y-4 mt-4 min-h-[180px]">
|
||||
<TabsContent value="import" className="space-y-4 min-h-[180px]">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Skill URL</Label>
|
||||
<Input
|
||||
@@ -189,15 +203,27 @@ function CreateSkillDialog({
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime" className="min-h-[180px]">
|
||||
<RuntimeLocalSkillImportPanel
|
||||
active={tab === "runtime"}
|
||||
onImported={(skill) => {
|
||||
onRuntimeImported?.(skill);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
{tab === "create" ? (
|
||||
{tab === "create" && (
|
||||
<Button onClick={handleCreate} disabled={loading || !name.trim()}>
|
||||
{loading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
) : (
|
||||
)}
|
||||
{tab === "import" && (
|
||||
<Button onClick={handleImport} disabled={loading || !importUrl.trim()}>
|
||||
{loading ? (
|
||||
detectedSource === "clawhub"
|
||||
@@ -213,6 +239,9 @@ function CreateSkillDialog({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* The runtime tab embeds its own "Import to Workspace" button
|
||||
inside the panel since it can only enable once a runtime +
|
||||
skill are picked. */}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -439,9 +468,6 @@ function SkillDetail({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 flex-1 min-w-0">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -620,7 +646,6 @@ export default function SkillsPage() {
|
||||
const { data: skills = [], isLoading } = useQuery(skillListOptions(wsId));
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showRuntimeImport, setShowRuntimeImport] = useState(false);
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_skills_layout",
|
||||
});
|
||||
@@ -728,36 +753,20 @@ export default function SkillsPage() {
|
||||
<div className="overflow-y-auto h-full border-r">
|
||||
<PageHeader className="justify-between">
|
||||
<h1 className="text-sm font-semibold">Skills</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowRuntimeImport(true)}
|
||||
>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Import from runtime</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Create skill</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Add skill</TooltipContent>
|
||||
</Tooltip>
|
||||
</PageHeader>
|
||||
{skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
@@ -766,23 +775,14 @@ export default function SkillsPage() {
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center max-w-[280px]">
|
||||
Workspace skills are shared across your team and injected into agent runs. Skills already installed in your local runtime are used automatically.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setShowRuntimeImport(true)}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
<HardDrive className="h-3 w-3" />
|
||||
Import From Runtime
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Skill
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Skill
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
@@ -824,16 +824,7 @@ export default function SkillsPage() {
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Skill
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowRuntimeImport(true)}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
>
|
||||
<HardDrive className="h-3 w-3" />
|
||||
Import From Runtime
|
||||
Add Skill
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -845,13 +836,7 @@ export default function SkillsPage() {
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={handleCreate}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
)}
|
||||
{showRuntimeImport && (
|
||||
<RuntimeLocalSkillImportDialog
|
||||
open={showRuntimeImport}
|
||||
onClose={() => setShowRuntimeImport(false)}
|
||||
onImported={(skill) => setSelectedId(skill.id)}
|
||||
onRuntimeImported={(skill) => setSelectedId(skill.id)}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -13,6 +13,11 @@ const (
|
||||
maxLocalSkillFileSize int64 = 1 << 20
|
||||
maxLocalSkillBundleSize int64 = 8 << 20
|
||||
maxLocalSkillFileCount = 128
|
||||
// Cap how deep skill discovery descends below a runtime root. opencode
|
||||
// stores skills two levels deep (e.g. `release/reporter/SKILL.md`); a
|
||||
// few extra levels covers any realistic future layout while bounding
|
||||
// work in case an installer accidentally points us at $HOME.
|
||||
maxLocalSkillDirDepth = 4
|
||||
)
|
||||
|
||||
type runtimeLocalSkillSummary struct {
|
||||
@@ -156,11 +161,21 @@ func collectLocalSkillFiles(skillDir string, includeContent bool) ([]SkillFileDa
|
||||
files := make([]SkillFileData, 0)
|
||||
var totalSize int64
|
||||
|
||||
err := filepath.WalkDir(skillDir, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
// filepath.WalkDir does not follow a symlinked root, so when the runtime
|
||||
// root contains symlinks into a shared skill installer (e.g. lark-cli's
|
||||
// ~/.agents/skills/<name>) walking from the symlink path enumerates zero
|
||||
// children and every such skill ends up reporting 0 files. Resolve the
|
||||
// real path first so the walk descends into the actual directory.
|
||||
walkRoot := skillDir
|
||||
if resolved, err := filepath.EvalSymlinks(skillDir); err == nil {
|
||||
walkRoot = resolved
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(walkRoot, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return nil
|
||||
}
|
||||
if path == skillDir {
|
||||
if path == walkRoot {
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
@@ -179,7 +194,7 @@ func collectLocalSkillFiles(skillDir string, includeContent bool) ([]SkillFileDa
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(skillDir, path)
|
||||
rel, err := filepath.Rel(walkRoot, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -234,68 +249,18 @@ func listRuntimeLocalSkills(provider string) ([]runtimeLocalSkillSummary, bool,
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
// Walk the runtime root with two extensions over filepath.WalkDir:
|
||||
// - Follow symlinks at every level. Installers like lark-cli ship
|
||||
// each skill as a symlink into a shared ~/.agents/skills/<name>;
|
||||
// the previous WalkDir path silently dropped them via the
|
||||
// os.ModeSymlink early return.
|
||||
// - Allow nested layouts. opencode stores skills as
|
||||
// `release/reporter/SKILL.md`, and `loadRuntimeLocalSkillBundle`
|
||||
// already accepts slash-delimited keys, so the list endpoint
|
||||
// must surface those nested skills too.
|
||||
skills := make([]runtimeLocalSkillSummary, 0)
|
||||
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return nil
|
||||
}
|
||||
if path == root {
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if isIgnoredLocalSkillEntry(entry.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
mainPath := filepath.Join(path, "SKILL.md")
|
||||
if _, err := os.Stat(mainPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
key, err := normalizeLocalSkillKey(rel)
|
||||
if err != nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
content, err := readLocalSkillMainFile(path)
|
||||
if err != nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
name, description := parseLocalSkillFrontmatter(content)
|
||||
if name == "" {
|
||||
name = filepath.Base(path)
|
||||
}
|
||||
|
||||
files, err := collectLocalSkillFiles(path, false)
|
||||
if err != nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
skills = append(skills, runtimeLocalSkillSummary{
|
||||
Key: key,
|
||||
Name: name,
|
||||
Description: description,
|
||||
SourcePath: relativizeHomePath(path),
|
||||
Provider: provider,
|
||||
FileCount: len(files),
|
||||
})
|
||||
return filepath.SkipDir
|
||||
})
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
visited := make(map[string]bool)
|
||||
enumerateLocalSkills(provider, root, root, 0, visited, &skills)
|
||||
|
||||
sort.Slice(skills, func(i, j int) bool {
|
||||
return skills[i].Key < skills[j].Key
|
||||
@@ -303,6 +268,96 @@ func listRuntimeLocalSkills(provider string) ([]runtimeLocalSkillSummary, bool,
|
||||
return skills, true, nil
|
||||
}
|
||||
|
||||
// enumerateLocalSkills walks `currentDir` looking for skill directories
|
||||
// (directories that contain a SKILL.md). When one is found it is registered
|
||||
// at a key relative to `walkRoot` and the recursion stops at that branch —
|
||||
// we never descend into a directory that already qualifies as a skill, even
|
||||
// if it happens to contain nested SKILL.md files of its own.
|
||||
//
|
||||
// `visited` keys on the resolved (symlink-followed) absolute path so a
|
||||
// cyclic symlink can't loop forever; this is the only reason we eagerly
|
||||
// EvalSymlinks up front. Errors from EvalSymlinks just stop the descent on
|
||||
// that branch — most often it's a dangling link, which we want to ignore.
|
||||
func enumerateLocalSkills(
|
||||
provider, walkRoot, currentDir string,
|
||||
depth int,
|
||||
visited map[string]bool,
|
||||
skills *[]runtimeLocalSkillSummary,
|
||||
) {
|
||||
if depth > maxLocalSkillDirDepth {
|
||||
return
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(currentDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if visited[resolved] {
|
||||
return
|
||||
}
|
||||
visited[resolved] = true
|
||||
|
||||
entries, err := os.ReadDir(currentDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if isIgnoredLocalSkillEntry(name) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(currentDir, name)
|
||||
info, statErr := os.Stat(path) // follows symlinks
|
||||
if statErr != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
mainPath := filepath.Join(path, "SKILL.md")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
rel, err := filepath.Rel(walkRoot, path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key, err := normalizeLocalSkillKey(rel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := readLocalSkillMainFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
skillName, description := parseLocalSkillFrontmatter(content)
|
||||
if skillName == "" {
|
||||
skillName = filepath.Base(path)
|
||||
}
|
||||
|
||||
files, err := collectLocalSkillFiles(path, false)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
*skills = append(*skills, runtimeLocalSkillSummary{
|
||||
Key: key,
|
||||
Name: skillName,
|
||||
Description: description,
|
||||
SourcePath: relativizeHomePath(path),
|
||||
Provider: provider,
|
||||
// `files` is the supporting bundle (collectLocalSkillFiles
|
||||
// intentionally excludes SKILL.md so the bundle's `Content`
|
||||
// field can carry it without duplication on import). For the
|
||||
// list summary the user expects the total file count, so add
|
||||
// one back for SKILL.md itself.
|
||||
FileCount: len(files) + 1,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// No SKILL.md here — descend looking for nested skills.
|
||||
enumerateLocalSkills(provider, walkRoot, path, depth+1, visited, skills)
|
||||
}
|
||||
}
|
||||
|
||||
func loadRuntimeLocalSkillBundle(provider, skillKey string) (*runtimeLocalSkillBundle, bool, error) {
|
||||
root, supported, err := localSkillRootForProvider(provider)
|
||||
if err != nil || !supported {
|
||||
|
||||
@@ -3,6 +3,7 @@ package daemon
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -57,14 +58,78 @@ func TestListRuntimeLocalSkills_Claude(t *testing.T) {
|
||||
if skill.Description != "Review pull requests" {
|
||||
t.Fatalf("description = %q", skill.Description)
|
||||
}
|
||||
if skill.FileCount != 1 {
|
||||
t.Fatalf("file_count = %d, want 1", skill.FileCount)
|
||||
// 2 = supporting file (templates/check.md) + SKILL.md itself.
|
||||
// Bundle file count purposely excludes SKILL.md (it travels in
|
||||
// `Content`) but the summary count adds it back so the user sees
|
||||
// the real total.
|
||||
if skill.FileCount != 2 {
|
||||
t.Fatalf("file_count = %d, want 2", skill.FileCount)
|
||||
}
|
||||
if skill.SourcePath != "~/.claude/skills/review-helper" {
|
||||
t.Fatalf("source_path = %q", skill.SourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Skill installers (for example lark-cli) place every skill at a shared
|
||||
// location like ~/.agents/skills/<name> and symlink each one into the
|
||||
// runtime root (~/.claude/skills/<name>). The previous filepath.WalkDir
|
||||
// path filtered every symlink out via os.ModeSymlink, so users with
|
||||
// dozens of installed skills only saw the few they had cloned in place.
|
||||
// listRuntimeLocalSkills must follow those symlinks.
|
||||
func TestListRuntimeLocalSkills_FollowsSymlinkedSkillDirs(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
// Real skill lives outside the runtime root.
|
||||
target := writeTestLocalSkill(t, filepath.Join(home, ".agents", "skills"), "lark-doc", map[string]string{
|
||||
"SKILL.md": "---\nname: Lark Doc\ndescription: Drive lark docs\n---\n# Lark Doc\n",
|
||||
"helper.md": "stub",
|
||||
})
|
||||
|
||||
// Runtime root points at it via symlink, the way installers ship it.
|
||||
skillsRoot := filepath.Join(home, ".claude", "skills")
|
||||
if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
|
||||
t.Fatalf("mkdir skills root: %v", err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(skillsRoot, "lark-doc")); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
// Sanity: also seed a regular non-symlink skill so we know enumeration
|
||||
// returns both, in stable order.
|
||||
writeTestLocalSkill(t, skillsRoot, "review-helper", map[string]string{
|
||||
"SKILL.md": "---\nname: Review Helper\n---\n",
|
||||
})
|
||||
|
||||
skills, supported, err := listRuntimeLocalSkills("claude")
|
||||
if err != nil {
|
||||
t.Fatalf("listRuntimeLocalSkills: %v", err)
|
||||
}
|
||||
if !supported {
|
||||
t.Fatal("claude should be supported")
|
||||
}
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("expected 2 skills, got %d (%v)", len(skills), skills)
|
||||
}
|
||||
|
||||
bySymlinkKey := skills[0]
|
||||
if bySymlinkKey.Key != "lark-doc" {
|
||||
bySymlinkKey = skills[1]
|
||||
}
|
||||
if bySymlinkKey.Key != "lark-doc" {
|
||||
t.Fatalf("symlinked skill missing from result: %v", skills)
|
||||
}
|
||||
if bySymlinkKey.Name != "Lark Doc" {
|
||||
t.Fatalf("symlinked skill name = %q, want Lark Doc", bySymlinkKey.Name)
|
||||
}
|
||||
// Source path is reported relative to the *runtime root* (~/.claude/...),
|
||||
// not the resolved target — that's what the user expects to see in the
|
||||
// import dialog and matches the non-symlink case.
|
||||
if bySymlinkKey.SourcePath != "~/.claude/skills/lark-doc" {
|
||||
t.Fatalf("symlinked skill source_path = %q", bySymlinkKey.SourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRuntimeLocalSkills_CodexUsesSharedCODEXHOME(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
codexHome := t.TempDir()
|
||||
@@ -96,6 +161,55 @@ func TestListRuntimeLocalSkills_CodexUsesSharedCODEXHOME(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// opencode (and possibly future providers) lay skills out one level deep,
|
||||
// e.g. ~/.config/opencode/skills/release/reporter/SKILL.md.
|
||||
// loadRuntimeLocalSkillBundle already accepts that nested key, so the list
|
||||
// endpoint must surface those skills too — otherwise the import dialog
|
||||
// hides skills the load endpoint can fetch and users can't pick them.
|
||||
//
|
||||
// The walker also has to short-circuit at the outermost SKILL.md it finds:
|
||||
// nested SKILL.md files inside an already-registered skill (e.g. inside
|
||||
// `top/SKILL.md`'s own template tree) are part of the parent skill's
|
||||
// bundle, not separate skills.
|
||||
func TestListRuntimeLocalSkills_DescendsIntoNestedSkillDirs(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
root := filepath.Join(home, ".config", "opencode", "skills")
|
||||
|
||||
// Top-level skill — should register at key="top" and its child SKILL.md
|
||||
// must NOT register as a separate skill.
|
||||
writeTestLocalSkill(t, root, "top", map[string]string{
|
||||
"SKILL.md": "---\nname: Top\n---\n",
|
||||
"templates/SKILL.md": "not a real skill — sub-template that happens to share the filename",
|
||||
})
|
||||
|
||||
// Nested skill — only valid SKILL.md is at depth 2.
|
||||
writeTestLocalSkill(t, root, "release/reporter", map[string]string{
|
||||
"SKILL.md": "---\nname: Release Reporter\n---\n",
|
||||
})
|
||||
|
||||
skills, supported, err := listRuntimeLocalSkills("opencode")
|
||||
if err != nil {
|
||||
t.Fatalf("listRuntimeLocalSkills: %v", err)
|
||||
}
|
||||
if !supported {
|
||||
t.Fatal("opencode should be supported")
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(skills))
|
||||
for _, s := range skills {
|
||||
keys = append(keys, s.Key)
|
||||
}
|
||||
// Two registered skills, "top" and "release/reporter" — and crucially
|
||||
// NOT "top/templates" (the inner SKILL.md must be ignored once the
|
||||
// parent qualified).
|
||||
wantKeys := []string{"release/reporter", "top"}
|
||||
if !reflect.DeepEqual(keys, wantKeys) {
|
||||
t.Fatalf("keys = %v, want %v", keys, wantKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeLocalSkillBundle_OpenCode(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
Reference in New Issue
Block a user