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