@extuitive/skill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/zip.mjs ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * A ZIP writer, because one host takes skills as an upload rather than as files on disk.
3
+ *
4
+ * Claude Desktop's Customize > Skills panel accepts a `.zip` containing the skill folder and
5
+ * nothing else — there is no directory it scans and no CLI to hand a path to. So the bundle
6
+ * has to be built here.
7
+ *
8
+ * Written out longhand rather than shelling out to `zip` or taking a dependency. `zip` is not
9
+ * on Windows and is not guaranteed anywhere else, `Compress-Archive` is a different command
10
+ * with different quoting, and this package installs by `npx` straight from a repository
11
+ * where a dependency would mean an install step before the installer runs. The format needed
12
+ * here is the 1989 one: local headers, a central directory, an end record, no Zip64 and no
13
+ * encryption. Skill bundles are kilobytes.
14
+ */
15
+ import { deflateRawSync } from "node:zlib";
16
+
17
+ /**
18
+ * CRC-32, which the format requires per entry and no Node built-in exposes.
19
+ *
20
+ * `>>> 0` after every step is not decoration. Bitwise operators in JavaScript produce signed
21
+ * 32-bit integers, so a checksum with the high bit set comes out negative and writes as the
22
+ * wrong four bytes — a corrupt archive that most tools open anyway and a stricter uploader
23
+ * rejects.
24
+ */
25
+ const CRC_TABLE = (() => {
26
+ const table = new Int32Array(256);
27
+ for (let index = 0; index < 256; index += 1) {
28
+ let value = index;
29
+ for (let bit = 0; bit < 8; bit += 1) {
30
+ value = (value & 1) === 1 ? (value >>> 1) ^ 0xedb88320 : value >>> 1;
31
+ }
32
+ table[index] = value;
33
+ }
34
+ return table;
35
+ })();
36
+
37
+ function crc32(buffer) {
38
+ let crc = 0xffffffff;
39
+ for (const byte of buffer) {
40
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff];
41
+ }
42
+ return (crc ^ 0xffffffff) >>> 0;
43
+ }
44
+
45
+ /**
46
+ * A JavaScript `Date` as the MS-DOS date and time the format stores.
47
+ *
48
+ * Two-second resolution and a 1980 epoch, both inherent to the format. Dates before 1980
49
+ * cannot be represented at all, so they are clamped rather than allowed to wrap into a
50
+ * timestamp from the future.
51
+ *
52
+ * Read in UTC, which matters more than it looks. The format has no timezone field, so local
53
+ * getters would encode the machine's offset into the bytes — and callers build archives they
54
+ * expect to be a function of their contents alone. With local time, the 1980 epoch lands on
55
+ * 1979 anywhere west of UTC, gets clamped back up to 1980 with December 31 still attached,
56
+ * and shifts again if the same machine rebuilds from a different timezone. A comparison that
57
+ * is supposed to mean "the skill changed" would then mean "you flew somewhere".
58
+ */
59
+ function dosDateTime(date) {
60
+ const year = Math.max(date.getUTCFullYear(), 1980);
61
+ return {
62
+ time:
63
+ (Math.floor(date.getUTCSeconds() / 2) & 0x1f) |
64
+ ((date.getUTCMinutes() & 0x3f) << 5) |
65
+ ((date.getUTCHours() & 0x1f) << 11),
66
+ date:
67
+ (date.getUTCDate() & 0x1f) |
68
+ (((date.getUTCMonth() + 1) & 0x0f) << 5) |
69
+ ((year - 1980) << 9),
70
+ };
71
+ }
72
+
73
+ const LOCAL_HEADER = 0x04034b50;
74
+ const CENTRAL_HEADER = 0x02014b50;
75
+ const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
76
+
77
+ /** Unix permissions live in the top 16 bits of the external attributes field. */
78
+ const FILE_MODE = (0o100644 << 16) >>> 0;
79
+ const DIRECTORY_MODE = (((0o040755 << 16) >>> 0) | 0x10) >>> 0;
80
+
81
+ /**
82
+ * Build a ZIP from entries already in memory.
83
+ *
84
+ * `entries` is `{ path, data }` for files and `{ path, directory: true }` for folders, with
85
+ * `path` always using forward slashes — the format says so, and a backslash from a Windows
86
+ * caller produces an archive whose entries unpack as one file with a strange name.
87
+ *
88
+ * Directory entries are written even though most readers infer folders from file paths,
89
+ * because "most" is doing real work in that sentence and the cost is thirty bytes each.
90
+ *
91
+ * @param {Array<{ path: string, data?: Buffer, directory?: boolean }>} entries
92
+ * @param {{ modifiedAt?: Date }} [options]
93
+ * @returns {Buffer}
94
+ */
95
+ export function createZip(entries, { modifiedAt = new Date() } = {}) {
96
+ const stamp = dosDateTime(modifiedAt);
97
+ const locals = [];
98
+ const centrals = [];
99
+ let offset = 0;
100
+
101
+ for (const entry of entries) {
102
+ const isDirectory = entry.directory === true;
103
+ const name = isDirectory === true ? `${entry.path.replace(/\/$/, "")}/` : entry.path;
104
+ const nameBytes = Buffer.from(name, "utf8");
105
+ const raw = isDirectory === true ? Buffer.alloc(0) : entry.data;
106
+
107
+ // Directories carry no payload, and deflating an empty buffer costs bytes rather than
108
+ // saving them, so both are stored uncompressed.
109
+ const deflate = isDirectory === false && raw.length > 0;
110
+ const body = deflate === true ? deflateRawSync(raw, { level: 9 }) : raw;
111
+ const method = deflate === true ? 8 : 0;
112
+ const checksum = crc32(raw);
113
+
114
+ const local = Buffer.alloc(30);
115
+ local.writeUInt32LE(LOCAL_HEADER, 0);
116
+ local.writeUInt16LE(20, 4);
117
+ local.writeUInt16LE(0, 6);
118
+ local.writeUInt16LE(method, 8);
119
+ local.writeUInt16LE(stamp.time, 10);
120
+ local.writeUInt16LE(stamp.date, 12);
121
+ local.writeUInt32LE(checksum, 14);
122
+ local.writeUInt32LE(body.length, 18);
123
+ local.writeUInt32LE(raw.length, 22);
124
+ local.writeUInt16LE(nameBytes.length, 26);
125
+ local.writeUInt16LE(0, 28);
126
+
127
+ const central = Buffer.alloc(46);
128
+ central.writeUInt32LE(CENTRAL_HEADER, 0);
129
+ // "Made by" a Unix system, so the permissions below are read rather than ignored.
130
+ central.writeUInt16LE(0x031e, 4);
131
+ central.writeUInt16LE(20, 6);
132
+ central.writeUInt16LE(0, 8);
133
+ central.writeUInt16LE(method, 10);
134
+ central.writeUInt16LE(stamp.time, 12);
135
+ central.writeUInt16LE(stamp.date, 14);
136
+ central.writeUInt32LE(checksum, 16);
137
+ central.writeUInt32LE(body.length, 20);
138
+ central.writeUInt32LE(raw.length, 24);
139
+ central.writeUInt16LE(nameBytes.length, 28);
140
+ central.writeUInt16LE(0, 30);
141
+ central.writeUInt16LE(0, 32);
142
+ central.writeUInt16LE(0, 34);
143
+ central.writeUInt16LE(0, 36);
144
+ central.writeUInt32LE(isDirectory === true ? DIRECTORY_MODE : FILE_MODE, 38);
145
+ central.writeUInt32LE(offset, 42);
146
+
147
+ locals.push(local, nameBytes, body);
148
+ centrals.push(central, nameBytes);
149
+ offset += local.length + nameBytes.length + body.length;
150
+ }
151
+
152
+ const directory = Buffer.concat(centrals);
153
+ const end = Buffer.alloc(22);
154
+ end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY, 0);
155
+ end.writeUInt16LE(0, 4);
156
+ end.writeUInt16LE(0, 6);
157
+ end.writeUInt16LE(entries.length, 8);
158
+ end.writeUInt16LE(entries.length, 10);
159
+ end.writeUInt32LE(directory.length, 12);
160
+ end.writeUInt32LE(offset, 16);
161
+ end.writeUInt16LE(0, 20);
162
+
163
+ return Buffer.concat([...locals, directory, end]);
164
+ }
165
+
166
+ /**
167
+ * What an existing archive contains, read from its central directory.
168
+ *
169
+ * Exists so callers can ask "is this bundle still the right one" without comparing
170
+ * compressed bytes. Deflate output depends on the zlib built into the running Node, so two
171
+ * archives can hold identical files and differ byte-for-byte after a Node upgrade — and this
172
+ * package runs under `npx`, where that is a normal Tuesday. Comparing bytes would report the
173
+ * bundle as stale, exit `doctor` non-zero, and ask for a re-upload that changes nothing.
174
+ *
175
+ * Only the central directory is read. Every field needed — name, CRC-32, uncompressed size —
176
+ * is stored there in the clear, so nothing has to be inflated to answer the question.
177
+ *
178
+ * @returns {Array<{ path: string, crc: number, size: number }> | null} null if this is not
179
+ * an archive we wrote: truncated, corrupt, or carrying anything after its end record.
180
+ */
181
+ export function readZipManifest(buffer) {
182
+ const END_SIZE = 22;
183
+ // Deliberately not a backwards scan for the signature. The format allows a trailing
184
+ // comment, we never write one, and insisting the end record is the last thing in the file
185
+ // is what makes appended or truncated bytes fail the check rather than pass it.
186
+ const start = buffer.length - END_SIZE;
187
+ if (start < 0 || buffer.readUInt32LE(start) !== END_OF_CENTRAL_DIRECTORY) {
188
+ return null;
189
+ }
190
+
191
+ const count = buffer.readUInt16LE(start + 10);
192
+ let offset = buffer.readUInt32LE(start + 16);
193
+ const entries = [];
194
+
195
+ for (let index = 0; index < count; index += 1) {
196
+ if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL_HEADER) {
197
+ return null;
198
+ }
199
+ const nameLength = buffer.readUInt16LE(offset + 28);
200
+ const extraLength = buffer.readUInt16LE(offset + 30);
201
+ const commentLength = buffer.readUInt16LE(offset + 32);
202
+
203
+ entries.push({
204
+ path: buffer.toString("utf8", offset + 46, offset + 46 + nameLength),
205
+ crc: buffer.readUInt32LE(offset + 16),
206
+ size: buffer.readUInt32LE(offset + 24),
207
+ });
208
+ offset += 46 + nameLength + extraLength + commentLength;
209
+ }
210
+
211
+ return entries;
212
+ }
213
+
214
+ /**
215
+ * Whether an existing archive already holds exactly these entries.
216
+ *
217
+ * A CRC-32 and a length per file, which is what the archive itself stores and enough to
218
+ * answer the only question being asked: has the skill changed since this was built. Two
219
+ * different files sharing both is a collision nobody has produced in a skill directory.
220
+ */
221
+ export function zipHoldsEntries(buffer, entries) {
222
+ const existing = readZipManifest(buffer);
223
+ if (existing === null || existing.length !== entries.length) {
224
+ return false;
225
+ }
226
+
227
+ const describe = (list) =>
228
+ list
229
+ .map(({ path, crc, size }) => `${path}:${crc}:${size}`)
230
+ .sort()
231
+ .join("\n");
232
+
233
+ return (
234
+ describe(existing) ===
235
+ describe(
236
+ entries.map((entry) => {
237
+ const raw = entry.directory === true ? Buffer.alloc(0) : entry.data;
238
+ return {
239
+ path: entry.directory === true ? `${entry.path.replace(/\/$/, "")}/` : entry.path,
240
+ crc: crc32(raw),
241
+ size: raw.length,
242
+ };
243
+ }),
244
+ )
245
+ );
246
+ }