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(agent: { id: string; team: string | null }): Promise<{ slug: string; name: string }[]>;
103 /** The workspace's audit log. */
104 audit(action: string, name: string, message: string): void;
105};
106
107export type LibraryContext = {
108 db: D1Database;
109 workspaceId: string;
110 slug: string;
111 viewer: { id: string; username: string; kind?: string };
112 /** Owns the workspace (or is its token). */
113 owner: boolean;
114 ports: LibraryPorts;
115 now?: Date;
116};
117
118/** What the viewer may do: owners everything; maintainers for their teams. */
119export type Actor = { username: string; owner: boolean; maintains: ReadonlySet<string> };
120
121export function mayWrite(actor: Actor): boolean {
122 return actor.owner || actor.maintains.size > 0;
123}
124
125export function mayChange(actor: Actor, scope: SkillScope, target: string): boolean {
126 return actor.owner || (scope === "team" && actor.maintains.has(target));
127}
128
129export function mayEdit(actor: Actor, row: Pick<SkillRow, "status" | "created_by" | "mirrored">): boolean {
130 if (row.mirrored) return false;
131 if (!mayWrite(actor)) return false;
132 return actor.owner || row.status === "draft" || row.created_by === actor.username;
133}
134
135export function mayDelete(actor: Actor, row: Pick<SkillRow, "status" | "created_by">, attachments: readonly Pick<AttachmentRow, "scope" | "target">[]): boolean {
136 if (actor.owner) return true;
137 if (row.status === "draft") return mayWrite(actor) || row.created_by === actor.username;
138 return mayWrite(actor) && row.created_by === actor.username && attachments.every((a) => a.scope === "team" && actor.maintains.has(a.target));
139}
140
141function json<T>(raw: string | null | undefined, fallback: T): T {
142 if (!raw) return fallback;
143 try {
144 return JSON.parse(raw) as T;
145 } catch {
146 return fallback;
147 }
148}
149
150export async function digestOf(skillMd: string, files: readonly SkillFile[]): Promise<string> {
151 const data = new TextEncoder().encode(`${skillMd}\u0000${JSON.stringify(files.map((f) => [f.path, f.encoding ?? "utf8", f.content]))}`);
152 const hash = await crypto.subtle.digest("SHA-256", data);
153 return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
154}
155
156/** A version's SKILL.md and files, for `use_skill`. */
157export async function readVersion(db: D1Database, skillId: string, version: number): Promise<StoredVersion | null> {
158 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 }>();
159 return row ? { skill_md: row.skill_md, files: json<SkillFile[]>(row.files, []) } : null;
160}
161
162async function skillByName(db: D1Database, workspaceId: string, name: string): Promise<SkillRow | null> {
163 return db.prepare("SELECT * FROM skills WHERE workspace_id = ? AND name = ? AND archived_at IS NULL").bind(workspaceId, name).first<SkillRow>();
164}
165
166async function attachmentsOf(db: D1Database, skillIds: string[]): Promise<AttachmentRow[]> {
167 if (!skillIds.length) return [];
168 const rows = await db
169 .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")
170 .bind(JSON.stringify(skillIds))
171 .all<AttachmentRow>();
172 return rows.results;
173}
174
175/**
176 * Writes `checked` as the skill's next version (a new skill when there is
177 * none), or publishes a draft in place, and moves the attachments `move`
178 * picks to it. An unchanged folder writes nothing. Safe against two saves
179 * at once: the second is told to look again.
180 */
181export async function writeVersion(
182 db: D1Database,
183 input: {
184 workspaceId: string;
185 existing: SkillRow | null;
186 checked: CheckedSkill;
187 origin: SkillOrigin;
188 note: string | null;
189 by: string;
190 now: Date;
191 status: SkillStatus;
192 mirrored: boolean;
193 move: (attachment: AttachmentRow) => boolean;
194 },
195): Promise<Result<{ id: string; version: number; changed: boolean; moved: number }>> {
196 const { existing, checked, now } = input;
197 const at = now.toISOString();
198 const digest = await digestOf(checked.skill_md, checked.files);
199 const filesJson = JSON.stringify(checked.files);
200 const tools = JSON.stringify(checked.tools);
201 const origin = JSON.stringify(input.origin);
202 const summary = [checked.name, checked.description, tools, checked.requires_computer ? 1 : 0, checked.files.length, checked.bytes, origin, input.mirrored ? 1 : 0] as const;
203
204 if (!existing) {
205 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 }>();
206 if ((count?.n ?? 0) >= LIBRARY_MAX) return fail("invalid", `A workspace's library holds at most ${LIBRARY_MAX} skills.`);
207 const id = newId("skl", now.getTime());
208 try {
209 await db.batch([
210 db
211 .prepare(
212 `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)
213 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1, ?12, ?13, ?12, ?13)`,
214 )
215 .bind(id, input.workspaceId, ...summary, input.status, input.by, at),
216 versionInsert(db, id, 1, checked, filesJson, origin, input.note, digest, input.by, at),
217 ]);
218 } catch (error) {
219 if (String(error).includes("UNIQUE")) return fail("conflict", `The library already has a skill called ${checked.name}.`);
220 throw error;
221 }
222 return ok({ id, version: 1, changed: true, moved: 0 });
223 }
224
225 if (checked.name !== existing.name) {
226 const taken = await skillByName(db, input.workspaceId, checked.name);
227 if (taken && taken.id !== existing.id) return fail("conflict", `The library already has a skill called ${checked.name}.`);
228 }
229
230 // A draft is published in place: it has no history yet.
231 if (existing.status === "draft") {
232 const [updated] = await db.batch([
233 db
234 .prepare(
235 `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
236 WHERE id = ?12 AND version = ?13 AND status = 'draft'`,
237 )
238 .bind(...summary, input.status, input.by, at, existing.id, existing.version),
239 db
240 .prepare(
241 `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
242 WHERE skill_id = ?11 AND version = ?12`,
243 )
244 .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),
245 ]);
246 if (!updated?.meta?.changes) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`);
247 return ok({ id: existing.id, version: existing.version, changed: true, moved: 0 });
248 }
249
250 const latest = await db.prepare("SELECT digest FROM skill_versions WHERE skill_id = ? AND version = ?").bind(existing.id, existing.version).first<{ digest: string }>();
251 const attachments = await attachmentsOf(db, [existing.id]);
252 if (latest?.digest === digest && checked.name === existing.name) {
253 // Nothing new; a stale attachment may still be moved on request.
254 const stale = attachments.filter((a) => a.version !== existing.version && input.move(a));
255 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();
256 if (!!existing.mirrored !== input.mirrored) await db.prepare("UPDATE skills SET mirrored = ? WHERE id = ?").bind(input.mirrored ? 1 : 0, existing.id).run();
257 return ok({ id: existing.id, version: existing.version, changed: false, moved: stale.length });
258 }
259 const version = existing.version + 1;
260 const moving = attachments.filter(input.move).map((a) => a.id);
261 try {
262 const [updated] = await db.batch([
263 db
264 .prepare(
265 `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
266 WHERE id = ?12 AND version = ?13`,
267 )
268 .bind(...summary, version, input.by, at, existing.id, existing.version),
269 versionInsert(db, existing.id, version, checked, filesJson, origin, input.note, digest, input.by, at),
270 db
271 .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)")
272 .bind(version, JSON.stringify(moving), existing.id),
273 ]);
274 if (!updated?.meta?.changes) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`);
275 } catch (error) {
276 if (String(error).includes("UNIQUE")) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`);
277 throw error;
278 }
279 return ok({ id: existing.id, version, changed: true, moved: moving.length });
280}
281
282function versionInsert(db: D1Database, id: string, version: number, checked: CheckedSkill, files: string, origin: string, note: string | null, digest: string, by: string, at: string): D1PreparedStatement {
283 return db
284 .prepare(
285 `INSERT INTO skill_versions (skill_id, version, description, tools, requires_computer, skill_md, files, bytes, origin, note, digest, created_by, created_at)
286 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)`,
287 )
288 .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);
289}
290
291/** A version note, tidied: one line, at most 200 characters. */
292function cleanNote(note: unknown): string | null {
293 if (typeof note !== "string") return null;
294 const line = note.replace(/\s+/g, " ").trim().slice(0, 200);
295 return line || null;
296}
297
298/** Text from a repository blob, or its bytes as base64. */
299function blobFile(path: string, data: string): SkillFile {
300 const binary = atob(data);
301 const bytes = new Uint8Array(binary.length);
302 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
303 return asSkillFile(path, bytes);
304}
305
306/** A skill's folder read from a repository: every file under `dir` at the listing's commit. */
307async function readRepoFolder(
308 ports: Pick<LibraryPorts, "blobs">,
309 repoId: string,
310 listing: { path: string; hash: string | null }[],
311 dir: string,
312): Promise<Result<SkillFile[]>> {
313 const prefix = dir ? `${dir}/` : "";
314 const wanted = listing.filter((f): f is { path: string; hash: string } => !!f.hash && f.path.startsWith(prefix));
315 if (!wanted.length) return fail("not_found", `There are no files in ${dir || "the repository's top folder"}.`);
316 if (wanted.length > SKILL_FILES_MAX) return fail("invalid", `A skill holds at most ${SKILL_FILES_MAX} files; ${dir || "that folder"} has ${wanted.length}.`);
317 const data = new Map<string, string | null>();
318 const hashes = [...new Set(wanted.map((f) => f.hash))];
319 for (let i = 0; i < hashes.length; i += 100) {
320 for (const blob of await ports.blobs(repoId, hashes.slice(i, i + 100))) data.set(blob.hash, blob.data);
321 }
322 const files: SkillFile[] = [];
323 let bytes = 0;
324 for (const file of wanted) {
325 const content = data.get(file.hash);
326 if (content == null) return fail("invalid", `${file.path} is too large for a skill (at most 1 MB for the whole folder).`);
327 const one = blobFile(file.path.slice(prefix.length), content);
328 bytes += skillFileBytes(one);
329 if (bytes > SKILL_FOLDER_MAX_BYTES) return fail("invalid", `${dir || "That folder"} is over 1 MB, the most a skill holds.`);
330 files.push(one);
331 }
332 return ok(files);
333}
334
335/**
336 * Reads the repository a library follows: each `.g1t/skills/<name>/`
337 * folder becomes or updates the skill of its name (moving its
338 * attachments), and skills whose folder is gone stop following it.
339 * Returns what changed and what couldn't be read.
340 */
341export async function syncMirror(
342 db: D1Database,
343 ports: Pick<LibraryPorts, "listFiles" | "blobs">,
344 mirror: MirrorRow,
345 now: Date,
346): Promise<{ changed: string[]; problems: string[]; commit: string | null }> {
347 const changed: string[] = [];
348 const problems: string[] = [];
349 let commit: string | null = null;
350 try {
351 const listing = await ports.listFiles(mirror.repo_id, null);
352 commit = listing.commit;
353 const base = `${SKILLS_REPO_DIR}/`;
354 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();
355 if (listing.truncated) problems.push("The repository has more files than g1t reads at once, so some skills may be missing.");
356 const seen = new Set<string>();
357 for (const folder of folders.slice(0, 200)) {
358 seen.add(folder);
359 const files = await readRepoFolder(ports, mirror.repo_id, listing.files, `${base}${folder}`);
360 if (!files.ok) {
361 problems.push(`${folder}: ${files.error.message}`);
362 continue;
363 }
364 const checked = checkSkillFolder(files.value, { expectName: folder });
365 if (!checked.ok) {
366 problems.push(`${folder}: ${checked.message}`);
367 continue;
368 }
369 const existing = await skillByName(db, mirror.workspace_id, checked.skill.name);
370 if (existing && !existing.mirrored) {
371 problems.push(`${folder}: the library already has a skill called ${folder} that isn't from this repository. Rename one of them.`);
372 continue;
373 }
374 const written = await writeVersion(db, {
375 workspaceId: mirror.workspace_id,
376 existing,
377 checked: checked.skill,
378 origin: { kind: "mirror", repo: mirror.repo, path: `${base}${folder}`, commit: commit ?? "" },
379 note: commit ? `From ${mirror.repo} at ${commit.slice(0, 8)}` : null,
380 by: mirror.linked_by,
381 now,
382 status: "published",
383 mirrored: true,
384 // The repository's own review is the review: every attachment follows.
385 move: () => true,
386 });
387 if (!written.ok) problems.push(`${folder}: ${written.error.message}`);
388 else if (written.value.changed) changed.push(folder);
389 }
390 // Gone from the repository: kept in the library, editable here again.
391 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 }>();
392 for (const { name } of following.results) {
393 if (seen.has(name)) continue;
394 await db.prepare("UPDATE skills SET mirrored = 0 WHERE workspace_id = ? AND name = ? AND archived_at IS NULL").bind(mirror.workspace_id, name).run();
395 problems.push(`${name} is no longer in the repository. It stays in the library, and can be edited here.`);
396 }
397 } catch (error) {
398 console.error("agents: a skills repository wasn't read", mirror.repo, String(error));
399 problems.push("The repository couldn't be read just now.");
400 }
401 await db
402 .prepare("UPDATE skill_mirrors SET commit_sha = COALESCE(?, commit_sha), synced_at = ?, error = ? WHERE workspace_id = ?")
403 .bind(commit, now.toISOString(), problems.length ? problems.join("\n").slice(0, 4000) : null, mirror.workspace_id)
404 .run();
405 return { changed, problems, commit };
406}
407
408/** After a push to a repository's default branch: every library that follows it is read again. */
409export async function onPush(db: D1Database, ports: Pick<LibraryPorts, "listFiles" | "blobs">, repoId: string, now = new Date()): Promise<number> {
410 const mirrors = await db.prepare("SELECT * FROM skill_mirrors WHERE repo_id = ?").bind(repoId).all<MirrorRow>();
411 for (const mirror of mirrors.results) await syncMirror(db, ports, mirror, now);
412 return mirrors.results.length;
413}
414
415function mirrorOut(row: MirrorRow | null): SkillMirror | null {
416 if (!row) return null;
417 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 };
418}
419
420/** A library skill as its pages show it. */
421function skillOut(row: SkillRow, attachments: AttachmentRow[], actor: Actor, labels: Labels): LibrarySkill {
422 return {
423 id: row.id,
424 name: row.name,
425 description: row.description,
426 status: row.status,
427 version: row.version,
428 tools: json<string[]>(row.tools, []),
429 requires_computer: !!row.requires_computer,
430 files: row.files,
431 bytes: row.bytes,
432 origin: json<SkillOrigin>(row.origin, { kind: "written" }),
433 mirrored: !!row.mirrored,
434 attachments: attachments.map((a) => attachmentOut(a, actor, labels)),
435 created_by: row.created_by,
436 created_at: row.created_at,
437 updated_by: row.updated_by,
438 updated_at: row.updated_at,
439 can_edit: mayEdit(actor, row),
440 can_delete: mayDelete(actor, row, attachments),
441 };
442}
443
444type Labels = { agents: Map<string, { handle: string; display_name: string }>; teams: Map<string, string> };
445
446function attachmentOut(a: AttachmentRow, actor: Actor, labels: Labels): SkillAttachment {
447 const agent = a.scope === "agent" ? labels.agents.get(a.target) : null;
448 const target = a.scope === "agent" ? (agent?.handle ?? null) : a.scope === "team" ? a.target : null;
449 const label = a.scope === "workspace" ? "Every agent" : a.scope === "agent" ? (agent ? `@${agent.handle}` : "An archived agent") : (labels.teams.get(a.target) ?? a.target);
450 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) };
451}
452
453/** One workspace's library, as one viewer may use it. */
454export class Library {
455 private readonly ctx: LibraryContext;
456 private teamList: { slug: string; name: string; can_manage: boolean }[] | null = null;
457
458 constructor(ctx: LibraryContext) {
459 this.ctx = ctx;
460 }
461
462 private get db(): D1Database {
463 return this.ctx.db;
464 }
465
466 private now(): Date {
467 return this.ctx.now ?? new Date();
468 }
469
470 private async teams(): Promise<{ slug: string; name: string; can_manage: boolean }[]> {
471 this.teamList ??= (await this.ctx.ports.teams().catch(() => null)) ?? [];
472 return this.teamList;
473 }
474
475 async actor(): Promise<Actor> {
476 const teams = this.ctx.viewer.kind === "agent" ? [] : await this.teams();
477 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)) };
478 }
479
480 private async labels(): Promise<Labels> {
481 const [agents, teams] = await Promise.all([
482 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 }>(),
483 this.teams(),
484 ]);
485 return { agents: new Map(agents.results.map((a) => [a.id, a])), teams: new Map(teams.map((t) => [t.slug, t.name])) };
486 }
487
488 private audit(action: string, name: string, message: string): void {
489 try {
490 this.ctx.ports.audit(action, name, message);
491 } catch {
492 // The log never fails the change.
493 }
494 }
495
496 async library(): Promise<Result<SkillLibrary>> {
497 const [rows, mirror, actor, labels] = await Promise.all([
498 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>(),
499 this.db.prepare("SELECT * FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId).first<MirrorRow>(),
500 this.actor(),
501 this.labels(),
502 ]);
503 const attachments = await attachmentsOf(this.db, rows.results.map((r) => r.id));
504 const teams = await this.teams();
505 return ok({
506 skills: rows.results.map((row) => skillOut(row, attachments.filter((a) => a.skill_id === row.id), actor, labels)),
507 mirror: mirrorOut(mirror),
508 can_write: mayWrite(actor),
509 can_manage: actor.owner,
510 teams: teams.filter((t) => actor.maintains.has(t.slug)).map((t) => ({ slug: t.slug, name: t.name })),
511 agents: actor.owner ? [...labels.agents.values()].map((a) => ({ handle: a.handle, display_name: a.display_name })) : [],
512 });
513 }
514
515 private async named(name: unknown): Promise<Result<SkillRow>> {
516 const key = String(name ?? "").trim().toLowerCase();
517 const row = key ? await skillByName(this.db, this.ctx.workspaceId, key) : null;
518 if (row) return ok(row);
519 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.`);
520 return fail("not_found", `The library has no skill called ${key || "that"}.`);
521 }
522
523 async detail(name: unknown, version?: unknown): Promise<Result<SkillDetail>> {
524 const found = await this.named(name);
525 if (!found.ok) return found;
526 const row = found.value;
527 const shown = version == null || version === "" ? row.version : Math.floor(Number(version));
528 const [stored, versions, attachments, actor, labels] = await Promise.all([
529 this.db.prepare("SELECT * FROM skill_versions WHERE skill_id = ? AND version = ?").bind(row.id, shown).first<VersionRow>(),
530 this.db
531 .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")
532 .bind(row.id)
533 .all<{ version: number; description: string; note: string | null; origin: string; bytes: number; files: number; created_by: string; created_at: string }>(),
534 attachmentsOf(this.db, [row.id]),
535 this.actor(),
536 this.labels(),
537 ]);
538 if (!stored) return fail("not_found", `${row.name} has no version ${shown}.`);
539 const split = splitFrontMatter(stored.skill_md);
540 let extra: Record<string, unknown> = {};
541 if (split.ok) {
542 try {
543 const front = parseFrontMatter(split.yaml);
544 for (const [key, value] of Object.entries(front)) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value;
545 } catch {
546 extra = {};
547 }
548 }
549 const files: SkillFileEntry[] = json<SkillFile[]>(stored.files, []).map((f) => {
550 const bytes = skillFileBytes(f);
551 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/") };
552 });
553 const history: SkillVersionEntry[] = versions.results.map((v) => ({
554 version: v.version,
555 description: v.description,
556 note: v.note,
557 origin: json<SkillOrigin>(v.origin, { kind: "written" }),
558 bytes: v.bytes,
559 files: v.files ?? 0,
560 created_by: v.created_by,
561 created_at: v.created_at,
562 }));
563 return ok({
564 skill: skillOut(row, attachments, actor, labels),
565 shown,
566 skill_md: stored.skill_md,
567 instructions: split.ok ? split.body.trim() : stored.skill_md,
568 tools: json<string[]>(stored.tools, []),
569 requires_computer: !!stored.requires_computer,
570 extra,
571 files,
572 versions: history,
573 });
574 }
575
576 /** Writes a skill from the editor: a new one, a new version, or a draft published. */
577 async save(name: unknown, input: SkillInput): Promise<Result<SkillDetail>> {
578 const actor = await this.actor();
579 if (!mayWrite(actor)) return fail("forbidden", "Only the workspace's owners and team maintainers write skills.");
580 if (!input || typeof input !== "object") return fail("invalid", "Say what the skill is.");
581 let existing: SkillRow | null = null;
582 let prior: StoredVersion | null = null;
583 if (name != null && name !== "") {
584 const found = await this.named(name);
585 if (!found.ok) return found;
586 existing = found.value;
587 if (existing.mirrored) return fail("invalid", `${existing.name} follows the repository: change it there, in ${SKILLS_REPO_DIR}/${existing.name}/.`);
588 if (!mayEdit(actor, existing)) return fail("forbidden", "Owners edit any skill; team maintainers edit the skills they wrote.");
589 prior = await readVersion(this.db, existing.id, existing.version);
590 }
591 const skillName = String(input.name ?? "").trim().toLowerCase();
592 const problem = skillNameProblem(skillName);
593 if (problem) return fail("invalid", problem);
594 const description = String(input.description ?? "").trim();
595 const instructions = String(input.instructions ?? "").trim();
596 const tools = Array.isArray(input.tools) ? input.tools.filter((t): t is string => typeof t === "string") : [];
597 let extra: Record<string, unknown> = {};
598 if (prior) {
599 const split = splitFrontMatter(prior.skill_md);
600 try {
601 if (split.ok) for (const [key, value] of Object.entries(parseFrontMatter(split.yaml))) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value;
602 } catch {
603 extra = {};
604 }
605 }
606 // The current version's files, less those removed, with those added (by path).
607 const removed = new Set(Array.isArray(input.remove_files) ? input.remove_files.filter((p): p is string => typeof p === "string") : []);
608 const added = Array.isArray(input.add_files) ? input.add_files.filter((f): f is SkillFile => !!f && typeof f.path === "string" && typeof f.content === "string") : [];
609 const addedPaths = new Set(added.map((f) => f.path.replace(/^\.\//, "")));
610 const files = [...(prior?.files ?? []).filter((f) => !removed.has(f.path) && !addedPaths.has(f.path)), ...added];
611 const skillMd = renderSkillMd({ name: skillName, description, tools, requires_computer: input.requires_computer === true, body: instructions, extra });
612 const checked = checkSkillFolder([{ path: "SKILL.md", content: skillMd }, ...files.filter((f) => f?.path !== "SKILL.md")]);
613 if (!checked.ok) return fail("invalid", checked.message);
614 const publishing = existing?.status === "draft";
615 const updateAll = input.update_attachments !== false;
616 const written = await writeVersion(this.db, {
617 workspaceId: this.ctx.workspaceId,
618 existing,
619 checked: checked.skill,
620 origin: existing && publishing ? json<SkillOrigin>(existing.origin, { kind: "written" }) : { kind: "written" },
621 note: cleanNote(input.note),
622 by: actor.username,
623 now: this.now(),
624 status: "published",
625 mirrored: false,
626 move: (a) => updateAll && mayChange(actor, a.scope, a.target),
627 });
628 if (!written.ok) return written;
629 const verb = !existing ? "Wrote" : publishing ? "Published" : written.value.changed ? "Changed" : "Saved";
630 if (written.value.changed || !existing) this.audit(publishing ? "publish_skill" : existing ? "update_skill" : "create_skill", skillName, `${verb} the skill ${skillName} (version ${written.value.version})`);
631 return this.detail(skillName);
632 }
633
634 /** Imports a skill from an upload or a repository folder. */
635 async import(source: SkillImport, replace: boolean): Promise<Result<SkillDetail>> {
636 const actor = await this.actor();
637 if (!mayWrite(actor)) return fail("forbidden", "Only the workspace's owners and team maintainers import skills.");
638 let files: SkillFile[];
639 let origin: SkillOrigin;
640 if (source?.kind === "upload") {
641 const filename = String(source.filename ?? "").slice(0, 200);
642 const read = await readUpload(filename, String(source.data_base64 ?? ""));
643 if (!read.ok) return fail("invalid", read.message);
644 files = read.files;
645 origin = { kind: "upload", filename: filename || "SKILL.md" };
646 } else if (source?.kind === "repository") {
647 const full = String(source.repo ?? "").trim().replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
648 if (!/^[^/\s]+\/[^/\s]+$/.test(full)) return fail("invalid", "Name the repository as workspace/name.");
649 const repo = await this.ctx.ports.repo(full);
650 if (!repo) return fail("not_found", `There is no repository ${full} you can read.`);
651 let dir = String(source.path ?? "").trim().replace(/^\/+|\/+$/g, "");
652 if (/(^|\/)SKILL\.md$/i.test(dir)) dir = dir.replace(/\/?SKILL\.md$/i, "");
653 const ref = String(source.ref ?? "").trim() || repo.default_branch;
654 const listing = await this.ctx.ports.listFiles(repo.id, ref).catch(() => null);
655 if (!listing?.commit) return fail("not_found", `${full} has no branch, tag or commit called ${ref}.`);
656 const read = await readRepoFolder(this.ctx.ports, repo.id, listing.files, dir);
657 if (!read.ok) return read;
658 files = read.value;
659 origin = { kind: "repository", repo: repo.full, path: dir || ".", ref, commit: listing.commit };
660 } else return fail("invalid", "Import from an upload or a repository folder.");
661 const checked = checkSkillFolder(files);
662 if (!checked.ok) return fail("invalid", checked.message);
663 const existing = await skillByName(this.db, this.ctx.workspaceId, checked.skill.name);
664 if (existing && !replace) {
665 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.`);
666 }
667 if (existing?.mirrored) return fail("invalid", `${existing.name} follows the repository: change it there, in ${SKILLS_REPO_DIR}/${existing.name}/.`);
668 if (existing && !mayEdit(actor, existing)) return fail("forbidden", "Owners edit any skill; team maintainers edit the skills they wrote.");
669 const written = await writeVersion(this.db, {
670 workspaceId: this.ctx.workspaceId,
671 existing,
672 checked: checked.skill,
673 origin,
674 note: origin.kind === "repository" ? `Imported from ${origin.repo} at ${origin.commit.slice(0, 8)}` : `Imported from ${origin.kind === "upload" ? origin.filename : "an upload"}`,
675 by: actor.username,
676 now: this.now(),
677 status: "published",
678 mirrored: false,
679 move: (a) => mayChange(actor, a.scope, a.target),
680 });
681 if (!written.ok) return written;
682 this.audit("import_skill", checked.skill.name, `Imported the skill ${checked.skill.name} (version ${written.value.version})`);
683 return this.detail(checked.skill.name);
684 }
685
686 /** Saves a draft an agent wrote from a session; a person publishes it after reviewing it. */
687 async saveDraft(checked: CheckedSkill, origin: Extract<SkillOrigin, { kind: "session" }>): Promise<Result<SkillDetail>> {
688 let name = checked.name;
689 for (let n = 2; await skillByName(this.db, this.ctx.workspaceId, name); n++) {
690 name = `${checked.name.slice(0, 60)}-${n}`;
691 if (n > 50) return fail("conflict", "Too many skills share that name.");
692 }
693 const renamed = name === checked.name ? checked : { ...checked, name, skill_md: checked.skill_md.replace(/^name:.*$/m, `name: ${name}`) };
694 const written = await writeVersion(this.db, {
695 workspaceId: this.ctx.workspaceId,
696 existing: null,
697 checked: renamed,
698 origin,
699 note: `Drafted by @${origin.agent} from the session "${origin.title}"`,
700 by: this.ctx.viewer.username,
701 now: this.now(),
702 status: "draft",
703 mirrored: false,
704 move: () => false,
705 });
706 if (!written.ok) return written;
707 this.audit("draft_skill", name, `Saved a draft skill ${name} from a session of @${origin.agent}`);
708 return this.detail(name);
709 }
710
711 async attach(name: unknown, scope: unknown, target: unknown): Promise<Result<SkillDetail>> {
712 const found = await this.named(name);
713 if (!found.ok) return found;
714 const row = found.value;
715 if (row.status === "draft") return fail("invalid", "Publish the draft before attaching it.");
716 if (scope !== "agent" && scope !== "team" && scope !== "workspace") return fail("invalid", "Attach a skill to an agent, a team or the whole workspace.");
717 const actor = await this.actor();
718 let key = "";
719 let label = "every agent";
720 if (scope === "agent") {
721 const handle = String(target ?? "").trim().replace(/^@/, "").toLowerCase();
722 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 }>();
723 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
724 key = agent.id;
725 label = `@${agent.handle}`;
726 } else if (scope === "team") {
727 const slug = String(target ?? "").trim().toLowerCase();
728 const team = (await this.teams()).find((t) => t.slug === slug);
729 if (!team) return fail("not_found", `${this.ctx.slug} has no team called ${slug}.`);
730 key = team.slug;
731 label = team.name;
732 }
733 if (!mayChange(actor, scope, key)) {
734 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.");
735 }
736 // At most SKILLS_PER_AGENT_MAX reach any one agent: counted for what this attachment adds to.
737 const reach =
738 scope === "workspace"
739 ? "(a.scope = 'workspace' AND ?3 = ?3)"
740 : scope === "team"
741 ? "(a.scope = 'workspace' OR (a.scope = 'team' AND a.target = ?3))"
742 : "(a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?3) OR (a.scope = 'team' AND a.target = (SELECT COALESCE(team, '') FROM agents WHERE id = ?3)))";
743 // And for each agent it reaches (by their home team): the most any one of them has already.
744 const which = scope === "workspace" ? "?3 = ?3" : scope === "team" ? "COALESCE(ag.team, '') = ?3" : "ag.id = ?3";
745 const [count, most] = await Promise.all([
746 this.db
747 .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}`)
748 .bind(this.ctx.workspaceId, row.id, key)
749 .first<{ n: number }>(),
750 this.db
751 .prepare(
752 `SELECT COALESCE(MAX(n), 0) AS n FROM (
753 SELECT ag.id, COUNT(DISTINCT a.skill_id) AS n
754 FROM agents ag
755 JOIN skill_attachments a ON a.workspace_id = ag.workspace_id
756 AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ag.id) OR (a.scope = 'team' AND a.target = COALESCE(ag.team, '')))
757 JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL
758 WHERE ag.workspace_id = ?1 AND ag.archived_at IS NULL AND a.skill_id <> ?2 AND ${which}
759 GROUP BY ag.id)`,
760 )
761 .bind(this.ctx.workspaceId, row.id, key)
762 .first<{ n: number }>(),
763 ]);
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, team, 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; team: string | null; 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({ id: agent.id, team: agent.team }).catch(() => (agent.team ? [{ slug: agent.team, name: agent.team }] : [])), 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}