feat(acp): expose built-in skills through sources list acp calls (#9045)
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
This commit is contained in:
@@ -30,6 +30,7 @@ describe("listSkills", () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ sources: [] })
|
||||
.mockResolvedValueOnce({
|
||||
sources: [
|
||||
{
|
||||
@@ -58,6 +59,9 @@ describe("listSkills", () => {
|
||||
type: "skill",
|
||||
});
|
||||
expect(mockGooseSourcesList).toHaveBeenNthCalledWith(2, {
|
||||
type: "builtinSkill",
|
||||
});
|
||||
expect(mockGooseSourcesList).toHaveBeenNthCalledWith(3, {
|
||||
type: "skill",
|
||||
projectDir: "/tmp/alpha",
|
||||
});
|
||||
@@ -84,6 +88,7 @@ describe("listSkills", () => {
|
||||
|
||||
it("recognizes legacy .goose project skill paths", async () => {
|
||||
mockGooseSourcesList
|
||||
.mockResolvedValueOnce({ sources: [] })
|
||||
.mockResolvedValueOnce({ sources: [] })
|
||||
.mockResolvedValueOnce({
|
||||
sources: [
|
||||
@@ -133,6 +138,7 @@ describe("listSkills", () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ sources: [] })
|
||||
.mockRejectedValueOnce(new Error("permission denied"))
|
||||
.mockResolvedValueOnce({
|
||||
sources: [
|
||||
@@ -150,10 +156,129 @@ describe("listSkills", () => {
|
||||
const { listSkills } = await import("./skills");
|
||||
const skills = await listSkills(["/tmp/alpha", "/tmp/beta"]);
|
||||
|
||||
expect(mockGooseSourcesList).toHaveBeenCalledTimes(3);
|
||||
expect(mockGooseSourcesList).toHaveBeenCalledTimes(4);
|
||||
expect(skills.map((skill) => skill.name)).toEqual([
|
||||
"code-review",
|
||||
"test-writer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps filesystem skills when built-in skill listing fails", async () => {
|
||||
mockGooseSourcesList
|
||||
.mockResolvedValueOnce({
|
||||
sources: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "code-review",
|
||||
description: "Reviews code",
|
||||
content: "Review carefully",
|
||||
directory: "/Users/test/.agents/skills/code-review",
|
||||
global: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("unknown source type"))
|
||||
.mockResolvedValueOnce({
|
||||
sources: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "test-writer",
|
||||
description: "Writes tests",
|
||||
content: "Write tests",
|
||||
directory: "/tmp/alpha/.agents/skills/test-writer",
|
||||
global: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { listSkills } = await import("./skills");
|
||||
const skills = await listSkills(["/tmp/alpha"]);
|
||||
|
||||
expect(mockGooseSourcesList).toHaveBeenNthCalledWith(1, {
|
||||
type: "skill",
|
||||
});
|
||||
expect(mockGooseSourcesList).toHaveBeenNthCalledWith(2, {
|
||||
type: "builtinSkill",
|
||||
});
|
||||
expect(mockGooseSourcesList).toHaveBeenNthCalledWith(3, {
|
||||
type: "skill",
|
||||
projectDir: "/tmp/alpha",
|
||||
});
|
||||
expect(skills.map((skill) => skill.name)).toEqual([
|
||||
"code-review",
|
||||
"test-writer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches and maps built-in skills without filesystem project/global metadata", async () => {
|
||||
mockGooseSourcesList
|
||||
.mockResolvedValueOnce({
|
||||
sources: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "personal-review",
|
||||
description: "Reviews personal code",
|
||||
content: "Review local changes",
|
||||
directory: "/Users/test/.agents/skills/personal-review",
|
||||
global: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sources: [
|
||||
{
|
||||
type: "builtinSkill",
|
||||
name: "goose-doc-guide",
|
||||
description: "Goose documentation guide",
|
||||
content: "Use Goose docs",
|
||||
directory: "builtin://skills/goose-doc-guide",
|
||||
global: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sources: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "project-helper",
|
||||
description: "Helps project work",
|
||||
content: "Use project context",
|
||||
directory: "/tmp/alpha/.agents/skills/project-helper",
|
||||
global: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { listSkills } = await import("./skills");
|
||||
const skills = await listSkills(["/tmp/alpha"]);
|
||||
|
||||
expect(mockGooseSourcesList).toHaveBeenNthCalledWith(1, {
|
||||
type: "skill",
|
||||
});
|
||||
expect(mockGooseSourcesList).toHaveBeenNthCalledWith(2, {
|
||||
type: "builtinSkill",
|
||||
});
|
||||
expect(mockGooseSourcesList).toHaveBeenNthCalledWith(3, {
|
||||
type: "skill",
|
||||
projectDir: "/tmp/alpha",
|
||||
});
|
||||
expect(skills.map((skill) => skill.name)).toEqual([
|
||||
"personal-review",
|
||||
"goose-doc-guide",
|
||||
"project-helper",
|
||||
]);
|
||||
expect(skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "builtin:goose-doc-guide",
|
||||
name: "goose-doc-guide",
|
||||
path: "builtin://skills/goose-doc-guide",
|
||||
fileLocation: "builtin://skills/goose-doc-guide",
|
||||
sourceKind: "builtin",
|
||||
sourceLabel: "Built in",
|
||||
projectLinks: [],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../lib/skillsPath";
|
||||
|
||||
const SKILL_SOURCE_TYPE = "skill" as const;
|
||||
const BUILTIN_SKILL_SOURCE_TYPE = "builtinSkill" as const;
|
||||
|
||||
export interface SkillProjectLink {
|
||||
id: string;
|
||||
@@ -14,7 +15,7 @@ export interface SkillProjectLink {
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
export type SkillSourceKind = "global" | "project";
|
||||
export type SkillSourceKind = "global" | "project" | "builtin";
|
||||
|
||||
export interface SkillInfo {
|
||||
id: string;
|
||||
@@ -33,13 +34,42 @@ export type EditingSkill = Pick<
|
||||
"name" | "description" | "instructions" | "path" | "fileLocation"
|
||||
>;
|
||||
|
||||
type SkillSourceEntry = SourceEntry & { type: typeof SKILL_SOURCE_TYPE };
|
||||
type FilesystemSkillSourceEntry = SourceEntry & {
|
||||
type: typeof SKILL_SOURCE_TYPE;
|
||||
};
|
||||
type BuiltinSkillSourceEntry = SourceEntry & {
|
||||
type: typeof BUILTIN_SKILL_SOURCE_TYPE;
|
||||
};
|
||||
type SkillSourceEntry = FilesystemSkillSourceEntry | BuiltinSkillSourceEntry;
|
||||
|
||||
function isSkillSource(source: SourceEntry): source is SkillSourceEntry {
|
||||
function isFilesystemSkillSource(
|
||||
source: SourceEntry,
|
||||
): source is FilesystemSkillSourceEntry {
|
||||
return source.type === SKILL_SOURCE_TYPE;
|
||||
}
|
||||
|
||||
function isSkillSource(source: SourceEntry): source is SkillSourceEntry {
|
||||
return (
|
||||
source.type === SKILL_SOURCE_TYPE ||
|
||||
source.type === BUILTIN_SKILL_SOURCE_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
function toSkillInfo(source: SkillSourceEntry): SkillInfo {
|
||||
if (source.type === BUILTIN_SKILL_SOURCE_TYPE) {
|
||||
return {
|
||||
id: `builtin:${source.name}`,
|
||||
name: source.name,
|
||||
description: source.description,
|
||||
instructions: source.content,
|
||||
path: source.directory,
|
||||
fileLocation: source.directory,
|
||||
sourceKind: "builtin",
|
||||
sourceLabel: "Built in",
|
||||
projectLinks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sourceKind: SkillSourceKind = source.global ? "global" : "project";
|
||||
const projectRoot = source.global
|
||||
? null
|
||||
@@ -93,20 +123,29 @@ export async function listSkills(
|
||||
projectDirs: string[] = [],
|
||||
): Promise<SkillInfo[]> {
|
||||
const client = await getClient();
|
||||
const fetchSources = (projectDir?: string) =>
|
||||
const fetchSources = (
|
||||
type: typeof SKILL_SOURCE_TYPE | typeof BUILTIN_SKILL_SOURCE_TYPE,
|
||||
projectDir?: string,
|
||||
) =>
|
||||
client.goose.GooseSourcesList({
|
||||
type: SKILL_SOURCE_TYPE,
|
||||
type,
|
||||
...(projectDir ? { projectDir } : {}),
|
||||
});
|
||||
|
||||
const globalResponse = await fetchSources();
|
||||
const [globalResponse, builtinResponse] = await Promise.all([
|
||||
fetchSources(SKILL_SOURCE_TYPE),
|
||||
fetchSources(BUILTIN_SKILL_SOURCE_TYPE).catch(() => null),
|
||||
]);
|
||||
const projectResponses = await Promise.allSettled(
|
||||
uniqueProjectDirs(projectDirs).map((projectDir) =>
|
||||
fetchSources(projectDir),
|
||||
fetchSources(SKILL_SOURCE_TYPE, projectDir),
|
||||
),
|
||||
);
|
||||
const responses = [
|
||||
{ response: globalResponse, projectResponse: false },
|
||||
...(builtinResponse
|
||||
? [{ response: builtinResponse, projectResponse: false }]
|
||||
: []),
|
||||
...projectResponses.flatMap((result) =>
|
||||
result.status === "fulfilled"
|
||||
? [{ response: result.value, projectResponse: true }]
|
||||
@@ -122,7 +161,10 @@ export async function listSkills(
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${source.global ? "global" : "project"}:${source.directory}`;
|
||||
const key =
|
||||
source.type === BUILTIN_SKILL_SOURCE_TYPE
|
||||
? `builtin:${source.name}`
|
||||
: `${source.global ? "global" : "project"}:${source.directory}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
@@ -158,7 +200,7 @@ export async function updateSkill(
|
||||
content: instructions,
|
||||
});
|
||||
|
||||
if (!isSkillSource(response.source)) {
|
||||
if (!isFilesystemSkillSource(response.source)) {
|
||||
throw new Error(`Unexpected source type returned: ${response.source.type}`);
|
||||
}
|
||||
|
||||
@@ -191,5 +233,5 @@ export async function importSkills(
|
||||
global: true,
|
||||
});
|
||||
|
||||
return response.sources.filter(isSkillSource).map(toSkillInfo);
|
||||
return response.sources.filter(isFilesystemSkillSource).map(toSkillInfo);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ export function useSkillImportExport(onAfterImport: () => Promise<void>) {
|
||||
const { t } = useTranslation(["skills"]);
|
||||
|
||||
const handleExport = async (skill: SkillInfo) => {
|
||||
if (skill.sourceKind === "builtin") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await exportSkill(skill.path);
|
||||
downloadExport(result.json, result.filename);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SkillInfo } from "../api/skills";
|
||||
|
||||
export type SkillsFilter = "all" | "global" | `project:${string}`;
|
||||
export type SkillsFilter = "all" | "global" | "builtin" | `project:${string}`;
|
||||
|
||||
export interface SkillsSection {
|
||||
id: string;
|
||||
@@ -77,9 +77,11 @@ export function filterSkills(
|
||||
? true
|
||||
: filters.activeFilter === "global"
|
||||
? skill.sourceKind === "global"
|
||||
: skill.projectLinks.some(
|
||||
(project) => `project:${project.id}` === filters.activeFilter,
|
||||
);
|
||||
: filters.activeFilter === "builtin"
|
||||
? skill.sourceKind === "builtin"
|
||||
: skill.projectLinks.some(
|
||||
(project) => `project:${project.id}` === filters.activeFilter,
|
||||
);
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
@@ -89,7 +91,11 @@ export function groupSkills(
|
||||
filteredSkills: SkillInfo[],
|
||||
activeFilter: SkillsFilter,
|
||||
projectFilters: { id: string; name: string }[],
|
||||
labels: { personalTitle: string; projectsFallback: string },
|
||||
labels: {
|
||||
personalTitle: string;
|
||||
builtinTitle: string;
|
||||
projectsFallback: string;
|
||||
},
|
||||
): SkillsSection[] {
|
||||
if (activeFilter === "global") {
|
||||
return [
|
||||
@@ -101,6 +107,16 @@ export function groupSkills(
|
||||
];
|
||||
}
|
||||
|
||||
if (activeFilter === "builtin") {
|
||||
return [
|
||||
{
|
||||
id: "builtin",
|
||||
title: labels.builtinTitle,
|
||||
skills: [...filteredSkills].sort(compareSkillsByName),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (activeFilter.startsWith("project:")) {
|
||||
const projectId = activeFilter.slice("project:".length);
|
||||
const projectName =
|
||||
@@ -120,6 +136,10 @@ export function groupSkills(
|
||||
.filter((skill) => skill.sourceKind === "global")
|
||||
.sort(compareSkillsByName);
|
||||
|
||||
const builtinSkills = filteredSkills
|
||||
.filter((skill) => skill.sourceKind === "builtin")
|
||||
.sort(compareSkillsByName);
|
||||
|
||||
const projectSections = projectFilters
|
||||
.map((project) => ({
|
||||
id: `project:${project.id}`,
|
||||
@@ -142,6 +162,15 @@ export function groupSkills(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(builtinSkills.length > 0
|
||||
? [
|
||||
{
|
||||
id: "builtin",
|
||||
title: labels.builtinTitle,
|
||||
skills: builtinSkills,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...projectSections,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -97,6 +97,61 @@ export function SkillDetailPage({
|
||||
const editLabel = t("common:actions.edit");
|
||||
const revealLabel = t("view.reveal");
|
||||
const moreLabel = t("view.more");
|
||||
const isBuiltin = skill.sourceKind === "builtin";
|
||||
const actions = (
|
||||
<>
|
||||
{onStartChat ? (
|
||||
<SkillHeaderActionButton
|
||||
label={startChatLabel}
|
||||
icon={<IconMessagePlus className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
onClick={() => onStartChat(skill)}
|
||||
/>
|
||||
) : null}
|
||||
{!isBuiltin ? (
|
||||
<>
|
||||
<SkillHeaderActionButton
|
||||
label={editLabel}
|
||||
icon={<IconPencil className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
onClick={() => onEdit(skill)}
|
||||
/>
|
||||
<SkillHeaderActionButton
|
||||
label={revealLabel}
|
||||
icon={<IconFolderOpen className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
onClick={() => onReveal(skill)}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="outline-flat"
|
||||
aria-label={moreLabel}
|
||||
>
|
||||
<IconDots className="size-3.5" />
|
||||
<span className="sr-only">{moreLabel}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8}>
|
||||
<DropdownMenuItem onSelect={() => onShare(skill)}>
|
||||
<IconShare className="size-3.5" />
|
||||
{t("view.share")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete(skill)}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
{t("common:actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailPageShell>
|
||||
@@ -117,111 +172,71 @@ export function SkillDetailPage({
|
||||
description={skill.description}
|
||||
actionsPlacement="below"
|
||||
descriptionClassName="max-w-3xl leading-relaxed"
|
||||
actions={
|
||||
<>
|
||||
{onStartChat ? (
|
||||
<SkillHeaderActionButton
|
||||
label={startChatLabel}
|
||||
icon={<IconMessagePlus className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
onClick={() => onStartChat(skill)}
|
||||
/>
|
||||
) : null}
|
||||
<SkillHeaderActionButton
|
||||
label={editLabel}
|
||||
icon={<IconPencil className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
onClick={() => onEdit(skill)}
|
||||
/>
|
||||
<SkillHeaderActionButton
|
||||
label={revealLabel}
|
||||
icon={<IconFolderOpen className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
onClick={() => onReveal(skill)}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="outline-flat"
|
||||
aria-label={moreLabel}
|
||||
>
|
||||
<IconDots className="size-3.5" />
|
||||
<span className="sr-only">{moreLabel}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8}>
|
||||
<DropdownMenuItem onSelect={() => onShare(skill)}>
|
||||
<IconShare className="size-3.5" />
|
||||
{t("view.share")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete(skill)}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
{t("common:actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
actions={actions}
|
||||
actionsClassName="gap-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PageColumns
|
||||
defaultSidebarSize={28}
|
||||
minSidebarSize={22}
|
||||
maxSidebarSize={36}
|
||||
minContentSize={52}
|
||||
sidebar={
|
||||
<aside className="space-y-5">
|
||||
<section className="space-y-5 border-b border-border pb-5">
|
||||
<DetailField
|
||||
label={t("view.source")}
|
||||
contentClassName="space-y-1 text-foreground"
|
||||
>
|
||||
{sourceLabels.map((label) => (
|
||||
<p key={label}>{label}</p>
|
||||
))}
|
||||
</DetailField>
|
||||
|
||||
{skill.projectLinks.length > 0 ? (
|
||||
<DetailField
|
||||
label={t("view.projects")}
|
||||
contentClassName="space-y-1.5"
|
||||
>
|
||||
{skill.projectLinks.map((project) => (
|
||||
<div key={`${project.id}-${project.workingDir}`}>
|
||||
<p>{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.workingDir}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</DetailField>
|
||||
) : null}
|
||||
|
||||
<DetailField
|
||||
label={t("view.location")}
|
||||
contentAs="p"
|
||||
contentClassName="break-all text-foreground"
|
||||
>
|
||||
{skill.fileLocation}
|
||||
</DetailField>
|
||||
</section>
|
||||
</aside>
|
||||
}
|
||||
>
|
||||
{isBuiltin ? (
|
||||
<section className="space-y-4 pb-6">
|
||||
<DetailField label={t("view.instructions")} />
|
||||
<MessageResponse className="min-w-0 text-sm leading-6">
|
||||
{skill.instructions || " "}
|
||||
</MessageResponse>
|
||||
</section>
|
||||
</PageColumns>
|
||||
) : (
|
||||
<PageColumns
|
||||
defaultSidebarSize={28}
|
||||
minSidebarSize={22}
|
||||
maxSidebarSize={36}
|
||||
minContentSize={52}
|
||||
sidebar={
|
||||
<aside className="space-y-5">
|
||||
<section className="space-y-5 border-b border-border pb-5">
|
||||
<DetailField
|
||||
label={t("view.source")}
|
||||
contentClassName="space-y-1 text-foreground"
|
||||
>
|
||||
{sourceLabels.map((label) => (
|
||||
<p key={label}>{label}</p>
|
||||
))}
|
||||
</DetailField>
|
||||
|
||||
{skill.projectLinks.length > 0 ? (
|
||||
<DetailField
|
||||
label={t("view.projects")}
|
||||
contentClassName="space-y-1.5"
|
||||
>
|
||||
{skill.projectLinks.map((project) => (
|
||||
<div key={`${project.id}-${project.workingDir}`}>
|
||||
<p>{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.workingDir}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</DetailField>
|
||||
) : null}
|
||||
|
||||
<DetailField
|
||||
label={t("view.location")}
|
||||
contentAs="p"
|
||||
contentClassName="break-all text-foreground"
|
||||
>
|
||||
{skill.fileLocation}
|
||||
</DetailField>
|
||||
</section>
|
||||
</aside>
|
||||
}
|
||||
>
|
||||
<section className="space-y-4 pb-6">
|
||||
<DetailField label={t("view.instructions")} />
|
||||
<MessageResponse className="min-w-0 text-sm leading-6">
|
||||
{skill.instructions || " "}
|
||||
</MessageResponse>
|
||||
</section>
|
||||
</PageColumns>
|
||||
)}
|
||||
</DetailPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SkillsToolbarProps {
|
||||
onSearchChange: (value: string) => void;
|
||||
activeFilter: SkillsFilter;
|
||||
onActiveFilterChange: (filter: SkillsFilter) => void;
|
||||
hasBuiltinSkills: boolean;
|
||||
projectFilters: { id: string; name: string }[];
|
||||
dropHandlers?: React.HTMLAttributes<HTMLDivElement>;
|
||||
isDragOver?: boolean;
|
||||
@@ -41,6 +42,7 @@ export function SkillsToolbar({
|
||||
onSearchChange,
|
||||
activeFilter,
|
||||
onActiveFilterChange,
|
||||
hasBuiltinSkills,
|
||||
projectFilters,
|
||||
dropHandlers,
|
||||
isDragOver,
|
||||
@@ -74,6 +76,14 @@ export function SkillsToolbar({
|
||||
>
|
||||
{t("view.filtersGlobal")}
|
||||
</FilterButton>
|
||||
{hasBuiltinSkills ? (
|
||||
<FilterButton
|
||||
active={activeFilter === "builtin"}
|
||||
onClick={() => onActiveFilterChange("builtin")}
|
||||
>
|
||||
{t("view.filtersBuiltin")}
|
||||
</FilterButton>
|
||||
) : null}
|
||||
{projectFilters.map((project) => {
|
||||
const filterValue = `project:${project.id}` as const;
|
||||
return (
|
||||
|
||||
@@ -78,6 +78,10 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
}, [loadSkills]);
|
||||
|
||||
const projectFilters = useMemo(() => uniqueProjectFilters(skills), [skills]);
|
||||
const hasBuiltinSkills = useMemo(
|
||||
() => skills.some((skill) => skill.sourceKind === "builtin"),
|
||||
[skills],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeFilter.startsWith("project:")) {
|
||||
@@ -90,6 +94,12 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
}
|
||||
}, [activeFilter, projectFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFilter === "builtin" && !hasBuiltinSkills) {
|
||||
setActiveFilter("all");
|
||||
}
|
||||
}, [activeFilter, hasBuiltinSkills]);
|
||||
|
||||
const filteredSkills = useMemo(
|
||||
() => filterSkills(skills, { search, activeFilter }),
|
||||
[skills, search, activeFilter],
|
||||
@@ -99,6 +109,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
() =>
|
||||
groupSkills(filteredSkills, activeFilter, projectFilters, {
|
||||
personalTitle: t("view.filtersGlobal"),
|
||||
builtinTitle: t("view.filtersBuiltin"),
|
||||
projectsFallback: t("view.projects"),
|
||||
}),
|
||||
[filteredSkills, activeFilter, projectFilters, t],
|
||||
@@ -117,11 +128,18 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
skills.find((skill) => skill.id === activeSkillId) ?? null;
|
||||
|
||||
const handleDelete = (skill: SkillInfo) => {
|
||||
if (skill.sourceKind === "builtin") {
|
||||
return;
|
||||
}
|
||||
setDeletingSkill(skill);
|
||||
};
|
||||
|
||||
const handleConfirmDeleteSkill = async () => {
|
||||
if (!deletingSkill) return;
|
||||
if (deletingSkill.sourceKind === "builtin") {
|
||||
setDeletingSkill(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteSkill(deletingSkill.path);
|
||||
await loadSkills();
|
||||
@@ -136,6 +154,9 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
};
|
||||
|
||||
const handleEdit = (skill: SkillInfo) => {
|
||||
if (skill.sourceKind === "builtin") {
|
||||
return;
|
||||
}
|
||||
setEditingSkill({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
@@ -147,6 +168,9 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
};
|
||||
|
||||
const handleReveal = useCallback((skill: SkillInfo) => {
|
||||
if (skill.sourceKind === "builtin") {
|
||||
return;
|
||||
}
|
||||
void revealInFileManager(skill.path);
|
||||
}, []);
|
||||
|
||||
@@ -193,6 +217,16 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
handleExport,
|
||||
} = useSkillImportExport(refreshSkills);
|
||||
|
||||
const handleShare = useCallback(
|
||||
(skill: SkillInfo) => {
|
||||
if (skill.sourceKind === "builtin") {
|
||||
return;
|
||||
}
|
||||
void handleExport(skill);
|
||||
},
|
||||
[handleExport],
|
||||
);
|
||||
|
||||
const handleSelectSkill = (skill: SkillInfo) => {
|
||||
setActiveSkillId(skill.id);
|
||||
};
|
||||
@@ -217,7 +251,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
onBack={() => setActiveSkillId(null)}
|
||||
onEdit={handleEdit}
|
||||
onReveal={handleReveal}
|
||||
onShare={handleExport}
|
||||
onShare={handleShare}
|
||||
onStartChat={onStartChatWithSkill ? handleStartChat : undefined}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
@@ -261,6 +295,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
onSearchChange={setSearch}
|
||||
activeFilter={activeFilter}
|
||||
onActiveFilterChange={setActiveFilter}
|
||||
hasBuiltinSkills={hasBuiltinSkills}
|
||||
projectFilters={projectFilters}
|
||||
dropHandlers={dropHandlers}
|
||||
isDragOver={isDragOver}
|
||||
|
||||
@@ -60,6 +60,18 @@ const mockSkills: SkillInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const builtinSkill: SkillInfo = {
|
||||
id: "builtin:goose-doc-guide",
|
||||
name: "goose-doc-guide",
|
||||
description: "Reference Goose documentation",
|
||||
instructions: "Fetch Goose docs before answering.",
|
||||
path: "builtin://skills/goose-doc-guide",
|
||||
fileLocation: "builtin://skills/goose-doc-guide",
|
||||
sourceKind: "builtin" as const,
|
||||
sourceLabel: "Built in",
|
||||
projectLinks: [],
|
||||
};
|
||||
|
||||
vi.mock("../../api/skills", () => ({
|
||||
listSkills: vi.fn().mockResolvedValue([]),
|
||||
createSkill: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -87,12 +99,13 @@ vi.mock("@/features/projects/stores/projectStore", () => ({
|
||||
) => selector({ projects: mockProjects }),
|
||||
}));
|
||||
|
||||
const { listSkills, deleteSkill, updateSkill } = (await import(
|
||||
const { listSkills, deleteSkill, updateSkill, exportSkill } = (await import(
|
||||
"../../api/skills"
|
||||
)) as unknown as {
|
||||
listSkills: ReturnType<typeof vi.fn>;
|
||||
deleteSkill: ReturnType<typeof vi.fn>;
|
||||
updateSkill: ReturnType<typeof vi.fn>;
|
||||
exportSkill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -377,6 +390,31 @@ describe("SkillsView", () => {
|
||||
expect(screen.getByText("test-writer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups and filters built-in skills separately", async () => {
|
||||
listSkills.mockResolvedValue([...mockSkills, builtinSkill]);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SkillsView />);
|
||||
await screen.findByText("goose-doc-guide");
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Built in" }),
|
||||
).toBeInTheDocument();
|
||||
const sectionButtons = screen
|
||||
.getAllByRole("button")
|
||||
.filter((button) => /\d skill/.test(button.textContent ?? ""));
|
||||
expect(sectionButtons.map((button) => button.textContent)).toEqual([
|
||||
expect.stringContaining("Personal"),
|
||||
expect.stringContaining("Built in"),
|
||||
expect.stringContaining("alpha"),
|
||||
]);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Built in" }));
|
||||
|
||||
expect(screen.getByText("goose-doc-guide")).toBeInTheDocument();
|
||||
expect(screen.queryByText("code-review")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("test-writer")).not.toBeInTheDocument();
|
||||
});
|
||||
it("shows a delete confirmation from the detail panel", async () => {
|
||||
listSkills.mockResolvedValue(mockSkills);
|
||||
const user = userEvent.setup();
|
||||
@@ -403,6 +441,46 @@ describe("SkillsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows built-in details without filesystem actions and still starts chat", async () => {
|
||||
listSkills.mockResolvedValue([...mockSkills, builtinSkill]);
|
||||
const onStartChatWithSkill = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SkillsView onStartChatWithSkill={onStartChatWithSkill} />);
|
||||
await screen.findByText("goose-doc-guide");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Open goose-doc-guide details" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Fetch Goose docs before answering."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Location")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("builtin://skills/goose-doc-guide"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Edit" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Show in folder" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "More" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Start chat" }));
|
||||
|
||||
expect(onStartChatWithSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "goose-doc-guide" }),
|
||||
null,
|
||||
);
|
||||
expect(updateSkill).not.toHaveBeenCalled();
|
||||
expect(deleteSkill).not.toHaveBeenCalled();
|
||||
expect(exportSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes saved project working directories into listSkills", async () => {
|
||||
mockProjects = [
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"exportedTo": "Exported to {{filename}}",
|
||||
"filePath": "File path",
|
||||
"filtersAllSources": "All",
|
||||
"filtersBuiltin": "Built in",
|
||||
"filtersGlobal": "Personal",
|
||||
"importError": "Failed to import skill",
|
||||
"importSuccess": "Skill imported",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"exportedTo": "Exportado a {{filename}}",
|
||||
"filePath": "Ruta del archivo",
|
||||
"filtersAllSources": "Todas",
|
||||
"filtersBuiltin": "Integradas",
|
||||
"filtersGlobal": "Personal",
|
||||
"importError": "Error al importar la skill",
|
||||
"importSuccess": "Skill importada",
|
||||
|
||||
@@ -764,8 +764,9 @@ export type CreateSourceResponse = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A source discovered by Goose and backed by an on-disk path. Sources may be
|
||||
* either `global` (shared across all projects) or project-specific.
|
||||
* A source discovered by Goose. Filesystem sources use an on-disk path;
|
||||
* built-in sources use a stable synthetic path. Sources may be either
|
||||
* `global` (shared across all projects) or project-specific.
|
||||
*/
|
||||
export type SourceEntry = {
|
||||
type: SourceType;
|
||||
@@ -774,7 +775,8 @@ export type SourceEntry = {
|
||||
content: string;
|
||||
/**
|
||||
* Absolute path to the source on disk. A directory for skills, a file for
|
||||
* recipes and agents.
|
||||
* recipes and agents. Built-in skills use read-only synthetic
|
||||
* `builtin://skills/<name>` paths.
|
||||
*/
|
||||
directory: string;
|
||||
/**
|
||||
@@ -792,9 +794,10 @@ export type SourceEntry = {
|
||||
/**
|
||||
* List discovered sources.
|
||||
*
|
||||
* Today this endpoint only returns skills. If `type` is omitted, it defaults
|
||||
* to listing skill sources. Both global and project-scoped skills are included
|
||||
* when `project_dir` is set.
|
||||
* If `type` is omitted or `skill`, this lists filesystem/plugin skills only.
|
||||
* Both global and project-scoped skills are included when `project_dir` is
|
||||
* set. If `type` is `builtinSkill`, this lists shipped read-only built-in
|
||||
* skills.
|
||||
*/
|
||||
export type ListSourcesRequest = {
|
||||
type?: SourceType | null;
|
||||
|
||||
@@ -774,8 +774,9 @@ export const zCreateSourceRequest = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* A source discovered by Goose and backed by an on-disk path. Sources may be
|
||||
* either `global` (shared across all projects) or project-specific.
|
||||
* A source discovered by Goose. Filesystem sources use an on-disk path;
|
||||
* built-in sources use a stable synthetic path. Sources may be either
|
||||
* `global` (shared across all projects) or project-specific.
|
||||
*/
|
||||
export const zSourceEntry = z.object({
|
||||
type: zSourceType,
|
||||
@@ -794,9 +795,10 @@ export const zCreateSourceResponse = z.object({
|
||||
/**
|
||||
* List discovered sources.
|
||||
*
|
||||
* Today this endpoint only returns skills. If `type` is omitted, it defaults
|
||||
* to listing skill sources. Both global and project-scoped skills are included
|
||||
* when `project_dir` is set.
|
||||
* If `type` is omitted or `skill`, this lists filesystem/plugin skills only.
|
||||
* Both global and project-scoped skills are included when `project_dir` is
|
||||
* set. If `type` is `builtinSkill`, this lists shipped read-only built-in
|
||||
* skills.
|
||||
*/
|
||||
export const zListSourcesRequest = z.object({
|
||||
type: z.union([
|
||||
|
||||
Reference in New Issue
Block a user