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.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # SkillShark ๐Ÿฆˆ
2
+
3
+ **Share an agent skill like you'd share a file.** SkillShark packages a Claude Code skill (or command) into a secret GitHub gist and hands you an unlisted, self-verifying link; the receiver installs it with one command and zero setup โ€” no GitHub account, no server, no registry.
4
+
5
+ > โš ๏ธ **Secret gists are unlisted, NOT private โ€” anyone with the link can read them.**
6
+ > SkillShark scans for secret-shaped files (`.env`, keys, tokens) and refuses to package
7
+ > them unless you `--force`. If you ever leak one anyway: `skillshark revoke` the share
8
+ > *and rotate the secret* โ€” gists keep revision history.
9
+
10
+ ## Install
11
+
12
+ Zero-install:
13
+
14
+ ```sh
15
+ npx skillshark install <link>
16
+ ```
17
+
18
+ Or globally:
19
+
20
+ ```sh
21
+ npm install -g skillshark
22
+ ```
23
+
24
+ Requirements: Node โ‰ฅ 20. **Senders** also need the [GitHub CLI](https://cli.github.com) authenticated (`gh auth login`). **Receivers need nothing** โ€” public links are fetched over plain anonymous HTTPS.
25
+
26
+ ## The four commands
27
+
28
+ **share** โ€” package a skill and get an unlisted link (auto-copied to your clipboard):
29
+
30
+ ```sh
31
+ skillshark share /j
32
+ # โ†’ https://gist.github.com/8a1bc94ef23d4b6a9c01e57f8d2a4b3c#fp=3f9a7c21
33
+ ```
34
+
35
+ Accepts a name (`j`, `/j` โ€” resolved across `./.claude/skills`, `./.claude/commands`, and their `~/` equivalents) or any path. Useful flags: `--expires 30m|6h|24h|7d|30d` (advisory, default 7d), `--dry-run`, `--name`, `--force`, `--no-clipboard`, `-q` (print only the URL).
36
+
37
+ **install** โ€” download, verify, preview, confirm, copy:
38
+
39
+ ```sh
40
+ skillshark install https://gist.github.com/<id>#fp=<fp8> # a SkillShark link
41
+ skillshark install <gist-id> # bare id works too
42
+ skillshark install gh:acme/skills/review@main # any public repo path
43
+ ```
44
+
45
+ Skills land in `.claude/skills/<name>/`, commands in `.claude/commands/<name>.md` (project scope when the cwd has `.claude/` or `.git`, else `--project`/`--global`/`--dir`). Useful flags: `--yes`, `--force`, `--allow-exec`, `--dir <path>`.
46
+
47
+ **inspect** โ€” look before you leap (writes nothing):
48
+
49
+ ```sh
50
+ skillshark inspect <link> --cat SKILL.md
51
+ ```
52
+
53
+ Inspect downloads and verifies the full package, so what you read is ground truth from checksummed bytes โ€” never sender-declared metadata. The gist page itself is also a free browser preview (`SKILLSHARK.json` + `SKILL.md`).
54
+
55
+ **revoke** โ€” delete a share you created:
56
+
57
+ ```sh
58
+ skillshark revoke j # or the gist id
59
+ ```
60
+
61
+ The gist dies immediately; anyone holding the link gets "deleted by the sender."
62
+ (GitHub's anonymous API cache can serve a just-deleted gist for up to ~a minute
63
+ before the 404 propagates everywhere.)
64
+
65
+ ## Security model
66
+
67
+ - **SkillShark never executes package content.** Install = copy files. No postinstall hooks, no scripts, ever.
68
+ - **Executable bits are stripped by default.** Executables are flagged in the preview; `--allow-exec` is required to keep them.
69
+ - **Everything is verified.** Per-file sha256 checksums and a tree fingerprint are checked after extraction; path traversal, symlinks, absolute paths, decompression bombs, and oversized payloads all abort the install with nothing written.
70
+ - **Links are self-verifying.** `share` appends `#fp=<fingerprint>` to the URL; `install` recomputes the fingerprint from the downloaded bytes and hard-fails on mismatch โ€” if the gist was edited after sharing, you'll know.
71
+ - **Receivers need no GitHub account.** The receive path uses anonymous HTTPS only and provably never invokes `gh` (enforced by tests).
72
+
73
+ The honest framing: a skill is *instructions an AI will obey*. SkillShark is for sharing between people who already trust each other โ€” it makes installs informed and tamper-evident, not safe-from-strangers. Read the preview.
74
+
75
+ ## Odds and ends
76
+
77
+ - **Uninstall** = delete the directory (`rm -rf .claude/skills/<name>`). Install records live in `~/.config/skillshark/installs.json` (override the location with `$SKILLSHARK_CONFIG_DIR`).
78
+ - **Expiry is advisory.** GitHub can't enforce TTLs: installers refuse past the expiry, but the bytes persist until you `revoke`.
79
+ - **Too big for a gist (~5 MB)?** Put it in a repo and share `gh:owner/repo/path` instead.
80
+ - Exit codes: `0` success/benign no-op ยท `1` runtime or remote failure ยท `2` usage error.
81
+
82
+ ## Development
83
+
84
+ ```sh
85
+ npm test # offline unit + integration suite (includes all security cases)
86
+ npm run acceptance # real-network end-to-end: share โ†’ install โ†’ tamper โ†’ revoke
87
+ ```
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // Arg parsing + dispatch. Kept tiny: every behavior lives in src/.
3
+ import process from 'node:process';
4
+ import os from 'node:os';
5
+ import { CliError } from '../src/errors.js';
6
+ import { VERSION } from '../src/version.js';
7
+ import { getConfigDir } from '../src/config.js';
8
+ import { makeUi, realPrompts } from '../src/ui.js';
9
+ import { makeGhApi } from '../src/gh.js';
10
+ import { copyToClipboard } from '../src/clipboard.js';
11
+ import { runShare, runRevoke } from '../src/share.js';
12
+ import { runInstall, runInspect } from '../src/install.js';
13
+
14
+ const HELP = `skillshark โ€” share agent skills like files
15
+
16
+ USAGE
17
+ skillshark <command> [options]
18
+
19
+ COMMANDS
20
+ share <path|name> Package a skill and upload it as a secret gist
21
+ install <source> Download, verify, preview, and install a shared skill
22
+ inspect <source> Preview a shared skill without installing anything
23
+ revoke <id|name> Delete a share you created (deletes the gist)
24
+
25
+ SOURCES (install / inspect)
26
+ https://gist.github.com/<id>#fp=<hex> a SkillShark link
27
+ <gist id> bare 20-32 char hex id
28
+ gh:owner/repo[/path][@ref] any public GitHub repo path
29
+
30
+ GLOBAL OPTIONS
31
+ -y, --yes Skip prompts (non-interactive)
32
+ -q, --quiet Print only the essential result (URL or path)
33
+ --json Machine-readable output
34
+ --no-color Disable color (NO_COLOR is also honored)
35
+ -h, --help Show help (try: skillshark help <command>)
36
+ -V, --version Show version
37
+
38
+ EXAMPLES
39
+ skillshark share /j share the "j" skill (secret gist)
40
+ skillshark install <gist-url|id> install a shared skill
41
+ skillshark install gh:acme/skills/review install straight from a repo path
42
+ skillshark inspect <gist-url> --cat SKILL.md
43
+ skillshark revoke j delete the share
44
+
45
+ Secret gists are unlisted, NOT private โ€” anyone with the link can read them.
46
+ SkillShark never executes package content; install only copies files.`;
47
+
48
+ const COMMAND_HELP = {
49
+ share: `skillshark share <path|name> โ€” package a skill and get an unlisted link
50
+
51
+ -e, --expires <dur> Advisory expiry: 30m | 6h | 24h | 7d | 30d (default 7d)
52
+ --name <name> Override the inferred name
53
+ --force Include secret-shaped files the scanner would skip
54
+ --no-clipboard Don't copy the link to the clipboard
55
+ --dry-run Show exactly what would be packaged; upload nothing
56
+ -q, --quiet Print only the URL
57
+ --json Print { id, url, revision, expiresAt, fingerprint, size, files }
58
+
59
+ Sharing needs an authenticated gh (https://cli.github.com). The link is
60
+ unlisted, not private: anyone holding it can read the gist. Undo with
61
+ "skillshark revoke <name>".`,
62
+ install: `skillshark install <source> โ€” download, verify, preview, confirm, copy
63
+
64
+ -y, --yes Install without prompting (documented as dangerous)
65
+ --project Install into ./.claude/... (default when cwd is a project)
66
+ --global Install into ~/.claude/... (all projects)
67
+ --dir <path> Install into an explicit directory (required for
68
+ prompt/bundle packages; overrides agent detection)
69
+ --force Overwrite an existing, differing artifact
70
+ --allow-exec Keep executable bits (stripped by default)
71
+ -q, --quiet Print only the installed path
72
+ --json Print { name, type, agent, installedPath, ... }
73
+
74
+ SkillShark never executes anything from a package. Integrity: per-file sha256
75
+ + tree fingerprint, and the #fp= fragment in the link is enforced.`,
76
+ inspect: `skillshark inspect <source> โ€” look before you leap (writes nothing)
77
+
78
+ --cat <path> Print one file from the package
79
+ --files File listing only
80
+ --json Machine-readable summary
81
+
82
+ Inspect downloads and verifies the full package, then shows you ground truth
83
+ from checksummed bytes. Expired shares still display (only install refuses).`,
84
+ revoke: `skillshark revoke <id|name> โ€” delete a share you created
85
+
86
+ -y, --yes Skip the confirmation prompt
87
+ --json Print { revoked: <id> }
88
+
89
+ Deletes the underlying gist via your gh auth. The link dies immediately.`,
90
+ };
91
+
92
+ // flag spec: long name โ†’ { short, takesValue, key }
93
+ const GLOBAL_FLAGS = {
94
+ yes: { short: 'y', key: 'yes' },
95
+ quiet: { short: 'q', key: 'quiet' },
96
+ json: { key: 'json' },
97
+ 'no-color': { key: 'noColor' },
98
+ help: { short: 'h', key: 'help' },
99
+ version: { short: 'V', key: 'version' },
100
+ };
101
+ const COMMAND_FLAGS = {
102
+ share: {
103
+ expires: { short: 'e', takesValue: true, key: 'expires' },
104
+ name: { takesValue: true, key: 'name' },
105
+ force: { key: 'force' },
106
+ 'no-clipboard': { key: 'noClipboard' },
107
+ 'dry-run': { key: 'dryRun' },
108
+ },
109
+ install: {
110
+ project: { key: 'project' },
111
+ global: { key: 'global' },
112
+ force: { key: 'force' },
113
+ dir: { takesValue: true, key: 'dir' },
114
+ 'allow-exec': { key: 'allowExec' },
115
+ agent: { takesValue: true, key: 'agent' },
116
+ },
117
+ inspect: {
118
+ cat: { takesValue: true, key: 'cat' },
119
+ files: { key: 'files' },
120
+ },
121
+ revoke: {},
122
+ };
123
+
124
+ function parseArgv(argv) {
125
+ const [first, ...rest] = argv;
126
+ if (!first || first === 'help') {
127
+ return { command: 'help', topic: rest[0] ?? null, opts: {}, positionals: [] };
128
+ }
129
+ if (first === '--help' || first === '-h') return { command: 'help', topic: null, opts: {}, positionals: [] };
130
+ if (first === '--version' || first === '-V') return { command: 'version', opts: {}, positionals: [] };
131
+ const command = first;
132
+ const flagDefs = { ...GLOBAL_FLAGS, ...(COMMAND_FLAGS[command] ?? {}) };
133
+ const shorts = {};
134
+ for (const [long, def] of Object.entries(flagDefs)) {
135
+ if (def.short) shorts[def.short] = long;
136
+ }
137
+ const opts = {};
138
+ const positionals = [];
139
+ for (let i = 0; i < rest.length; i++) {
140
+ const tok = rest[i];
141
+ if (tok === '--') {
142
+ positionals.push(...rest.slice(i + 1));
143
+ break;
144
+ }
145
+ let long = null;
146
+ if (tok.startsWith('--')) long = tok.slice(2);
147
+ else if (tok.startsWith('-') && tok.length === 2) long = shorts[tok[1]] ?? null;
148
+ else {
149
+ positionals.push(tok);
150
+ continue;
151
+ }
152
+ let inlineValue = null;
153
+ if (long && long.includes('=')) {
154
+ const eq = long.indexOf('=');
155
+ inlineValue = long.slice(eq + 1);
156
+ long = long.slice(0, eq);
157
+ }
158
+ const def = long ? flagDefs[long] : null;
159
+ if (!def) throw new CliError(`Unknown option "${tok}" for "${command}". Try: skillshark help ${command}`, 2);
160
+ if (def.takesValue) {
161
+ const value = inlineValue ?? rest[++i];
162
+ if (value === undefined) throw new CliError(`Option --${long} needs a value.`, 2);
163
+ opts[def.key] = value;
164
+ } else {
165
+ opts[def.key] = true;
166
+ }
167
+ }
168
+ return { command, opts, positionals };
169
+ }
170
+
171
+ async function main() {
172
+ const parsed = parseArgv(process.argv.slice(2));
173
+ const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
174
+ const color = parsed.opts.noColor || process.env.NO_COLOR ? false : undefined;
175
+ const ui = makeUi({ color });
176
+
177
+ if (parsed.command === 'help') {
178
+ if (parsed.topic && COMMAND_HELP[parsed.topic]) ui.out(COMMAND_HELP[parsed.topic]);
179
+ else ui.out(HELP);
180
+ return 0;
181
+ }
182
+ if (parsed.command === 'version') {
183
+ ui.out(VERSION);
184
+ return 0;
185
+ }
186
+ if (parsed.opts.help) {
187
+ ui.out(COMMAND_HELP[parsed.command] ?? HELP);
188
+ return 0;
189
+ }
190
+ if (parsed.opts.version) {
191
+ ui.out(VERSION);
192
+ return 0;
193
+ }
194
+
195
+ const known = new Set(['share', 'install', 'inspect', 'revoke']);
196
+ if (!known.has(parsed.command)) {
197
+ throw new CliError(`Unknown command "${parsed.command}". Commands: share, install, inspect, revoke. Try: skillshark --help`, 2);
198
+ }
199
+ if (parsed.opts.agent && parsed.opts.agent !== 'claude-code') {
200
+ throw new CliError(`Only --agent claude-code is supported in v0.1 (got "${parsed.opts.agent}").`, 2);
201
+ }
202
+
203
+ const arg = parsed.positionals[0];
204
+ if (!arg) {
205
+ const noun = parsed.command === 'share' ? '<path|name>' : parsed.command === 'revoke' ? '<id|name>' : '<source>';
206
+ throw new CliError(`Usage: skillshark ${parsed.command} ${noun}. Try: skillshark help ${parsed.command}`, 2);
207
+ }
208
+ if (parsed.positionals.length > 1) {
209
+ throw new CliError(`Too many arguments: ${parsed.positionals.slice(1).join(' ')}`, 2);
210
+ }
211
+
212
+ // --json is non-interactive by definition (ยง1.9)
213
+ const effectiveTTY = parsed.opts.json ? false : isTTY;
214
+ const deps = {
215
+ fetch: globalThis.fetch,
216
+ cwd: process.cwd(),
217
+ home: os.homedir(),
218
+ env: process.env,
219
+ isTTY: effectiveTTY,
220
+ configDir: getConfigDir(process.env),
221
+ ui,
222
+ prompts: effectiveTTY ? await realPrompts() : null,
223
+ ghApi: makeGhApi(),
224
+ clipboard: (text) => copyToClipboard(text),
225
+ };
226
+
227
+ switch (parsed.command) {
228
+ case 'share':
229
+ await runShare(arg, parsed.opts, deps);
230
+ return 0;
231
+ case 'install':
232
+ await runInstall(arg, parsed.opts, deps);
233
+ return 0;
234
+ case 'inspect':
235
+ await runInspect(arg, parsed.opts, deps);
236
+ return 0;
237
+ case 'revoke':
238
+ await runRevoke(arg, parsed.opts, deps);
239
+ return 0;
240
+ default:
241
+ return 2;
242
+ }
243
+ }
244
+
245
+ try {
246
+ const code = await main();
247
+ process.exit(code);
248
+ } catch (err) {
249
+ if (err instanceof CliError) {
250
+ const stream = err.exitCode === 0 ? process.stdout : process.stderr;
251
+ stream.write(`${err.exitCode === 0 ? '' : 'โœ— '}${err.message}\n`);
252
+ process.exit(err.exitCode);
253
+ }
254
+ process.stderr.write(`Unexpected error: ${err?.stack ?? err}\n`);
255
+ process.exit(1);
256
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "skillshark",
3
+ "version": "0.1.0",
4
+ "description": "Share agent skills like files โ€” secret gists out, safe verified installs in. No server; GitHub is the backend.",
5
+ "type": "module",
6
+ "bin": {
7
+ "skillshark": "./bin/skillshark.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "keywords": [
15
+ "claude-code",
16
+ "agent-skills",
17
+ "skills",
18
+ "gist",
19
+ "cli",
20
+ "sharing"
21
+ ],
22
+ "author": "Aaron Turkel",
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test 'test/**/*.test.js'",
28
+ "acceptance": "bash scripts/acceptance.sh"
29
+ },
30
+ "dependencies": {
31
+ "@clack/prompts": "^0.11.0",
32
+ "picocolors": "^1.1.1",
33
+ "tar": "^7.4.3"
34
+ },
35
+ "license": "MIT"
36
+ }
@@ -0,0 +1,45 @@
1
+ // Clipboard helper โ€” the second of exactly two modules allowed to touch
2
+ // child_process. Best effort, never blocks longer than ~500 ms, never fails
3
+ // the share: pbcopy โ†’ wl-copy โ†’ xclip โ†’ clip.exe โ†’ OSC52 โ†’ shrug.
4
+ import { execFile } from 'node:child_process';
5
+ import { writeFileSync } from 'node:fs';
6
+
7
+ const TIMEOUT_MS = 500;
8
+
9
+ function tryPipe(cmd, args, text) {
10
+ return new Promise((resolve) => {
11
+ let child;
12
+ try {
13
+ child = execFile(cmd, args, { timeout: TIMEOUT_MS }, (err) => resolve(!err));
14
+ } catch {
15
+ resolve(false);
16
+ return;
17
+ }
18
+ child.on('error', () => resolve(false));
19
+ if (child.stdin) {
20
+ child.stdin.on('error', () => {});
21
+ child.stdin.end(text);
22
+ }
23
+ });
24
+ }
25
+
26
+ function tryOsc52(text) {
27
+ try {
28
+ // works over SSH when the terminal supports it; harmless otherwise
29
+ writeFileSync('/dev/tty', `\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export async function copyToClipboard(text, { platform = process.platform } = {}) {
37
+ const candidates =
38
+ platform === 'darwin'
39
+ ? [['pbcopy', []]]
40
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['clip.exe', []]];
41
+ for (const [cmd, args] of candidates) {
42
+ if (await tryPipe(cmd, args, text)) return true;
43
+ }
44
+ return tryOsc52(text);
45
+ }
package/src/config.js ADDED
@@ -0,0 +1,62 @@
1
+ // Client-side state: ~/.config/skillshark/ (or $SKILLSHARK_CONFIG_DIR).
2
+ // config.json โ€” shares cache so `revoke <name>` resolves offline.
3
+ // installs.json โ€” local install records (client-side by design).
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ export function getConfigDir(env = process.env) {
9
+ return env.SKILLSHARK_CONFIG_DIR || path.join(os.homedir(), '.config', 'skillshark');
10
+ }
11
+
12
+ async function readJson(file, fallback) {
13
+ try {
14
+ return JSON.parse(await readFile(file, 'utf8'));
15
+ } catch {
16
+ return fallback;
17
+ }
18
+ }
19
+
20
+ async function writeJson(file, value) {
21
+ await mkdir(path.dirname(file), { recursive: true });
22
+ await writeFile(file, JSON.stringify(value, null, 2) + '\n');
23
+ }
24
+
25
+ export async function loadConfig(dir) {
26
+ const cfg = await readJson(path.join(dir, 'config.json'), {});
27
+ if (!Array.isArray(cfg.shares)) cfg.shares = [];
28
+ return cfg;
29
+ }
30
+
31
+ export async function saveConfig(dir, cfg) {
32
+ await writeJson(path.join(dir, 'config.json'), cfg);
33
+ }
34
+
35
+ export async function addShareRecord(dir, record) {
36
+ const cfg = await loadConfig(dir);
37
+ cfg.shares.unshift(record);
38
+ await saveConfig(dir, cfg);
39
+ }
40
+
41
+ export async function findShareRecord(dir, idOrName) {
42
+ const cfg = await loadConfig(dir);
43
+ return cfg.shares.find((s) => s.id === idOrName) ?? cfg.shares.find((s) => s.name === idOrName) ?? null;
44
+ }
45
+
46
+ export async function removeShareRecord(dir, id) {
47
+ const cfg = await loadConfig(dir);
48
+ cfg.shares = cfg.shares.filter((s) => s.id !== id);
49
+ await saveConfig(dir, cfg);
50
+ }
51
+
52
+ export async function loadInstalls(dir) {
53
+ const v = await readJson(path.join(dir, 'installs.json'), []);
54
+ return Array.isArray(v) ? v : [];
55
+ }
56
+
57
+ export async function addInstallRecord(dir, record) {
58
+ const installs = await loadInstalls(dir);
59
+ const next = installs.filter((r) => r.path !== record.path);
60
+ next.unshift(record);
61
+ await writeJson(path.join(dir, 'installs.json'), next);
62
+ }