@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/LICENSE +21 -0
- package/README.md +514 -0
- package/bin/cli.mjs +932 -0
- package/package.json +40 -0
- package/skills/extuitive/SKILL.md +63 -0
- package/skills/extuitive/references/connect.md +75 -0
- package/skills/extuitive/references/init.md +83 -0
- package/skills/extuitive/references/select.md +89 -0
- package/skills/extuitive/references/tools.md +252 -0
- package/skills/extuitive/references/upload-status.md +102 -0
- package/skills/extuitive/references/upload.md +160 -0
- package/skills/extuitive/scripts/upload.mjs +340 -0
- package/src/constants.mjs +63 -0
- package/src/doctor.mjs +513 -0
- package/src/exec.mjs +139 -0
- package/src/hosts.mjs +350 -0
- package/src/install.mjs +541 -0
- package/src/mcp-setup.mjs +476 -0
- package/src/zip.mjs +246 -0
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
|
+
}
|