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.
- package/README.md +65 -6
- package/bin/skillshark.js +53 -16
- package/package.json +10 -2
- package/src/agents.js +336 -0
- package/src/discover.js +93 -41
- package/src/gh.js +32 -9
- package/src/install.js +294 -120
- package/src/interactive.js +189 -0
- package/src/share.js +36 -17
- package/src/source.js +33 -9
- package/src/transports/gist.js +60 -18
- package/src/transports/repo.js +54 -21
- package/src/ui.js +25 -0
- package/src/version.js +1 -1
package/src/install.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
// The §4.2 pipeline: fetch → extract (guarded) → verify (sha256 + tree
|
|
2
|
-
// fingerprint + #fp=) → expiry → preview →
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// fingerprint + #fp=) → expiry → preview → destination (agent + scope +
|
|
3
|
+
// optional rename/conversion) → conflict → confirm → atomic write → record.
|
|
4
|
+
// Never executes anything from a package. The receive path never shells out:
|
|
5
|
+
// this module and the transports it uses import no subprocess machinery at
|
|
6
|
+
// all (enforced by test and by the DoD grep).
|
|
6
7
|
import { mkdtemp, mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
7
|
-
import { existsSync } from 'node:fs';
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import os from 'node:os';
|
|
10
11
|
import { CliError, MSG } from './errors.js';
|
|
11
|
-
import { parseSource, formatSource } from './source.js';
|
|
12
|
+
import { parseSource, formatSource, resolveHost } from './source.js';
|
|
12
13
|
import { fetchGistPackage } from './transports/gist.js';
|
|
13
14
|
import { fetchRepoTree } from './transports/repo.js';
|
|
14
15
|
import { extractTarball, hashTree, readManifest, verifyTreeAgainstManifest, MANIFEST_NAME } from './pkg.js';
|
|
15
16
|
import { treeFingerprint, sha256hex, fp8, formatFp8 } from './fingerprint.js';
|
|
16
17
|
import { inferMetadata, findExternalRefs } from './discover.js';
|
|
18
|
+
import { AGENTS, AGENT_IDS, getAgent, detectAgents, extractCanonical, rewriteFrontmatterName, NAME_RE } from './agents.js';
|
|
17
19
|
import { addInstallRecord } from './config.js';
|
|
18
20
|
import { renderPreview, renderFileTree, humanSize, displayPath, plural } from './ui.js';
|
|
19
21
|
import { VERSION } from './version.js';
|
|
@@ -33,12 +35,13 @@ function classifyRepoTree(actual, subPath, repo) {
|
|
|
33
35
|
return { type: 'bundle', agent: '' };
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
export async function fetchAndVerify(sourceStr, deps) {
|
|
37
|
-
const
|
|
38
|
+
export async function fetchAndVerify(sourceStr, deps, opts = {}) {
|
|
39
|
+
const defaultHost = resolveHost(opts, deps);
|
|
40
|
+
const src = parseSource(sourceStr, { defaultHost });
|
|
38
41
|
const workDir = await mkdtemp(path.join(os.tmpdir(), 'skillshark-recv-'));
|
|
39
42
|
|
|
40
43
|
if (src.kind === 'gist') {
|
|
41
|
-
const gist = await fetchGistPackage(src.id, { fetch: deps.fetch });
|
|
44
|
+
const gist = await fetchGistPackage(src.id, { fetch: deps.fetch, host: src.host, ghApi: deps.ghApi });
|
|
42
45
|
await extractTarball(gist.tarball, workDir);
|
|
43
46
|
const manifest = await readManifest(workDir);
|
|
44
47
|
const actual = await hashTree(workDir);
|
|
@@ -53,13 +56,13 @@ export async function fetchAndVerify(sourceStr, deps) {
|
|
|
53
56
|
fingerprint,
|
|
54
57
|
sender: gist.owner,
|
|
55
58
|
fpVerified: Boolean(src.fp),
|
|
56
|
-
sourceRecord:
|
|
59
|
+
sourceRecord: `${formatSource({ ...src })}@${gist.revision ?? 'unknown'}`,
|
|
57
60
|
};
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
// repo: no manifest exists; run the share-side inference on the extracted
|
|
61
64
|
// tree and synthesize one in memory. The commit SHA is the integrity.
|
|
62
|
-
const { sha } = await fetchRepoTree(src, workDir, { fetch: deps.fetch });
|
|
65
|
+
const { sha } = await fetchRepoTree(src, workDir, { fetch: deps.fetch, ghApi: deps.ghApi });
|
|
63
66
|
const actual = await hashTree(workDir, { exclude: [] });
|
|
64
67
|
if (actual.length === 0) {
|
|
65
68
|
throw new CliError(`No files found at gh:${src.owner}/${src.repo}${src.path ? `/${src.path}` : ''}@${sha.slice(0, 7)}.`, 1);
|
|
@@ -143,75 +146,184 @@ export function diffTrees(existingFiles, incomingFiles) {
|
|
|
143
146
|
return { added, changed, removed };
|
|
144
147
|
}
|
|
145
148
|
|
|
146
|
-
async function existingTreeFor(target,
|
|
147
|
-
if (targetIsFile) {
|
|
149
|
+
async function existingTreeFor(target, plan) {
|
|
150
|
+
if (plan.targetIsFile) {
|
|
148
151
|
const data = await readFile(target);
|
|
149
|
-
return [{ path:
|
|
152
|
+
return [{ path: plan.files[0].path, sha256: sha256hex(data), size: data.length }];
|
|
150
153
|
}
|
|
151
|
-
|
|
152
|
-
// comparison must include the one we wrote
|
|
153
|
-
const exclude = manifest.files.some((f) => f.path === MANIFEST_NAME) ? [] : undefined;
|
|
154
|
+
const exclude = plan.files.some((f) => f.path === MANIFEST_NAME) ? [] : undefined;
|
|
154
155
|
return hashTree(target, exclude ? { exclude } : undefined);
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
// ---
|
|
158
|
+
// --- destination: agent choice, scope, plan -----------------------------------
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
160
|
+
function isProjectish(cwd) {
|
|
161
|
+
if (existsSync(path.join(cwd, '.git'))) return true;
|
|
162
|
+
for (const probe of ['.claude', '.cursor', '.codex', '.windsurf', '.gemini', '.opencode', '.github']) {
|
|
163
|
+
if (existsSync(path.join(cwd, probe))) return true;
|
|
162
164
|
}
|
|
163
|
-
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Which agent are we installing for? --agent wins; else the package's native
|
|
169
|
+
// agent when it's detected locally; else (TTY) offer the agents that ARE here.
|
|
170
|
+
async function chooseAgent(manifest, opts, deps, interactive) {
|
|
171
|
+
if (opts.agent) return { agentId: opts.agent }; // validated before the fetch
|
|
172
|
+
const srcAgent = manifest.agent || null;
|
|
173
|
+
if (!srcAgent) {
|
|
164
174
|
throw new CliError(
|
|
165
|
-
`This package is a ${manifest.type} with no agent convention —
|
|
175
|
+
`This package is a ${manifest.type} with no agent convention — re-run with --agent <${AGENT_IDS.join('|')}> or --dir <path>.`,
|
|
166
176
|
2,
|
|
167
177
|
);
|
|
168
178
|
}
|
|
169
|
-
if (
|
|
170
|
-
|
|
179
|
+
if (AGENTS[srcAgent] && AGENTS[srcAgent].detect(deps)) return { agentId: srcAgent };
|
|
180
|
+
const others = detectAgents(deps).filter((id) => id !== srcAgent);
|
|
181
|
+
if (interactive && others.length) {
|
|
182
|
+
const srcLabel = AGENTS[srcAgent]?.label ?? srcAgent;
|
|
183
|
+
deps.ui.warn(`This is a ${srcLabel} ${manifest.type}, but ${srcLabel} isn't detected here. Detected: ${others.map((o) => AGENTS[o].label).join(', ')}.`);
|
|
184
|
+
const choice = await deps.prompts.select({
|
|
185
|
+
message: 'Install for:',
|
|
186
|
+
options: [
|
|
187
|
+
{ value: srcAgent, label: `${srcLabel} anyway`, hint: 'native, creates its directories' },
|
|
188
|
+
...others.map((o) => ({ value: o, label: `${AGENTS[o].label}`, hint: 'converted — instructions only' })),
|
|
189
|
+
{ value: '__cancel', label: 'Cancel' },
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
if (choice === null || choice === '__cancel') return { cancelled: true };
|
|
193
|
+
return { agentId: choice };
|
|
171
194
|
}
|
|
195
|
+
return { agentId: srcAgent }; // non-TTY default: native, predictable
|
|
196
|
+
}
|
|
172
197
|
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
198
|
+
async function chooseScope(agent, kind, name, opts, deps, interactive, loud) {
|
|
199
|
+
const allowed = agent.scopes(kind);
|
|
200
|
+
if (allowed === 'global') {
|
|
201
|
+
if (opts.project) {
|
|
202
|
+
throw new CliError(`${agent.label} ${kind}s live in your home directory only — drop --project.`, 2);
|
|
203
|
+
}
|
|
204
|
+
if (loud) deps.ui.info(`${agent.label} ${kind}s are global — installing under ~/.`);
|
|
205
|
+
return { root: deps.home, scope: 'global' };
|
|
206
|
+
}
|
|
207
|
+
if (allowed === 'project') {
|
|
208
|
+
if (opts.global) {
|
|
209
|
+
throw new CliError(`${agent.label} ${kind}s are project-scoped — run inside the project (drop --global).`, 2);
|
|
210
|
+
}
|
|
211
|
+
if (!opts.project && !interactive && !isProjectish(deps.cwd)) {
|
|
212
|
+
throw new CliError(
|
|
213
|
+
"This directory doesn't look like a project (no .git or agent directories). Re-run with --project to confirm, or --dir <path>.",
|
|
214
|
+
2,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return { root: deps.cwd, scope: 'project' };
|
|
218
|
+
}
|
|
219
|
+
// both
|
|
220
|
+
if (opts.project) return { root: deps.cwd, scope: 'project' };
|
|
221
|
+
if (opts.global) return { root: deps.home, scope: 'global' };
|
|
222
|
+
if (interactive) {
|
|
223
|
+
const projRel = agent.targetRel(kind, name, 'project').join('/');
|
|
224
|
+
const globRel = agent.targetRel(kind, name, 'global').join('/');
|
|
185
225
|
const choice = await deps.prompts.select({
|
|
186
226
|
message: 'Install to:',
|
|
187
227
|
options: [
|
|
188
|
-
{ value: 'project', label:
|
|
189
|
-
{ value: 'global', label: `~/${
|
|
228
|
+
{ value: 'project', label: projRel, hint: 'this project' },
|
|
229
|
+
{ value: 'global', label: `~/${globRel}`, hint: 'all projects' },
|
|
190
230
|
{ value: 'cancel', label: 'cancel' },
|
|
191
231
|
],
|
|
192
232
|
});
|
|
193
233
|
if (choice === null || choice === 'cancel') return { cancelled: true };
|
|
194
|
-
root
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
234
|
+
return { root: choice === 'global' ? deps.home : deps.cwd, scope: choice };
|
|
235
|
+
}
|
|
236
|
+
if (isProjectish(deps.cwd)) return { root: deps.cwd, scope: 'project' };
|
|
237
|
+
throw new CliError(
|
|
238
|
+
"Can't tell if this is a project (no .git or agent directories here). Pass --project, --global, or --dir.",
|
|
239
|
+
2,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Build the exact file set that will land on disk (post-rename, post-conversion).
|
|
244
|
+
function buildPlan({ workDir, manifest, name, agentId, kind, target, deps, loud }) {
|
|
245
|
+
const agent = getAgent(agentId);
|
|
246
|
+
const native = agentId === (manifest.agent || null);
|
|
247
|
+
const container = agent.container(kind);
|
|
248
|
+
const notes = [];
|
|
249
|
+
let files;
|
|
250
|
+
let conversion = null;
|
|
251
|
+
|
|
252
|
+
const readPkgFile = (rel) => readFileSync(path.join(workDir, ...rel.split('/')));
|
|
253
|
+
|
|
254
|
+
if (native && container === 'dir') {
|
|
255
|
+
// native multi-file skill, copied verbatim (modulo rename)
|
|
256
|
+
files = manifest.files.map((f) => ({
|
|
257
|
+
path: f.path,
|
|
258
|
+
data: readPkgFile(f.path),
|
|
259
|
+
executable: Boolean(f.executable),
|
|
260
|
+
}));
|
|
261
|
+
if (name !== manifest.name) {
|
|
262
|
+
const primary = files.find((f) => f.path === 'SKILL.md');
|
|
263
|
+
if (primary) {
|
|
264
|
+
primary.data = Buffer.from(rewriteFrontmatterName(primary.data.toString('utf8'), name));
|
|
265
|
+
notes.push(`Renamed "${manifest.name}" → "${name}" (frontmatter name updated).`);
|
|
266
|
+
} else {
|
|
267
|
+
notes.push(`Renamed "${manifest.name}" → "${name}".`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else if (native && manifest.files.length === 1) {
|
|
271
|
+
// native single-file artifact, verbatim; rename = filename change
|
|
272
|
+
const f = manifest.files[0];
|
|
273
|
+
const filename = path.basename(target);
|
|
274
|
+
let data = readPkgFile(f.path);
|
|
275
|
+
if (name !== manifest.name) {
|
|
276
|
+
data = Buffer.from(rewriteFrontmatterName(data.toString('utf8'), name));
|
|
277
|
+
notes.push(`Renamed "${manifest.name}" → "${name}".`);
|
|
278
|
+
}
|
|
279
|
+
files = [{ path: filename, data, executable: Boolean(f.executable) }];
|
|
199
280
|
} else {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
);
|
|
281
|
+
// conversion (cross-agent, or a multi-file package squeezed into one doc)
|
|
282
|
+
const canonical = extractCanonical(manifest, (p) => readPkgFile(p).toString('utf8'));
|
|
283
|
+
const rendered = agent.render(kind, { name, description: canonical.description, body: canonical.body });
|
|
284
|
+
const filename = container === 'dir' ? rendered.filename : path.basename(target);
|
|
285
|
+
files = [{ path: filename, data: Buffer.from(rendered.content), executable: false }];
|
|
286
|
+
const fromLabel = manifest.agent ? `${AGENTS[manifest.agent]?.label ?? manifest.agent} ${manifest.type}` : manifest.type;
|
|
287
|
+
conversion = { from: manifest.agent || null, to: agentId, dropped: canonical.dropped };
|
|
288
|
+
notes.push(`Converting ${fromLabel} → ${agent.label} ${kind} (best effort — review the result).`);
|
|
289
|
+
if (canonical.dropped.length) {
|
|
290
|
+
notes.push(`${plural(canonical.dropped.length, 'bundled file')} cannot come along: ${canonical.dropped.join(', ')}.`);
|
|
291
|
+
}
|
|
292
|
+
if (name !== manifest.name) notes.push(`Renamed "${manifest.name}" → "${name}".`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const fingerprint = treeFingerprint(files.map((f) => ({ path: f.path, sha256: sha256hex(f.data) })));
|
|
296
|
+
if (loud) {
|
|
297
|
+
for (const note of notes) {
|
|
298
|
+
if (note.startsWith('Convert') || note.includes('cannot come along')) deps.ui.warn(note);
|
|
299
|
+
else deps.ui.info(note);
|
|
300
|
+
}
|
|
204
301
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
302
|
+
return { agentId, kind, target, targetIsFile: container === 'file', files, fingerprint, conversion, notes };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function verbatimDirPlan(workDir, manifest, target) {
|
|
306
|
+
const files = manifest.files.map((f) => ({
|
|
307
|
+
path: f.path,
|
|
308
|
+
data: readFileSync(path.join(workDir, ...f.path.split('/'))),
|
|
309
|
+
executable: Boolean(f.executable),
|
|
310
|
+
}));
|
|
311
|
+
return {
|
|
312
|
+
agentId: manifest.agent || null,
|
|
313
|
+
kind: manifest.type,
|
|
314
|
+
target,
|
|
315
|
+
targetIsFile: false,
|
|
316
|
+
files,
|
|
317
|
+
fingerprint: treeFingerprint(files.map((f) => ({ path: f.path, sha256: sha256hex(f.data) }))),
|
|
318
|
+
conversion: null,
|
|
319
|
+
notes: [],
|
|
320
|
+
};
|
|
210
321
|
}
|
|
211
322
|
|
|
212
323
|
// --- atomic write (§4.2 step 9) ------------------------------------------------
|
|
213
324
|
|
|
214
|
-
async function atomicWrite({
|
|
325
|
+
async function atomicWrite({ plan, allowExec, beforeRename }) {
|
|
326
|
+
const { target, targetIsFile } = plan;
|
|
215
327
|
const parent = path.dirname(target);
|
|
216
328
|
await mkdir(parent, { recursive: true });
|
|
217
329
|
const token = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -219,18 +331,15 @@ async function atomicWrite({ workDir, manifest, target, targetIsFile, allowExec,
|
|
|
219
331
|
let written = 0;
|
|
220
332
|
try {
|
|
221
333
|
if (targetIsFile) {
|
|
222
|
-
const f =
|
|
223
|
-
|
|
224
|
-
await writeFile(stage, data, { mode: allowExec && f.executable ? 0o755 : 0o644 });
|
|
334
|
+
const f = plan.files[0];
|
|
335
|
+
await writeFile(stage, f.data, { mode: allowExec && f.executable ? 0o755 : 0o644 });
|
|
225
336
|
written = 1;
|
|
226
337
|
} else {
|
|
227
338
|
await mkdir(stage);
|
|
228
|
-
for (const f of
|
|
229
|
-
const src = path.join(workDir, ...f.path.split('/'));
|
|
339
|
+
for (const f of plan.files) {
|
|
230
340
|
const dest = path.join(stage, ...f.path.split('/'));
|
|
231
341
|
await mkdir(path.dirname(dest), { recursive: true });
|
|
232
|
-
|
|
233
|
-
await writeFile(dest, data, { mode: allowExec && f.executable ? 0o755 : 0o644 });
|
|
342
|
+
await writeFile(dest, f.data, { mode: allowExec && f.executable ? 0o755 : 0o644 });
|
|
234
343
|
written += 1;
|
|
235
344
|
}
|
|
236
345
|
}
|
|
@@ -264,9 +373,14 @@ export async function runInstall(sourceStr, opts, deps) {
|
|
|
264
373
|
2,
|
|
265
374
|
);
|
|
266
375
|
}
|
|
376
|
+
if (opts.name && !NAME_RE.test(opts.name)) {
|
|
377
|
+
throw new CliError(`--name must be a simple name (letters, digits, ".", "_", "-"), got "${opts.name}".`, 2);
|
|
378
|
+
}
|
|
379
|
+
if (opts.agent) getAgent(opts.agent); // validate early, before the network
|
|
267
380
|
const interactive = deps.isTTY && !opts.yes;
|
|
381
|
+
const loud = !opts.json && !opts.quiet;
|
|
268
382
|
const ui = deps.ui;
|
|
269
|
-
const verified = await fetchAndVerify(sourceStr, deps);
|
|
383
|
+
const verified = await ui.spin('Fetching and verifying the package', () => fetchAndVerify(sourceStr, deps, opts));
|
|
270
384
|
const { workDir, manifest, fingerprint, sourceRecord } = verified;
|
|
271
385
|
|
|
272
386
|
try {
|
|
@@ -282,52 +396,86 @@ export async function runInstall(sourceStr, opts, deps) {
|
|
|
282
396
|
.filter((f) => f.path.endsWith('.md'))
|
|
283
397
|
.map((f) => ({ ...f, abs: path.join(workDir, ...f.path.split('/')) })),
|
|
284
398
|
);
|
|
285
|
-
if (
|
|
399
|
+
if (loud) {
|
|
286
400
|
renderPreview(ui, { manifest, fingerprint, fpFromLink: verified.fpVerified, externalRefs });
|
|
287
401
|
ui.out('');
|
|
288
402
|
}
|
|
289
403
|
|
|
290
|
-
// step 6 — agent
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
404
|
+
// step 6 — destination: --dir verbatim, or agent + scope (+ rename/conversion)
|
|
405
|
+
let makePlan;
|
|
406
|
+
if (opts.dir) {
|
|
407
|
+
if (opts.name && opts.name !== manifest.name) {
|
|
408
|
+
ui.warn('--name is ignored with --dir (the directory you chose is the name).');
|
|
409
|
+
}
|
|
410
|
+
const target = path.resolve(deps.cwd, opts.dir);
|
|
411
|
+
makePlan = () => verbatimDirPlan(workDir, manifest, target);
|
|
412
|
+
} else {
|
|
413
|
+
const agentChoice = await chooseAgent(manifest, opts, deps, interactive);
|
|
414
|
+
if (agentChoice.cancelled) {
|
|
415
|
+
ui.out(' Cancelled. Nothing was installed.');
|
|
416
|
+
return { status: 'cancelled' };
|
|
417
|
+
}
|
|
418
|
+
const agentId = agentChoice.agentId;
|
|
419
|
+
const agent = getAgent(agentId);
|
|
420
|
+
const kind = agent.mapKind(manifest.type);
|
|
421
|
+
if (!kind) {
|
|
422
|
+
throw new CliError(
|
|
423
|
+
`A ${manifest.type} package has no ${agent.label} representation — install it with --dir <path>.`,
|
|
424
|
+
2,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const baseName = opts.name ?? manifest.name;
|
|
428
|
+
const scope = await chooseScope(agent, kind, baseName, opts, deps, interactive, loud);
|
|
429
|
+
if (scope.cancelled) {
|
|
430
|
+
ui.out(' Cancelled. Nothing was installed.');
|
|
431
|
+
return { status: 'cancelled' };
|
|
432
|
+
}
|
|
433
|
+
makePlan = (n = baseName, quiet = false) =>
|
|
434
|
+
buildPlan({
|
|
435
|
+
workDir,
|
|
436
|
+
manifest,
|
|
437
|
+
name: n,
|
|
438
|
+
agentId,
|
|
439
|
+
kind,
|
|
440
|
+
target: path.join(scope.root, ...agent.targetRel(kind, n, scope.scope)),
|
|
441
|
+
deps,
|
|
442
|
+
loud: loud && !quiet,
|
|
443
|
+
});
|
|
295
444
|
}
|
|
296
|
-
let
|
|
445
|
+
let plan = makePlan();
|
|
297
446
|
|
|
298
447
|
// step 7 — conflict
|
|
299
|
-
let
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const existingFiles = await existingTreeFor(target, targetIsFile, manifest);
|
|
448
|
+
let planName = opts.name ?? manifest.name;
|
|
449
|
+
for (;;) {
|
|
450
|
+
if (!existsSync(plan.target)) break;
|
|
451
|
+
const existingFiles = await existingTreeFor(plan.target, plan);
|
|
304
452
|
if (existingFiles.length === 0) break; // empty dir → nothing to conflict with
|
|
305
453
|
const existingFp = treeFingerprint(existingFiles);
|
|
306
|
-
if (existingFp === fingerprint) {
|
|
307
|
-
const msg = `ⓘ "${
|
|
308
|
-
if (opts.json) ui.out(JSON.stringify({ status: 'identical', name:
|
|
454
|
+
if (existingFp === plan.fingerprint) {
|
|
455
|
+
const msg = `ⓘ "${planName}" is already installed at ${displayPath(plan.target, deps)} and is identical (${formatFp8(plan.fingerprint)}). Nothing to do.`;
|
|
456
|
+
if (opts.json) ui.out(JSON.stringify({ status: 'identical', name: planName, installedPath: plan.target }));
|
|
309
457
|
else ui.out(` ${msg}`);
|
|
310
|
-
return { status: 'identical', target };
|
|
458
|
+
return { status: 'identical', target: plan.target };
|
|
311
459
|
}
|
|
312
|
-
const diff = diffTrees(existingFiles,
|
|
460
|
+
const diff = diffTrees(existingFiles, plan.files.map((f) => ({ path: f.path, sha256: sha256hex(f.data) })));
|
|
313
461
|
if (!interactive) {
|
|
314
462
|
if (opts.force) break;
|
|
315
463
|
throw new CliError(
|
|
316
|
-
`"${
|
|
464
|
+
`"${planName}" already exists at ${displayPath(plan.target, deps)} and differs ` +
|
|
317
465
|
`(+${diff.added.length} ~${diff.changed.length} -${diff.removed.length}). Re-run with --force to overwrite.`,
|
|
318
466
|
1,
|
|
319
467
|
);
|
|
320
468
|
}
|
|
321
|
-
ui.warn(`"${
|
|
469
|
+
ui.warn(`"${planName}" already exists at ${displayPath(plan.target, deps)} and differs:`);
|
|
322
470
|
for (const p of diff.changed) ui.out(` ~ ${p} (changed)`);
|
|
323
471
|
for (const p of diff.added) ui.out(` + ${p} (added)`);
|
|
324
472
|
for (const p of diff.removed) ui.out(` - ${p} (removed)`);
|
|
325
473
|
const action = await deps.prompts.select({
|
|
326
474
|
message: 'What now?',
|
|
327
475
|
options: [
|
|
328
|
-
{ value: 'overwrite', label: `Overwrite ${displayPath(target, deps)}` },
|
|
329
|
-
{ value: '
|
|
330
|
-
{ value: '
|
|
476
|
+
{ value: 'overwrite', label: `Overwrite ${displayPath(plan.target, deps)}` },
|
|
477
|
+
{ value: 'rename', label: 'Install under a different name…' },
|
|
478
|
+
{ value: 'side', label: `Install side-by-side as "${planName}-2"` },
|
|
331
479
|
{ value: 'cancel', label: 'Cancel' },
|
|
332
480
|
],
|
|
333
481
|
});
|
|
@@ -336,84 +484,95 @@ export async function runInstall(sourceStr, opts, deps) {
|
|
|
336
484
|
return { status: 'cancelled' };
|
|
337
485
|
}
|
|
338
486
|
if (action === 'overwrite') break;
|
|
487
|
+
if (action === 'rename') {
|
|
488
|
+
const entered = deps.prompts.text
|
|
489
|
+
? await deps.prompts.text({ message: 'New name:', placeholder: `${planName}-2` })
|
|
490
|
+
: null;
|
|
491
|
+
if (!entered || !NAME_RE.test(entered)) {
|
|
492
|
+
ui.warn('Names are letters, digits, ".", "_", "-". Try again.');
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
planName = entered;
|
|
496
|
+
plan = makePlan(planName, true);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
339
499
|
if (action === 'side') {
|
|
340
500
|
let n = 2;
|
|
341
|
-
let cand
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
: `${target.replace(/-\d+$/, '')}-${n}`;
|
|
501
|
+
let cand = `${planName}-${n}`;
|
|
502
|
+
while (n < 100) {
|
|
503
|
+
const p = makePlan(cand, true);
|
|
504
|
+
if (!existsSync(p.target)) break;
|
|
346
505
|
n += 1;
|
|
347
|
-
|
|
348
|
-
|
|
506
|
+
cand = `${planName}-${n}`;
|
|
507
|
+
}
|
|
508
|
+
planName = cand;
|
|
509
|
+
plan = makePlan(planName, true);
|
|
349
510
|
continue;
|
|
350
511
|
}
|
|
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
512
|
}
|
|
360
513
|
|
|
361
514
|
// step 8 — confirm
|
|
362
515
|
if (interactive) {
|
|
363
516
|
const go = await deps.prompts.confirm({
|
|
364
|
-
message: `Install to ${displayPath(target, deps)}?`,
|
|
517
|
+
message: `Install to ${displayPath(plan.target, deps)}?`,
|
|
365
518
|
});
|
|
366
519
|
if (go !== true) {
|
|
367
520
|
ui.out(' Cancelled. Nothing was installed.');
|
|
368
521
|
return { status: 'cancelled' };
|
|
369
522
|
}
|
|
370
|
-
} else if (
|
|
371
|
-
ui.out(` Installing to ${displayPath(target, deps)}`);
|
|
523
|
+
} else if (loud) {
|
|
524
|
+
ui.out(` Installing to ${displayPath(plan.target, deps)}`);
|
|
372
525
|
}
|
|
373
526
|
|
|
374
527
|
// step 9 — atomic write (exec bits stripped unless --allow-exec)
|
|
375
528
|
const filesWritten = await atomicWrite({
|
|
376
|
-
|
|
377
|
-
manifest,
|
|
378
|
-
target,
|
|
379
|
-
targetIsFile,
|
|
529
|
+
plan,
|
|
380
530
|
allowExec: Boolean(opts.allowExec),
|
|
381
531
|
beforeRename: deps.beforeRename,
|
|
382
532
|
});
|
|
383
533
|
|
|
384
534
|
// step 10 — record
|
|
385
535
|
await addInstallRecord(deps.configDir, {
|
|
386
|
-
name:
|
|
387
|
-
agent:
|
|
388
|
-
path: target,
|
|
389
|
-
fingerprint,
|
|
536
|
+
name: planName,
|
|
537
|
+
agent: plan.agentId,
|
|
538
|
+
path: plan.target,
|
|
539
|
+
fingerprint: plan.fingerprint,
|
|
390
540
|
installedAt: new Date().toISOString(),
|
|
391
541
|
source: sourceRecord,
|
|
542
|
+
...(plan.conversion ? { convertedFrom: plan.conversion.from } : {}),
|
|
392
543
|
});
|
|
393
544
|
|
|
394
545
|
// step 11 — report
|
|
395
546
|
if (opts.json) {
|
|
396
547
|
ui.out(JSON.stringify({
|
|
397
|
-
name:
|
|
548
|
+
name: planName,
|
|
398
549
|
type: manifest.type,
|
|
399
|
-
agent:
|
|
400
|
-
|
|
550
|
+
agent: plan.agentId,
|
|
551
|
+
kind: plan.kind,
|
|
552
|
+
installedPath: plan.target,
|
|
401
553
|
filesWritten,
|
|
402
|
-
fingerprint,
|
|
554
|
+
fingerprint: plan.fingerprint,
|
|
403
555
|
source: sourceRecord,
|
|
556
|
+
convertedFrom: plan.conversion?.from ?? null,
|
|
557
|
+
renamedFrom: planName !== manifest.name ? manifest.name : null,
|
|
404
558
|
}));
|
|
405
559
|
} else if (opts.quiet) {
|
|
406
|
-
ui.out(target);
|
|
560
|
+
ui.out(plan.target);
|
|
407
561
|
} else {
|
|
408
562
|
ui.ok('Verified checksums');
|
|
409
|
-
ui.ok(`Installed to ${displayPath(target, deps)}`);
|
|
410
|
-
|
|
411
|
-
|
|
563
|
+
ui.ok(`Installed to ${displayPath(plan.target, deps)}`);
|
|
564
|
+
const label = plan.agentId ? (AGENTS[plan.agentId]?.label ?? plan.agentId) : null;
|
|
565
|
+
if (plan.agentId === 'claude-code') {
|
|
566
|
+
ui.out(` Available in Claude Code as the "${planName}" ${plan.kind} — restart the session to pick it up.`);
|
|
567
|
+
} else if (label && plan.kind === 'rule') {
|
|
568
|
+
ui.out(` Active in ${label} as the "${planName}" rule — restart the session to pick it up.`);
|
|
569
|
+
} else if (label) {
|
|
570
|
+
ui.out(` Available in ${label} as /${planName} — restart the session to pick it up.`);
|
|
412
571
|
} else {
|
|
413
572
|
ui.out(' Restart the session to pick it up.');
|
|
414
573
|
}
|
|
415
574
|
}
|
|
416
|
-
return { status: 'installed', target, filesWritten, fingerprint };
|
|
575
|
+
return { status: 'installed', target: plan.target, filesWritten, fingerprint: plan.fingerprint, plan };
|
|
417
576
|
} finally {
|
|
418
577
|
await rm(workDir, { recursive: true, force: true });
|
|
419
578
|
}
|
|
@@ -444,9 +603,21 @@ function summaryLine(verified) {
|
|
|
444
603
|
return line;
|
|
445
604
|
}
|
|
446
605
|
|
|
606
|
+
function installTargetsLine(manifest) {
|
|
607
|
+
const native = manifest.agent && AGENTS[manifest.agent] ? manifest.agent : null;
|
|
608
|
+
const convertible = AGENT_IDS.filter((id) => id !== native && AGENTS[id].mapKind(manifest.type) !== null);
|
|
609
|
+
const parts = [];
|
|
610
|
+
if (native) parts.push(`${native} (native)`);
|
|
611
|
+
if (convertible.length) parts.push(`convertible → ${convertible.join(', ')}`);
|
|
612
|
+
if (!parts.length) return null;
|
|
613
|
+
return `Installs to: ${parts.join(' · ')}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
447
616
|
export async function runInspect(sourceStr, opts, deps) {
|
|
448
617
|
const ui = deps.ui;
|
|
449
|
-
const verified = await
|
|
618
|
+
const verified = await (opts.json || opts.files
|
|
619
|
+
? fetchAndVerify(sourceStr, deps, opts)
|
|
620
|
+
: ui.spin('Fetching and verifying the package', () => fetchAndVerify(sourceStr, deps, opts)));
|
|
450
621
|
const { workDir, manifest } = verified;
|
|
451
622
|
try {
|
|
452
623
|
if (opts.json) {
|
|
@@ -464,6 +635,7 @@ export async function runInspect(sourceStr, opts, deps) {
|
|
|
464
635
|
source: verified.sourceRecord,
|
|
465
636
|
fpVerified: verified.fpVerified,
|
|
466
637
|
dependencies: manifest.dependencies ?? [],
|
|
638
|
+
installTargets: AGENT_IDS.filter((id) => AGENTS[id].mapKind(manifest.type) !== null),
|
|
467
639
|
}, null, 2));
|
|
468
640
|
return { status: 'inspected' };
|
|
469
641
|
}
|
|
@@ -474,6 +646,8 @@ export async function runInspect(sourceStr, opts, deps) {
|
|
|
474
646
|
return { status: 'inspected' };
|
|
475
647
|
}
|
|
476
648
|
ui.out(` ${summaryLine(verified)}`);
|
|
649
|
+
const targets = installTargetsLine(manifest);
|
|
650
|
+
if (targets) ui.out(` ${targets}`);
|
|
477
651
|
const exp = expiryState(manifest);
|
|
478
652
|
if (exp.state === 'expired') {
|
|
479
653
|
ui.warn('This share is past its advisory expiry — install will refuse it.');
|