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:
Kalvin C
2026-05-06 07:49:03 -07:00
committed by GitHub
parent 6fc44bf0ed
commit 4ef091a356
17 changed files with 636 additions and 139 deletions
+9 -6
View File
@@ -831,8 +831,9 @@ impl std::fmt::Display for SourceType {
}
}
/// 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.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SourceEntry {
@@ -842,7 +843,8 @@ pub struct SourceEntry {
pub description: String,
pub 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.
pub directory: String,
/// True when the source lives in the user's global sources directory; false
/// when it lives inside a specific project.
@@ -889,9 +891,10 @@ pub struct CreateSourceResponse {
/// 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.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/sources/list", response = ListSourcesResponse)]
#[serde(rename_all = "camelCase")]
+3 -3
View File
@@ -1938,7 +1938,7 @@
},
"directory": {
"type": "string",
"description": "Absolute path to the source on disk. A directory for skills, a file for\nrecipes and agents."
"description": "Absolute path to the source on disk. A directory for skills, a file for\nrecipes and agents. Built-in skills use read-only synthetic\n`builtin://skills/<name>` paths."
},
"global": {
"type": "boolean",
@@ -1960,7 +1960,7 @@
"directory",
"global"
],
"description": "A source discovered by Goose and backed by an on-disk path. Sources may be\neither `global` (shared across all projects) or project-specific."
"description": "A source discovered by Goose. Filesystem sources use an on-disk path;\nbuilt-in sources use a stable synthetic path. Sources may be either\n`global` (shared across all projects) or project-specific."
},
"ListSourcesRequest": {
"type": "object",
@@ -1982,7 +1982,7 @@
]
}
},
"description": "List discovered sources.\n\nToday this endpoint only returns skills. If `type` is omitted, it defaults\nto listing skill sources. Both global and project-scoped skills are included\nwhen `project_dir` is set.",
"description": "List discovered sources.\n\nIf `type` is omitted or `skill`, this lists filesystem/plugin skills only.\nBoth global and project-scoped skills are included when `project_dir` is\nset. If `type` is `builtinSkill`, this lists shipped read-only built-in\nskills.",
"x-side": "agent",
"x-method": "_goose/sources/list"
},
+2
View File
@@ -369,8 +369,10 @@ pub fn discover_skills(working_dir: Option<&Path>) -> Vec<SourceEntry> {
if let Some(source) = parse_skill_content(content, &PathBuf::new(), true) {
if !seen.contains(&source.name) {
seen.insert(source.name.clone());
let directory = format!("builtin://skills/{}", source.name);
sources.push(SourceEntry {
source_type: SourceType::BuiltinSkill,
directory,
..source
});
}
+119 -6
View File
@@ -36,6 +36,17 @@ fn require_skill_type(source_type: SourceType) -> Result<(), Error> {
Ok(())
}
fn require_listable_type(source_type: Option<SourceType>) -> Result<SourceType, Error> {
match source_type.unwrap_or(SourceType::Skill) {
SourceType::Skill => Ok(SourceType::Skill),
SourceType::BuiltinSkill => Ok(SourceType::BuiltinSkill),
other => Err(Error::invalid_params().data(format!(
"Source type '{}' is not supported. Only 'skill' and 'builtinSkill' are currently supported for listing.",
other
))),
}
}
fn source_entry(
source_type: SourceType,
name: &str,
@@ -55,6 +66,14 @@ fn source_entry(
}
}
fn builtin_skill_entry(mut source: SourceEntry) -> SourceEntry {
source.source_type = SourceType::BuiltinSkill;
source.directory = format!("builtin://skills/{}", source.name);
source.global = true;
source.supporting_files.clear();
source
}
pub fn create_source(
source_type: SourceType,
name: &str,
@@ -155,9 +174,7 @@ pub fn list_sources(
source_type: Option<SourceType>,
project_dir: Option<&str>,
) -> Result<Vec<SourceEntry>, Error> {
if let Some(t) = source_type {
require_skill_type(t)?;
}
let listed_type = require_listable_type(source_type)?;
let working_dir = project_dir
.map(str::trim)
@@ -166,7 +183,14 @@ pub fn list_sources(
let mut sources: Vec<SourceEntry> = discover_skills(working_dir.as_deref())
.into_iter()
.filter(|s| s.source_type == SourceType::Skill)
.filter(|s| s.source_type == listed_type)
.map(|s| {
if listed_type == SourceType::BuiltinSkill {
builtin_skill_entry(s)
} else {
s
}
})
.collect();
sources.sort_by(|a, b| a.name.cmp(&b.name));
@@ -495,7 +519,64 @@ mod tests {
}
#[test]
fn rejects_non_skill_source_type() {
fn list_sources_lists_builtin_skills() {
let listed = list_sources(Some(SourceType::BuiltinSkill), None).unwrap();
let builtin = listed
.iter()
.find(|source| source.name == "goose-doc-guide")
.expect("expected goose-doc-guide builtin skill");
assert_eq!(builtin.source_type, SourceType::BuiltinSkill);
assert!(builtin.global);
assert_eq!(builtin.directory, "builtin://skills/goose-doc-guide");
assert!(builtin.supporting_files.is_empty());
assert!(!builtin.content.is_empty());
}
#[test]
fn list_skill_excludes_builtin_skills() {
let listed = list_sources(Some(SourceType::Skill), None).unwrap();
assert!(!listed
.iter()
.any(|source| source.source_type == SourceType::BuiltinSkill));
}
#[test]
fn filesystem_skill_suppresses_same_named_builtin() {
let tmp = TempDir::new().unwrap();
let project = tmp.path();
let skill_dir = project
.join(".agents")
.join("skills")
.join("goose-doc-guide");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
build_skill_md("goose-doc-guide", "project override", "Use project docs"),
)
.unwrap();
let builtins = list_sources(
Some(SourceType::BuiltinSkill),
Some(project.to_str().unwrap()),
)
.unwrap();
assert!(!builtins
.iter()
.any(|source| source.name == "goose-doc-guide"));
let skills =
list_sources(Some(SourceType::Skill), Some(project.to_str().unwrap())).unwrap();
let project_skill = skills
.iter()
.find(|source| source.name == "goose-doc-guide")
.expect("expected project skill");
assert_eq!(project_skill.source_type, SourceType::Skill);
assert_eq!(project_skill.description, "project override");
}
#[test]
fn mutations_reject_non_writable_source_types() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().to_str().unwrap();
@@ -510,17 +591,49 @@ mod tests {
.unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = update_source(
SourceType::BuiltinSkill,
"builtin://skills/x",
"x",
"d",
"c",
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = update_source(SourceType::Recipe, "x", "x", "d", "c").unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = delete_source(SourceType::BuiltinSkill, "builtin://skills/x").unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = delete_source(SourceType::Subrecipe, "x").unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = list_sources(Some(SourceType::BuiltinSkill), Some(project)).unwrap_err();
let listed = list_sources(Some(SourceType::BuiltinSkill), Some(project)).unwrap();
assert!(listed
.iter()
.any(|source| source.source_type == SourceType::BuiltinSkill));
let err = list_sources(Some(SourceType::Recipe), Some(project)).unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = export_source(SourceType::BuiltinSkill, "builtin://skills/x").unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = export_source(SourceType::Recipe, "x").unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let payload = serde_json::json!({
"version": 1,
"type": "builtinSkill",
"name": "x",
"description": "d",
"content": "c",
})
.to_string();
let err = import_sources(&payload, false, Some(project)).unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
}
#[test]
@@ -112,6 +112,40 @@ fn test_custom_get_extensions() {
});
}
#[test]
fn test_custom_list_builtin_skill_sources() {
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let response = send_custom(
conn.cx(),
"_goose/sources/list",
serde_json::json!({ "type": "builtinSkill" }),
)
.await
.expect("builtin skill sources list should succeed");
let sources = response
.get("sources")
.and_then(|value| value.as_array())
.expect("missing sources array");
let builtin = sources
.iter()
.find(|source| source.get("name") == Some(&serde_json::json!("goose-doc-guide")))
.expect("expected goose-doc-guide builtin skill");
assert_eq!(
builtin.get("type"),
Some(&serde_json::json!("builtinSkill"))
);
assert_eq!(builtin.get("global"), Some(&serde_json::json!(true)));
assert_eq!(
builtin.get("directory"),
Some(&serde_json::json!("builtin://skills/goose-doc-guide"))
);
});
}
#[test]
fn test_custom_provider_inventory_includes_metadata() {
run_test(async {
@@ -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: [],
}),
]),
);
});
});
+52 -10
View File
@@ -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",
+9 -6
View File
@@ -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;
+7 -5
View File
@@ -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([