Dedupe and organize skills/sources (#8731)

This commit is contained in:
Jack Amadeo
2026-04-23 15:07:45 -04:00
committed by GitHub
parent 586fa2b3c8
commit 5dd9f08d57
28 changed files with 1245 additions and 786 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ React UI ──► @aaif/goose-sdk (TS) ──► goose-acp (WebSocket, ACP
The skills → sources migration in [#8675](https://github.com/block/goose/pull/8675) is the clearest illustration of the rule. **It deleted 319 lines of Tauri-command code in `src-tauri/src/commands/skills.rs` and replaced them with ACP custom methods.** If you find yourself wanting to add an `invoke()` command that proxies to `goose`, that PR is what "doing it the other way" looks like. Copy this shape when adding new endpoints:
1. **Define the request/response in `crates/goose-sdk/src/custom_requests.rs`.** Use the `JsonRpcRequest` / `JsonRpcResponse` derives and the `#[request(method = "_goose/<area>/<action>", response = ...)]` attribute. Sources uses namespaced methods like `_goose/sources/create`, `_goose/sources/list`, `_goose/sources/update`, `_goose/sources/delete`, `_goose/sources/export`, `_goose/sources/import` with paired request/response structs (`CreateSourceRequest` / `CreateSourceResponse`, etc.).
1. **Define the request/response in `crates/goose-sdk/src/custom_requests.rs`.** Use the `JsonRpcRequest` / `JsonRpcResponse` derives and the `#[request(method = "_goose/<area>/<action>", response = ...)]` attribute. Sources uses namespaced methods like `_goose/sources/create`, `_goose/sources/list`, `_goose/sources/update`, `_goose/sources/delete`, `_goose/sources/export`, `_goose/sources/import` with paired request/response structs (`CreateSourceRequest` / `CreateSourceResponse`, etc.). Keep the docs on those structs aligned with the implementation: today `_goose/sources/list` is still skill-only; create/import take an explicit target scope (`global`, plus `projectDir` for project sources), while update/delete/export operate on an existing skill by absolute `path`.
2. **Implement the handler in `crates/goose-acp/src/server.rs`** with `#[custom_method(YourRequest)]`. Keep it thin: unpack the request, call into the `goose` crate, wrap the result. The sources handlers are ~5 lines each — e.g. `on_list_sources` just calls `goose::sources::list_sources(...)` and returns the typed response. Errors map to `sacp::Error::invalid_params()` / `internal_error()`.
3. **Put the real logic in the `goose` crate.** Sources lives in `crates/goose/src/sources.rs` — filesystem CRUD, frontmatter parsing, scope resolution, all of it. `goose-acp` knows nothing about where skills are stored on disk; it just forwards typed arguments. This separation is the point.
4. **Regenerate the SDK.** The TS methods on `GooseClient` are generated into `ui/sdk/src/generated/`. Do not hand-edit generated files.
+46 -32
View File
@@ -1,28 +1,36 @@
import type { SourceEntry } from "@aaif/goose-sdk";
import { getClient } from "@/shared/api/acpConnection";
const SKILL_SOURCE_TYPE = "skill" as const;
export interface SkillInfo {
name: string;
description: string;
instructions: string;
path: string;
fileLocation: string;
}
// Shape returned by _goose/sources/*. Narrowed to skill-type sources here.
interface SourceEntry {
type: "skill";
name: string;
description: string;
content: string;
directory: string;
global: boolean;
type SkillSourceEntry = SourceEntry & { type: typeof SKILL_SOURCE_TYPE };
function isSkillSource(source: SourceEntry): source is SkillSourceEntry {
return source.type === SKILL_SOURCE_TYPE;
}
function toSkillInfo(source: SourceEntry): SkillInfo {
function getSkillFileLocation(directory: string): string {
const separator = directory.includes("\\") ? "\\" : "/";
return directory.endsWith(separator)
? `${directory}SKILL.md`
: `${directory}${separator}SKILL.md`;
}
function toSkillInfo(source: SkillSourceEntry): SkillInfo {
return {
name: source.name,
description: source.description,
instructions: source.content,
path: source.directory,
fileLocation: getSkillFileLocation(source.directory),
};
}
@@ -32,8 +40,8 @@ export async function createSkill(
instructions: string,
): Promise<void> {
const client = await getClient();
await client.extMethod("_goose/sources/create", {
type: "skill",
await client.goose.GooseSourcesCreate({
type: SKILL_SOURCE_TYPE,
name,
description,
content: instructions,
@@ -43,46 +51,51 @@ export async function createSkill(
export async function listSkills(): Promise<SkillInfo[]> {
const client = await getClient();
const raw = await client.extMethod("_goose/sources/list", { type: "skill" });
const sources = (raw.sources ?? []) as SourceEntry[];
return sources.map(toSkillInfo);
const response = await client.goose.GooseSourcesList({
type: SKILL_SOURCE_TYPE,
});
return response.sources.filter(isSkillSource).map(toSkillInfo);
}
export async function deleteSkill(name: string): Promise<void> {
export async function deleteSkill(path: string): Promise<void> {
const client = await getClient();
await client.extMethod("_goose/sources/delete", {
type: "skill",
name,
global: true,
await client.goose.GooseSourcesDelete({
type: SKILL_SOURCE_TYPE,
path,
});
}
export async function updateSkill(
path: string,
name: string,
description: string,
instructions: string,
): Promise<SkillInfo> {
const client = await getClient();
const raw = await client.extMethod("_goose/sources/update", {
type: "skill",
const response = await client.goose.GooseSourcesUpdate({
type: SKILL_SOURCE_TYPE,
path,
name,
description,
content: instructions,
global: true,
});
return toSkillInfo(raw.source as SourceEntry);
if (!isSkillSource(response.source)) {
throw new Error(`Unexpected source type returned: ${response.source.type}`);
}
return toSkillInfo(response.source);
}
export async function exportSkill(
name: string,
path: string,
): Promise<{ json: string; filename: string }> {
const client = await getClient();
const raw = await client.extMethod("_goose/sources/export", {
type: "skill",
name,
global: true,
const response = await client.goose.GooseSourcesExport({
type: SKILL_SOURCE_TYPE,
path,
});
return { json: raw.json as string, filename: raw.filename as string };
return { json: response.json, filename: response.filename };
}
export async function importSkills(
@@ -92,12 +105,13 @@ export async function importSkills(
if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) {
throw new Error("File must have a .skill.json or .json extension");
}
const data = new TextDecoder().decode(new Uint8Array(fileBytes));
const client = await getClient();
const raw = await client.extMethod("_goose/sources/import", {
const response = await client.goose.GooseSourcesImport({
data,
global: true,
});
const sources = (raw.sources ?? []) as SourceEntry[];
return sources.map(toSkillInfo);
return response.sources.filter(isSkillSource).map(toSkillInfo);
}
@@ -1,6 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label";
@@ -14,13 +13,56 @@ import {
} from "@/shared/ui/dialog";
import { createSkill, updateSkill } from "../api/skills";
const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const MAX_SKILL_NAME_LENGTH = 64;
function isValidSkillName(name: string): boolean {
return (
name.length > 0 &&
name.length <= MAX_SKILL_NAME_LENGTH &&
!name.startsWith("-") &&
!name.endsWith("-") &&
[...name].every(
(char) =>
(char >= "a" && char <= "z") ||
(char >= "0" && char <= "9") ||
char === "-",
)
);
}
function formatSkillName(raw: string): string {
return raw
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/^-/, "")
.slice(0, MAX_SKILL_NAME_LENGTH);
}
function getRenamedSkillFileLocation(
fileLocation: string,
name: string,
): string {
const separator = fileLocation.includes("\\") ? "\\" : "/";
const parts = fileLocation.split(separator);
if (parts.length >= 2) {
parts[parts.length - 2] = name;
}
return parts.join(separator);
}
interface CreateSkillDialogProps {
isOpen: boolean;
onClose: () => void;
onCreated?: () => void;
editingSkill?: { name: string; description: string; instructions: string };
editingSkill?: {
name: string;
description: string;
instructions: string;
path: string;
fileLocation: string;
};
}
export function CreateSkillDialog({
@@ -53,17 +95,11 @@ export function CreateSkillDialog({
}
}, [isOpen, editingSkill]);
const nameValid = name.length > 0 && KEBAB_CASE_REGEX.test(name);
const nameValid = isValidSkillName(name);
const canSave = nameValid && description.trim().length > 0 && !saving;
const handleNameChange = (raw: string) => {
if (isEditing) return; // name is read-only in edit mode
const formatted = raw
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-/, "");
setName(formatted);
setName(formatSkillName(raw));
setError(null);
};
@@ -82,7 +118,12 @@ export function CreateSkillDialog({
setError(null);
try {
if (isEditing) {
await updateSkill(name, description.trim(), instructions);
await updateSkill(
editingSkill.path,
name,
description.trim(),
instructions,
);
} else {
await createSkill(name, description.trim(), instructions);
}
@@ -121,8 +162,6 @@ export function CreateSkillDialog({
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("dialog.namePlaceholder")}
readOnly={isEditing}
className={cn(isEditing && "opacity-60 cursor-not-allowed")}
/>
{name.length > 0 && !nameValid && (
<p className="text-xs text-destructive">
@@ -147,6 +186,13 @@ export function CreateSkillDialog({
/>
</div>
{isEditing && editingSkill && (
<p className="-mt-2 break-all text-[11px] text-muted-foreground">
{t("dialog.pathOnDisk")}:{" "}
{getRenamedSkillFileLocation(editingSkill.fileLocation, name)}
</p>
)}
{/* Instructions */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
+23 -16
View File
@@ -98,7 +98,14 @@ export function SkillsView() {
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<
{ name: string; description: string; instructions: string } | undefined
| {
name: string;
description: string;
instructions: string;
path: string;
fileLocation: string;
}
| undefined
>(undefined);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -107,11 +114,11 @@ export function SkillsView() {
const importInputRef = useRef<HTMLInputElement>(null);
const loadSkills = useCallback(async () => {
setLoading(true);
try {
const result = await listSkills();
setSkills(result);
} catch {
// skills directory may not exist yet
setSkills([]);
} finally {
setLoading(false);
@@ -129,7 +136,7 @@ export function SkillsView() {
const handleConfirmDeleteSkill = async () => {
if (!deletingSkill) return;
try {
await deleteSkill(deletingSkill.name);
await deleteSkill(deletingSkill.path);
await loadSkills();
} catch {
// best-effort
@@ -142,6 +149,8 @@ export function SkillsView() {
name: skill.name,
description: skill.description,
instructions: skill.instructions,
path: skill.path,
fileLocation: skill.fileLocation,
});
setDialogOpen(true);
};
@@ -164,7 +173,7 @@ export function SkillsView() {
const handleExport = async (skill: SkillInfo) => {
try {
const result = await exportSkill(skill.name);
const result = await exportSkill(skill.path);
const blob = new Blob([result.json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -195,7 +204,6 @@ export function SkillsView() {
console.error("Failed to import skill:", err);
}
// Reset the input so the same file can be re-selected
if (importInputRef.current) {
importInputRef.current.value = "";
}
@@ -242,7 +250,6 @@ export function SkillsView() {
<div className="flex flex-1 flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto min-h-0">
<div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition">
{/* Header */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold font-display tracking-tight">
@@ -281,14 +288,18 @@ export function SkillsView() {
</div>
</div>
{/* Search */}
<SearchBar
value={search}
onChange={setSearch}
placeholder={t("view.searchPlaceholder")}
/>
{/* Skills list */}
{loading && (
<div className="py-8 text-sm text-muted-foreground" role="status">
{t("common:labels.loading")}
</div>
)}
{!loading && filtered.length > 0 && (
<div className="space-y-2">
{filtered.map((skill) => (
@@ -296,8 +307,10 @@ export function SkillsView() {
key={skill.name}
className="flex items-start justify-between gap-3 rounded-lg border border-border px-4 py-3"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{skill.name}</p>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">{skill.name}</p>
</div>
{skill.description && (
<p className="text-xs text-muted-foreground mt-0.5">
{skill.description}
@@ -314,7 +327,6 @@ export function SkillsView() {
</div>
))}
{/* New Skill card */}
<Button
type="button"
variant="ghost"
@@ -334,7 +346,6 @@ export function SkillsView() {
</div>
)}
{/* Empty state */}
{!loading && filtered.length === 0 && (
<div
{...dropHandlers}
@@ -373,7 +384,6 @@ export function SkillsView() {
</div>
</div>
{/* Hidden file input for drag-and-drop import */}
<input
ref={dropFileInputRef}
type="file"
@@ -382,7 +392,6 @@ export function SkillsView() {
onChange={handleDropFileChange}
/>
{/* Create / Edit dialog */}
<CreateSkillDialog
isOpen={dialogOpen}
onClose={handleDialogClose}
@@ -390,7 +399,6 @@ export function SkillsView() {
editingSkill={editingSkill}
/>
{/* Delete confirmation dialog */}
<AlertDialog
open={!!deletingSkill}
onOpenChange={(open) => !open && setDeletingSkill(null)}
@@ -416,7 +424,6 @@ export function SkillsView() {
</AlertDialogContent>
</AlertDialog>
{/* Export notification toast */}
{notification && (
<div className="fixed bottom-4 right-4 z-50 rounded-lg border border-border bg-background px-4 py-3 shadow-popover text-sm animate-in fade-in slide-in-from-bottom-2">
{notification}
@@ -10,6 +10,7 @@ vi.mock("../../api/skills", () => ({
description: "test",
instructions: "",
path: "",
fileLocation: "/mock/.agents/skills/test/SKILL.md",
}),
}));
@@ -53,6 +54,8 @@ describe("CreateSkillDialog", () => {
name: "my-skill",
description: "desc",
instructions: "instr",
path: "/mock/.agents/skills/my-skill",
fileLocation: "/mock/.agents/skills/my-skill/SKILL.md",
}}
/>,
);
@@ -63,14 +66,24 @@ describe("CreateSkillDialog", () => {
// ── Name validation ────────────────────────────────────────────────
describe("name validation", () => {
it("allows valid kebab-case names", async () => {
it("allows consecutive hyphens to match backend validation", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
const descriptionInput = screen.getByPlaceholderText(
"What it does and when to use it...",
);
await user.type(nameInput, "my-skill");
expect(nameInput).toHaveValue("my-skill");
expect(screen.queryByText(/must be kebab-case/i)).not.toBeInTheDocument();
await user.type(nameInput, "double--hyphen");
await user.type(descriptionInput, "A valid description");
expect(nameInput).toHaveValue("double--hyphen");
expect(
screen.queryByText(/cannot start or end with a hyphen/i),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /create skill/i }),
).toBeEnabled();
});
it("auto-formats input (uppercase to lowercase, spaces to hyphens)", async () => {
@@ -82,28 +95,30 @@ describe("CreateSkillDialog", () => {
expect(nameInput).toHaveValue("my-skill");
});
it("allows typing hyphens", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.type(nameInput, "code-review");
expect(nameInput).toHaveValue("code-review");
});
it("shows validation error for invalid name with trailing hyphen", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
// Type a single hyphen — the formatter strips leading hyphens,
// but we can produce an invalid state by clearing and typing a
// non-kebab string. Actually the formatter is aggressive, so let's
// just check that when name is non-empty but invalid, the error shows.
// We type "a-" which gives "a-" — valid prefix but trailing hyphen fails regex.
await user.type(nameInput, "a-");
expect(nameInput).toHaveValue("a-");
expect(screen.getByText(/must be kebab-case/i)).toBeInTheDocument();
expect(
screen.getByText(/cannot start or end with a hyphen/i),
).toBeInTheDocument();
});
it("truncates names at 64 characters", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
const longName = "a".repeat(65);
await user.type(nameInput, longName);
expect(nameInput).toHaveValue("a".repeat(64));
expect(
screen.queryByText(/cannot start or end with a hyphen/i),
).not.toBeInTheDocument();
});
it("save button is disabled when name is empty", () => {
@@ -120,6 +135,8 @@ describe("CreateSkillDialog", () => {
name: "code-review",
description: "Reviews code",
instructions: "Review the code carefully",
path: "/mock/.agents/skills/code-review",
fileLocation: "/mock/.agents/skills/code-review/SKILL.md",
};
it("pre-fills fields with existing skill data", () => {
@@ -139,12 +156,46 @@ describe("CreateSkillDialog", () => {
).toHaveValue("Review the code carefully");
});
it("name field is read-only in edit mode", () => {
it("name field is editable in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
const nameInput = screen.getByPlaceholderText("my-skill-name");
expect(nameInput).toHaveAttribute("readOnly");
await user.clear(nameInput);
await user.type(nameInput, "renamed-skill");
expect(nameInput).toHaveValue("renamed-skill");
});
it("shows the skill path on disk as minimal helper text in edit mode", () => {
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
expect(
screen.getByText(
"Path on disk: /mock/.agents/skills/code-review/SKILL.md",
),
).toBeInTheDocument();
});
it("updates the path helper text when the name changes in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.clear(nameInput);
await user.type(nameInput, "renamed-skill");
expect(
screen.getByText(
"Path on disk: /mock/.agents/skills/renamed-skill/SKILL.md",
),
).toBeInTheDocument();
});
it('save button text is "Save Changes" in edit mode', () => {
@@ -155,6 +206,28 @@ describe("CreateSkillDialog", () => {
screen.getByRole("button", { name: /save changes/i }),
).toBeInTheDocument();
});
it("allows editing skills whose names contain consecutive hyphens", () => {
render(
<CreateSkillDialog
{...defaultProps}
editingSkill={{
name: "double--hyphen",
description: "Existing description",
instructions: "Existing instructions",
path: "/mock/.agents/skills/double--hyphen",
fileLocation: "/mock/.agents/skills/double--hyphen/SKILL.md",
}}
/>,
);
expect(
screen.getByRole("button", { name: /save changes/i }),
).toBeEnabled();
expect(
screen.queryByText(/cannot start or end with a hyphen/i),
).not.toBeInTheDocument();
});
});
// ── Form submission ────────────────────────────────────────────────
@@ -193,6 +266,8 @@ describe("CreateSkillDialog", () => {
name: "code-review",
description: "Reviews code",
instructions: "Review carefully",
path: "/mock/.agents/skills/code-review",
fileLocation: "/mock/.agents/skills/code-review/SKILL.md",
}}
/>,
);
@@ -207,12 +282,42 @@ describe("CreateSkillDialog", () => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(updateSkill).toHaveBeenCalledWith(
"/mock/.agents/skills/code-review",
"code-review",
"Updated description",
"Review carefully",
);
});
it("calls updateSkill API with the renamed skill name in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog
{...defaultProps}
editingSkill={{
name: "code-review",
description: "Reviews code",
instructions: "Review carefully",
path: "/mock/.agents/skills/code-review",
fileLocation: "/mock/.agents/skills/code-review/SKILL.md",
}}
/>,
);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.clear(nameInput);
await user.type(nameInput, "renamed-skill");
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(updateSkill).toHaveBeenCalledWith(
"/mock/.agents/skills/code-review",
"renamed-skill",
"Reviews code",
"Review carefully",
);
});
it("calls onCreated callback after successful save", async () => {
const user = userEvent.setup();
const onCreated = vi.fn();
@@ -8,13 +8,15 @@ const mockSkills = [
name: "code-review",
description: "Reviews code",
instructions: "Review the code...",
path: "/path",
path: "/path/code-review",
fileLocation: "/path/code-review/SKILL.md",
},
{
name: "test-writer",
description: "Writes tests",
instructions: "Write tests...",
path: "/path",
path: "/path/test-writer",
fileLocation: "/path/test-writer/SKILL.md",
},
];
@@ -192,8 +194,43 @@ describe("SkillsView", () => {
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(deleteSkill).toHaveBeenCalledWith("code-review");
expect(deleteSkill).toHaveBeenCalledWith("/path/code-review");
});
});
it("does not show the path on disk in the list view and still allows deleting discovered skills", async () => {
listSkills.mockResolvedValue([
{
name: "claude-skill",
description: "Imported from Claude",
instructions: "Use this skill...",
path: "/Users/test/.claude/skills/claude-skill",
fileLocation: "/Users/test/.claude/skills/claude-skill/SKILL.md",
},
]);
const user = userEvent.setup();
render(<SkillsView />);
expect(await screen.findByText("claude-skill")).toBeInTheDocument();
expect(screen.queryByText("Path on disk:")).not.toBeInTheDocument();
expect(
screen.queryByText("/Users/test/.claude/skills/claude-skill/SKILL.md"),
).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Options for claude-skill"));
expect(
screen.getByRole("menuitem", { name: /edit/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /duplicate/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /export/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /delete/i }),
).toBeInTheDocument();
});
});
});
@@ -9,7 +9,8 @@
"instructionsPlaceholder": "Markdown instructions the agent will follow...",
"name": "Name",
"namePlaceholder": "my-skill-name",
"nameValidation": "Must be kebab-case (e.g. code-review)",
"nameValidation": "Use 164 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.",
"pathOnDisk": "Path on disk",
"newTitle": "New Skill",
"saving": "Saving..."
},
@@ -9,7 +9,8 @@
"instructionsPlaceholder": "Instrucciones en Markdown que seguirá el agente...",
"name": "Nombre",
"namePlaceholder": "mi-skill",
"nameValidation": "Debe estar en kebab-case (p. ej. code-review)",
"nameValidation": "Usa de 1 a 64 letras minúsculas, números o guiones. El nombre no puede empezar ni terminar con un guion.",
"pathOnDisk": "Ruta en disco",
"newTitle": "Nueva skill",
"saving": "Guardando..."
},
+24 -6
View File
@@ -96,6 +96,7 @@ export function buildInitScript(options?: {
content: s.instructions ?? s.content ?? "",
directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""),
global: true,
supportingFiles: [],
});
function nowIso() {
@@ -195,26 +196,43 @@ export function buildInitScript(options?: {
content: message.params?.content ?? "",
directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
global: message.params?.global ?? true,
supportingFiles: [],
},
});
case "_goose/sources/update":
case "_goose/sources/update": {
const path = message.params?.path ?? "/mock/.agents/skills/updated-skill";
const nextName = message.params?.name;
const name =
typeof nextName === "string" && nextName.length > 0
? nextName
: String(path).split("/").filter(Boolean).at(-1) ?? "updated-skill";
const segments = String(path).split("/").filter(Boolean);
if (segments.length > 0) {
segments[segments.length - 1] = name;
}
const directory = \`/\${segments.join("/")}\`;
return jsonRpcResult(message.id, {
source: {
name: message.params?.name ?? "updated-skill",
name,
type: "skill",
description: message.params?.description ?? "",
content: message.params?.content ?? "",
directory: "/mock/.agents/skills/" + (message.params?.name ?? "updated-skill"),
global: message.params?.global ?? true,
directory,
global: true,
supportingFiles: [],
},
});
}
case "_goose/sources/delete":
return jsonRpcResult(message.id, {});
case "_goose/sources/export":
case "_goose/sources/export": {
const path = message.params?.path ?? "/mock/.agents/skills/skill";
const name = String(path).split("/").filter(Boolean).at(-1) ?? "skill";
return jsonRpcResult(message.id, {
json: "{}",
filename: (message.params?.name ?? "skill") + ".skill.json",
filename: name + ".skill.json",
});
}
case "_goose/sources/import":
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
default:
+33 -7
View File
@@ -95,17 +95,17 @@ test.describe("Skills view", () => {
await expect(nameInput).toHaveValue("my-skill-name");
});
test("shows kebab-case validation error for trailing hyphen", async ({
test("shows validation error for trailing hyphen", async ({
tauriMocked: page,
}) => {
await navigateToSkills(page);
await page.getByRole("button", { name: "New Skill" }).first().click();
const dialog = page.getByRole("dialog");
// Type something that ends with a hyphen (the auto-formatter will produce "test-")
await dialog.getByPlaceholder("my-skill-name").pressSequentially("test ");
// The regex /^[a-z0-9]+(-[a-z0-9]+)*$/ won't match "test-", so error shows
await expect(
dialog.getByText("Must be kebab-case (e.g. code-review)"),
dialog.getByText(
"Use 164 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.",
),
).toBeVisible();
});
@@ -145,19 +145,45 @@ test.describe("Skills view", () => {
await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible();
});
test("Edit opens edit dialog with pre-filled fields", async ({
test("Edit opens edit dialog with pre-filled editable fields", async ({
tauriMocked: page,
}) => {
await navigateToSkills(page);
await page.getByLabel("Options for code-review").click();
await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.locator("h2", { hasText: "Edit Skill" })).toBeVisible();
// Name should be pre-filled and read-only
const nameInput = dialog.getByPlaceholder("my-skill-name");
const descriptionInput = dialog.getByPlaceholder(
"What it does and when to use it...",
);
const instructionsInput = dialog.getByPlaceholder(
"Markdown instructions the agent will follow...",
);
await expect(nameInput).toHaveValue("code-review");
await expect(nameInput).toHaveAttribute("readonly", "");
await expect(descriptionInput).toHaveValue(
"Reviews code for quality and best practices",
);
await expect(instructionsInput).toHaveValue(
"When asked to review code, analyze the diff and provide feedback on code quality, potential bugs, and best practices.",
);
await expect(
dialog.getByText(
"Path on disk: /mock/.agents/skills/code-review/SKILL.md",
),
).toBeVisible();
await nameInput.fill("renamed-skill");
await expect(nameInput).toHaveValue("renamed-skill");
await expect(
dialog.getByText(
"Path on disk: /mock/.agents/skills/renamed-skill/SKILL.md",
),
).toBeVisible();
});
test("Delete triggers confirmation dialog", async ({ tauriMocked: page }) => {
+24 -20
View File
@@ -403,7 +403,7 @@ export type UnarchiveSessionRequest = {
};
/**
* Create a new source (global or project-scoped).
* Create a new source in an explicit target scope (global or project-scoped).
*/
export type CreateSourceRequest = {
type: SourceType;
@@ -420,15 +420,15 @@ export type CreateSourceRequest = {
/**
* The type of source entity.
*/
export type SourceType = 'skill';
export type SourceType = 'skill' | 'builtinSkill' | 'recipe' | 'subrecipe' | 'agent';
export type CreateSourceResponse = {
source: SourceEntry;
};
/**
* A source — a user-editable entity backed by an on-disk directory. Sources
* may be either `global` (shared across all projects) or project-specific.
* A source discovered by Goose and backed by an on-disk path. Sources may be
* either `global` (shared across all projects) or project-specific.
*/
export type SourceEntry = {
type: SourceType;
@@ -436,7 +436,8 @@ export type SourceEntry = {
description: string;
content: string;
/**
* Absolute path to the source's directory on disk.
* Absolute path to the source on disk. A directory for skills, a file for
* recipes and agents.
*/
directory: string;
/**
@@ -444,11 +445,19 @@ export type SourceEntry = {
* when it lives inside a specific project.
*/
global: boolean;
/**
* Paths (absolute) of additional files that live alongside the source.
* Only skills currently populate this; empty for other source types.
*/
supportingFiles?: Array<string>;
};
/**
* List sources. If `type` is omitted, sources of all known types are returned.
* Both global and project-scoped sources are included when `project_dir` is set.
* 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.
*/
export type ListSourcesRequest = {
type?: SourceType | null;
@@ -460,15 +469,14 @@ export type ListSourcesResponse = {
};
/**
* Update an existing source's description and content.
* Update an existing source's name, description, and content by absolute path.
*/
export type UpdateSourceRequest = {
type: SourceType;
path: string;
name: string;
description: string;
content: string;
global: boolean;
projectDir?: string | null;
};
export type UpdateSourceResponse = {
@@ -476,23 +484,19 @@ export type UpdateSourceResponse = {
};
/**
* Delete a source and its on-disk directory.
* Delete a source and its on-disk directory by absolute path.
*/
export type DeleteSourceRequest = {
type: SourceType;
name: string;
global: boolean;
projectDir?: string | null;
path: string;
};
/**
* Export a source as a portable JSON payload.
* Export a source at an absolute path as a portable JSON payload.
*/
export type ExportSourceRequest = {
type: SourceType;
name: string;
global: boolean;
projectDir?: string | null;
path: string;
};
export type ExportSourceResponse = {
@@ -502,8 +506,8 @@ export type ExportSourceResponse = {
/**
* Import a source from a JSON export payload produced by `_goose/sources/export`.
* The imported source is written under the given scope; on name collisions a
* `-imported` suffix is appended.
* The imported source is written into the explicit target scope; on name
* collisions a `-imported` suffix is appended.
*/
export type ImportSourcesRequest = {
data: string;
+26 -30
View File
@@ -345,10 +345,16 @@ export const zUnarchiveSessionRequest = z.object({
/**
* The type of source entity.
*/
export const zSourceType = z.enum(['skill']);
export const zSourceType = z.enum([
'skill',
'builtinSkill',
'recipe',
'subrecipe',
'agent'
]);
/**
* Create a new source (global or project-scoped).
* Create a new source in an explicit target scope (global or project-scoped).
*/
export const zCreateSourceRequest = z.object({
type: zSourceType,
@@ -363,8 +369,8 @@ export const zCreateSourceRequest = z.object({
});
/**
* A source — a user-editable entity backed by an on-disk directory. Sources
* may be either `global` (shared across all projects) or project-specific.
* A source discovered by Goose and backed by an on-disk path. Sources may be
* either `global` (shared across all projects) or project-specific.
*/
export const zSourceEntry = z.object({
type: zSourceType,
@@ -372,7 +378,8 @@ export const zSourceEntry = z.object({
description: z.string(),
content: z.string(),
directory: z.string(),
global: z.boolean()
global: z.boolean(),
supportingFiles: z.array(z.string()).optional()
});
export const zCreateSourceResponse = z.object({
@@ -380,8 +387,11 @@ export const zCreateSourceResponse = z.object({
});
/**
* List sources. If `type` is omitted, sources of all known types are returned.
* Both global and project-scoped sources are included when `project_dir` is set.
* 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.
*/
export const zListSourcesRequest = z.object({
type: z.union([
@@ -399,18 +409,14 @@ export const zListSourcesResponse = z.object({
});
/**
* Update an existing source's description and content.
* Update an existing source's name, description, and content by absolute path.
*/
export const zUpdateSourceRequest = z.object({
type: zSourceType,
path: z.string(),
name: z.string(),
description: z.string(),
content: z.string(),
global: z.boolean(),
projectDir: z.union([
z.string(),
z.null()
]).optional()
content: z.string()
});
export const zUpdateSourceResponse = z.object({
@@ -418,29 +424,19 @@ export const zUpdateSourceResponse = z.object({
});
/**
* Delete a source and its on-disk directory.
* Delete a source and its on-disk directory by absolute path.
*/
export const zDeleteSourceRequest = z.object({
type: zSourceType,
name: z.string(),
global: z.boolean(),
projectDir: z.union([
z.string(),
z.null()
]).optional()
path: z.string()
});
/**
* Export a source as a portable JSON payload.
* Export a source at an absolute path as a portable JSON payload.
*/
export const zExportSourceRequest = z.object({
type: zSourceType,
name: z.string(),
global: z.boolean(),
projectDir: z.union([
z.string(),
z.null()
]).optional()
path: z.string()
});
export const zExportSourceResponse = z.object({
@@ -450,8 +446,8 @@ export const zExportSourceResponse = z.object({
/**
* Import a source from a JSON export payload produced by `_goose/sources/export`.
* The imported source is written under the given scope; on name collisions a
* `-imported` suffix is appended.
* The imported source is written into the explicit target scope; on name
* collisions a `-imported` suffix is appended.
*/
export const zImportSourcesRequest = z.object({
data: z.string(),