Skip to content
969 linesCodeBlameRaw
1/**
2 * The skill library (docs.g1t.sh/guides/agent-skills/, @g1t/contracts
3 * skill-library.ts): a workspace's own skills, every change a new version,
4 * attached to agents, teams or the whole workspace at a pinned version.
5 *
6 * - **Sources:** written in the editor; imported from an upload (SKILL.md
7 * or a zip) or a repository folder at one commit; drafted by an agent
8 * from a finished session and published once a person reviews it; or
9 * followed from the repository the library is linked to
10 * (`.g1t/skills/<name>/` on its default branch, read again after every
11 * push there). Writing back to that repository is coming.
12 * - **Who:** everyone in the workspace sees the library and can save a
13 * draft from a session they can see; team maintainers write and import
14 * skills, edit their own, publish drafts and attach to their teams;
15 * owners do everything, for any skill.
16 * - **Versions:** each attachment pins one. Saving moves the attachments
17 * the person saving may change (unless they say not to); the others show
18 * an update available. A push to the linked repository moves every
19 * attachment of the skills it changed: the repository's review is the
20 * review.
21 * - **Never a permission:** a skill names tools agents have; it gives none.
22 *
23 * Identity and repositories come through `LibraryPorts`, so this runs
24 * against SQLite in tests.
25 */
26import type { Result, SkillAttachment, SkillDetail, SkillFileEntry, SkillImport, SkillInput, SkillLibrary, SkillMirror, SkillOrigin, SkillScope, SkillStatus, SkillVersionEntry } from "@g1t/contracts";
27import type { AgentSkillLine, AgentSkills, LibrarySkill } from "../../../packages/contracts/src/skill-library.ts";
28
29import { newId } from "../../../packages/contracts/src/ids.ts";
30import { fail, ok } from "../../../packages/contracts/src/result.ts";
31import {
32 type CheckedSkill,
33 type SkillFile,
34 SKILLS_PER_AGENT_MAX,
35 SKILLS_REPO_DIR,
36 SKILLS_REPO_DIRS,
37 SKILL_FILES_MAX,
38 SKILL_FOLDER_MAX_BYTES,
39 checkSkillFolder,
40 parseFrontMatter,
41 renderSkillMd,
42 skillFileBytes,
43 skillNameProblem,
44 splitFrontMatter,
45} from "../../../packages/contracts/src/skill-format.ts";
46import { FOUNDATIONAL_SKILLS, FOUNDATIONAL_SKILLS_VERSION } from "../../../packages/contracts/src/skills.ts";
47import { asSkillFile, readUpload } from "./skill-zip.ts";
48import type { StoredVersion } from "./skills.ts";
49
50/** The most skills one workspace's library holds. */
51export const LIBRARY_MAX = 1000;
52/** Text files up to this size are shown on a skill's page. */
53const SHOWN_FILE_BYTES = 200 * 1024;
54
55export type SkillRow = {
56 id: string;
57 workspace_id: string;
58 name: string;
59 /** The plugin it came with (`name@marketplace`), or null. */
60 plugin: string | null;
61 status: SkillStatus;
62 version: number;
63 description: string;
64 tools: string;
65 requires_computer: number;
66 files: number;
67 bytes: number;
68 origin: string;
69 mirrored: number;
70 created_by: string;
71 created_at: string;
72 updated_by: string;
73 updated_at: string;
74};
75
76export type AttachmentRow = { id: string; skill_id: string; scope: SkillScope; target: string; version: number; attached_by: string; attached_at: string };
77
78type VersionRow = {
79 version: number;
80 description: string;
81 tools: string;
82 requires_computer: number;
83 skill_md: string;
84 files: string;
85 bytes: number;
86 origin: string;
87 note: string | null;
88 created_by: string;
89 created_at: string;
90};
91
92type MirrorRow = { workspace_id: string; repo_id: string; repo: string; branch: string; commit_sha: string | null; synced_at: string | null; error: string | null; linked_by: string; linked_at: string };
93
94/** What the library needs from identity and repositories, as the viewer. */
95export type LibraryPorts = {
96 /** The workspace's teams the viewer can see, and whether they may manage each; null when identity didn't answer. */
97 teams(): Promise<{ slug: string; name: string; can_manage: boolean }[] | null>;
98 /** A repository the viewer can read, by `workspace/name`. */
99 repo(full: string): Promise<{ id: string; full: string; default_branch: string } | null>;
100 /** Every file at a branch, tag or commit (the default branch when null), without a viewer: check access first. */
101 listFiles(repoId: string, ref: string | null): Promise<{ commit: string | null; files: { path: string; hash: string | null }[]; truncated: boolean }>;
102 /** Blobs as base64; null data for one missing or over 1 MB. */
103 blobs(repoId: string, hashes: string[]): Promise<{ hash: string; data: string | null }[]>;
104 /** The visible teams an agent is on. */
105 agentTeams(agentId: string): Promise<{ slug: string; name: string }[]>;
106 /** The workspace's visible teams, each with the agents on it, by id. */
107 teamAgentIndex(): Promise<{ slug: string; agent_ids: string[] }[]>;
108 /** The workspace's audit log. */
109 audit(action: string, name: string, message: string): void;
110};
111
112export type LibraryContext = {
113 db: D1Database;
114 workspaceId: string;
115 slug: string;
116 viewer: { id: string; username: string; kind?: string };
117 /** Owns the workspace (or is its token). */
118 owner: boolean;
119 ports: LibraryPorts;
120 now?: Date;
121};
122
123/** What the viewer may do: owners everything; maintainers for their teams. */
124export type Actor = { username: string; owner: boolean; maintains: ReadonlySet<string> };
125
126export function mayWrite(actor: Actor): boolean {
127 return actor.owner || actor.maintains.size > 0;
128}
129
130export function mayChange(actor: Actor, scope: SkillScope, target: string): boolean {
131 return actor.owner || (scope === "team" && actor.maintains.has(target));
132}
133
134export function mayEdit(actor: Actor, row: Pick<SkillRow, "status" | "created_by" | "mirrored" | "plugin">): boolean {
135 if (row.mirrored || row.plugin) return false;
136 if (!mayWrite(actor)) return false;
137 return actor.owner || row.status === "draft" || row.created_by === actor.username;
138}
139
140export function mayDelete(actor: Actor, row: Pick<SkillRow, "status" | "created_by" | "plugin">, attachments: readonly Pick<AttachmentRow, "scope" | "target">[]): boolean {
141 if (row.plugin) return false;
142 if (actor.owner) return true;
143 if (row.status === "draft") return mayWrite(actor) || row.created_by === actor.username;
144 return mayWrite(actor) && row.created_by === actor.username && attachments.every((a) => a.scope === "team" && actor.maintains.has(a.target));
145}
146
147function json<T>(raw: string | null | undefined, fallback: T): T {
148 if (!raw) return fallback;
149 try {
150 return JSON.parse(raw) as T;
151 } catch {
152 return fallback;
153 }
154}
155
156export async function digestOf(skillMd: string, files: readonly SkillFile[]): Promise<string> {
157 const data = new TextEncoder().encode(`${skillMd}\u0000${JSON.stringify(files.map((f) => [f.path, f.encoding ?? "utf8", f.content]))}`);
158 const hash = await crypto.subtle.digest("SHA-256", data);
159 return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
160}
161
162/** A version's SKILL.md and files, for `use_skill`. */
163export async function readVersion(db: D1Database, skillId: string, version: number): Promise<StoredVersion | null> {
164 const row = await db.prepare("SELECT skill_md, files FROM skill_versions WHERE skill_id = ? AND version = ?").bind(skillId, version).first<{ skill_md: string; files: string }>();
165 return row ? { skill_md: row.skill_md, files: json<SkillFile[]>(row.files, []) } : null;
166}
167
168/** A skill by name: the workspace's own (`plugin` null), or a plugin's. Names are matched without case, as agents say them. */
169export async function skillByName(db: D1Database, workspaceId: string, name: string, plugin: string | null = null): Promise<SkillRow | null> {
170 return db.prepare("SELECT * FROM skills WHERE workspace_id = ? AND lower(name) = lower(?) AND COALESCE(plugin, '') = ? AND archived_at IS NULL").bind(workspaceId, name, plugin ?? "").first<SkillRow>();
171}
172
173/** The part of a plugin's ref agents use as a prefix: `pdf@anthropic-agent-skills` is `pdf`. */
174export function pluginPrefix(ref: string): string {
175 const at = ref.lastIndexOf("@");
176 return at > 0 ? ref.slice(0, at) : ref;
177}
178
179/** `plugin:name` for a plugin's skill, the name alone otherwise. */
180export function qualifiedName(row: Pick<SkillRow, "name" | "plugin">): string {
181 return row.plugin ? `${pluginPrefix(row.plugin)}:${row.name}` : row.name;
182}
183
184async function attachmentsOf(db: D1Database, skillIds: string[]): Promise<AttachmentRow[]> {
185 if (!skillIds.length) return [];
186 const rows = await db
187 .prepare("SELECT id, skill_id, scope, target, version, attached_by, attached_at FROM skill_attachments WHERE skill_id IN (SELECT value FROM json_each(?)) ORDER BY attached_at")
188 .bind(JSON.stringify(skillIds))
189 .all<AttachmentRow>();
190 return rows.results;
191}
192
193/**
194 * Writes `checked` as the skill's next version (a new skill when there is
195 * none), or publishes a draft in place, and moves the attachments `move`
196 * picks to it. An unchanged folder writes nothing. Safe against two saves
197 * at once: the second is told to look again.
198 */
199export async function writeVersion(
200 db: D1Database,
201 input: {
202 workspaceId: string;
203 existing: SkillRow | null;
204 checked: CheckedSkill;
205 origin: SkillOrigin;
206 note: string | null;
207 by: string;
208 now: Date;
209 status: SkillStatus;
210 mirrored: boolean;
211 /** The plugin it comes with, for a plugin's skill. */
212 plugin?: string | null;
213 move: (attachment: AttachmentRow) => boolean;
214 },
215): Promise<Result<{ id: string; version: number; changed: boolean; moved: number }>> {
216 const { existing, checked, now } = input;
217 const at = now.toISOString();
218 const digest = await digestOf(checked.skill_md, checked.files);
219 const filesJson = JSON.stringify(checked.files);
220 const tools = JSON.stringify(checked.tools);
221 const origin = JSON.stringify(input.origin);
222 const summary = [checked.name, checked.description, tools, checked.requires_computer ? 1 : 0, checked.files.length, checked.bytes, origin, input.mirrored ? 1 : 0] as const;
223
224 if (!existing) {
225 const count = await db.prepare("SELECT COUNT(*) AS n FROM skills WHERE workspace_id = ? AND archived_at IS NULL").bind(input.workspaceId).first<{ n: number }>();
226 if ((count?.n ?? 0) >= LIBRARY_MAX) return fail("invalid", `A workspace's library holds at most ${LIBRARY_MAX} skills.`);
227 const id = newId("skl", now.getTime());
228 try {
229 await db.batch([
230 db
231 .prepare(
232 `INSERT INTO skills (id, workspace_id, name, description, tools, requires_computer, files, bytes, origin, mirrored, status, version, created_by, created_at, updated_by, updated_at, plugin)
233 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1, ?12, ?13, ?12, ?13, ?14)`,
234 )
235 .bind(id, input.workspaceId, ...summary, input.status, input.by, at, input.plugin ?? null),
236 versionInsert(db, id, 1, checked, filesJson, origin, input.note, digest, input.by, at),
237 ]);
238 } catch (error) {
239 if (String(error).includes("UNIQUE")) return fail("conflict", `The library already has a skill called ${input.plugin ? `${pluginPrefix(input.plugin)}:` : ""}${checked.name}.`);
240 throw error;
241 }
242 return ok({ id, version: 1, changed: true, moved: 0 });
243 }
244
245 if (checked.name !== existing.name) {
246 const taken = await skillByName(db, input.workspaceId, checked.name, existing.plugin);
247 if (taken && taken.id !== existing.id) return fail("conflict", `The library already has a skill called ${checked.name}.`);
248 }
249
250 // A draft is published in place: it has no history yet.
251 if (existing.status === "draft") {
252 const [updated] = await db.batch([
253 db
254 .prepare(
255 `UPDATE skills SET name = ?1, description = ?2, tools = ?3, requires_computer = ?4, files = ?5, bytes = ?6, origin = ?7, mirrored = ?8, status = ?9, updated_by = ?10, updated_at = ?11
256 WHERE id = ?12 AND version = ?13 AND status = 'draft'`,
257 )
258 .bind(...summary, input.status, input.by, at, existing.id, existing.version),
259 db
260 .prepare(
261 `UPDATE skill_versions SET description = ?1, tools = ?2, requires_computer = ?3, skill_md = ?4, files = ?5, bytes = ?6, note = ?7, digest = ?8, created_by = ?9, created_at = ?10
262 WHERE skill_id = ?11 AND version = ?12`,
263 )
264 .bind(checked.description, tools, checked.requires_computer ? 1 : 0, checked.skill_md, filesJson, checked.bytes, input.note, digest, input.by, at, existing.id, existing.version),
265 ]);
266 if (!updated?.meta?.changes) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`);
267 return ok({ id: existing.id, version: existing.version, changed: true, moved: 0 });
268 }
269
270 const latest = await db.prepare("SELECT digest FROM skill_versions WHERE skill_id = ? AND version = ?").bind(existing.id, existing.version).first<{ digest: string }>();
271 const attachments = await attachmentsOf(db, [existing.id]);
272 if (latest?.digest === digest && checked.name === existing.name) {
273 // Nothing new; a stale attachment may still be moved on request.
274 const stale = attachments.filter((a) => a.version !== existing.version && input.move(a));
275 if (stale.length) await db.prepare("UPDATE skill_attachments SET version = ? WHERE id IN (SELECT value FROM json_each(?))").bind(existing.version, JSON.stringify(stale.map((a) => a.id))).run();
276 if (!!existing.mirrored !== input.mirrored) await db.prepare("UPDATE skills SET mirrored = ? WHERE id = ?").bind(input.mirrored ? 1 : 0, existing.id).run();
277 return ok({ id: existing.id, version: existing.version, changed: false, moved: stale.length });
278 }
279 const version = existing.version + 1;
280 const moving = attachments.filter(input.move).map((a) => a.id);
281 try {
282 const [updated] = await db.batch([
283 db
284 .prepare(
285 `UPDATE skills SET name = ?1, description = ?2, tools = ?3, requires_computer = ?4, files = ?5, bytes = ?6, origin = ?7, mirrored = ?8, version = ?9, updated_by = ?10, updated_at = ?11
286 WHERE id = ?12 AND version = ?13`,
287 )
288 .bind(...summary, version, input.by, at, existing.id, existing.version),
289 versionInsert(db, existing.id, version, checked, filesJson, origin, input.note, digest, input.by, at),
290 db
291 .prepare("UPDATE skill_attachments SET version = ?1 WHERE id IN (SELECT value FROM json_each(?2)) AND EXISTS (SELECT 1 FROM skills WHERE id = ?3 AND version = ?1)")
292 .bind(version, JSON.stringify(moving), existing.id),
293 ]);
294 if (!updated?.meta?.changes) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`);
295 } catch (error) {
296 if (String(error).includes("UNIQUE")) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`);
297 throw error;
298 }
299 return ok({ id: existing.id, version, changed: true, moved: moving.length });
300}
301
302function versionInsert(db: D1Database, id: string, version: number, checked: CheckedSkill, files: string, origin: string, note: string | null, digest: string, by: string, at: string): D1PreparedStatement {
303 return db
304 .prepare(
305 `INSERT INTO skill_versions (skill_id, version, description, tools, requires_computer, skill_md, files, bytes, origin, note, digest, created_by, created_at)
306 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)`,
307 )
308 .bind(id, version, checked.description, JSON.stringify(checked.tools), checked.requires_computer ? 1 : 0, checked.skill_md, files, checked.bytes, origin, note, digest, by, at);
309}
310
311/** A version note, tidied: one line, at most 200 characters. */
312function cleanNote(note: unknown): string | null {
313 if (typeof note !== "string") return null;
314 const line = note.replace(/\s+/g, " ").trim().slice(0, 200);
315 return line || null;
316}
317
318/** Text from a repository blob, or its bytes as base64. */
319function blobFile(path: string, data: string): SkillFile {
320 const binary = atob(data);
321 const bytes = new Uint8Array(binary.length);
322 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
323 return asSkillFile(path, bytes);
324}
325
326/** A skill's folder read from a repository: every file under `dir` at the listing's commit. */
327async function readRepoFolder(
328 ports: Pick<LibraryPorts, "blobs">,
329 repoId: string,
330 listing: { path: string; hash: string | null }[],
331 dir: string,
332): Promise<Result<SkillFile[]>> {
333 const prefix = dir ? `${dir}/` : "";
334 const wanted = listing.filter((f): f is { path: string; hash: string } => !!f.hash && f.path.startsWith(prefix));
335 if (!wanted.length) return fail("not_found", `There are no files in ${dir || "the repository's top folder"}.`);
336 if (wanted.length > SKILL_FILES_MAX) return fail("invalid", `A skill holds at most ${SKILL_FILES_MAX} files; ${dir || "that folder"} has ${wanted.length}.`);
337 const data = new Map<string, string | null>();
338 const hashes = [...new Set(wanted.map((f) => f.hash))];
339 for (let i = 0; i < hashes.length; i += 100) {
340 for (const blob of await ports.blobs(repoId, hashes.slice(i, i + 100))) data.set(blob.hash, blob.data);
341 }
342 const files: SkillFile[] = [];
343 let bytes = 0;
344 for (const file of wanted) {
345 const content = data.get(file.hash);
346 if (content == null) return fail("invalid", `${file.path} is too large for a skill (at most 1 MB for the whole folder).`);
347 const one = blobFile(file.path.slice(prefix.length), content);
348 bytes += skillFileBytes(one);
349 if (bytes > SKILL_FOLDER_MAX_BYTES) return fail("invalid", `${dir || "That folder"} is over 1 MB, the most a skill holds.`);
350 files.push(one);
351 }
352 return ok(files);
353}
354
355/**
356 * Reads the repository a library follows: each `.g1t/skills/<name>/`
357 * folder becomes or updates the skill of its name (moving its
358 * attachments), and skills whose folder is gone stop following it.
359 * Returns what changed and what couldn't be read.
360 */
361export async function syncMirror(
362 db: D1Database,
363 ports: Pick<LibraryPorts, "listFiles" | "blobs">,
364 mirror: MirrorRow,
365 now: Date,
366): Promise<{ changed: string[]; problems: string[]; commit: string | null }> {
367 const changed: string[] = [];
368 const problems: string[] = [];
369 let commit: string | null = null;
370 try {
371 const listing = await ports.listFiles(mirror.repo_id, null);
372 commit = listing.commit;
373 // `.claude/skills/` first, as Claude Code reads it; `.g1t/skills/` still, for libraries linked before.
374 const base = `${SKILLS_REPO_DIRS.find((dir) => listing.files.some((f) => f.path.startsWith(`${dir}/`))) ?? SKILLS_REPO_DIR}/`;
375 const folders = [...new Set(listing.files.filter((f) => f.path.startsWith(base) && f.path.slice(base.length).includes("/")).map((f) => f.path.slice(base.length).split("/")[0]!))].sort();
376 if (listing.truncated) problems.push("The repository has more files than g1t reads at once, so some skills may be missing.");
377 const seen = new Set<string>();
378 for (const folder of folders.slice(0, 200)) {
379 seen.add(folder);
380 const files = await readRepoFolder(ports, mirror.repo_id, listing.files, `${base}${folder}`);
381 if (!files.ok) {
382 problems.push(`${folder}: ${files.error.message}`);
383 continue;
384 }
385 const checked = checkSkillFolder(files.value, { expectName: folder });
386 if (!checked.ok) {
387 problems.push(`${folder}: ${checked.message}`);
388 continue;
389 }
390 const existing = await skillByName(db, mirror.workspace_id, checked.skill.name);
391 if (existing && !existing.mirrored) {
392 problems.push(`${folder}: the library already has a skill called ${folder} that isn't from this repository. Rename one of them.`);
393 continue;
394 }
395 const written = await writeVersion(db, {
396 workspaceId: mirror.workspace_id,
397 existing,
398 checked: checked.skill,
399 origin: { kind: "mirror", repo: mirror.repo, path: `${base}${folder}`, commit: commit ?? "" },
400 note: commit ? `From ${mirror.repo} at ${commit.slice(0, 8)}` : null,
401 by: mirror.linked_by,
402 now,
403 status: "published",
404 mirrored: true,
405 // The repository's own review is the review: every attachment follows.
406 move: () => true,
407 });
408 if (!written.ok) problems.push(`${folder}: ${written.error.message}`);
409 else if (written.value.changed) changed.push(folder);
410 }
411 // Gone from the repository: kept in the library, editable here again.
412 const following = await db.prepare("SELECT name FROM skills WHERE workspace_id = ? AND mirrored = 1 AND archived_at IS NULL").bind(mirror.workspace_id).all<{ name: string }>();
413 for (const { name } of following.results) {
414 if (seen.has(name)) continue;
415 await db.prepare("UPDATE skills SET mirrored = 0 WHERE workspace_id = ? AND name = ? AND archived_at IS NULL").bind(mirror.workspace_id, name).run();
416 problems.push(`${name} is no longer in the repository. It stays in the library, and can be edited here.`);
417 }
418 } catch (error) {
419 console.error("agents: a skills repository wasn't read", mirror.repo, String(error));
420 problems.push("The repository couldn't be read just now.");
421 }
422 await db
423 .prepare("UPDATE skill_mirrors SET commit_sha = COALESCE(?, commit_sha), synced_at = ?, error = ? WHERE workspace_id = ?")
424 .bind(commit, now.toISOString(), problems.length ? problems.join("\n").slice(0, 4000) : null, mirror.workspace_id)
425 .run();
426 return { changed, problems, commit };
427}
428
429/** After a push to a repository's default branch: every library that follows it is read again. */
430export async function onPush(db: D1Database, ports: Pick<LibraryPorts, "listFiles" | "blobs">, repoId: string, now = new Date()): Promise<number> {
431 const mirrors = await db.prepare("SELECT * FROM skill_mirrors WHERE repo_id = ?").bind(repoId).all<MirrorRow>();
432 for (const mirror of mirrors.results) await syncMirror(db, ports, mirror, now);
433 return mirrors.results.length;
434}
435
436function mirrorOut(row: MirrorRow | null): SkillMirror | null {
437 if (!row) return null;
438 return { repo: row.repo, branch: row.branch, commit: row.commit_sha, synced_at: row.synced_at, error: row.error, linked_by: row.linked_by, linked_at: row.linked_at };
439}
440
441/** A library skill as its pages show it. */
442function skillOut(row: SkillRow, attachments: AttachmentRow[], actor: Actor, labels: Labels): LibrarySkill {
443 return {
444 id: row.id,
445 name: row.name,
446 plugin: row.plugin,
447 qualified_name: qualifiedName(row),
448 description: row.description,
449 status: row.status,
450 version: row.version,
451 tools: json<string[]>(row.tools, []),
452 requires_computer: !!row.requires_computer,
453 files: row.files,
454 bytes: row.bytes,
455 origin: json<SkillOrigin>(row.origin, { kind: "written" }),
456 mirrored: !!row.mirrored,
457 attachments: attachments.map((a) => attachmentOut(a, actor, labels)),
458 created_by: row.created_by,
459 created_at: row.created_at,
460 updated_by: row.updated_by,
461 updated_at: row.updated_at,
462 can_edit: mayEdit(actor, row),
463 can_delete: mayDelete(actor, row, attachments),
464 };
465}
466
467type Labels = { agents: Map<string, { handle: string; display_name: string }>; teams: Map<string, string> };
468
469function attachmentOut(a: AttachmentRow, actor: Actor, labels: Labels): SkillAttachment {
470 const agent = a.scope === "agent" ? labels.agents.get(a.target) : null;
471 const target = a.scope === "agent" ? (agent?.handle ?? null) : a.scope === "team" ? a.target : null;
472 const label = a.scope === "workspace" ? "Every agent" : a.scope === "agent" ? (agent ? `@${agent.handle}` : "An archived agent") : (labels.teams.get(a.target) ?? a.target);
473 return { id: a.id, scope: a.scope, target, label, version: a.version, attached_by: a.attached_by, attached_at: a.attached_at, can_change: mayChange(actor, a.scope, a.target) };
474}
475
476/** One workspace's library, as one viewer may use it. */
477export class Library {
478 private readonly ctx: LibraryContext;
479 private teamList: { slug: string; name: string; can_manage: boolean }[] | null = null;
480
481 constructor(ctx: LibraryContext) {
482 this.ctx = ctx;
483 }
484
485 private get db(): D1Database {
486 return this.ctx.db;
487 }
488
489 private now(): Date {
490 return this.ctx.now ?? new Date();
491 }
492
493 private async teams(): Promise<{ slug: string; name: string; can_manage: boolean }[]> {
494 this.teamList ??= (await this.ctx.ports.teams().catch(() => null)) ?? [];
495 return this.teamList;
496 }
497
498 async actor(): Promise<Actor> {
499 const teams = this.ctx.viewer.kind === "agent" ? [] : await this.teams();
500 return { username: this.ctx.viewer.username, owner: this.ctx.owner, maintains: new Set(teams.filter((t) => this.ctx.owner || t.can_manage).map((t) => t.slug)) };
501 }
502
503 private async labels(): Promise<Labels> {
504 const [agents, teams] = await Promise.all([
505 this.db.prepare("SELECT id, handle, display_name FROM agents WHERE workspace_id = ? AND archived_at IS NULL ORDER BY builtin DESC, handle").bind(this.ctx.workspaceId).all<{ id: string; handle: string; display_name: string }>(),
506 this.teams(),
507 ]);
508 return { agents: new Map(agents.results.map((a) => [a.id, a])), teams: new Map(teams.map((t) => [t.slug, t.name])) };
509 }
510
511 private audit(action: string, name: string, message: string): void {
512 try {
513 this.ctx.ports.audit(action, name, message);
514 } catch {
515 // The log never fails the change.
516 }
517 }
518
519 async library(): Promise<Result<SkillLibrary>> {
520 const [rows, mirror, actor, labels] = await Promise.all([
521 this.db.prepare("SELECT * FROM skills WHERE workspace_id = ? AND archived_at IS NULL ORDER BY status = 'draft' DESC, plugin IS NOT NULL, plugin, name LIMIT ?").bind(this.ctx.workspaceId, LIBRARY_MAX).all<SkillRow>(),
522 this.db.prepare("SELECT * FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId).first<MirrorRow>(),
523 this.actor(),
524 this.labels(),
525 ]);
526 const attachments = await attachmentsOf(this.db, rows.results.map((r) => r.id));
527 const teams = await this.teams();
528 return ok({
529 skills: rows.results.map((row) => skillOut(row, attachments.filter((a) => a.skill_id === row.id), actor, labels)),
530 mirror: mirrorOut(mirror),
531 can_write: mayWrite(actor),
532 can_manage: actor.owner,
533 teams: teams.filter((t) => actor.maintains.has(t.slug)).map((t) => ({ slug: t.slug, name: t.name })),
534 agents: actor.owner ? [...labels.agents.values()].map((a) => ({ handle: a.handle, display_name: a.display_name })) : [],
535 });
536 }
537
538 private async named(name: unknown): Promise<Result<SkillRow>> {
539 const key = String(name ?? "").trim().toLowerCase();
540 const colon = key.indexOf(":");
541 // `plugin:name`: a plugin's skill, by the plugin's name.
542 if (colon > 0) {
543 const row = await this.db
544 .prepare("SELECT * FROM skills WHERE workspace_id = ? AND lower(name) = ? AND plugin IS NOT NULL AND lower(plugin) LIKE ? AND archived_at IS NULL")
545 .bind(this.ctx.workspaceId, key.slice(colon + 1), `${key.slice(0, colon)}@%`)
546 .first<SkillRow>();
547 return row ? ok(row) : fail("not_found", `No installed plugin brings a skill called ${key}.`);
548 }
549 const row = key ? await skillByName(this.db, this.ctx.workspaceId, key) : null;
550 if (row) return ok(row);
551 if (FOUNDATIONAL_SKILLS.some((s) => s.id === key)) return fail("not_found", `${key} is one of g1t's foundational skills: see it on any agent's Skills tab.`);
552 return fail("not_found", `The library has no skill called ${key || "that"}.`);
553 }
554
555 async detail(name: unknown, version?: unknown): Promise<Result<SkillDetail>> {
556 const found = await this.named(name);
557 if (!found.ok) return found;
558 const row = found.value;
559 const shown = version == null || version === "" ? row.version : Math.floor(Number(version));
560 const [stored, versions, attachments, actor, labels] = await Promise.all([
561 this.db.prepare("SELECT * FROM skill_versions WHERE skill_id = ? AND version = ?").bind(row.id, shown).first<VersionRow>(),
562 this.db
563 .prepare("SELECT version, description, note, origin, bytes, json_array_length(files) AS files, created_by, created_at FROM skill_versions WHERE skill_id = ? ORDER BY version DESC LIMIT 200")
564 .bind(row.id)
565 .all<{ version: number; description: string; note: string | null; origin: string; bytes: number; files: number; created_by: string; created_at: string }>(),
566 attachmentsOf(this.db, [row.id]),
567 this.actor(),
568 this.labels(),
569 ]);
570 if (!stored) return fail("not_found", `${row.name} has no version ${shown}.`);
571 const split = splitFrontMatter(stored.skill_md);
572 let extra: Record<string, unknown> = {};
573 if (split.ok) {
574 try {
575 const front = parseFrontMatter(split.yaml);
576 for (const [key, value] of Object.entries(front)) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value;
577 } catch {
578 extra = {};
579 }
580 }
581 const files: SkillFileEntry[] = json<SkillFile[]>(stored.files, []).map((f) => {
582 const bytes = skillFileBytes(f);
583 return { path: f.path, bytes, encoding: f.encoding === "base64" ? "base64" : "utf8", content: f.encoding !== "base64" && bytes <= SHOWN_FILE_BYTES ? f.content : null, script: f.path.startsWith("scripts/") };
584 });
585 const history: SkillVersionEntry[] = versions.results.map((v) => ({
586 version: v.version,
587 description: v.description,
588 note: v.note,
589 origin: json<SkillOrigin>(v.origin, { kind: "written" }),
590 bytes: v.bytes,
591 files: v.files ?? 0,
592 created_by: v.created_by,
593 created_at: v.created_at,
594 }));
595 return ok({
596 skill: skillOut(row, attachments, actor, labels),
597 shown,
598 skill_md: stored.skill_md,
599 instructions: split.ok ? split.body.trim() : stored.skill_md,
600 tools: json<string[]>(stored.tools, []),
601 requires_computer: !!stored.requires_computer,
602 extra,
603 files,
604 versions: history,
605 });
606 }
607
608 /** Writes a skill from the editor: a new one, a new version, or a draft published. */
609 async save(name: unknown, input: SkillInput): Promise<Result<SkillDetail>> {
610 const actor = await this.actor();
611 if (!mayWrite(actor)) return fail("forbidden", "Only the workspace's owners and team maintainers write skills.");
612 if (!input || typeof input !== "object") return fail("invalid", "Say what the skill is.");
613 let existing: SkillRow | null = null;
614 let prior: StoredVersion | null = null;
615 if (name != null && name !== "") {
616 const found = await this.named(name);
617 if (!found.ok) return found;
618 existing = found.value;
619 if (existing.mirrored) return fail("invalid", `${existing.name} follows the repository: change it there, in ${SKILLS_REPO_DIR}/${existing.name}/.`);
620 if (!mayEdit(actor, existing)) return fail("forbidden", "Owners edit any skill; team maintainers edit the skills they wrote.");
621 prior = await readVersion(this.db, existing.id, existing.version);
622 }
623 const skillName = String(input.name ?? "").trim().toLowerCase();
624 const problem = skillNameProblem(skillName);
625 if (problem) return fail("invalid", problem);
626 const description = String(input.description ?? "").trim();
627 const instructions = String(input.instructions ?? "").trim();
628 const tools = Array.isArray(input.tools) ? input.tools.filter((t): t is string => typeof t === "string") : [];
629 let extra: Record<string, unknown> = {};
630 if (prior) {
631 const split = splitFrontMatter(prior.skill_md);
632 try {
633 if (split.ok) for (const [key, value] of Object.entries(parseFrontMatter(split.yaml))) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value;
634 } catch {
635 extra = {};
636 }
637 }
638 // The current version's files, less those removed, with those added (by path).
639 const removed = new Set(Array.isArray(input.remove_files) ? input.remove_files.filter((p): p is string => typeof p === "string") : []);
640 const added = Array.isArray(input.add_files) ? input.add_files.filter((f): f is SkillFile => !!f && typeof f.path === "string" && typeof f.content === "string") : [];
641 const addedPaths = new Set(added.map((f) => f.path.replace(/^\.\//, "")));
642 const files = [...(prior?.files ?? []).filter((f) => !removed.has(f.path) && !addedPaths.has(f.path)), ...added];
643 const skillMd = renderSkillMd({ name: skillName, description, tools, requires_computer: input.requires_computer === true, body: instructions, extra });
644 const checked = checkSkillFolder([{ path: "SKILL.md", content: skillMd }, ...files.filter((f) => f?.path !== "SKILL.md")]);
645 if (!checked.ok) return fail("invalid", checked.message);
646 const publishing = existing?.status === "draft";
647 const updateAll = input.update_attachments !== false;
648 const written = await writeVersion(this.db, {
649 workspaceId: this.ctx.workspaceId,
650 existing,
651 checked: checked.skill,
652 origin: existing && publishing ? json<SkillOrigin>(existing.origin, { kind: "written" }) : { kind: "written" },
653 note: cleanNote(input.note),
654 by: actor.username,
655 now: this.now(),
656 status: "published",
657 mirrored: false,
658 move: (a) => updateAll && mayChange(actor, a.scope, a.target),
659 });
660 if (!written.ok) return written;
661 const verb = !existing ? "Wrote" : publishing ? "Published" : written.value.changed ? "Changed" : "Saved";
662 if (written.value.changed || !existing) this.audit(publishing ? "publish_skill" : existing ? "update_skill" : "create_skill", skillName, `${verb} the skill ${skillName} (version ${written.value.version})`);
663 return this.detail(skillName);
664 }
665
666 /** Imports a skill from an upload or a repository folder. */
667 async import(source: SkillImport, replace: boolean): Promise<Result<SkillDetail>> {
668 const actor = await this.actor();
669 if (!mayWrite(actor)) return fail("forbidden", "Only the workspace's owners and team maintainers import skills.");
670 let files: SkillFile[];
671 let origin: SkillOrigin;
672 if (source?.kind === "upload") {
673 const filename = String(source.filename ?? "").slice(0, 200);
674 const read = await readUpload(filename, String(source.data_base64 ?? ""));
675 if (!read.ok) return fail("invalid", read.message);
676 files = read.files;
677 origin = { kind: "upload", filename: filename || "SKILL.md" };
678 } else if (source?.kind === "repository") {
679 const full = String(source.repo ?? "").trim().replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
680 if (!/^[^/\s]+\/[^/\s]+$/.test(full)) return fail("invalid", "Name the repository as workspace/name.");
681 const repo = await this.ctx.ports.repo(full);
682 if (!repo) return fail("not_found", `There is no repository ${full} you can read.`);
683 let dir = String(source.path ?? "").trim().replace(/^\/+|\/+$/g, "");
684 if (/(^|\/)SKILL\.md$/i.test(dir)) dir = dir.replace(/\/?SKILL\.md$/i, "");
685 const ref = String(source.ref ?? "").trim() || repo.default_branch;
686 const listing = await this.ctx.ports.listFiles(repo.id, ref).catch(() => null);
687 if (!listing?.commit) return fail("not_found", `${full} has no branch, tag or commit called ${ref}.`);
688 const read = await readRepoFolder(this.ctx.ports, repo.id, listing.files, dir);
689 if (!read.ok) return read;
690 files = read.value;
691 origin = { kind: "repository", repo: repo.full, path: dir || ".", ref, commit: listing.commit };
692 } else return fail("invalid", "Import from an upload or a repository folder.");
693 const checked = checkSkillFolder(files);
694 if (!checked.ok) return fail("invalid", checked.message);
695 const existing = await skillByName(this.db, this.ctx.workspaceId, checked.skill.name);
696 if (existing && !replace) {
697 return fail("conflict", `The library already has a skill called ${checked.skill.name}. Import it as a new version of ${checked.skill.name}, or change the name in its SKILL.md.`);
698 }
699 if (existing?.mirrored) return fail("invalid", `${existing.name} follows the repository: change it there, in ${SKILLS_REPO_DIR}/${existing.name}/.`);
700 if (existing && !mayEdit(actor, existing)) return fail("forbidden", "Owners edit any skill; team maintainers edit the skills they wrote.");
701 const written = await writeVersion(this.db, {
702 workspaceId: this.ctx.workspaceId,
703 existing,
704 checked: checked.skill,
705 origin,
706 note: origin.kind === "repository" ? `Imported from ${origin.repo} at ${origin.commit.slice(0, 8)}` : `Imported from ${origin.kind === "upload" ? origin.filename : "an upload"}`,
707 by: actor.username,
708 now: this.now(),
709 status: "published",
710 mirrored: false,
711 move: (a) => mayChange(actor, a.scope, a.target),
712 });
713 if (!written.ok) return written;
714 this.audit("import_skill", checked.skill.name, `Imported the skill ${checked.skill.name} (version ${written.value.version})`);
715 return this.detail(checked.skill.name);
716 }
717
718 /** Saves a draft an agent wrote from a session; a person publishes it after reviewing it. */
719 async saveDraft(checked: CheckedSkill, origin: Extract<SkillOrigin, { kind: "session" }>): Promise<Result<SkillDetail>> {
720 let name = checked.name;
721 for (let n = 2; await skillByName(this.db, this.ctx.workspaceId, name); n++) {
722 name = `${checked.name.slice(0, 60)}-${n}`;
723 if (n > 50) return fail("conflict", "Too many skills share that name.");
724 }
725 const renamed = name === checked.name ? checked : { ...checked, name, skill_md: checked.skill_md.replace(/^name:.*$/m, `name: ${name}`) };
726 const written = await writeVersion(this.db, {
727 workspaceId: this.ctx.workspaceId,
728 existing: null,
729 checked: renamed,
730 origin,
731 note: `Drafted by @${origin.agent} from the session "${origin.title}"`,
732 by: this.ctx.viewer.username,
733 now: this.now(),
734 status: "draft",
735 mirrored: false,
736 move: () => false,
737 });
738 if (!written.ok) return written;
739 this.audit("draft_skill", name, `Saved a draft skill ${name} from a session of @${origin.agent}`);
740 return this.detail(name);
741 }
742
743 async attach(name: unknown, scope: unknown, target: unknown): Promise<Result<SkillDetail>> {
744 const found = await this.named(name);
745 if (!found.ok) return found;
746 const row = found.value;
747 if (row.status === "draft") return fail("invalid", "Publish the draft before attaching it.");
748 if (scope !== "agent" && scope !== "team" && scope !== "workspace") return fail("invalid", "Attach a skill to an agent, a team or the whole workspace.");
749 const actor = await this.actor();
750 let key = "";
751 let label = "every agent";
752 if (scope === "agent") {
753 const handle = String(target ?? "").trim().replace(/^@/, "").toLowerCase();
754 const agent = await this.db.prepare("SELECT id, handle FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL").bind(this.ctx.workspaceId, handle).first<{ id: string; handle: string }>();
755 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
756 key = agent.id;
757 label = `@${agent.handle}`;
758 } else if (scope === "team") {
759 const slug = String(target ?? "").trim().toLowerCase();
760 const team = (await this.teams()).find((t) => t.slug === slug);
761 if (!team) return fail("not_found", `${this.ctx.slug} has no team called ${slug}.`);
762 key = team.slug;
763 label = team.name;
764 }
765 if (!mayChange(actor, scope, key)) {
766 return fail("forbidden", scope === "team" ? "Only owners and the team's maintainers attach skills to it." : "Only the workspace's owners attach skills to agents and to every agent.");
767 }
768 // At most SKILLS_PER_AGENT_MAX reach any one agent, through the teams it is on (team
769 // memberships, from identity): counted for each agent this attachment reaches.
770 const index = await this.ctx.ports.teamAgentIndex().catch(() => []);
771 const memberships = JSON.stringify(index.flatMap((team) => team.agent_ids.map((id) => [id, team.slug])));
772 const which = scope === "workspace" ? "?3 = ?3" : scope === "team" ? "EXISTS (SELECT 1 FROM m WHERE m.agent_id = ag.id AND m.slug = ?3)" : "ag.id = ?3";
773 const most = await this.db
774 .prepare(
775 `WITH m(agent_id, slug) AS (SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?4))
776 SELECT COALESCE(MAX(n), 0) AS n FROM (
777 SELECT ag.id, COUNT(DISTINCT a.skill_id) AS n
778 FROM agents ag
779 JOIN skill_attachments a ON a.workspace_id = ag.workspace_id
780 AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ag.id) OR (a.scope = 'team' AND EXISTS (SELECT 1 FROM m WHERE m.agent_id = ag.id AND m.slug = a.target)))
781 JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL
782 WHERE ag.workspace_id = ?1 AND ag.archived_at IS NULL AND a.skill_id <> ?2 AND ${which}
783 GROUP BY ag.id)`,
784 )
785 .bind(this.ctx.workspaceId, row.id, key, memberships)
786 .first<{ n: number }>();
787 // And what is attached where it lands already, for a team or workspace with no agents yet.
788 const reach =
789 scope === "workspace" ? "(a.scope = 'workspace' AND ?3 = ?3)" : scope === "team" ? "(a.scope = 'workspace' OR (a.scope = 'team' AND a.target = ?3))" : "(a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?3))";
790 const count = await this.db
791 .prepare(`SELECT COUNT(DISTINCT a.skill_id) AS n FROM skill_attachments a JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL WHERE a.workspace_id = ?1 AND a.skill_id <> ?2 AND ${reach}`)
792 .bind(this.ctx.workspaceId, row.id, key)
793 .first<{ n: number }>();
794 if (Math.max(count?.n ?? 0, most?.n ?? 0) >= SKILLS_PER_AGENT_MAX) {
795 return fail("invalid", `An agent has at most ${SKILLS_PER_AGENT_MAX} skills from the library, and ${scope === "workspace" ? "an agent" : label} would have more. Detach one first.`);
796 }
797 const inserted = await this.db
798 .prepare(
799 `INSERT INTO skill_attachments (id, workspace_id, skill_id, scope, target, version, attached_by, attached_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
800 ON CONFLICT (skill_id, scope, target) DO NOTHING`,
801 )
802 .bind(newId("ska"), this.ctx.workspaceId, row.id, scope, key, row.version, actor.username, this.now().toISOString())
803 .run();
804 if (!inserted.meta?.changes) return fail("conflict", `${row.name} is already attached to ${label}.`);
805 this.audit("attach_skill", row.name, `Attached the skill ${row.name} (version ${row.version}) to ${label}`);
806 return this.detail(row.name);
807 }
808
809 private async attachment(name: unknown, id: unknown): Promise<Result<{ row: SkillRow; attachment: AttachmentRow; actor: Actor }>> {
810 const found = await this.named(name);
811 if (!found.ok) return found;
812 const attachment = await this.db
813 .prepare("SELECT id, skill_id, scope, target, version, attached_by, attached_at FROM skill_attachments WHERE id = ? AND skill_id = ?")
814 .bind(String(id ?? ""), found.value.id)
815 .first<AttachmentRow>();
816 if (!attachment) return fail("not_found", `${found.value.name} isn't attached there.`);
817 const actor = await this.actor();
818 if (!mayChange(actor, attachment.scope, attachment.target)) {
819 return fail("forbidden", attachment.scope === "team" ? "Only owners and the team's maintainers change what is attached to it." : "Only the workspace's owners change this attachment.");
820 }
821 return ok({ row: found.value, attachment, actor });
822 }
823
824 async detach(name: unknown, id: unknown): Promise<Result<SkillDetail>> {
825 const found = await this.attachment(name, id);
826 if (!found.ok) return found;
827 await this.db.prepare("DELETE FROM skill_attachments WHERE id = ?").bind(found.value.attachment.id).run();
828 this.audit("detach_skill", found.value.row.name, `Detached the skill ${found.value.row.name} (${found.value.attachment.scope})`);
829 return this.detail(found.value.row.name);
830 }
831
832 async pin(name: unknown, id: unknown, version: unknown): Promise<Result<SkillDetail>> {
833 const found = await this.attachment(name, id);
834 if (!found.ok) return found;
835 const { row, attachment } = found.value;
836 const to = version == null ? row.version : Math.floor(Number(version));
837 const exists = await this.db.prepare("SELECT 1 AS one FROM skill_versions WHERE skill_id = ? AND version = ?").bind(row.id, to).first();
838 if (!exists) return fail("not_found", `${row.name} has no version ${to}.`);
839 if (to !== attachment.version) {
840 await this.db.prepare("UPDATE skill_attachments SET version = ? WHERE id = ?").bind(to, attachment.id).run();
841 this.audit("pin_skill", row.name, `Moved the skill ${row.name} from version ${attachment.version} to ${to} (${attachment.scope})`);
842 }
843 return this.detail(row.name);
844 }
845
846 async remove(name: unknown): Promise<Result<null>> {
847 const found = await this.named(name);
848 if (!found.ok) return found;
849 const row = found.value;
850 const [actor, attachments] = await Promise.all([this.actor(), attachmentsOf(this.db, [row.id])]);
851 if (!mayDelete(actor, row, attachments)) {
852 return fail("forbidden", row.status === "draft" ? "Only whoever saved the draft, owners and team maintainers discard it." : "Owners delete any skill; team maintainers delete the skills they wrote that only their teams use.");
853 }
854 const at = this.now().toISOString();
855 await this.db.batch([
856 this.db.prepare("UPDATE skills SET archived_at = ?, mirrored = 0 WHERE id = ? AND archived_at IS NULL").bind(at, row.id),
857 this.db.prepare("DELETE FROM skill_attachments WHERE skill_id = ?").bind(row.id),
858 ]);
859 this.audit(row.status === "draft" ? "discard_skill" : "delete_skill", row.name, `${row.status === "draft" ? "Discarded the draft" : "Deleted the skill"} ${row.name}`);
860 return ok(null);
861 }
862
863 /** An agent's skills: g1t's foundational ones and the library's that reach it, each once, on or off. */
864 async agentSkills(handle: unknown): Promise<Result<AgentSkills>> {
865 const key = String(handle ?? "").trim().replace(/^@/, "").toLowerCase();
866 const agent = await this.db
867 .prepare("SELECT id, handle, skills_off FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL")
868 .bind(this.ctx.workspaceId, key)
869 .first<{ id: string; handle: string; skills_off: string | null }>();
870 if (!agent) return fail("not_found", `There is no agent called @${key}.`);
871 const off = new Set(json<string[]>(agent.skills_off, []));
872 const [teams, actor] = await Promise.all([this.ctx.ports.agentTeams(agent.id).catch(() => []), this.actor()]);
873 const teamNames = new Map(teams.map((t) => [t.slug, t.name]));
874 const rows = await this.db
875 .prepare(
876 `SELECT s.id AS skill_id, s.name, s.plugin, s.version AS latest, v.description, a.id AS attachment_id, a.version, v.tools, v.requires_computer, a.scope, a.target, a.attached_at
877 FROM skill_attachments a
878 JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL AND s.status = 'published'
879 JOIN skill_versions v ON v.skill_id = a.skill_id AND v.version = a.version
880 WHERE a.workspace_id = ?1
881 AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?2) OR (a.scope = 'team' AND a.target IN (SELECT value FROM json_each(?3))))
882 LIMIT 500`,
883 )
884 .bind(this.ctx.workspaceId, agent.id, JSON.stringify(teams.map((t) => t.slug)))
885 .all<{ skill_id: string; name: string; plugin: string | null; latest: number; description: string; attachment_id: string; version: number; tools: string; requires_computer: number; scope: SkillScope; target: string; attached_at: string }>();
886 const order: Record<SkillScope, number> = { agent: 0, team: 1, workspace: 2 };
887 const seen = new Set<string>();
888 const library: AgentSkillLine[] = [];
889 for (const r of [...rows.results].sort((a, b) => order[a.scope] - order[b.scope] || a.attached_at.localeCompare(b.attached_at))) {
890 if (seen.has(r.skill_id)) continue;
891 seen.add(r.skill_id);
892 library.push({
893 id: r.skill_id,
894 name: qualifiedName(r),
895 plugin: r.plugin,
896 description: r.description,
897 foundational: false,
898 on: !off.has(r.skill_id),
899 via: r.scope,
900 via_label: r.scope === "workspace" ? "Every agent" : r.scope === "agent" ? "This agent" : (teamNames.get(r.target) ?? r.target),
901 attachment_id: r.attachment_id,
902 version: String(r.version),
903 update: r.latest > r.version ? r.latest : null,
904 requires_computer: !!r.requires_computer,
905 tools: json<string[]>(r.tools, []),
906 can_change: mayChange(actor, r.scope, r.target),
907 });
908 }
909 const foundational: AgentSkillLine[] = FOUNDATIONAL_SKILLS.map((s) => ({
910 id: s.id,
911 name: s.name,
912 plugin: null,
913 description: s.description,
914 foundational: true,
915 on: !off.has(s.id),
916 via: null,
917 via_label: null,
918 attachment_id: null,
919 version: FOUNDATIONAL_SKILLS_VERSION,
920 update: null,
921 requires_computer: false,
922 tools: [...new Set(s.abilities.filter((a) => a.status === "ready").flatMap((a) => a.tools))],
923 can_change: false,
924 }));
925 const onLibrary = library.filter((l) => l.on);
926 return ok({
927 handle: agent.handle,
928 skills: [...foundational, ...library.sort((a, b) => a.name.localeCompare(b.name))],
929 over_limit: Math.max(0, onLibrary.length - SKILLS_PER_AGENT_MAX),
930 });
931 }
932
933 /** Links the repository the library follows, or unlinks it; then reads it. Owners only. */
934 async setMirror(repo: unknown): Promise<Result<{ mirror: SkillMirror | null; changed: string[]; problems: string[] }>> {
935 if (!this.ctx.owner) return fail("forbidden", "Only the workspace's owners link a repository to the library.");
936 if (repo == null || repo === "") {
937 await this.db.batch([
938 this.db.prepare("DELETE FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId),
939 this.db.prepare("UPDATE skills SET mirrored = 0 WHERE workspace_id = ? AND mirrored = 1").bind(this.ctx.workspaceId),
940 ]);
941 this.audit("unlink_skills_repository", "repository", "Stopped following a repository for skills");
942 return ok({ mirror: null, changed: [], problems: [] });
943 }
944 const full = String(repo).trim().replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
945 if (!/^[^/\s]+\/[^/\s]+$/.test(full)) return fail("invalid", "Name the repository as workspace/name.");
946 const found = await this.ctx.ports.repo(full);
947 if (!found) return fail("not_found", `There is no repository ${full} you can read.`);
948 const at = this.now().toISOString();
949 await this.db
950 .prepare(
951 `INSERT INTO skill_mirrors (workspace_id, repo_id, repo, branch, linked_by, linked_at) VALUES (?, ?, ?, ?, ?, ?)
952 ON CONFLICT (workspace_id) DO UPDATE SET repo_id = excluded.repo_id, repo = excluded.repo, branch = excluded.branch, linked_by = excluded.linked_by, linked_at = excluded.linked_at, commit_sha = NULL, synced_at = NULL, error = NULL`,
953 )
954 .bind(this.ctx.workspaceId, found.id, found.full, found.default_branch, this.ctx.viewer.username, at)
955 .run();
956 this.audit("link_skills_repository", "repository", `Follows ${found.full} for skills`);
957 return this.sync();
958 }
959
960 async sync(): Promise<Result<{ mirror: SkillMirror | null; changed: string[]; problems: string[] }>> {
961 const actor = await this.actor();
962 if (!mayWrite(actor)) return fail("forbidden", "Only the workspace's owners and team maintainers read the repository again.");
963 const mirror = await this.db.prepare("SELECT * FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId).first<MirrorRow>();
964 if (!mirror) return fail("not_found", "The library doesn't follow a repository.");
965 const result = await syncMirror(this.db, this.ctx.ports, mirror, this.now());
966 const after = await this.db.prepare("SELECT * FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId).first<MirrorRow>();
967 return ok({ mirror: mirrorOut(after), changed: result.changed, problems: result.problems });
968 }
969}