skillshark 0.1.0 → 0.3.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,189 @@
1
+ // The interactive session: `skillshark` with no arguments (TTY only) drops
2
+ // into a guided menu; bare `share`/`install`/`inspect`/`revoke` run their
3
+ // wizard one-shot. Built on @clack/prompts — every flow ends in the same
4
+ // audited pipelines as the flag-driven CLI; the wizard only collects answers.
5
+ import { CliError } from './errors.js';
6
+ import { discoverAll } from './discover.js';
7
+ import { parseSource } from './source.js';
8
+ import { runShare, runRevoke } from './share.js';
9
+ import { runInstall, runInspect } from './install.js';
10
+ import { loadConfig } from './config.js';
11
+ import { displayPath } from './ui.js';
12
+ import { AGENTS } from './agents.js';
13
+ import { VERSION } from './version.js';
14
+
15
+ const FIN = [
16
+ ' |\\',
17
+ ' | \\',
18
+ ' ~~~;~~\\~~~~',
19
+ ];
20
+
21
+ async function ux() {
22
+ const clack = await import('@clack/prompts');
23
+ const pc = (await import('picocolors')).default;
24
+ return { clack, pc };
25
+ }
26
+
27
+ function bail(clack, value) {
28
+ return value === null || value === undefined || clack.isCancel(value);
29
+ }
30
+
31
+ // --- share wizard --------------------------------------------------------------
32
+
33
+ async function wizardShare(deps, { clack, pc }) {
34
+ const found = await deps.ui.spin('Scanning for shareable artifacts', () => discoverAll(deps));
35
+ if (!found.length) {
36
+ clack.log.warn('Nothing shareable here or in your home directory. Create a skill first (e.g. .claude/skills/<name>/SKILL.md).');
37
+ return { status: 'cancelled' };
38
+ }
39
+ const pick = await clack.select({
40
+ message: `Share which artifact? ${pc.dim(`(${found.length} found)`)}`,
41
+ maxItems: 12,
42
+ options: found.map((a) => ({
43
+ value: a.root,
44
+ label: a.name,
45
+ hint: `${AGENTS[a.agent]?.label ?? a.agent} ${a.type} · ${a.scope === 'global' ? '~' : displayPath(a.root, deps)}`,
46
+ })),
47
+ });
48
+ if (bail(clack, pick)) return { status: 'cancelled' };
49
+
50
+ const expires = await clack.select({
51
+ message: 'Advisory expiry (installers refuse after this; the gist lives until you revoke):',
52
+ initialValue: '7d',
53
+ options: [
54
+ { value: '30m', label: '30 minutes' },
55
+ { value: '6h', label: '6 hours' },
56
+ { value: '24h', label: '24 hours' },
57
+ { value: '7d', label: '7 days', hint: 'default' },
58
+ { value: '30d', label: '30 days' },
59
+ ],
60
+ });
61
+ if (bail(clack, expires)) return { status: 'cancelled' };
62
+
63
+ return runShare(pick, { expires }, deps);
64
+ }
65
+
66
+ // --- install / inspect wizards ----------------------------------------------------
67
+
68
+ async function askSource(deps, { clack }) {
69
+ for (;;) {
70
+ const source = await clack.text({
71
+ message: 'Paste the link (gist URL, bare id, or gh:owner/repo[/path][@ref]):',
72
+ placeholder: 'https://gist.github.com/…#fp=…',
73
+ });
74
+ if (bail(clack, source)) return null;
75
+ try {
76
+ parseSource(String(source).trim());
77
+ return String(source).trim();
78
+ } catch (err) {
79
+ if (err instanceof CliError) {
80
+ clack.log.error(err.message);
81
+ continue;
82
+ }
83
+ throw err;
84
+ }
85
+ }
86
+ }
87
+
88
+ async function wizardInstall(deps, u) {
89
+ const source = await askSource(deps, u);
90
+ if (source === null) return { status: 'cancelled' };
91
+ return runInstall(source, {}, deps);
92
+ }
93
+
94
+ async function wizardInspect(deps, u) {
95
+ const { clack } = u;
96
+ const source = await askSource(deps, u);
97
+ if (source === null) return { status: 'cancelled' };
98
+ await runInspect(source, {}, deps);
99
+ const next = await clack.select({
100
+ message: 'And now?',
101
+ options: [
102
+ { value: 'install', label: 'Install it' },
103
+ { value: 'done', label: 'Done' },
104
+ ],
105
+ });
106
+ if (!bail(clack, next) && next === 'install') {
107
+ return runInstall(source, {}, deps);
108
+ }
109
+ return { status: 'inspected' };
110
+ }
111
+
112
+ // --- revoke wizard -----------------------------------------------------------------
113
+
114
+ async function wizardRevoke(deps, { clack, pc }) {
115
+ const cfg = await loadConfig(deps.configDir);
116
+ let idOrName = null;
117
+ if (cfg.shares.length) {
118
+ const pick = await clack.select({
119
+ message: 'Revoke which share?',
120
+ maxItems: 12,
121
+ options: [
122
+ ...cfg.shares.map((s) => ({
123
+ value: s.id,
124
+ label: s.name,
125
+ hint: `${s.id.slice(0, 12)}… · expires ${s.expiresAt ? s.expiresAt.slice(0, 10) : 'never'}`,
126
+ })),
127
+ { value: '__manual', label: 'Enter a gist id or name…' },
128
+ ],
129
+ });
130
+ if (bail(clack, pick)) return { status: 'cancelled' };
131
+ idOrName = pick === '__manual' ? null : pick;
132
+ }
133
+ if (idOrName === null) {
134
+ const entered = await clack.text({ message: 'Gist id (or share name):' });
135
+ if (bail(clack, entered) || !String(entered).trim()) return { status: 'cancelled' };
136
+ idOrName = String(entered).trim();
137
+ }
138
+ clack.log.warn(pc.yellow('Revoking deletes the gist — anyone holding the link loses access immediately.'));
139
+ return runRevoke(idOrName, {}, deps);
140
+ }
141
+
142
+ // --- the session --------------------------------------------------------------------
143
+
144
+ const WIZARDS = {
145
+ share: wizardShare,
146
+ install: wizardInstall,
147
+ inspect: wizardInspect,
148
+ revoke: wizardRevoke,
149
+ };
150
+
151
+ // action: run a single wizard (bare `skillshark share` etc.); null: full menu.
152
+ export async function runInteractive(deps, action = null) {
153
+ const u = await ux();
154
+ const { clack, pc } = u;
155
+
156
+ clack.intro(`${pc.cyan(FIN.join('\n '))}\n ${pc.bold('skillshark')} ${pc.dim(`v${VERSION} — share agent skills like files`)}`);
157
+
158
+ if (action) {
159
+ const result = await WIZARDS[action](deps, u);
160
+ clack.outro(result?.status === 'cancelled' ? 'Nothing happened. 🦈' : 'Done. 🦈');
161
+ return 0;
162
+ }
163
+
164
+ for (;;) {
165
+ const choice = await clack.select({
166
+ message: 'What are we doing?',
167
+ options: [
168
+ { value: 'share', label: 'Share', hint: 'package a local skill → unlisted link' },
169
+ { value: 'install', label: 'Install', hint: 'from a link or repo path' },
170
+ { value: 'inspect', label: 'Inspect', hint: 'look before you leap' },
171
+ { value: 'revoke', label: 'Revoke', hint: 'kill a link you shared' },
172
+ { value: 'quit', label: 'Quit' },
173
+ ],
174
+ });
175
+ if (bail(clack, choice) || choice === 'quit') break;
176
+ try {
177
+ await WIZARDS[choice](deps, u);
178
+ } catch (err) {
179
+ if (err instanceof CliError) {
180
+ clack.log.error(err.message);
181
+ } else {
182
+ throw err;
183
+ }
184
+ }
185
+ clack.log.message('');
186
+ }
187
+ clack.outro('Swim safe. 🦈');
188
+ return 0;
189
+ }
package/src/share.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { CliError } from './errors.js';
6
+ import { resolveHost, DEFAULT_HOST } from './source.js';
6
7
  import { resolveShareArg, collectFiles, inferMetadata, findExternalRefs } from './discover.js';
7
8
  import { buildTarball } from './pkg.js';
8
9
  import { treeFingerprint, fp8 } from './fingerprint.js';
@@ -153,14 +154,22 @@ export async function runShare(arg, opts, deps) {
153
154
  } catch { /* preview is optional */ }
154
155
  }
155
156
 
156
- const { id, revision } = await createGist({
157
- manifestJson,
158
- primaryDoc,
159
- tarballB64: b64,
160
- description: gistDescription({ name: manifest.name, agent: manifest.agent, type: manifest.type, fp8: shortFp }),
161
- ghApi: deps.ghApi,
162
- });
163
- const url = `https://gist.github.com/${id}#fp=${shortFp}`;
157
+ const host = resolveHost(opts, deps);
158
+ const { id, revision, htmlUrl } = await ui.spin('Uploading as a secret gist', () =>
159
+ createGist({
160
+ manifestJson,
161
+ primaryDoc,
162
+ tarballB64: b64,
163
+ description: gistDescription({ name: manifest.name, agent: manifest.agent, type: manifest.type, fp8: shortFp }),
164
+ ghApi: deps.ghApi,
165
+ host,
166
+ }));
167
+ // github.com gets the short canonical form; enterprise hosts keep the
168
+ // html_url GitHub handed back (subdomain isolation varies per install)
169
+ const base = host === DEFAULT_HOST ? `https://gist.github.com/${id}` : (htmlUrl ?? `https://${host}/gist/${id}`);
170
+ const url = `${base}#fp=${shortFp}`;
171
+ // the paste-and-go line: receivers run this with zero setup
172
+ const installCommand = `npx skillshark install '${url}'`;
164
173
 
165
174
  await addShareRecord(deps.configDir, {
166
175
  id,
@@ -168,37 +177,41 @@ export async function runShare(arg, opts, deps) {
168
177
  url,
169
178
  revision,
170
179
  expiresAt: manifest.expiresAt,
180
+ ...(host !== DEFAULT_HOST ? { host } : {}),
171
181
  });
172
182
 
173
183
  let copied = false;
174
184
  if (!opts.noClipboard && deps.clipboard) {
175
- copied = await deps.clipboard(url);
185
+ copied = await deps.clipboard(installCommand);
176
186
  }
177
187
 
178
188
  if (opts.json) {
179
189
  ui.out(JSON.stringify({
180
190
  id,
181
191
  url,
192
+ installCommand,
182
193
  revision,
183
194
  expiresAt: manifest.expiresAt,
184
195
  fingerprint,
185
196
  size: manifest.totalSize,
186
197
  files: manifest.files.map((f) => f.path),
198
+ ...(host !== DEFAULT_HOST ? { host } : {}),
187
199
  }));
188
200
  } else if (opts.quiet) {
189
201
  ui.out(url);
190
202
  } else {
191
203
  ui.out('');
192
204
  ui.ok('Uploaded as a secret gist (unlisted — anyone with the link can read it)');
193
- if (copied) ui.ok(`Link copied to clipboard · advisory expiry in ${expires.label}`);
205
+ if (copied) ui.ok(`Install one-liner copied to clipboard — they just paste it · advisory expiry in ${expires.label}`);
194
206
  else ui.out(` Advisory expiry in ${expires.label}`);
195
207
  ui.out('');
196
- ui.out(` ${url}`);
208
+ ui.out(` ${installCommand}`);
197
209
  ui.out('');
198
- ui.out(' They run: skillshark install <the link> (no GitHub account needed)');
199
- ui.out(` Undo: skillshark revoke ${manifest.name} (deletes the gist)`);
210
+ const who = host === DEFAULT_HOST ? '(no GitHub account needed)' : `(needs gh auth on ${host})`;
211
+ ui.out(` Link only: ${url} ${who}`);
212
+ ui.out(` Undo: skillshark revoke ${manifest.name} (deletes the gist)`);
200
213
  }
201
- return { status: 'shared', id, url, fingerprint };
214
+ return { status: 'shared', id, url, installCommand, fingerprint };
202
215
  }
203
216
 
204
217
  // --- revoke (§4.4) -----------------------------------------------------------
