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