Dedupe and organize skills/sources (#8731)
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 1–64 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..."
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 1–64 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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user