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/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 → target conflict confirm →
3
- // atomic writerecord. 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).
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, targetIsFile, manifest) {
147
- if (targetIsFile) {
148
+ async function existingTreeFor(target, plan) {
149
+ if (plan.targetIsFile) {
148
150
  const data = await readFile(target);
149
- return [{ path: manifest.files[0].path, sha256: sha256hex(data), size: data.length }];
151
+ return [{ path: plan.files[0].path, sha256: sha256hex(data), size: data.length }];
150
152
  }
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;
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
- // --- target resolution (§4.2 step 6) ------------------------------------------
157
+ // --- destination: agent choice, scope, plan -----------------------------------
158
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' };
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
- if (manifest.type !== 'skill' && manifest.type !== 'command') {
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 — choose a destination with --dir <path>.`,
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 (manifest.type === 'command' && manifest.files.length !== 1) {
170
- throw new CliError('Malformed command package: expected exactly one file.', 1);
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
- 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`;
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: `${sub}`, hint: 'this project' },
189
- { value: 'global', label: `~/${sub}`, hint: 'all projects' },
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 = choice === 'global' ? deps.home : deps.cwd;
195
- scope = choice;
196
- } else if (detectable) {
197
- root = deps.cwd;
198
- scope = 'project';
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
- 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
- );
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
- 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 };
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({ workDir, manifest, target, targetIsFile, allowExec, beforeRename }) {
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 = 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 });
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 manifest.files) {
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
- const data = await readFile(src);
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 (!opts.json && !opts.quiet) {
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 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' };
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 { target, targetIsFile } = resolved;
444
+ let plan = makePlan();
297
445
 
298
446
  // 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);
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 = `ⓘ "${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 }));
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, manifest.files);
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
- `"${manifest.name}" already exists at ${displayPath(target, deps)} and differs ` +
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(`"${manifest.name}" already exists at ${displayPath(target, deps)} and differs:`);
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: 'side', label: `Install side-by-side as "${manifest.name}-2"` },
330
- { value: 'diff', label: 'Show diff' },
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
- do {
343
- cand = targetIsFile
344
- ? target.replace(/\.md$/, `-${n}.md`)
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
- } while (existsSync(cand) && n < 100);
348
- target = cand;
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 (!opts.json && !opts.quiet) {
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
- workDir,
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: manifest.name,
387
- agent: manifest.agent || null,
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: manifest.name,
547
+ name: planName,
398
548
  type: manifest.type,
399
- agent: manifest.agent || null,
400
- installedPath: target,
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
- 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.`);
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 fetchAndVerify(sourceStr, deps);
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.');