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 +87 -0
- package/bin/skillshark.js +256 -0
- package/package.json +36 -0
- package/src/clipboard.js +45 -0
- package/src/config.js +62 -0
- package/src/discover.js +298 -0
- package/src/errors.js +21 -0
- package/src/fingerprint.js +29 -0
- package/src/gh.js +34 -0
- package/src/install.js +502 -0
- package/src/pkg.js +260 -0
- package/src/share.js +249 -0
- package/src/source.js +58 -0
- package/src/transports/gist.js +117 -0
- package/src/transports/repo.js +98 -0
- package/src/ui.js +103 -0
- package/src/version.js +2 -0
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
|
+
}
|
package/src/clipboard.js
ADDED
|
@@ -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
|
+
}
|