skillshark 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.
@@ -0,0 +1,298 @@
1
+ // Local artifact discovery + metadata inference. Authors never write a
2
+ // manifest; everything is inferred at share time (§4.1).
3
+ import { readdir, readFile, stat, lstat } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { sha256hex } from './fingerprint.js';
7
+ import { CliError } from './errors.js';
8
+
9
+ // Never packaged, not even with --force.
10
+ const HARD_EXCLUDE_DIRS = new Set(['.git', 'node_modules']);
11
+ const HARD_EXCLUDE_FILES = [/^\.DS_Store$/, /\.log$/i];
12
+ // Secret-shaped: skipped with a warning; --force includes them.
13
+ const SECRET_PATTERNS = [
14
+ { re: /^\.env$/, label: 'secret pattern' },
15
+ { re: /^\.env\..+$/, label: 'secret pattern' },
16
+ { re: /\.pem$/i, label: 'secret pattern' },
17
+ { re: /^id_rsa/, label: 'secret pattern' },
18
+ { re: /token/i, label: 'secret pattern' },
19
+ { re: /secret/i, label: 'secret pattern' },
20
+ ];
21
+ const RESERVED_NAMES = new Set(['skillshark.json']);
22
+
23
+ function hardExcluded(name) {
24
+ return HARD_EXCLUDE_FILES.some((re) => re.test(name));
25
+ }
26
+
27
+ function secretMatch(name) {
28
+ return SECRET_PATTERNS.find((p) => p.re.test(name)) ?? null;
29
+ }
30
+
31
+ // --- share-argument resolution (§4.1) ---------------------------------------
32
+
33
+ // Candidate locations for a bare name, in search order.
34
+ export function nameCandidates(name, { cwd, home }) {
35
+ return [
36
+ { root: path.join(cwd, '.claude', 'skills', name), isDir: true, type: 'skill', where: '.claude/skills (project)' },
37
+ { root: path.join(cwd, '.claude', 'commands', `${name}.md`), isDir: false, type: 'command', where: '.claude/commands (project)' },
38
+ { root: path.join(home, '.claude', 'skills', name), isDir: true, type: 'skill', where: '~/.claude/skills (global)' },
39
+ { root: path.join(home, '.claude', 'commands', `${name}.md`), isDir: false, type: 'command', where: '~/.claude/commands (global)' },
40
+ ];
41
+ }
42
+
43
+ async function existsAs(p, wantDir) {
44
+ try {
45
+ const s = await stat(p);
46
+ return wantDir ? s.isDirectory() : s.isFile();
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ // Classify an explicit path by its on-disk convention (§4.1): a directory under
53
+ // .claude/skills → skill, a .md under .claude/commands → command, otherwise
54
+ // prompt (file) or bundle (directory).
55
+ export function classifyPath(absPath, isDir) {
56
+ const norm = absPath.split(path.sep).join('/');
57
+ if (isDir && /\/\.claude\/skills\/[^/]+$/.test(norm)) return { type: 'skill', agent: 'claude-code' };
58
+ if (!isDir && /\/\.claude\/commands\/[^/]+\.md$/.test(norm)) return { type: 'command', agent: 'claude-code' };
59
+ return { type: isDir ? 'bundle' : 'prompt', agent: '' };
60
+ }
61
+
62
+ // List every skill/command name visible from here (for suggestions).
63
+ export async function knownNames({ cwd, home }) {
64
+ const names = new Set();
65
+ for (const dir of [
66
+ path.join(cwd, '.claude', 'skills'),
67
+ path.join(home, '.claude', 'skills'),
68
+ ]) {
69
+ try {
70
+ for (const ent of await readdir(dir, { withFileTypes: true })) {
71
+ if (ent.isDirectory()) names.add(ent.name);
72
+ }
73
+ } catch { /* location absent */ }
74
+ }
75
+ for (const dir of [
76
+ path.join(cwd, '.claude', 'commands'),
77
+ path.join(home, '.claude', 'commands'),
78
+ ]) {
79
+ try {
80
+ for (const ent of await readdir(dir, { withFileTypes: true })) {
81
+ if (ent.isFile() && ent.name.endsWith('.md')) names.add(ent.name.slice(0, -3));
82
+ }
83
+ } catch { /* location absent */ }
84
+ }
85
+ return [...names].sort();
86
+ }
87
+
88
+ function editDistance(a, b) {
89
+ const m = a.length, n = b.length;
90
+ const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
91
+ for (let j = 0; j <= n; j++) d[0][j] = j;
92
+ for (let i = 1; i <= m; i++) {
93
+ for (let j = 1; j <= n; j++) {
94
+ d[i][j] = Math.min(
95
+ d[i - 1][j] + 1,
96
+ d[i][j - 1] + 1,
97
+ d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
98
+ );
99
+ }
100
+ }
101
+ return d[m][n];
102
+ }
103
+
104
+ export function nearestNames(name, candidates, max = 3) {
105
+ return candidates
106
+ .map((c) => ({ c, d: editDistance(name.toLowerCase(), c.toLowerCase()) }))
107
+ .filter(({ c, d }) => d <= 3 || c.toLowerCase().includes(name.toLowerCase()))
108
+ .sort((a, b) => a.d - b.d)
109
+ .slice(0, max)
110
+ .map(({ c }) => c);
111
+ }
112
+
113
+ // Resolve the share argument: an existing path wins; otherwise a name (one
114
+ // leading "/" stripped) searched across the four known locations.
115
+ // Returns { matches: [{ root, isDir, type, agent, where }] }; throws CliError(2)
116
+ // for no hit. Multiple hits are returned for the caller to pick or refuse.
117
+ export async function resolveShareArg(arg, { cwd, home }) {
118
+ const asPath = path.resolve(cwd, arg);
119
+ if (existsSync(asPath)) {
120
+ const s = await stat(asPath);
121
+ const isDir = s.isDirectory();
122
+ const { type, agent } = classifyPath(asPath, isDir);
123
+ return { matches: [{ root: asPath, isDir, type, agent, where: 'explicit path' }] };
124
+ }
125
+ const name = arg.startsWith('/') ? arg.slice(1) : arg;
126
+ if (!name || name.includes('/')) {
127
+ throw new CliError(`No such path or skill: "${arg}"`, 2);
128
+ }
129
+ const matches = [];
130
+ for (const cand of nameCandidates(name, { cwd, home })) {
131
+ if (await existsAs(cand.root, cand.isDir)) {
132
+ matches.push({ ...cand, agent: 'claude-code' });
133
+ }
134
+ }
135
+ if (matches.length === 0) {
136
+ const known = await knownNames({ cwd, home });
137
+ const near = nearestNames(name, known);
138
+ let msg = `No skill named "${name}" found here or in ~/.claude.`;
139
+ if (near.length) msg += `\nDid you mean: ${near.join(', ')}?`;
140
+ else if (known.length) msg += `\nAvailable: ${known.slice(0, 8).join(', ')}`;
141
+ throw new CliError(msg, 2);
142
+ }
143
+ return { matches, name };
144
+ }
145
+
146
+ // --- file collection (§4.1 excludes) ----------------------------------------
147
+
148
+ // Walk an artifact and apply the exclude rules. Returns:
149
+ // files: [{ path, abs, size, mode, executable, sha256 }]
150
+ // warnings: [{ path, reason, forceable }]
151
+ export async function collectFiles(root, { isDir, force = false } = {}) {
152
+ const files = [];
153
+ const warnings = [];
154
+
155
+ async function addFile(abs, rel) {
156
+ const data = await readFile(abs);
157
+ const s = await stat(abs);
158
+ const executable = Boolean(s.mode & 0o111);
159
+ files.push({
160
+ path: rel,
161
+ abs,
162
+ size: data.length,
163
+ mode: executable ? '0755' : '0644',
164
+ executable,
165
+ sha256: sha256hex(data),
166
+ });
167
+ }
168
+
169
+ async function consider(abs, rel, name) {
170
+ if (RESERVED_NAMES.has(name)) {
171
+ warnings.push({ path: rel, reason: 'reserved name', forceable: false });
172
+ return;
173
+ }
174
+ if (hardExcluded(name)) return; // silently never packaged
175
+ const secret = secretMatch(name);
176
+ if (secret && !force) {
177
+ warnings.push({ path: rel, reason: secret.label, forceable: true });
178
+ return;
179
+ }
180
+ await addFile(abs, rel);
181
+ }
182
+
183
+ if (!isDir) {
184
+ const name = path.basename(root);
185
+ await consider(root, name, name);
186
+ } else {
187
+ async function walk(dir, prefix) {
188
+ const dirents = (await readdir(dir, { withFileTypes: true }))
189
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
190
+ for (const ent of dirents) {
191
+ const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
192
+ const abs = path.join(dir, ent.name);
193
+ const ls = await lstat(abs);
194
+ if (ls.isSymbolicLink()) {
195
+ warnings.push({ path: rel, reason: 'symlink', forceable: false });
196
+ continue;
197
+ }
198
+ if (ent.isDirectory()) {
199
+ if (HARD_EXCLUDE_DIRS.has(ent.name)) continue;
200
+ await walk(abs, rel);
201
+ } else if (ent.isFile()) {
202
+ await consider(abs, rel, ent.name);
203
+ }
204
+ }
205
+ }
206
+ await walk(root, '');
207
+ }
208
+ files.sort((a, b) => (a.path < b.path ? -1 : 1));
209
+ return { files, warnings };
210
+ }
211
+
212
+ // --- metadata inference (§4.1) ----------------------------------------------
213
+
214
+ // Tiny frontmatter reader: a leading "---" block of `key: value` lines.
215
+ export function parseFrontmatter(text) {
216
+ const data = {};
217
+ if (!text.startsWith('---')) return { data, body: text };
218
+ const end = text.indexOf('\n---', 3);
219
+ if (end === -1) return { data, body: text };
220
+ const block = text.slice(text.indexOf('\n') + 1, end);
221
+ for (const line of block.split('\n')) {
222
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
223
+ if (!m) continue;
224
+ let value = m[2].trim();
225
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
226
+ value = value.slice(1, -1);
227
+ }
228
+ data[m[1]] = value;
229
+ }
230
+ return { data, body: text.slice(end + 4) };
231
+ }
232
+
233
+ function firstHeadingOrParagraph(body) {
234
+ const heading = body.match(/^#+\s+(.+)$/m);
235
+ if (heading) return heading[1].trim();
236
+ for (const block of body.split(/\n\s*\n/)) {
237
+ const t = block.trim();
238
+ if (t && !t.startsWith('#') && !t.startsWith('---')) return t.split('\n')[0].trim();
239
+ }
240
+ return '';
241
+ }
242
+
243
+ // The artifact's primary document: SKILL.md for a skill, the file itself for
244
+ // single-file artifacts, else the only .md present.
245
+ export function primaryDocPath(files, { isDir, type }) {
246
+ if (!isDir) return files[0]?.path ?? null;
247
+ const skillMd = files.find((f) => f.path === 'SKILL.md');
248
+ if (skillMd) return skillMd.path;
249
+ const mds = files.filter((f) => f.path.endsWith('.md') && !f.path.includes('/'));
250
+ if (mds.length === 1) return mds[0].path;
251
+ return null;
252
+ }
253
+
254
+ // name: frontmatter `name:` → basename (--name overrides, applied by caller).
255
+ // description: frontmatter → first heading → first paragraph → "".
256
+ export async function inferMetadata({ root, isDir, type, agent, files }) {
257
+ let fm = {};
258
+ let body = '';
259
+ const docRel = primaryDocPath(files, { isDir, type });
260
+ if (docRel) {
261
+ const docAbs = isDir ? path.join(root, ...docRel.split('/')) : root;
262
+ if (docAbs.endsWith('.md') || docRel.endsWith('.md')) {
263
+ try {
264
+ const text = await readFile(docAbs, 'utf8');
265
+ const parsed = parseFrontmatter(text);
266
+ fm = parsed.data;
267
+ body = parsed.body;
268
+ } catch { /* unreadable doc → fall back to basenames */ }
269
+ }
270
+ }
271
+ const base = path.basename(root).replace(/\.[^.]+$/, '');
272
+ const name = (fm.name && String(fm.name).trim()) || base;
273
+ const description = (fm.description && String(fm.description).trim()) || firstHeadingOrParagraph(body) || '';
274
+ const dependencies = [];
275
+ for (const key of ['requires', 'mcp']) {
276
+ if (fm[key]) dependencies.push({ [key]: fm[key] });
277
+ }
278
+ return { name, type, agent, description, dependencies, primaryDoc: docRel };
279
+ }
280
+
281
+ // §2.4 — relative references that escape the artifact ("../shared/util.md"):
282
+ // the skill may not work standalone. Scans packaged .md files.
283
+ export async function findExternalRefs(files) {
284
+ const refs = new Set();
285
+ for (const f of files) {
286
+ if (!f.path.endsWith('.md')) continue;
287
+ let text;
288
+ try {
289
+ text = await readFile(f.abs, 'utf8');
290
+ } catch {
291
+ continue;
292
+ }
293
+ for (const m of text.matchAll(/(?:^|[\s('"`(=])(\.\.\/[A-Za-z0-9_./-]+)/g)) {
294
+ refs.add(m[1]);
295
+ }
296
+ }
297
+ return [...refs].sort();
298
+ }
package/src/errors.js ADDED
@@ -0,0 +1,21 @@
1
+ // Exit codes (hard rule 6):
2
+ // 0 — success or benign no-op
3
+ // 1 — runtime/remote failure (expired, deleted, integrity, network)
4
+ // 2 — usage/local error (bad args, not found, gh missing, too large)
5
+ export class CliError extends Error {
6
+ constructor(message, exitCode = 1, details = undefined) {
7
+ super(message);
8
+ this.name = 'CliError';
9
+ this.exitCode = exitCode;
10
+ if (details !== undefined) this.details = details;
11
+ }
12
+ }
13
+
14
+ export const MSG = {
15
+ ghMissing:
16
+ 'Sharing needs the GitHub CLI: https://cli.github.com, then "gh auth login". (Receivers don\'t need gh for public links.)',
17
+ gistDeleted: 'This share was deleted by the sender (gist not found).',
18
+ downloadIntegrity: 'Download failed integrity check. Did not install anything.',
19
+ linkIntegrity:
20
+ 'Link integrity check failed — this share changed since the link was made, or the link was altered. Nothing was installed.',
21
+ };
@@ -0,0 +1,29 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function sha256hex(data) {
4
+ return createHash('sha256').update(data).digest('hex');
5
+ }
6
+
7
+ // §7.1 — tree fingerprint, independent of tar framing, mtimes, and file order.
8
+ // entries = path + NUL + sha256hex, lexicographically byte-sorted, joined with "\n",
9
+ // then sha256-hexed. skillshark.json itself is never part of `files`.
10
+ export function treeFingerprint(files) {
11
+ const entries = files.map((f) => Buffer.from(f.path + '\u0000' + f.sha256, 'utf8'));
12
+ entries.sort(Buffer.compare);
13
+ const nl = Buffer.from('\n');
14
+ const parts = [];
15
+ for (let i = 0; i < entries.length; i++) {
16
+ if (i > 0) parts.push(nl);
17
+ parts.push(entries[i]);
18
+ }
19
+ return sha256hex(Buffer.concat(parts));
20
+ }
21
+
22
+ export function fp8(fingerprint) {
23
+ return fingerprint.slice(0, 8);
24
+ }
25
+
26
+ // fp8 displayed as XXXX-XXXX (e.g. 3f9a-7c21)
27
+ export function formatFp8(fingerprint) {
28
+ return `${fingerprint.slice(0, 4)}-${fingerprint.slice(4, 8)}`;
29
+ }
package/src/gh.js ADDED
@@ -0,0 +1,34 @@
1
+ // The gh helper — the ONLY module (besides the clipboard) that touches
2
+ // child_process. Used exclusively by sender operations (share, revoke).
3
+ // Receivers never come through here. execFile with argument arrays only;
4
+ // user input is never interpolated into a shell string (hard rule 3).
5
+ import { execFile as execFileCb } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { CliError, MSG } from './errors.js';
8
+
9
+ const execFileP = promisify(execFileCb);
10
+
11
+ // Default runner; tests inject their own to capture or forbid calls.
12
+ export async function defaultGhRunner(args) {
13
+ const { stdout } = await execFileP('gh', args, { maxBuffer: 32 * 1024 * 1024 });
14
+ return stdout;
15
+ }
16
+
17
+ // Run `gh api ...` and map the usual failure modes onto exit-code-2 guidance.
18
+ export function makeGhApi(runner = defaultGhRunner) {
19
+ return async function ghApi(args) {
20
+ try {
21
+ return await runner(['api', ...args]);
22
+ } catch (err) {
23
+ if (err instanceof CliError) throw err;
24
+ if (err?.code === 'ENOENT') throw new CliError(MSG.ghMissing, 2);
25
+ const stderr = String(err?.stderr ?? '');
26
+ if (/not logged in|authentication|HTTP 401|gh auth login/i.test(stderr)) {
27
+ throw new CliError(MSG.ghMissing, 2);
28
+ }
29
+ if (/HTTP 404/.test(stderr)) throw new CliError('GitHub returned 404 for that id.', 1);
30
+ const detail = stderr.trim().split('\n')[0] || err.message;
31
+ throw new CliError(`gh failed: ${detail}`, 1);
32
+ }
33
+ };
34
+ }