Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
d259431de4 fix(daemon): list nested skills, not just depth-1 entries
Per #1480 review (MUL-1246): switching listRuntimeLocalSkills from
filepath.WalkDir to flat ReadDir lost coverage for nested skill
layouts. opencode stores skills as e.g. `release/reporter/SKILL.md`,
and loadRuntimeLocalSkillBundle accepts that slash-delimited key, so
the import dialog could no longer surface skills the load endpoint
was perfectly happy to fetch.

Replace the flat ReadDir with a recursive enumerator that:

- Follows symlinks at every level (so installer-style symlinked skill
  trees still work — that was the original reason for moving off
  WalkDir).
- Short-circuits at every SKILL.md: a directory that qualifies as a
  skill is registered, and its children are NOT scanned for further
  skills. Stale nested SKILL.md files inside a parent skill's bundle
  stay part of that bundle.
- Caps recursion at maxLocalSkillDirDepth=4 (covers opencode's depth=2
  with headroom) and tracks visited resolved paths so a cyclic symlink
  can't loop forever.

New regression test seeds both a top-level skill (with a decoy
SKILL.md inside its templates dir) and a depth-2 nested skill, and
asserts the walker registers exactly two keys — "top" and
"release/reporter" — with the inner templates SKILL.md correctly
ignored.
2026-04-22 14:34:25 +08:00
Jiang Bohan
1d8179a4f6 test(skills): drive new Add Skill dialog flow in skills-page test
Old test asserted the standalone "Import From Runtime" button. The PR
folded that into the unified "+ Add skill" dialog as the third tab, so
the test now opens the dialog, switches to the "From Runtime" tab, and
asserts the same end state.

Also stub useAuthStore so the runtime panel's "Mine"-only filter sees
the seeded runtime owner (user-1).
2026-04-22 14:20:19 +08:00
Jiang Bohan
07f5326fc3 fix(skills): unify Add Skill UX + surface every local skill with real file count
Iterating on the local-skill import flow that just landed. Three fixes
shipped together because they all surfaced while testing the same code
path on the Skills page.

UX — fold runtime import into the existing "+ Add Skill" dialog
- Drop the standalone HardDrive icon button + the empty-state
  "Import From Runtime" buttons. Adding a skill is now a single entry
  point: the "+" header button (or empty-state button) opens one dialog
  with three tabs: Create / Import URL / From Runtime.
- Extract the runtime-import body into RuntimeLocalSkillImportPanel so
  it can mount inline as a tab. The standalone Dialog wrapper stays for
  the per-runtime "Import this skill" flow on the agent skills tab,
  which preselects runtime + skill and benefits from its own modal.
- Cap the dialog at max-h-[85vh] with a scrollable tabs body so the
  From-Runtime tab (runtime selector + skill list + name/description
  form) no longer overflows the screen on shorter displays.
- Filter the runtime selector to runtimes the caller owns. Other users'
  runtimes were listed but the import endpoint rejects them anyway,
  matching the Runtimes page's "Mine" default.
- The selected-runtime label in the trigger now shows the runtime name
  (`Claude (MacBook-Air.local) (claude)`) instead of the raw UUID — the
  shadcn SelectValue needs explicit children when items don't render
  the bare value as their label.
- Drop the placeholder Sparkles icon to the left of the skill name /
  description inputs in the detail header — it was decorative noise.

Daemon — surface every installed local skill and report the right count
- listRuntimeLocalSkills used filepath.WalkDir, which silently dropped
  every symlinked skill via the os.ModeSymlink early return. Skill
  installers like lark-cli ship every skill at ~/.agents/skills/<name>
  and symlink each one into ~/.claude/skills/, so users with dozens of
  skills only saw the few they had cloned in place. Switch to ReadDir
  + os.Stat (which follows symlinks) on the runtime root.
- collectLocalSkillFiles also failed for symlinked skill dirs because
  filepath.WalkDir does not descend into a symlinked root, so every
  such skill reported 0 files. Resolve the skill dir via EvalSymlinks
  before walking.
- Bundle file count purposely excludes SKILL.md (it travels in the
  bundle's `Content` field to avoid duplication on import). The summary
  now adds 1 back so the user-facing count matches the real file total
  — every skill has SKILL.md, we just required it to be parseable.

Tests
- New TestListRuntimeLocalSkills_FollowsSymlinkedSkillDirs seeds a
  shared installer dir, symlinks one skill into the runtime root, and
  asserts both regular and symlinked skills come back with the right
  source path (~/.claude/...) and metadata.
- TestListRuntimeLocalSkills_Claude updated to expect file_count = 2
  (one supporting file + SKILL.md) and a comment explains the +1 split.
2026-04-22 14:15:11 +08:00
5 changed files with 633 additions and 150 deletions

View File

@@ -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>
);
}

View File

@@ -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", {

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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)