feat: add workspace-backed agent skills

This commit is contained in:
2026-07-16 08:41:10 +09:00
parent 05c50e32cf
commit 62ef89a163
13 changed files with 1295 additions and 12 deletions
@@ -1,4 +1,11 @@
import { workspaceApiPath, workspaceRoute } from "./http.ts";
import {
loadWorkspaceSkillCatalog,
workspaceApiPath,
workspaceRoute,
workspaceSkillActivationPath,
workspaceSkillCatalogPath,
workspaceSkillDetailPath,
} from "./http.ts";
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
@@ -21,7 +28,10 @@ function assert(condition: unknown, message: string): asserts condition {
Deno.test("workspace route helpers scope browser routes and API by immutable workspace id", () => {
assertEquals(workspaceRoute("workspace 1"), "/w/workspace%201");
assertEquals(workspaceRoute("workspace 1", "/objectives"), "/w/workspace%201/objectives");
assertEquals(
workspaceRoute("workspace 1", "/objectives"),
"/w/workspace%201/objectives",
);
assertEquals(
workspaceApiPath("workspace 1", "/repositories/repo-a"),
"/api/w/workspace%201/repositories/repo-a",
@@ -29,7 +39,9 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
});
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
const layout = await Deno.readTextFile(new URL("./../../../routes/+layout.ts", import.meta.url));
const layout = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
"unscoped layout may use only the workspace-id bootstrap endpoint",
@@ -39,7 +51,8 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
"unscoped layout should redirect to the scoped workspace route",
);
assert(
!layout.includes('`/api${path}`') && !layout.includes('"/api/repositories"'),
!layout.includes("`/api${path}`") &&
!layout.includes('"/api/repositories"'),
"layout must not fall back to unscoped workspace-scoped API calls",
);
@@ -53,3 +66,44 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
);
});
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {
assertEquals(workspaceSkillCatalogPath("ws 1"), "/api/w/ws%201/skills");
assertEquals(
workspaceSkillDetailPath("ws 1", "triage-errors"),
"/api/w/ws%201/skills/triage-errors",
);
assertEquals(
workspaceSkillActivationPath("ws 1", "triage-errors"),
"/api/w/ws%201/skills/triage-errors/activate",
);
});
Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
const result = await loadWorkspaceSkillCatalog(
((input: RequestInfo | URL) => {
assertEquals(String(input), "/api/w/ws-1/skills");
return Promise.resolve(
new Response(
JSON.stringify({
authority: "workspace-backend-skills-v0",
entries: [{
name: "triage-errors",
description: "Use when triaging errors.",
provenance: { kind: "workspace", id: "workspace:triage-errors" },
overrides: [],
diagnostics: [],
}],
diagnostics: [],
}),
{ status: 200 },
),
);
}) as typeof fetch,
"ws-1",
);
assertEquals(result.error, null);
assertEquals(result.data?.entries[0].name, "triage-errors");
assertEquals(JSON.stringify(result.data).includes("SKILL.md body"), false);
});
+97 -5
View File
@@ -3,19 +3,108 @@ export type ApiResult<T> = {
error: string | null;
};
export type SkillDiagnosticSeverity = "error" | "warning";
export type SkillDiagnostic = {
severity: SkillDiagnosticSeverity;
code: string;
message: string;
source?: string;
};
export type SkillProvenance = {
kind: "builtin" | "workspace";
id: string;
};
export type SkillCatalogEntry = {
name: string;
description: string;
provenance: SkillProvenance;
overrides: SkillProvenance[];
diagnostics: SkillDiagnostic[];
};
export type SkillCatalogResponse = {
authority: string;
entries: SkillCatalogEntry[];
diagnostics: SkillDiagnostic[];
};
export type SkillResourceRef = {
kind: string;
name: string;
supported: boolean;
diagnostic?: string;
};
export type SkillDetailResponse = {
name: string;
description: string;
provenance: SkillProvenance;
overrides: SkillProvenance[];
diagnostics: SkillDiagnostic[];
body: string;
allowed_tools: string[];
allowed_tools_status: string;
resources: SkillResourceRef[];
};
function normalizePath(path: string): string {
if (!path || path === '/') return '';
return path.startsWith('/') ? path : `/${path}`;
if (!path || path === "/") return "";
return path.startsWith("/") ? path : `/${path}`;
}
export function workspaceRoute(workspaceId: string, path = ''): string {
export function workspaceRoute(workspaceId: string, path = ""): string {
return `/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`;
}
export function workspaceApiPath(workspaceId: string, path = ''): string {
export function workspaceApiPath(workspaceId: string, path = ""): string {
return `/api/w/${encodeURIComponent(workspaceId)}${normalizePath(path)}`;
}
export function workspaceSkillCatalogPath(workspaceId: string): string {
return workspaceApiPath(workspaceId, "/skills");
}
export function workspaceSkillDetailPath(
workspaceId: string,
name: string,
): string {
return workspaceApiPath(workspaceId, `/skills/${encodeURIComponent(name)}`);
}
export function workspaceSkillActivationPath(
workspaceId: string,
name: string,
): string {
return workspaceApiPath(
workspaceId,
`/skills/${encodeURIComponent(name)}/activate`,
);
}
export async function loadWorkspaceSkillCatalog(
fetchFn: typeof fetch,
workspaceId: string,
): Promise<ApiResult<SkillCatalogResponse>> {
return loadJson<SkillCatalogResponse>(
fetchFn,
workspaceSkillCatalogPath(workspaceId),
);
}
export async function loadWorkspaceSkillDetail(
fetchFn: typeof fetch,
workspaceId: string,
name: string,
): Promise<ApiResult<SkillDetailResponse>> {
return loadJson<SkillDetailResponse>(
fetchFn,
workspaceSkillDetailPath(workspaceId, name),
);
}
export async function loadJson<T>(
fetchFn: typeof fetch,
path: string,
@@ -50,7 +139,10 @@ export async function workspaceApiJson<T>(path: string): Promise<T> {
return requireJson<T>(await fetch(path), path);
}
export async function workspaceApiJsonWithBody<T>(path: string, init: RequestInit): Promise<T> {
export async function workspaceApiJsonWithBody<T>(
path: string,
init: RequestInit,
): Promise<T> {
return requireJson<T>(
await fetch(path, {
headers: {