feat(admin): web-based skill management with file editor

Add full skill lifecycle to the org-admin web surface: create, read,
edit, disable. Skills are directories (SKILL.md manifest + supporting
files), content-addressed by SHA-256 in an immutable store.

Backend:
- skillStore: extract commitSkillContent (shared populate→inspect→
  dedup→atomic rename); add importSkillFromFiles (in-memory file list
  ingestion) and readSkillFiles (read stored version back as UTF-8)
- configuration: add installSkillFromFiles, readSkillFiles, disableSkill
  (soft-delete + archive bound role sessions), updateSkillDescription
  (label-only, no archival); refactor installSkill to share
  commitInstalledSkill
- agentConfigRoutes: wire skillStoreRoot; add GET
  /agent-skills/:name/files, PUT /agent-skills/:name (create/replace),
  PATCH /agent-skills/:name (description/disable)
- orgRoutes: pass readSkillStoreRoot() to agent config routes

Frontend:
- api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods
- SkillEditor.svelte: file tree + text editor + version/description form
- skills/+page.svelte: skill list, create form (generates SKILL.md
  template), per-skill editor
- layout: add 技能 nav item

ADR-0018: update Decision to reflect web surface joining host-console
CLI in the shared content-addressed ingestion pipeline.

Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web
installation is one implementation of it.
This commit is contained in:
2026-07-16 01:11:49 +08:00
parent ae5f78f036
commit 79f72ecca8
10 changed files with 904 additions and 43 deletions
+115 -9
View File
@@ -1,13 +1,18 @@
/**
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
*
* Surface for browsing and editing Organization-scoped Agent roles and the
* skills bound to them. Roles are org-owned data; the default-model picker is
* constrained to the env-default model registry (ADR-0017: there is no
* org-scoped model list — `OrganizationAgentRole.defaultModel` selects from the
* platform-enabled set). Skill *installation* is out of band (CLI / seed) per
* spec `Skill 管理是 org-scoped; 存储机制 OPEN`; this route only lists installed
* skills and binds them to roles.
* Surface for browsing and editing Organization-scoped Agent roles and skills.
* Roles are org-owned data; the default-model picker is constrained to the
* env-default model registry (ADR-0017: there is no org-scoped model list —
* `OrganizationAgentRole.defaultModel` selects from the platform-enabled set).
*
* Skill management (create, read, edit, disable) is performed in-band through
* the deep module `OrganizationAgentConfiguration`, which writes to the same
* content-addressed persistent store used by the host-console CLI. All skill
* content flows through `importSkillFromFiles` → `inspectSkillDirectory` →
* `commitSkillContent`, so the web path and CLI path share one ingestion
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
* / 16-byte limits, symlink rejection).
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
@@ -19,6 +24,7 @@ import { handleRouteError } from "../errors.js";
export interface AgentConfigRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly skillStoreRoot: string;
}
export async function registerAgentConfigRoutes(
@@ -26,8 +32,7 @@ export async function registerAgentConfigRoutes(
config: AgentConfigRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
// skillStoreRoot is null: this surface does not install skills (see header).
const agentConfig = new OrganizationAgentConfiguration(config.prisma, null);
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
try {
@@ -115,6 +120,107 @@ export async function registerAgentConfigRoutes(
}
});
app.get("/api/org/:orgSlug/agent-skills/:name/files", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
const skill = skills.find((s) => s.name === name && s.disabledAt === null);
if (skill === undefined) {
return reply.status(404).send({
error: { code: "not_found", message: `skill not found: ${name}` },
});
}
const files = await agentConfig.readSkillFiles({
organizationId: auth.organization.id,
contentDigest: skill.contentDigest,
});
return { files };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { version?: unknown; files?: unknown };
if (typeof body.version !== "string" || body.version.trim() === "") {
return reply.status(400).send({
error: { code: "bad_request", message: "version is required" },
});
}
if (!Array.isArray(body.files) || body.files.length === 0) {
return reply.status(400).send({
error: { code: "bad_request", message: "files must be a non-empty array" },
});
}
const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
for (const entry of body.files) {
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
return reply.status(400).send({
error: { code: "bad_request", message: "each file must be an object" },
});
}
const e = entry as { path?: unknown; content?: unknown };
if (typeof e.path !== "string" || typeof e.content !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "each file needs string path and content" },
});
}
skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") });
}
const result = await agentConfig.installSkillFromFiles({
organizationId: auth.organization.id,
files: skillFiles,
version: body.version,
});
// The manifest name is authoritative — reject if it doesn't match the
// URL param, so clients can't silently rename a skill under a different
// route key.
if (result.name !== name) {
return reply.status(400).send({
error: {
code: "bad_request",
message: `skill manifest name "${result.name}" does not match route "${name}"`,
},
});
}
return { id: result.id, name: result.name, contentDigest: result.contentDigest };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { description?: unknown; disabled?: unknown };
if (body.disabled === true) {
await agentConfig.disableSkill({ organizationId: auth.organization.id, name });
return { disabled: true };
}
if (typeof body.description === "string") {
await agentConfig.updateSkillDescription({
organizationId: auth.organization.id,
name,
description: body.description,
});
return { updated: true };
}
return reply.status(400).send({
error: { code: "bad_request", message: "provide description or disabled: true" },
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };