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,98 @@
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).
4
+ import { CliError } from '../errors.js';
5
+ import { USER_AGENT } from '../version.js';
6
+ import { extractTarball } from '../pkg.js';
7
+
8
+ export const REPO_TARBALL_LIMIT = 50 * 1024 * 1024;
9
+ const FETCH_HEADERS = {
10
+ Accept: 'application/vnd.github+json',
11
+ 'User-Agent': USER_AGENT,
12
+ 'X-GitHub-Api-Version': '2022-11-28',
13
+ };
14
+
15
+ async function getJson(fetch, url, what) {
16
+ let res;
17
+ try {
18
+ res = await fetch(url, { headers: FETCH_HEADERS });
19
+ } catch (e) {
20
+ throw new CliError(`Network error reaching GitHub: ${e.message}`, 1);
21
+ }
22
+ if (res.status === 404) throw new CliError(`${what} not found (private repos aren't supported in v0.1).`, 1);
23
+ if (res.status === 403 || res.status === 429) {
24
+ throw new CliError('GitHub rate limit hit (anonymous reads are 60/hour per IP). Try again in a bit.', 1);
25
+ }
26
+ if (!res.ok) throw new CliError(`GitHub API error for ${what} (HTTP ${res.status}).`, 1);
27
+ return res.json();
28
+ }
29
+
30
+ async function readBodyCapped(res, cap, what) {
31
+ const chunks = [];
32
+ let total = 0;
33
+ for await (const chunk of res.body) {
34
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
35
+ total += buf.length;
36
+ if (total > cap) {
37
+ throw new CliError(`${what} exceeds the ${Math.floor(cap / (1024 * 1024))} MB limit. Put less in, or install a deeper path.`, 1);
38
+ }
39
+ chunks.push(buf);
40
+ }
41
+ return Buffer.concat(chunks);
42
+ }
43
+
44
+ // Map a codeload entry path onto the requested subtree:
45
+ // strip the "<repo>-<ref>/" prefix, then select under `subPath` (or everything).
46
+ // A file `subPath` maps to its basename. Returns null to skip the entry.
47
+ export function subtreeMapper(subPath) {
48
+ const want = subPath ? subPath.split('/').filter(Boolean) : [];
49
+ return (cleanedPath) => {
50
+ const segs = cleanedPath.split('/').filter(Boolean);
51
+ if (segs.length <= 1) return null; // the prefix dir itself
52
+ const rest = segs.slice(1);
53
+ if (want.length === 0) return rest.join('/');
54
+ if (rest.length < want.length) return null;
55
+ for (let i = 0; i < want.length; i++) {
56
+ if (rest[i] !== want[i]) return null;
57
+ }
58
+ if (rest.length === want.length) return rest[rest.length - 1]; // subPath IS a file
59
+ return rest.slice(want.length).join('/');
60
+ };
61
+ }
62
+
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
+ let sha;
67
+ if (ref && /^[0-9a-f]{40}$/.test(ref)) {
68
+ sha = ref;
69
+ } else {
70
+ let resolvedRef = ref;
71
+ if (!resolvedRef) {
72
+ const repoInfo = await getJson(fetch, `https://api.github.com/repos/${owner}/${repo}`, `Repository ${owner}/${repo}`);
73
+ resolvedRef = repoInfo.default_branch;
74
+ if (!resolvedRef) throw new CliError(`Repository ${owner}/${repo} has no default branch.`, 1);
75
+ }
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
+ );
81
+ sha = commit.sha;
82
+ if (!sha) throw new CliError(`Could not resolve "${resolvedRef}" to a commit in ${owner}/${repo}.`, 1);
83
+ }
84
+
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);
92
+ }
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
+
96
+ await extractTarball(tarball, destDir, { transformPath: subtreeMapper(subPath) });
97
+ return { sha };
98
+ }
package/src/ui.js ADDED
@@ -0,0 +1,103 @@
1
+ // Terminal output + interactive prompts. Glyphs and color are decoration,
2
+ // never information — everything must read fine on a dumb pipe.
3
+ import pc from 'picocolors';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { formatFp8 } from './fingerprint.js';
7
+
8
+ export function humanSize(bytes) {
9
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
10
+ return `${(bytes / 1024).toFixed(1)} KB`;
11
+ }
12
+
13
+ export function plural(n, word) {
14
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
15
+ }
16
+
17
+ // Show a path the way the user thinks of it: relative inside cwd, ~ for home.
18
+ export function displayPath(p, { cwd = process.cwd(), home = os.homedir() } = {}) {
19
+ const abs = path.resolve(p);
20
+ if (abs === cwd) return '.';
21
+ if (abs.startsWith(cwd + path.sep)) return path.relative(cwd, abs);
22
+ if (abs === home) return '~';
23
+ if (abs.startsWith(home + path.sep)) return `~${path.sep}${path.relative(home, abs)}`;
24
+ return abs;
25
+ }
26
+
27
+ export function makeUi({ stdout = process.stdout, stderr = process.stderr, color = undefined } = {}) {
28
+ const enabled = color ?? (Boolean(stdout.isTTY) && !process.env.NO_COLOR);
29
+ const c = pc.createColors(enabled);
30
+ const write = (stream, s) => stream.write(s + '\n');
31
+ return {
32
+ colors: c,
33
+ out: (s = '') => write(stdout, s),
34
+ raw: (s) => stdout.write(s),
35
+ err: (s = '') => write(stderr, s),
36
+ ok: (s) => write(stdout, ` ${c.green('✓')} ${s}`),
37
+ warn: (s) => write(stdout, ` ${c.yellow('⚠')} ${s}`),
38
+ info: (s) => write(stdout, ` ${c.cyan('ⓘ')} ${s}`),
39
+ fail: (s) => write(stderr, ` ${c.red('✗')} ${s}`),
40
+ };
41
+ }
42
+
43
+ // The install/inspect preview, rendered only from verified bytes (§4.2 step 5).
44
+ export function renderPreview(ui, { manifest, fingerprint, fpFromLink, externalRefs }) {
45
+ const c = ui.colors;
46
+ const typeLabel = manifest.type.charAt(0).toUpperCase() + manifest.type.slice(1);
47
+ ui.out(` ${typeLabel}: ${c.bold(manifest.name)}`);
48
+ ui.out(` Type: ${manifest.type.padEnd(14)} Agent: ${manifest.agent || '—'}`);
49
+ ui.out(` Size: ${humanSize(manifest.totalSize ?? manifest.files.reduce((n, f) => n + (f.size ?? 0), 0)).padEnd(14)} Files: ${manifest.files.length}`);
50
+ let fpLine = ` Fingerprint: ${formatFp8(fingerprint)}`;
51
+ if (fpFromLink) fpLine += ` ${c.green('✓')} matches #fp in the link`;
52
+ ui.out(fpLine);
53
+ if (manifest.description) ui.out(` ${c.dim(manifest.description)}`);
54
+ ui.out('');
55
+ renderFileTree(ui, manifest);
56
+ ui.out('');
57
+ const execs = manifest.files.filter((f) => f.executable);
58
+ if (execs.length) {
59
+ ui.warn(`${plural(execs.length, 'executable script')}: ${execs.map((f) => f.path).join(', ')}`);
60
+ ui.out(' SkillShark will not run them and will install them without the executable');
61
+ ui.out(' bit (re-run with --allow-exec to keep it).');
62
+ }
63
+ if (externalRefs?.length) {
64
+ ui.warn(`references files outside the package (may not work standalone): ${externalRefs.join(', ')}`);
65
+ }
66
+ if (Array.isArray(manifest.dependencies) && manifest.dependencies.length) {
67
+ ui.info(`declares dependencies (informational only, never auto-installed): ${JSON.stringify(manifest.dependencies)}`);
68
+ }
69
+ }
70
+
71
+ export function renderFileTree(ui, manifest) {
72
+ const c = ui.colors;
73
+ ui.out(` ${c.bold(manifest.name)}/`);
74
+ const files = manifest.files;
75
+ const width = Math.max(...files.map((f) => f.path.length)) + 3;
76
+ files.forEach((f, i) => {
77
+ const glyph = i === files.length - 1 ? '└──' : '├──';
78
+ const exec = f.executable ? ` ${c.yellow('(executable)')}` : '';
79
+ ui.out(` ${glyph} ${f.path.padEnd(width)}${humanSize(f.size ?? 0)}${exec}`);
80
+ });
81
+ }
82
+
83
+ // Interactive prompt seam — the install pipeline calls deps.prompts.* so tests
84
+ // can stub it; this is the real @clack implementation, loaded lazily so the
85
+ // receive path in non-TTY/CI never even imports it.
86
+ export async function realPrompts() {
87
+ const clack = await import('@clack/prompts');
88
+ return {
89
+ async select({ message, options }) {
90
+ const value = await clack.select({
91
+ message,
92
+ options: options.map((o) => ({ value: o.value, label: o.label, hint: o.hint })),
93
+ });
94
+ if (clack.isCancel(value)) return null;
95
+ return value;
96
+ },
97
+ async confirm({ message }) {
98
+ const value = await clack.confirm({ message });
99
+ if (clack.isCancel(value)) return null;
100
+ return value;
101
+ },
102
+ };
103
+ }
package/src/version.js ADDED
@@ -0,0 +1,2 @@
1
+ export const VERSION = '0.1.0';
2
+ export const USER_AGENT = `skillshark/${VERSION}`;