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