Skip to content
137 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents → Skills is the workspace's skill library in the open SKILL.md format: write a skill in g1t, import a SKILL.md or zip, link a repository whose .g1t/skills folders publish on every push, or save a finished session as a skill once a person reviews it; attach a skill to an agent, a team or the whole workspace, each attachment pinned to a version. Agents see each skill's name and when to use it, and read the rest with use_skill when a request needs it; a skill never adds a permission, and scripts wait for an agent's own computer. The agent skills guide says how.1/**
2 * Reading an uploaded skill (docs.g1t.sh/guides/agent-skills/, "Import a
3 * skill"): a SKILL.md on its own, or a zip of the skill's folder. A zip
4 * holding one folder (`release-notes/SKILL.md`, as zipping a folder makes
5 * it) is read as that folder. Stored and deflated entries only, no zip64,
6 * and never more than the format's 1 MB once unpacked.
7 */
8import { SKILL_FILES_MAX, SKILL_FOLDER_MAX_BYTES, type SkillFile } from "../../../packages/contracts/src/skill-format.ts";
9
10/** The largest upload taken: a zip of a 1 MB folder is smaller than this. */
11export const MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
12
13export type Unpacked = { ok: true; files: SkillFile[] } | { ok: false; message: string };
14
15function base64Bytes(data: string): Uint8Array {
16 const binary = atob(data.replace(/\s+/g, ""));
17 const bytes = new Uint8Array(binary.length);
18 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
19 return bytes;
20}
21
22export function bytesBase64(bytes: Uint8Array): string {
23 let binary = "";
24 for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
25 return btoa(binary);
26}
27
28/** A file's content as text when it is UTF-8 without NUL bytes, else as base64. */
29export function asSkillFile(path: string, bytes: Uint8Array): SkillFile {
30 try {
31 const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
32 if (!text.includes("\u0000")) return { path, content: text, encoding: "utf8" };
33 } catch {
34 // Not UTF-8: kept as bytes.
35 }
36 return { path, content: bytesBase64(bytes), encoding: "base64" };
37}
38
39async function inflate(data: Uint8Array, size: number): Promise<Uint8Array> {
40 const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
41 const reader = stream.getReader();
42 const chunks: Uint8Array[] = [];
43 let total = 0;
44 for (;;) {
45 const { done, value } = await reader.read();
46 if (done) break;
47 total += value.length;
48 if (total > Math.max(size, 0) || total > SKILL_FOLDER_MAX_BYTES) {
49 await reader.cancel().catch(() => undefined);
50 throw new Error("too large");
51 }
52 chunks.push(value);
53 }
54 const out = new Uint8Array(total);
55 let at = 0;
56 for (const chunk of chunks) {
57 out.set(chunk, at);
58 at += chunk.length;
59 }
60 return out;
61}
62
63/** The files in a zip archive, by path; directories and macOS's `__MACOSX` copies left out. */
64export async function unzip(bytes: Uint8Array): Promise<Unpacked> {
65 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
66 // The end of central directory record: the last 22 bytes, or before a comment.
67 let end = -1;
68 for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--) {
69 if (view.getUint32(i, true) === 0x06054b50) {
70 end = i;
71 break;
72 }
73 }
74 if (end < 0) return { ok: false, message: "That file isn't a zip archive." };
75 const count = view.getUint16(end + 10, true);
76 let at = view.getUint32(end + 16, true);
77 if (count > SKILL_FILES_MAX * 2) return { ok: false, message: `A skill holds at most ${SKILL_FILES_MAX} files.` };
78 const files: SkillFile[] = [];
79 let unpacked = 0;
80 for (let n = 0; n < count; n++) {
81 if (at + 46 > bytes.length || view.getUint32(at, true) !== 0x02014b50) return { ok: false, message: "That zip archive is damaged." };
82 const flags = view.getUint16(at + 8, true);
83 const method = view.getUint16(at + 10, true);
84 const compressed = view.getUint32(at + 20, true);
85 const size = view.getUint32(at + 24, true);
86 const nameLength = view.getUint16(at + 28, true);
87 const extraLength = view.getUint16(at + 30, true);
88 const commentLength = view.getUint16(at + 32, true);
89 const local = view.getUint32(at + 42, true);
90 const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLength));
91 at += 46 + nameLength + extraLength + commentLength;
92 if (name.endsWith("/") || name.startsWith("__MACOSX/") || /(^|\/)\.DS_Store$/.test(name)) continue;
93 if (flags & 1) return { ok: false, message: `${name} is encrypted. Upload a zip without a password.` };
94 if (compressed === 0xffffffff || size === 0xffffffff) return { ok: false, message: "That zip archive is too large for a skill." };
95 unpacked += size;
96 if (unpacked > SKILL_FOLDER_MAX_BYTES) return { ok: false, message: "A skill's folder is at most 1 MB once unpacked." };
97 if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50) return { ok: false, message: "That zip archive is damaged." };
98 const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true);
99 const data = bytes.subarray(start, start + compressed);
100 let content: Uint8Array;
101 if (method === 0) content = data;
102 else if (method === 8) {
103 try {
104 content = await inflate(data, size);
105 } catch {
106 return { ok: false, message: `${name} couldn't be unpacked.` };
107 }
108 } else return { ok: false, message: `${name} is packed in a way g1t can't read. Zip it again with standard compression.` };
109 files.push(asSkillFile(name, content));
110 }
111 if (!files.length) return { ok: false, message: "That zip archive is empty." };
112 // One folder holding everything: read as that folder.
113 const tops = new Set(files.map((f) => (f.path.includes("/") ? f.path.slice(0, f.path.indexOf("/")) : "")));
114 if (tops.size === 1 && !tops.has("")) {
115 const top = [...tops][0]!;
116 return { ok: true, files: files.map((f) => ({ ...f, path: f.path.slice(top.length + 1) })) };
117 }
118 return { ok: true, files };
119}
120
121/** An upload: a zip by its content, else a SKILL.md as text. */
122export async function readUpload(filename: string, dataBase64: string): Promise<Unpacked> {
123 let bytes: Uint8Array;
124 try {
125 bytes = base64Bytes(dataBase64);
126 } catch {
127 return { ok: false, message: "That upload couldn't be read." };
128 }
129 if (!bytes.length) return { ok: false, message: "That file is empty." };
130 if (bytes.length > MAX_UPLOAD_BYTES) return { ok: false, message: "Upload at most 2 MB: a SKILL.md, or a zip of the skill's folder." };
131 const isZip = bytes.length > 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 3 || bytes[2] === 5);
132 if (isZip) return unzip(bytes);
133 if (!/\.(md|markdown|txt)$/i.test(filename) && filename) return { ok: false, message: "Upload a SKILL.md, or a zip of the skill's folder." };
134 const file = asSkillFile("SKILL.md", bytes);
135 if (file.encoding !== "utf8") return { ok: false, message: "SKILL.md must be text (UTF-8)." };
136 return { ok: true, files: [file] };
137}