@@ -207,6 +220,7 @@ export async function runRevoke(idOrName, opts, deps) {
207
220
  const ui = deps.ui;
208
221
  let id = null;
209
222
  let label = idOrName;
223
+ let host = resolveHost(opts, deps);
210
224
  if (/^[0-9a-f]{20,32}$/.test(idOrName)) {
211
225
  id = idOrName;
212
226
  } else {
@@ -214,9 +228,14 @@ export async function runRevoke(idOrName, opts, deps) {
214
228
  if (rec) {
215
229
  id = rec.id;
216
230
  label = `${rec.name} (${rec.id})`;
231
+ if (rec.host) host = rec.host; // enterprise share → revoke on its host
217
232
  } else {
218
- // cache miss → ask gh for our skillshark gists
219
- const out = await deps.ghApi(['gists', '--paginate']);
233
+ // cache miss → ask gh for our skillshark gists (on the chosen host)
234
+ const out = await deps.ghApi([
235
+ ...(host !== DEFAULT_HOST ? ['--hostname', host] : []),
236
+ 'gists',
237
+ '--paginate',
238
+ ]);
220
239
  let gists;
221
240
  try {
222
241
  gists = JSON.parse(out);
@@ -241,7 +260,7 @@ export async function runRevoke(idOrName, opts, deps) {
241
260
  return { status: 'cancelled' };
242
261
  }
243
262
  }
244
- await deleteGist(id, { ghApi: deps.ghApi });
263
+ await deleteGist(id, { ghApi: deps.ghApi, host });
245
264
  await removeShareRecord(deps.configDir, id);
246
265
  if (opts.json) ui.out(JSON.stringify({ revoked: id }));
247
266
  else ui.ok(`Revoked — the gist ${id} is gone. Anyone holding the link now gets "deleted by the sender".`);
package/src/source.js CHANGED
@@ -1,29 +1,51 @@
1
1
  // §7.3 — source parsing. Accepted forms:
2
2
  // https://gist.github.com/<id> (optionally <user>/<id>, optionally #fp=<hex>)
3
- // <20-32 char hex gist id>
3
+ // https://gist.<ghes-host>/<id> GitHub Enterprise, subdomain isolation
4
+ // https://<ghes-host>/gist/<id> GitHub Enterprise, path form
5
+ // <20-32 char hex gist id> (host from --host / GH_HOST, default github.com)
4
6
  // gh:owner/repo[/deep/path][@ref] (ref = branch, tag, or SHA; split on the LAST "@")
5
7
  import { CliError } from './errors.js';
6
8
 
9
+ export const DEFAULT_HOST = 'github.com';
10
+
7
11
  const USAGE = `Unrecognized source. Accepted forms:
8
12
  https://gist.github.com/8a1bc94ef23d4b6a9c01e57f8d2a4b3c#fp=3f9a7c21
13
+ https://ghe.example.com/gist/8a1bc94ef23d4b6a9c01e57f8d2a4b3c#fp=3f9a7c21
9
14
  8a1bc94ef23d4b6a9c01e57f8d2a4b3c
10
15
  gh:owner/repo[/deep/path][@ref]`;
11
16
 
12
17
  const NAME_RE = /^[A-Za-z0-9_.-]+$/;
18
+ const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i;
19
+
20
+ // The host a command should talk to: explicit flag → env → github.com.
21
+ export function resolveHost(opts = {}, deps = {}) {
22
+ const host = opts.host || deps.env?.SKILLSHARK_HOST || deps.env?.GH_HOST || DEFAULT_HOST;
23
+ if (!HOST_RE.test(host)) throw new CliError(`Invalid --host "${host}".`, 2);
24
+ return host.toLowerCase();
25
+ }
13
26
 
14
- export function parseSource(input) {
27
+ export function parseSource(input, { defaultHost = DEFAULT_HOST } = {}) {
15
28
  const s = String(input ?? '').trim();
16
29
  if (!s) throw new CliError(USAGE, 2);
17
30
 
18
- const gistUrl = s.match(
19
- /^https:\/\/gist\.github\.com\/(?:([A-Za-z0-9-]+)\/)?([0-9a-f]{20,32})\/?(?:#fp=([0-9a-f]{8,64}))?$/,
31
+ // gist.<host>/<id> github.com and GHES-with-subdomain-isolation alike
32
+ let m = s.match(
33
+ /^https:\/\/gist\.([a-z0-9.-]+)\/(?:([A-Za-z0-9-]+)\/)?([0-9a-f]{20,32})\/?(?:#fp=([0-9a-f]{8,64}))?$/i,
34
+ );
35
+ if (m) {
36
+ return { kind: 'gist', id: m[3], fp: m[4] ?? null, host: m[1].toLowerCase() };
37
+ }
38
+
39
+ // <host>/gist/<id> — GHES path form
40
+ m = s.match(
41
+ /^https:\/\/([a-z0-9.-]+)\/gist\/(?:([A-Za-z0-9-]+)\/)?([0-9a-f]{20,32})\/?(?:#fp=([0-9a-f]{8,64}))?$/i,
20
42
  );
21
- if (gistUrl) {
22
- return { kind: 'gist', id: gistUrl[2], fp: gistUrl[3] ?? null };
43
+ if (m && m[1].toLowerCase() !== 'github.com') {
44
+ return { kind: 'gist', id: m[3], fp: m[4] ?? null, host: m[1].toLowerCase() };
23
45
  }
24
46
 
25
47
  if (/^[0-9a-f]{20,32}$/.test(s)) {
26
- return { kind: 'gist', id: s, fp: null };
48
+ return { kind: 'gist', id: s, fp: null, host: defaultHost };
27
49
  }
28
50
 
29
51
  if (s.startsWith('gh:')) {
@@ -43,6 +65,7 @@ export function parseSource(input) {
43
65
  repo: segs[1],
44
66
  path: segs.length > 2 ? segs.slice(2).join('/') : null,
45
67
  ref,
68
+ host: defaultHost,
46
69
  };
47
70
  }
48
71
  }
@@ -51,8 +74,9 @@ export function parseSource(input) {
51
74
  }
52
75
 
53
76
  export function formatSource(src) {
54
- if (src.kind === 'gist') return `gist:${src.id}`;
77
+ const hostTag = src.host && src.host !== DEFAULT_HOST ? `${src.host}:` : '';
78
+ if (src.kind === 'gist') return `gist:${hostTag}${src.id}`;
55
79
  const p = src.path ? `/${src.path}` : '';
56
80
  const r = src.ref ? `@${src.ref}` : '';
57
- return `gh:${src.owner}/${src.repo}${p}${r}`;
81
+ return `gh:${hostTag}${src.owner}/${src.repo}${p}${r}`;
58
82
  }
@@ -1,12 +1,19 @@
1
1
  // Gist transport. Share/revoke go through `gh` (the sender's auth); the
2
- // receive path is ANONYMOUS https only — it must never invoke gh (§0.3).
2
+ // public github.com receive path is ANONYMOUS https only — it must never
3
+ // invoke gh (§0.3). GitHub Enterprise links are the deliberate exception:
4
+ // privacy means everything rides the receiver's own gh auth and no anonymous
5
+ // request ever leaves for the enterprise host.
3
6
  import { mkdtemp, writeFile, rm } from 'node:fs/promises';
4
7
  import path from 'node:path';
5
8
  import os from 'node:os';
6
9
  import { CliError, MSG } from '../errors.js';
7
10
  import { USER_AGENT } from '../version.js';
11
+ import { DEFAULT_HOST } from '../source.js';
8
12
 
9
13
  export const GIST_PAYLOAD_LIMIT = 5 * 1024 * 1024; // encoded bytes (§4.1)
14
+ // the gists API truncates inline file content at ~1 MB; on enterprise hosts we
15
+ // can't fall back to an anonymous raw_url fetch, so shares are capped honestly
16
+ export const ENTERPRISE_INLINE_LIMIT = 900 * 1024;
10
17
  const FETCH_HEADERS = {
11
18
  Accept: 'application/vnd.github+json',
12
19
  'User-Agent': USER_AGENT,
@@ -22,9 +29,19 @@ export function gistDescription({ name, agent, type, fp8 }) {
22
29
  return `skillshark: ${name} (${kind}) · fp ${fp8}`;
23
30
  }
24
31
 
32
+ function hostFlags(host) {
33
+ return host && host !== DEFAULT_HOST ? ['--hostname', host] : [];
34
+ }
35
+
25
36
  // One `gh api gists --method POST --input <tmp.json>` call; a JSON body file
26
37
  // avoids every shell-escaping pitfall (hard rule 3).
27
- export async function createGist({ manifestJson, primaryDoc, tarballB64, description, ghApi }) {
38
+ export async function createGist({ manifestJson, primaryDoc, tarballB64, description, ghApi, host = DEFAULT_HOST }) {
39
+ if (host !== DEFAULT_HOST && tarballB64.length > ENTERPRISE_INLINE_LIMIT) {
40
+ throw new CliError(
41
+ `That's too big for an enterprise gist share (~${Math.floor(ENTERPRISE_INLINE_LIMIT / 1024)} KB encoded cap — the API truncates larger files and enterprise receivers can't fetch around it anonymously). Put it in a repo on ${host} instead.`,
42
+ 2,
43
+ );
44
+ }
28
45
  const files = { 'SKILLSHARK.json': { content: manifestJson } };
29
46
  if (primaryDoc && primaryDoc.content.trim()) {
30
47
  files[path.basename(primaryDoc.name)] = { content: primaryDoc.content };
@@ -36,7 +53,7 @@ export async function createGist({ manifestJson, primaryDoc, tarballB64, descrip
36
53
  const bodyFile = path.join(tmpDir, 'gist-body.json');
37
54
  try {
38
55
  await writeFile(bodyFile, JSON.stringify(body));
39
- const stdout = await ghApi(['gists', '--method', 'POST', '--input', bodyFile]);
56
+ const stdout = await ghApi([...hostFlags(host), 'gists', '--method', 'POST', '--input', bodyFile]);
40
57
  let parsed;
41
58
  try {
42
59
  parsed = JSON.parse(stdout);
@@ -44,14 +61,14 @@ export async function createGist({ manifestJson, primaryDoc, tarballB64, descrip
44
61
  throw new CliError('Unexpected response from gh while creating the gist.', 1);
45
62
  }
46
63
  if (!parsed.id) throw new CliError('gh created no gist (no id in response).', 1);
47
- return { id: parsed.id, revision: parsed.history?.[0]?.version ?? null };
64
+ return { id: parsed.id, revision: parsed.history?.[0]?.version ?? null, htmlUrl: parsed.html_url ?? null };
48
65
  } finally {
49
66
  await rm(tmpDir, { recursive: true, force: true });
50
67
  }
51
68
  }
52
69
 
53
- export async function deleteGist(id, { ghApi }) {
54
- await ghApi(['--method', 'DELETE', `gists/${id}`]);
70
+ export async function deleteGist(id, { ghApi, host = DEFAULT_HOST }) {
71
+ await ghApi([...hostFlags(host), '--method', 'DELETE', `gists/${id}`]);
55
72
  }
56
73
 
57
74
  // --- receive side (anonymous fetch, no gh — ever) ---------------------------
@@ -70,25 +87,50 @@ async function readBodyCapped(res, cap, what) {
70
87
  return Buffer.concat(chunks);
71
88
  }
72
89
 
73
- export async function fetchGistPackage(id, { fetch }) {
74
- let res;
75
- try {
76
- res = await fetch(`https://api.github.com/gists/${id}`, { headers: FETCH_HEADERS });
77
- } catch (e) {
78
- throw new CliError(`Network error reaching GitHub: ${e.message}`, 1);
79
- }
80
- if (res.status === 404) throw new CliError(MSG.gistDeleted, 1);
81
- if (res.status === 403 || res.status === 429) {
82
- throw new CliError('GitHub rate limit hit (anonymous reads are 60/hour per IP). Try again in a bit.', 1);
90
+ export async function fetchGistPackage(id, { fetch, host = DEFAULT_HOST, ghApi = null }) {
91
+ let data;
92
+ if (host !== DEFAULT_HOST) {
93
+ // GitHub Enterprise: private by nature. Everything goes through the
94
+ // receiver's own gh auth; no anonymous request touches the host.
95
+ if (!ghApi) throw new CliError(`Can't reach ${host} without gh.`, 2);
96
+ let stdout;
97
+ try {
98
+ stdout = await ghApi(['--hostname', host, `gists/${id}`]);
99
+ } catch (err) {
100
+ if (err instanceof CliError && /404/.test(err.message)) throw new CliError(MSG.gistDeleted, 1);
101
+ throw err;
102
+ }
103
+ try {
104
+ data = JSON.parse(stdout);
105
+ } catch {
106
+ throw new CliError(`Unexpected response from ${host} while fetching the gist.`, 1);
107
+ }
108
+ } else {
109
+ let res;
110
+ try {
111
+ res = await fetch(`https://api.github.com/gists/${id}`, { headers: FETCH_HEADERS });
112
+ } catch (e) {
113
+ throw new CliError(`Network error reaching GitHub: ${e.message}`, 1);
114
+ }
115
+ if (res.status === 404) throw new CliError(MSG.gistDeleted, 1);
116
+ if (res.status === 403 || res.status === 429) {
117
+ throw new CliError('GitHub rate limit hit (anonymous reads are 60/hour per IP). Try again in a bit.', 1);
118
+ }
119
+ if (!res.ok) throw new CliError(`GitHub API error fetching the gist (HTTP ${res.status}).`, 1);
120
+ data = await res.json();
83
121
  }
84
- if (!res.ok) throw new CliError(`GitHub API error fetching the gist (HTTP ${res.status}).`, 1);
85
- const data = await res.json();
86
122
 
87
123
  const pkgFile = data.files?.['package.tgz.b64'];
88
124
  if (!pkgFile) {
89
125
  throw new CliError('No package at that link (the gist has no package.tgz.b64 — not a SkillShark share).', 1);
90
126
  }
91
127
  let b64;
128
+ if (pkgFile.truncated && host !== DEFAULT_HOST) {
129
+ throw new CliError(
130
+ `This enterprise share is too large to fetch inline (the ${host} API truncates files past ~1 MB). Ask the sender to share it as a repo path on ${host} instead.`,
131
+ 1,
132
+ );
133
+ }
92
134
  if (pkgFile.truncated) {
93
135
  let raw;
94
136
  try {
@@ -1,9 +1,12 @@
1
- // Repo transport (install-only in v0.1): gh:owner/repo[/path][@ref], fetched
2
- // anonymously from api.github.com + codeload. The one integrity anchor is the
3
- // commit SHA; #fp= does not apply here (§4.2).
1
+ // Repo transport (install-only): gh:owner/repo[/path][@ref], fetched
2
+ // anonymously from api.github.com + codeload for public github.com, or
3
+ // entirely through the receiver's gh auth for GitHub Enterprise hosts —
4
+ // no anonymous request ever leaves for an enterprise host. The integrity
5
+ // anchor is the commit SHA; #fp= does not apply here (§4.2).
4
6
  import { CliError } from '../errors.js';
5
7
  import { USER_AGENT } from '../version.js';
6
8
  import { extractTarball } from '../pkg.js';
9
+ import { DEFAULT_HOST } from '../source.js';
7
10
 
8
11
  export const REPO_TARBALL_LIMIT = 50 * 1024 * 1024;
9
12
  const FETCH_HEADERS = {
@@ -60,38 +63,68 @@ export function subtreeMapper(subPath) {
60
63
  };
61
64
  }
62
65
 
63
- // Resolve ref → commit SHA (pinned installs skip the API entirely), download
64
- // the codeload tarball, and extract just the subtree through the §7.2 guards.
65
- export async function fetchRepoTree({ owner, repo, path: subPath, ref }, destDir, { fetch }) {
66
+ async function ghJson(ghApi, host, apiPath, what) {
67
+ let stdout;
68
+ try {
69
+ stdout = await ghApi(['--hostname', host, apiPath]);
70
+ } catch (err) {
71
+ if (err instanceof CliError && /404/.test(err.message)) {
72
+ throw new CliError(`${what} not found on ${host}.`, 1);
73
+ }
74
+ throw err;
75
+ }
76
+ try {
77
+ return JSON.parse(stdout);
78
+ } catch {
79
+ throw new CliError(`Unexpected response from ${host} for ${what}.`, 1);
80
+ }
81
+ }
82
+
83
+ // Resolve ref → commit SHA (pinned public installs skip the API entirely),
84
+ // download the tarball, and extract just the subtree through the §7.2 guards.
85
+ export async function fetchRepoTree({ owner, repo, path: subPath, ref, host = DEFAULT_HOST }, destDir, { fetch, ghApi = null }) {
86
+ const enterprise = host !== DEFAULT_HOST;
87
+ if (enterprise && !ghApi) throw new CliError(`Can't reach ${host} without gh.`, 2);
66
88
  let sha;
67
89
  if (ref && /^[0-9a-f]{40}$/.test(ref)) {
68
90
  sha = ref;
69
91
  } else {
70
92
  let resolvedRef = ref;
71
93
  if (!resolvedRef) {
72
- const repoInfo = await getJson(fetch, `https://api.github.com/repos/${owner}/${repo}`, `Repository ${owner}/${repo}`);
94
+ const repoInfo = enterprise
95
+ ? await ghJson(ghApi, host, `repos/${owner}/${repo}`, `Repository ${owner}/${repo}`)
96
+ : await getJson(fetch, `https://api.github.com/repos/${owner}/${repo}`, `Repository ${owner}/${repo}`);
73
97
  resolvedRef = repoInfo.default_branch;
74
98
  if (!resolvedRef) throw new CliError(`Repository ${owner}/${repo} has no default branch.`, 1);
75
99
  }
76
- const commit = await getJson(
77
- fetch,
78
- `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(resolvedRef)}`,
79
- `Ref "${resolvedRef}" in ${owner}/${repo}`,
80
- );
100
+ const commitPath = `repos/${owner}/${repo}/commits/${encodeURIComponent(resolvedRef)}`;
101
+ const commit = enterprise
102
+ ? await ghJson(ghApi, host, commitPath, `Ref "${resolvedRef}" in ${owner}/${repo}`)
103
+ : await getJson(fetch, `https://api.github.com/${commitPath}`, `Ref "${resolvedRef}" in ${owner}/${repo}`);
81
104
  sha = commit.sha;
82
105
  if (!sha) throw new CliError(`Could not resolve "${resolvedRef}" to a commit in ${owner}/${repo}.`, 1);
83
106
  }
84
107
 
85
- let res;
86
- try {
87
- res = await fetch(`https://codeload.github.com/${owner}/${repo}/tar.gz/${sha}`, {
88
- headers: { 'User-Agent': USER_AGENT },
89
- });
90
- } catch (e) {
91
- throw new CliError(`Network error downloading the repo tarball: ${e.message}`, 1);
108
+ let tarball;
109
+ if (enterprise) {
110
+ // gh follows the tarball redirect and emits the bytes on stdout
111
+ tarball = await ghApi(['--hostname', host, `repos/${owner}/${repo}/tarball/${sha}`], { binary: true });
112
+ if (!Buffer.isBuffer(tarball)) tarball = Buffer.from(tarball);
113
+ if (tarball.length > REPO_TARBALL_LIMIT) {
114
+ throw new CliError(`The repo tarball exceeds the ${Math.floor(REPO_TARBALL_LIMIT / (1024 * 1024))} MB limit.`, 1);
115
+ }
116
+ } else {
117
+ let res;
118
+ try {
119
+ res = await fetch(`https://codeload.github.com/${owner}/${repo}/tar.gz/${sha}`, {
120
+ headers: { 'User-Agent': USER_AGENT },
121
+ });
122
+ } catch (e) {
123
+ throw new CliError(`Network error downloading the repo tarball: ${e.message}`, 1);
124
+ }
125
+ if (!res.ok) throw new CliError(`Could not download ${owner}/${repo}@${sha.slice(0, 7)} (HTTP ${res.status}).`, 1);
126
+ tarball = await readBodyCapped(res, REPO_TARBALL_LIMIT, 'The repo tarball');
92
127
  }
93
- if (!res.ok) throw new CliError(`Could not download ${owner}/${repo}@${sha.slice(0, 7)} (HTTP ${res.status}).`, 1);
94
- const tarball = await readBodyCapped(res, REPO_TARBALL_LIMIT, 'The repo tarball');
95
128
 
96
129
  await extractTarball(tarball, destDir, { transformPath: subtreeMapper(subPath) });
97
130
  return { sha };
package/src/ui.js CHANGED
@@ -37,9 +37,29 @@ export function makeUi({ stdout = process.stdout, stderr = process.stderr, color
37
37
  warn: (s) => write(stdout, ` ${c.yellow('⚠')} ${s}`),
38
38
  info: (s) => write(stdout, ` ${c.cyan('ⓘ')} ${s}`),
39
39
  fail: (s) => write(stderr, ` ${c.red('✗')} ${s}`),
40
+ // network-op wrapper; the TTY entrypoint swaps in a real spinner
41
+ spin: async (_label, fn) => fn(),
40
42
  };
41
43
  }
42
44
 
45
+ // Replace ui.spin with an animated @clack spinner (TTY only).
46
+ export async function attachSpinner(ui) {
47
+ const clack = await import('@clack/prompts');
48
+ ui.spin = async (label, fn) => {
49
+ const s = clack.spinner();
50
+ s.start(label);
51
+ try {
52
+ const result = await fn();
53
+ s.stop(`${label} — done`);
54
+ return result;
55
+ } catch (err) {
56
+ s.stop(`${label} — failed`, 1);
57
+ throw err;
58
+ }
59
+ };
60
+ return ui;
61
+ }
62
+
43
63
  // The install/inspect preview, rendered only from verified bytes (§4.2 step 5).
44
64
  export function renderPreview(ui, { manifest, fingerprint, fpFromLink, externalRefs }) {
45
65
  const c = ui.colors;
@@ -99,5 +119,10 @@ export async function realPrompts() {
99
119
  if (clack.isCancel(value)) return null;
100
120
  return value;
101
121
  },
122
+ async text({ message, placeholder }) {
123
+ const value = await clack.text({ message, placeholder });
124
+ if (clack.isCancel(value)) return null;
125
+ return String(value ?? '').trim();
126
+ },
102
127
  };
103
128
  }
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.1.0';
1
+ export const VERSION = '0.3.0';
2
2
  export const USER_AGENT = `skillshark/${VERSION}`;