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/src/install.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// The §4.2 pipeline: fetch → extract (guarded) → verify (sha256 + tree
|
|
2
|
+
// fingerprint + #fp=) → expiry → preview → target → conflict → confirm →
|
|
3
|
+
// atomic write → record. Never executes anything from a package. The receive
|
|
4
|
+
// path never shells out: this module and the transports it uses import no
|
|
5
|
+
// subprocess machinery at all (enforced by test and by the DoD grep).
|
|
6
|
+
import { mkdtemp, mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import { CliError, MSG } from './errors.js';
|
|
11
|
+
import { parseSource, formatSource } from './source.js';
|
|
12
|
+
import { fetchGistPackage } from './transports/gist.js';
|
|
13
|
+
import { fetchRepoTree } from './transports/repo.js';
|
|
14
|
+
import { extractTarball, hashTree, readManifest, verifyTreeAgainstManifest, MANIFEST_NAME } from './pkg.js';
|
|
15
|
+
import { treeFingerprint, sha256hex, fp8, formatFp8 } from './fingerprint.js';
|
|
16
|
+
import { inferMetadata, findExternalRefs } from './discover.js';
|
|
17
|
+
import { addInstallRecord } from './config.js';
|
|
18
|
+
import { renderPreview, renderFileTree, humanSize, displayPath, plural } from './ui.js';
|
|
19
|
+
import { VERSION } from './version.js';
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 86400000;
|
|
22
|
+
|
|
23
|
+
// --- fetch + verify (shared by install and inspect) -------------------------
|
|
24
|
+
|
|
25
|
+
function classifyRepoTree(actual, subPath, repo) {
|
|
26
|
+
if (subPath && /(^|\/)\.claude\/commands\/[^/]+\.md$/.test(subPath)) {
|
|
27
|
+
return { type: 'command', agent: 'claude-code' };
|
|
28
|
+
}
|
|
29
|
+
if (actual.some((f) => f.path === 'SKILL.md')) return { type: 'skill', agent: 'claude-code' };
|
|
30
|
+
if (actual.length === 1 && subPath && subPath.endsWith(actual[0].path)) {
|
|
31
|
+
return { type: 'prompt', agent: '' };
|
|
32
|
+
}
|
|
33
|
+
return { type: 'bundle', agent: '' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function fetchAndVerify(sourceStr, deps) {
|
|
37
|
+
const src = parseSource(sourceStr);
|
|
38
|
+
const workDir = await mkdtemp(path.join(os.tmpdir(), 'skillshark-recv-'));
|
|
39
|
+
|
|
40
|
+
if (src.kind === 'gist') {
|
|
41
|
+
const gist = await fetchGistPackage(src.id, { fetch: deps.fetch });
|
|
42
|
+
await extractTarball(gist.tarball, workDir);
|
|
43
|
+
const manifest = await readManifest(workDir);
|
|
44
|
+
const actual = await hashTree(workDir);
|
|
45
|
+
const fingerprint = verifyTreeAgainstManifest(actual, manifest);
|
|
46
|
+
if (src.fp && !fingerprint.startsWith(src.fp)) {
|
|
47
|
+
throw new CliError(MSG.linkIntegrity, 1);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
src,
|
|
51
|
+
workDir,
|
|
52
|
+
manifest,
|
|
53
|
+
fingerprint,
|
|
54
|
+
sender: gist.owner,
|
|
55
|
+
fpVerified: Boolean(src.fp),
|
|
56
|
+
sourceRecord: `gist:${src.id}@${gist.revision ?? 'unknown'}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// repo: no manifest exists; run the share-side inference on the extracted
|
|
61
|
+
// tree and synthesize one in memory. The commit SHA is the integrity.
|
|
62
|
+
const { sha } = await fetchRepoTree(src, workDir, { fetch: deps.fetch });
|
|
63
|
+
const actual = await hashTree(workDir, { exclude: [] });
|
|
64
|
+
if (actual.length === 0) {
|
|
65
|
+
throw new CliError(`No files found at gh:${src.owner}/${src.repo}${src.path ? `/${src.path}` : ''}@${sha.slice(0, 7)}.`, 1);
|
|
66
|
+
}
|
|
67
|
+
const { type, agent } = classifyRepoTree(actual, src.path, src.repo);
|
|
68
|
+
const withAbs = actual.map((f) => ({ ...f, abs: path.join(workDir, ...f.path.split('/')) }));
|
|
69
|
+
const meta = await inferMetadata({
|
|
70
|
+
root: workDir,
|
|
71
|
+
isDir: true,
|
|
72
|
+
type,
|
|
73
|
+
agent,
|
|
74
|
+
files: withAbs,
|
|
75
|
+
});
|
|
76
|
+
const fallbackName = path.basename(src.path ?? src.repo).replace(/\.[^.]+$/, '');
|
|
77
|
+
const manifest = {
|
|
78
|
+
skillshark: '2',
|
|
79
|
+
name: meta.name === path.basename(workDir) ? fallbackName : meta.name,
|
|
80
|
+
type,
|
|
81
|
+
agent,
|
|
82
|
+
description: meta.description,
|
|
83
|
+
files: actual.map((f) => ({ ...f, mode: f.executable ? '0755' : '0644' })),
|
|
84
|
+
totalSize: actual.reduce((n, f) => n + f.size, 0),
|
|
85
|
+
createdAt: null,
|
|
86
|
+
expiresAt: null,
|
|
87
|
+
tool: { name: 'skillshark', version: VERSION },
|
|
88
|
+
dependencies: meta.dependencies,
|
|
89
|
+
fingerprint: treeFingerprint(actual),
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
src,
|
|
93
|
+
workDir,
|
|
94
|
+
manifest,
|
|
95
|
+
fingerprint: manifest.fingerprint,
|
|
96
|
+
sender: src.owner,
|
|
97
|
+
fpVerified: false,
|
|
98
|
+
sourceRecord: `${formatSource({ ...src, ref: null })}@${sha}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- expiry (§4.2 step 4) ----------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export function expiryState(manifest, now = Date.now()) {
|
|
105
|
+
if (!manifest.expiresAt) return { state: 'none' };
|
|
106
|
+
const exp = Date.parse(manifest.expiresAt);
|
|
107
|
+
if (Number.isNaN(exp)) return { state: 'none' };
|
|
108
|
+
if (exp >= now) {
|
|
109
|
+
const remMs = exp - now;
|
|
110
|
+
const days = Math.floor(remMs / DAY_MS);
|
|
111
|
+
return { state: 'live', days, hours: Math.max(1, Math.floor(remMs / 3600000)) };
|
|
112
|
+
}
|
|
113
|
+
const days = Math.max(1, Math.floor((now - exp) / DAY_MS));
|
|
114
|
+
return { state: 'expired', days };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function expiredMessage(manifest, now = Date.now()) {
|
|
118
|
+
const { days } = expiryState(manifest, now);
|
|
119
|
+
return (
|
|
120
|
+
`The sender marked this share as expired ${plural(days, 'day')} ago.\n` +
|
|
121
|
+
`The files still exist until they prune — ask for a fresh link: skillshark share ${manifest.name}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- conflict diff (§4.2 step 7) ----------------------------------------------
|
|
126
|
+
|
|
127
|
+
export function diffTrees(existingFiles, incomingFiles) {
|
|
128
|
+
const existing = new Map(existingFiles.map((f) => [f.path, f.sha256]));
|
|
129
|
+
const incoming = new Map(incomingFiles.map((f) => [f.path, f.sha256]));
|
|
130
|
+
const added = [];
|
|
131
|
+
const changed = [];
|
|
132
|
+
const removed = [];
|
|
133
|
+
for (const [p, sha] of incoming) {
|
|
134
|
+
if (!existing.has(p)) added.push(p);
|
|
135
|
+
else if (existing.get(p) !== sha) changed.push(p);
|
|
136
|
+
}
|
|
137
|
+
for (const p of existing.keys()) {
|
|
138
|
+
if (!incoming.has(p)) removed.push(p);
|
|
139
|
+
}
|
|
140
|
+
added.sort();
|
|
141
|
+
changed.sort();
|
|
142
|
+
removed.sort();
|
|
143
|
+
return { added, changed, removed };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function existingTreeFor(target, targetIsFile, manifest) {
|
|
147
|
+
if (targetIsFile) {
|
|
148
|
+
const data = await readFile(target);
|
|
149
|
+
return [{ path: manifest.files[0].path, sha256: sha256hex(data), size: data.length }];
|
|
150
|
+
}
|
|
151
|
+
// if the package legitimately carries a skillshark.json (repo content), the
|
|
152
|
+
// comparison must include the one we wrote
|
|
153
|
+
const exclude = manifest.files.some((f) => f.path === MANIFEST_NAME) ? [] : undefined;
|
|
154
|
+
return hashTree(target, exclude ? { exclude } : undefined);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- target resolution (§4.2 step 6) ------------------------------------------
|
|
158
|
+
|
|
159
|
+
async function resolveTarget(manifest, opts, deps) {
|
|
160
|
+
if (opts.dir) {
|
|
161
|
+
return { target: path.resolve(deps.cwd, opts.dir), targetIsFile: false, scope: 'dir' };
|
|
162
|
+
}
|
|
163
|
+
if (manifest.type !== 'skill' && manifest.type !== 'command') {
|
|
164
|
+
throw new CliError(
|
|
165
|
+
`This package is a ${manifest.type} with no agent convention — choose a destination with --dir <path>.`,
|
|
166
|
+
2,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (manifest.type === 'command' && manifest.files.length !== 1) {
|
|
170
|
+
throw new CliError('Malformed command package: expected exactly one file.', 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const interactive = deps.isTTY && !opts.yes;
|
|
174
|
+
const detectable = existsSync(path.join(deps.cwd, '.claude')) || existsSync(path.join(deps.cwd, '.git'));
|
|
175
|
+
let root;
|
|
176
|
+
let scope;
|
|
177
|
+
if (opts.project) {
|
|
178
|
+
root = deps.cwd;
|
|
179
|
+
scope = 'project';
|
|
180
|
+
} else if (opts.global) {
|
|
181
|
+
root = deps.home;
|
|
182
|
+
scope = 'global';
|
|
183
|
+
} else if (interactive) {
|
|
184
|
+
const sub = manifest.type === 'skill' ? `.claude/skills/${manifest.name}` : `.claude/commands/${manifest.name}.md`;
|
|
185
|
+
const choice = await deps.prompts.select({
|
|
186
|
+
message: 'Install to:',
|
|
187
|
+
options: [
|
|
188
|
+
{ value: 'project', label: `${sub}`, hint: 'this project' },
|
|
189
|
+
{ value: 'global', label: `~/${sub}`, hint: 'all projects' },
|
|
190
|
+
{ value: 'cancel', label: 'cancel' },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
if (choice === null || choice === 'cancel') return { cancelled: true };
|
|
194
|
+
root = choice === 'global' ? deps.home : deps.cwd;
|
|
195
|
+
scope = choice;
|
|
196
|
+
} else if (detectable) {
|
|
197
|
+
root = deps.cwd;
|
|
198
|
+
scope = 'project';
|
|
199
|
+
} else {
|
|
200
|
+
throw new CliError(
|
|
201
|
+
"Can't tell if this is a project (no .claude/ or .git here). Re-run with --project, --global, or --dir <path>.",
|
|
202
|
+
2,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const target =
|
|
206
|
+
manifest.type === 'skill'
|
|
207
|
+
? path.join(root, '.claude', 'skills', manifest.name)
|
|
208
|
+
: path.join(root, '.claude', 'commands', `${manifest.name}.md`);
|
|
209
|
+
return { target, targetIsFile: manifest.type === 'command', scope };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- atomic write (§4.2 step 9) ------------------------------------------------
|
|
213
|
+
|
|
214
|
+
async function atomicWrite({ workDir, manifest, target, targetIsFile, allowExec, beforeRename }) {
|
|
215
|
+
const parent = path.dirname(target);
|
|
216
|
+
await mkdir(parent, { recursive: true });
|
|
217
|
+
const token = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
218
|
+
const stage = path.join(parent, `.skillshark-stage-${token}`);
|
|
219
|
+
let written = 0;
|
|
220
|
+
try {
|
|
221
|
+
if (targetIsFile) {
|
|
222
|
+
const f = manifest.files[0];
|
|
223
|
+
const data = await readFile(path.join(workDir, ...f.path.split('/')));
|
|
224
|
+
await writeFile(stage, data, { mode: allowExec && f.executable ? 0o755 : 0o644 });
|
|
225
|
+
written = 1;
|
|
226
|
+
} else {
|
|
227
|
+
await mkdir(stage);
|
|
228
|
+
for (const f of manifest.files) {
|
|
229
|
+
const src = path.join(workDir, ...f.path.split('/'));
|
|
230
|
+
const dest = path.join(stage, ...f.path.split('/'));
|
|
231
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
232
|
+
const data = await readFile(src);
|
|
233
|
+
await writeFile(dest, data, { mode: allowExec && f.executable ? 0o755 : 0o644 });
|
|
234
|
+
written += 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (beforeRename) await beforeRename();
|
|
238
|
+
let backup = null;
|
|
239
|
+
if (existsSync(target)) {
|
|
240
|
+
backup = path.join(parent, `.skillshark-old-${token}`);
|
|
241
|
+
await rename(target, backup);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await rename(stage, target);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (backup) await rename(backup, target).catch(() => {});
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
if (backup) await rm(backup, { recursive: true, force: true });
|
|
250
|
+
return written;
|
|
251
|
+
} catch (err) {
|
|
252
|
+
await rm(stage, { recursive: true, force: true });
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- the pipeline ---------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
export async function runInstall(sourceStr, opts, deps) {
|
|
260
|
+
// hard rule 5: a prompt with no TTY is a bug — refuse before touching the network
|
|
261
|
+
if (!deps.isTTY && !opts.yes) {
|
|
262
|
+
throw new CliError(
|
|
263
|
+
'Non-interactive install needs --yes (and --project/--global/--dir if the scope is ambiguous).',
|
|
264
|
+
2,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const interactive = deps.isTTY && !opts.yes;
|
|
268
|
+
const ui = deps.ui;
|
|
269
|
+
const verified = await fetchAndVerify(sourceStr, deps);
|
|
270
|
+
const { workDir, manifest, fingerprint, sourceRecord } = verified;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
// step 4 — advisory expiry: install refuses, inspect does not
|
|
274
|
+
const exp = expiryState(manifest);
|
|
275
|
+
if (exp.state === 'expired') {
|
|
276
|
+
throw new CliError(expiredMessage(manifest), 1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// step 5 — preview from verified bytes only
|
|
280
|
+
const externalRefs = await findExternalRefs(
|
|
281
|
+
manifest.files
|
|
282
|
+
.filter((f) => f.path.endsWith('.md'))
|
|
283
|
+
.map((f) => ({ ...f, abs: path.join(workDir, ...f.path.split('/')) })),
|
|
284
|
+
);
|
|
285
|
+
if (!opts.json && !opts.quiet) {
|
|
286
|
+
renderPreview(ui, { manifest, fingerprint, fpFromLink: verified.fpVerified, externalRefs });
|
|
287
|
+
ui.out('');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// step 6 — agent target + scope
|
|
291
|
+
const resolved = await resolveTarget(manifest, opts, deps);
|
|
292
|
+
if (resolved.cancelled) {
|
|
293
|
+
ui.out(' Cancelled. Nothing was installed.');
|
|
294
|
+
return { status: 'cancelled' };
|
|
295
|
+
}
|
|
296
|
+
let { target, targetIsFile } = resolved;
|
|
297
|
+
|
|
298
|
+
// step 7 — conflict
|
|
299
|
+
let conflictResolved = false;
|
|
300
|
+
while (!conflictResolved) {
|
|
301
|
+
const exists = existsSync(target);
|
|
302
|
+
if (!exists) break;
|
|
303
|
+
const existingFiles = await existingTreeFor(target, targetIsFile, manifest);
|
|
304
|
+
if (existingFiles.length === 0) break; // empty dir → nothing to conflict with
|
|
305
|
+
const existingFp = treeFingerprint(existingFiles);
|
|
306
|
+
if (existingFp === fingerprint) {
|
|
307
|
+
const msg = `ⓘ "${manifest.name}" is already installed at ${displayPath(target, deps)} and is identical (${formatFp8(fingerprint)}). Nothing to do.`;
|
|
308
|
+
if (opts.json) ui.out(JSON.stringify({ status: 'identical', name: manifest.name, installedPath: target }));
|
|
309
|
+
else ui.out(` ${msg}`);
|
|
310
|
+
return { status: 'identical', target };
|
|
311
|
+
}
|
|
312
|
+
const diff = diffTrees(existingFiles, manifest.files);
|
|
313
|
+
if (!interactive) {
|
|
314
|
+
if (opts.force) break;
|
|
315
|
+
throw new CliError(
|
|
316
|
+
`"${manifest.name}" already exists at ${displayPath(target, deps)} and differs ` +
|
|
317
|
+
`(+${diff.added.length} ~${diff.changed.length} -${diff.removed.length}). Re-run with --force to overwrite.`,
|
|
318
|
+
1,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
ui.warn(`"${manifest.name}" already exists at ${displayPath(target, deps)} and differs:`);
|
|
322
|
+
for (const p of diff.changed) ui.out(` ~ ${p} (changed)`);
|
|
323
|
+
for (const p of diff.added) ui.out(` + ${p} (added)`);
|
|
324
|
+
for (const p of diff.removed) ui.out(` - ${p} (removed)`);
|
|
325
|
+
const action = await deps.prompts.select({
|
|
326
|
+
message: 'What now?',
|
|
327
|
+
options: [
|
|
328
|
+
{ value: 'overwrite', label: `Overwrite ${displayPath(target, deps)}` },
|
|
329
|
+
{ value: 'side', label: `Install side-by-side as "${manifest.name}-2"` },
|
|
330
|
+
{ value: 'diff', label: 'Show diff' },
|
|
331
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
if (action === null || action === 'cancel') {
|
|
335
|
+
ui.out(' Cancelled. Nothing was installed.');
|
|
336
|
+
return { status: 'cancelled' };
|
|
337
|
+
}
|
|
338
|
+
if (action === 'overwrite') break;
|
|
339
|
+
if (action === 'side') {
|
|
340
|
+
let n = 2;
|
|
341
|
+
let cand;
|
|
342
|
+
do {
|
|
343
|
+
cand = targetIsFile
|
|
344
|
+
? target.replace(/\.md$/, `-${n}.md`)
|
|
345
|
+
: `${target.replace(/-\d+$/, '')}-${n}`;
|
|
346
|
+
n += 1;
|
|
347
|
+
} while (existsSync(cand) && n < 100);
|
|
348
|
+
target = cand;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (action === 'diff') {
|
|
352
|
+
ui.out('');
|
|
353
|
+
for (const p of diff.changed) ui.out(` ~ ${p} (changed)`);
|
|
354
|
+
for (const p of diff.added) ui.out(` + ${p} (added)`);
|
|
355
|
+
for (const p of diff.removed) ui.out(` - ${p} (removed)`);
|
|
356
|
+
ui.out('');
|
|
357
|
+
continue; // re-ask
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// step 8 — confirm
|
|
362
|
+
if (interactive) {
|
|
363
|
+
const go = await deps.prompts.confirm({
|
|
364
|
+
message: `Install to ${displayPath(target, deps)}?`,
|
|
365
|
+
});
|
|
366
|
+
if (go !== true) {
|
|
367
|
+
ui.out(' Cancelled. Nothing was installed.');
|
|
368
|
+
return { status: 'cancelled' };
|
|
369
|
+
}
|
|
370
|
+
} else if (!opts.json && !opts.quiet) {
|
|
371
|
+
ui.out(` Installing to ${displayPath(target, deps)}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// step 9 — atomic write (exec bits stripped unless --allow-exec)
|
|
375
|
+
const filesWritten = await atomicWrite({
|
|
376
|
+
workDir,
|
|
377
|
+
manifest,
|
|
378
|
+
target,
|
|
379
|
+
targetIsFile,
|
|
380
|
+
allowExec: Boolean(opts.allowExec),
|
|
381
|
+
beforeRename: deps.beforeRename,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// step 10 — record
|
|
385
|
+
await addInstallRecord(deps.configDir, {
|
|
386
|
+
name: manifest.name,
|
|
387
|
+
agent: manifest.agent || null,
|
|
388
|
+
path: target,
|
|
389
|
+
fingerprint,
|
|
390
|
+
installedAt: new Date().toISOString(),
|
|
391
|
+
source: sourceRecord,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// step 11 — report
|
|
395
|
+
if (opts.json) {
|
|
396
|
+
ui.out(JSON.stringify({
|
|
397
|
+
name: manifest.name,
|
|
398
|
+
type: manifest.type,
|
|
399
|
+
agent: manifest.agent || null,
|
|
400
|
+
installedPath: target,
|
|
401
|
+
filesWritten,
|
|
402
|
+
fingerprint,
|
|
403
|
+
source: sourceRecord,
|
|
404
|
+
}));
|
|
405
|
+
} else if (opts.quiet) {
|
|
406
|
+
ui.out(target);
|
|
407
|
+
} else {
|
|
408
|
+
ui.ok('Verified checksums');
|
|
409
|
+
ui.ok(`Installed to ${displayPath(target, deps)}`);
|
|
410
|
+
if (manifest.agent === 'claude-code') {
|
|
411
|
+
ui.out(` Available in Claude Code as the "${manifest.name}" ${manifest.type} — restart the session to pick it up.`);
|
|
412
|
+
} else {
|
|
413
|
+
ui.out(' Restart the session to pick it up.');
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { status: 'installed', target, filesWritten, fingerprint };
|
|
417
|
+
} finally {
|
|
418
|
+
await rm(workDir, { recursive: true, force: true });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- inspect (§4.3) ---------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
function summaryLine(verified) {
|
|
425
|
+
const { manifest, fingerprint, sender, fpVerified, src } = verified;
|
|
426
|
+
const typeLabel = manifest.type.charAt(0).toUpperCase() + manifest.type.slice(1);
|
|
427
|
+
const parts = [
|
|
428
|
+
`${typeLabel}: ${manifest.name}`,
|
|
429
|
+
manifest.agent || manifest.type,
|
|
430
|
+
plural(manifest.files.length, 'file'),
|
|
431
|
+
humanSize(manifest.totalSize ?? 0),
|
|
432
|
+
];
|
|
433
|
+
if (sender) parts.push(`shared by @${sender}`);
|
|
434
|
+
if (src.kind === 'repo') parts.push(`pinned ${verified.sourceRecord.split('@').pop().slice(0, 7)}`);
|
|
435
|
+
const exp = expiryState(manifest);
|
|
436
|
+
if (exp.state === 'live') {
|
|
437
|
+
parts.push(`advisory expiry in ${exp.days >= 1 ? `${exp.days}d` : `${exp.hours}h`}`);
|
|
438
|
+
} else if (exp.state === 'expired') {
|
|
439
|
+
parts.push(`expired ${plural(exp.days, 'day')} ago`);
|
|
440
|
+
}
|
|
441
|
+
let line = parts.join(' · ');
|
|
442
|
+
line += ` · Fingerprint ${formatFp8(fingerprint)}`;
|
|
443
|
+
if (fpVerified) line += ' ✓ matches the link';
|
|
444
|
+
return line;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export async function runInspect(sourceStr, opts, deps) {
|
|
448
|
+
const ui = deps.ui;
|
|
449
|
+
const verified = await fetchAndVerify(sourceStr, deps);
|
|
450
|
+
const { workDir, manifest } = verified;
|
|
451
|
+
try {
|
|
452
|
+
if (opts.json) {
|
|
453
|
+
ui.out(JSON.stringify({
|
|
454
|
+
name: manifest.name,
|
|
455
|
+
type: manifest.type,
|
|
456
|
+
agent: manifest.agent || null,
|
|
457
|
+
description: manifest.description ?? '',
|
|
458
|
+
files: manifest.files.map(({ path: p, size, sha256, executable }) => ({ path: p, size, sha256, executable })),
|
|
459
|
+
totalSize: manifest.totalSize,
|
|
460
|
+
fingerprint: verified.fingerprint,
|
|
461
|
+
fp8: fp8(verified.fingerprint),
|
|
462
|
+
sender: verified.sender,
|
|
463
|
+
expiresAt: manifest.expiresAt ?? null,
|
|
464
|
+
source: verified.sourceRecord,
|
|
465
|
+
fpVerified: verified.fpVerified,
|
|
466
|
+
dependencies: manifest.dependencies ?? [],
|
|
467
|
+
}, null, 2));
|
|
468
|
+
return { status: 'inspected' };
|
|
469
|
+
}
|
|
470
|
+
if (opts.files) {
|
|
471
|
+
for (const f of manifest.files) {
|
|
472
|
+
ui.out(`${f.path}\t${f.size}${f.executable ? '\t(executable)' : ''}`);
|
|
473
|
+
}
|
|
474
|
+
return { status: 'inspected' };
|
|
475
|
+
}
|
|
476
|
+
ui.out(` ${summaryLine(verified)}`);
|
|
477
|
+
const exp = expiryState(manifest);
|
|
478
|
+
if (exp.state === 'expired') {
|
|
479
|
+
ui.warn('This share is past its advisory expiry — install will refuse it.');
|
|
480
|
+
}
|
|
481
|
+
if (opts.cat) {
|
|
482
|
+
const f = manifest.files.find((x) => x.path === opts.cat);
|
|
483
|
+
if (!f) {
|
|
484
|
+
throw new CliError(
|
|
485
|
+
`No file "${opts.cat}" in this package. Files:\n ${manifest.files.map((x) => x.path).join('\n ')}`,
|
|
486
|
+
2,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const content = await readFile(path.join(workDir, ...f.path.split('/')), 'utf8');
|
|
490
|
+
ui.out('');
|
|
491
|
+
ui.out(` ── ${f.path} ${'─'.repeat(Math.max(4, 56 - f.path.length))}`);
|
|
492
|
+
ui.raw(content.endsWith('\n') ? content : content + '\n');
|
|
493
|
+
ui.out(` ${'─'.repeat(60)}`);
|
|
494
|
+
} else {
|
|
495
|
+
ui.out('');
|
|
496
|
+
renderFileTree(ui, manifest);
|
|
497
|
+
}
|
|
498
|
+
return { status: 'inspected' };
|
|
499
|
+
} finally {
|
|
500
|
+
await rm(workDir, { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
}
|