@skilly-hand/skilly-hand 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -16,6 +16,24 @@ All notable changes to this project are documented in this file.
16
16
  ### Removed
17
17
  - _None._
18
18
 
19
+ ## [0.5.0] - 2026-04-03
20
+ [View on npm](https://www.npmjs.com/package/@skilly-hand/skilly-hand/v/0.5.0)
21
+
22
+ ### Added
23
+ - Interactive command launcher when running `npx skilly-hand` in a TTY, including install skill/agent selection flow.
24
+ - New `selectedSkillIds` install path for explicitly choosing portable skills.
25
+ - Comprehensive CLI interaction tests in `tests/interactive-cli.test.js`.
26
+
27
+ ### Changed
28
+ - Help, docs, and install/uninstall confirmation messaging now reflect current behavior and naming (`skilly-hand` branding).
29
+ - CLI bin execution mode and command routing were refactored into testable `runCli`/service helpers.
30
+
31
+ ### Fixed
32
+ - Non-interactive invocation without a command now defaults to install output instead of opening prompts.
33
+
34
+ ### Removed
35
+ - _None._
36
+
19
37
  ## [0.4.0] - 2026-04-03
20
38
  [View on npm](https://www.npmjs.com/package/@skilly-hand/skilly-hand/v/0.4.0)
21
39
 
package/README.md CHANGED
@@ -37,6 +37,8 @@ npm install
37
37
  npx skilly-hand
38
38
  ```
39
39
 
40
+ `npx skilly-hand` opens an interactive command launcher when running in a TTY.
41
+
40
42
  ---
41
43
 
42
44
  ## Commands
@@ -49,6 +51,14 @@ npx skilly-hand
49
51
  | `npx skilly-hand doctor` | Diagnose installation and configuration issues |
50
52
  | `npx skilly-hand uninstall` | Remove installed skills |
51
53
 
54
+ ### Common Flags
55
+
56
+ | Flag | Description |
57
+ | ---- | ----------- |
58
+ | `--json` | Emit machine-readable output and disable interactive prompts |
59
+ | `--yes`, `-y` | Skip confirmation prompts for mutating commands (`install`, `uninstall`) |
60
+ | `--dry-run` | Preview install plan without writing files |
61
+
52
62
  ---
53
63
 
54
64
  ## Current Portable Catalog
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilly-hand/skilly-hand",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -40,5 +40,8 @@
40
40
  "detect": "node ./packages/cli/src/bin.js detect",
41
41
  "list": "node ./packages/cli/src/bin.js list",
42
42
  "doctor": "node ./packages/cli/src/bin.js doctor"
43
+ },
44
+ "dependencies": {
45
+ "@inquirer/prompts": "^7.10.1"
43
46
  }
44
47
  }
@@ -1,17 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
3
  import { createRequire } from "node:module";
4
+ import { pathToFileURL } from "node:url";
5
+ import { checkbox as inquirerCheckbox, confirm as inquirerConfirm, select as inquirerSelect } from "@inquirer/prompts";
4
6
  import { loadAllSkills } from "../../catalog/src/index.js";
5
- import { installProject, runDoctor, uninstallProject } from "../../core/src/index.js";
7
+ import {
8
+ DEFAULT_AGENTS,
9
+ installProject,
10
+ resolveSkillSelection,
11
+ runDoctor,
12
+ uninstallProject
13
+ } from "../../core/src/index.js";
6
14
  import { createTerminalRenderer } from "../../core/src/terminal.js";
7
15
  import { detectProject } from "../../detectors/src/index.js";
8
16
 
9
17
  const require = createRequire(import.meta.url);
10
18
  const { version } = require("../../../package.json");
11
19
 
12
- const renderer = createTerminalRenderer();
20
+ function isExecutedDirectly(metaUrl, argv1) {
21
+ if (!argv1) return false;
22
+ return metaUrl === pathToFileURL(argv1).href;
23
+ }
13
24
 
14
- function parseArgs(argv) {
25
+ export function parseArgs(argv) {
15
26
  const args = [...argv];
16
27
  const positional = [];
17
28
  const flags = {
@@ -54,9 +65,10 @@ function parseArgs(argv) {
54
65
  return { command: positional[0], flags };
55
66
  }
56
67
 
57
- function buildHelpText() {
68
+ function buildHelpText(renderer, appVersion) {
58
69
  const usage = renderer.section("Usage", renderer.list([
59
- "npx skilly-hand [install]",
70
+ "npx skilly-hand # interactive launcher when running in a TTY",
71
+ "npx skilly-hand install",
60
72
  "npx skilly-hand detect",
61
73
  "npx skilly-hand list",
62
74
  "npx skilly-hand doctor",
@@ -66,7 +78,7 @@ function buildHelpText() {
66
78
  const flags = renderer.section("Flags", renderer.list([
67
79
  "--dry-run Show install plan without writing files",
68
80
  "--json Emit stable JSON output for automation",
69
- "--yes, -y Reserved for future non-interactive confirmations",
81
+ "--yes, -y Skip install/uninstall confirmations",
70
82
  "--verbose, -v Reserved for future debug detail",
71
83
  "--agent, -a <name> codex|claude|cursor|gemini|copilot (repeatable)",
72
84
  "--cwd <path> Project root (defaults to current directory)",
@@ -76,21 +88,22 @@ function buildHelpText() {
76
88
  ], { bullet: "-" }));
77
89
 
78
90
  const examples = renderer.section("Examples", renderer.list([
91
+ "npx skilly-hand",
79
92
  "npx skilly-hand install --dry-run",
80
93
  "npx skilly-hand detect --json",
81
94
  "npx skilly-hand install --agent codex --agent claude",
82
- "npx skilly-hand list --include workflow"
95
+ "npx skilly-hand uninstall --yes"
83
96
  ], { bullet: "-" }));
84
97
 
85
98
  return renderer.joinBlocks([
86
- renderer.banner(version),
99
+ renderer.banner(appVersion),
87
100
  usage,
88
101
  flags,
89
102
  examples
90
103
  ]);
91
104
  }
92
105
 
93
- function printInstallResult(result, flags) {
106
+ function printInstallResult(renderer, appVersion, result, flags) {
94
107
  const mode = flags.dryRun ? "dry-run" : "apply";
95
108
  const preflight = renderer.section(
96
109
  "Install Preflight",
@@ -135,19 +148,19 @@ function printInstallResult(result, flags) {
135
148
 
136
149
  const nextSteps = result.applied
137
150
  ? renderer.nextSteps([
138
- "Review generated AGENTS and assistant instruction files.",
139
- "Run `npx skilly-hand doctor` to validate installation health.",
140
- "Use `npx skilly-hand uninstall` to restore backed-up files if needed."
141
- ])
151
+ "Review generated AGENTS and assistant instruction files.",
152
+ "Run `npx skilly-hand doctor` to validate installation health.",
153
+ "Use `npx skilly-hand uninstall` to restore backed-up files if needed."
154
+ ])
142
155
  : renderer.nextSteps([
143
- "Run `npx skilly-hand install` to apply this plan.",
144
- "Adjust `--include` and `--exclude` tags to tune skill selection."
145
- ]);
156
+ "Run `npx skilly-hand install` to apply this plan.",
157
+ "Adjust `--include` and `--exclude` tags to tune skill selection."
158
+ ]);
146
159
 
147
- renderer.write(renderer.joinBlocks([renderer.banner(version), preflight, detections, skills, status, nextSteps]));
160
+ renderer.write(renderer.joinBlocks([renderer.banner(appVersion), preflight, detections, skills, status, nextSteps]));
148
161
  }
149
162
 
150
- function printDetectResult(cwd, detections) {
163
+ function printDetectResult(renderer, cwd, detections) {
151
164
  const summary = renderer.section(
152
165
  "Detection Summary",
153
166
  renderer.kv([
@@ -166,7 +179,7 @@ function printDetectResult(cwd, detections) {
166
179
  renderer.write(renderer.joinBlocks([summary, findings]));
167
180
  }
168
181
 
169
- function printListResult(skills) {
182
+ function printListResult(renderer, skills) {
170
183
  const summary = renderer.section(
171
184
  "Catalog Summary",
172
185
  renderer.kv([["Skills available", String(skills.length)]])
@@ -193,7 +206,7 @@ function printListResult(skills) {
193
206
  renderer.write(renderer.joinBlocks([summary, table]));
194
207
  }
195
208
 
196
- function printDoctorResult(result) {
209
+ function printDoctorResult(renderer, result) {
197
210
  const badge = renderer.healthBadge(result.installed);
198
211
 
199
212
  const summary = renderer.section(
@@ -239,7 +252,7 @@ function printDoctorResult(result) {
239
252
  renderer.write(renderer.joinBlocks([badge, summary, lock, issues, probes]));
240
253
  }
241
254
 
242
- function printUninstallResult(result) {
255
+ function printUninstallResult(renderer, result) {
243
256
  if (result.removed) {
244
257
  renderer.write(
245
258
  renderer.joinBlocks([
@@ -261,34 +274,193 @@ function printUninstallResult(result) {
261
274
  );
262
275
  }
263
276
 
264
- async function main() {
265
- const { command, flags } = parseArgs(process.argv.slice(2));
277
+ export function buildErrorHint(message) {
278
+ if (message.startsWith("Unknown command:")) {
279
+ return "Run `npx skilly-hand --help` to see available commands.";
280
+ }
281
+ if (message.startsWith("Unknown flag:") || message.startsWith("Missing value")) {
282
+ return "Check command flags with `npx skilly-hand --help`.";
283
+ }
284
+ return "Retry with `--verbose` for expanded context if needed.";
285
+ }
266
286
 
267
- if (flags.help) {
268
- if (flags.json) {
269
- renderer.writeJson({
270
- command: command || "install",
271
- help: true,
272
- usage: [
273
- "npx skilly-hand [install]",
274
- "npx skilly-hand detect",
275
- "npx skilly-hand list",
276
- "npx skilly-hand doctor",
277
- "npx skilly-hand uninstall"
278
- ]
279
- });
280
- return;
281
- }
287
+ export function createPromptAdapter({ selectImpl, checkboxImpl, confirmImpl } = {}) {
288
+ return {
289
+ select: selectImpl || inquirerSelect,
290
+ checkbox: checkboxImpl || inquirerCheckbox,
291
+ confirm: confirmImpl || inquirerConfirm
292
+ };
293
+ }
294
+
295
+ function createServices(overrides = {}) {
296
+ return {
297
+ loadAllSkills,
298
+ installProject,
299
+ resolveSkillSelection,
300
+ runDoctor,
301
+ uninstallProject,
302
+ detectProject,
303
+ defaultAgents: DEFAULT_AGENTS,
304
+ ...overrides
305
+ };
306
+ }
307
+
308
+ function isInteractiveLauncherMode({ command, flags, stdout }) {
309
+ return !command && !flags.json && Boolean(stdout?.isTTY);
310
+ }
311
+
312
+ async function runInteractiveInstall({
313
+ cwd,
314
+ renderer,
315
+ prompt,
316
+ services,
317
+ appVersion
318
+ }) {
319
+ const [catalog, detections] = await Promise.all([
320
+ services.loadAllSkills(),
321
+ services.detectProject(cwd)
322
+ ]);
323
+ const portableCatalog = catalog.filter((skill) => skill.portable).sort((a, b) => a.id.localeCompare(b.id));
324
+ const preselected = new Set(
325
+ services
326
+ .resolveSkillSelection({ catalog, detections, includeTags: [], excludeTags: [] })
327
+ .map((skill) => skill.id)
328
+ );
329
+
330
+ const selectedSkillIds = await prompt.checkbox({
331
+ message: "Select skills to install",
332
+ choices: portableCatalog.map((skill) => ({
333
+ value: skill.id,
334
+ name: `${skill.id} - ${skill.title}`,
335
+ checked: preselected.has(skill.id)
336
+ }))
337
+ });
338
+
339
+ const selectedAgents = await prompt.checkbox({
340
+ message: "Select AI assistants to configure",
341
+ choices: services.defaultAgents.map((agent) => ({
342
+ value: agent,
343
+ name: agent,
344
+ checked: true
345
+ }))
346
+ });
347
+
348
+ const preview = await services.installProject({
349
+ cwd,
350
+ agents: selectedAgents,
351
+ dryRun: true,
352
+ selectedSkillIds
353
+ });
354
+
355
+ printInstallResult(renderer, appVersion, preview, {
356
+ dryRun: true,
357
+ include: [],
358
+ exclude: []
359
+ });
282
360
 
283
- renderer.write(buildHelpText());
361
+ const shouldApply = await prompt.confirm({
362
+ message: "Apply installation changes now?",
363
+ default: true
364
+ });
365
+
366
+ if (!shouldApply) {
367
+ renderer.write(renderer.status("info", "Installation cancelled.", "No files were written."));
284
368
  return;
285
369
  }
286
370
 
287
- const cwd = path.resolve(flags.cwd || process.cwd());
288
- const effectiveCommand = command || "install";
371
+ const applied = await services.installProject({
372
+ cwd,
373
+ agents: selectedAgents,
374
+ dryRun: false,
375
+ selectedSkillIds
376
+ });
377
+
378
+ printInstallResult(renderer, appVersion, applied, {
379
+ dryRun: false,
380
+ include: [],
381
+ exclude: []
382
+ });
383
+ }
384
+
385
+ async function runInteractiveSession({
386
+ cwd,
387
+ renderer,
388
+ prompt,
389
+ services,
390
+ appVersion
391
+ }) {
392
+ renderer.write(renderer.banner(appVersion));
393
+
394
+ while (true) {
395
+ const selection = await prompt.select({
396
+ message: "Select a command",
397
+ choices: [
398
+ { value: "install", name: "Install" },
399
+ { value: "detect", name: "Detect" },
400
+ { value: "list", name: "List" },
401
+ { value: "doctor", name: "Doctor" },
402
+ { value: "uninstall", name: "Uninstall" },
403
+ { value: "exit", name: "Exit" }
404
+ ]
405
+ });
406
+
407
+ if (selection === "exit") {
408
+ renderer.write(renderer.status("info", "Exited skilly-hand interactive mode."));
409
+ return;
410
+ }
411
+
412
+ if (selection === "install") {
413
+ await runInteractiveInstall({ cwd, renderer, prompt, services, appVersion });
414
+ continue;
415
+ }
416
+
417
+ if (selection === "detect") {
418
+ const detections = await services.detectProject(cwd);
419
+ printDetectResult(renderer, cwd, detections);
420
+ continue;
421
+ }
422
+
423
+ if (selection === "list") {
424
+ const skills = await services.loadAllSkills();
425
+ printListResult(renderer, skills);
426
+ continue;
427
+ }
428
+
429
+ if (selection === "doctor") {
430
+ const result = await services.runDoctor(cwd);
431
+ printDoctorResult(renderer, result);
432
+ continue;
433
+ }
289
434
 
290
- if (effectiveCommand === "detect") {
291
- const detections = await detectProject(cwd);
435
+ if (selection === "uninstall") {
436
+ const confirmed = await prompt.confirm({
437
+ message: "Remove the skilly-hand installation from this project?",
438
+ default: false
439
+ });
440
+
441
+ if (!confirmed) {
442
+ renderer.write(renderer.status("info", "Uninstall cancelled."));
443
+ continue;
444
+ }
445
+
446
+ const result = await services.uninstallProject(cwd);
447
+ printUninstallResult(renderer, result);
448
+ }
449
+ }
450
+ }
451
+
452
+ async function runCommand({
453
+ command,
454
+ flags,
455
+ cwd,
456
+ stdout,
457
+ renderer,
458
+ prompt,
459
+ services,
460
+ appVersion
461
+ }) {
462
+ if (command === "detect") {
463
+ const detections = await services.detectProject(cwd);
292
464
  if (flags.json) {
293
465
  renderer.writeJson({
294
466
  command: "detect",
@@ -298,12 +470,12 @@ async function main() {
298
470
  });
299
471
  return;
300
472
  }
301
- printDetectResult(cwd, detections);
473
+ printDetectResult(renderer, cwd, detections);
302
474
  return;
303
475
  }
304
476
 
305
- if (effectiveCommand === "list") {
306
- const skills = await loadAllSkills();
477
+ if (command === "list") {
478
+ const skills = await services.loadAllSkills();
307
479
  if (flags.json) {
308
480
  renderer.writeJson({
309
481
  command: "list",
@@ -312,12 +484,12 @@ async function main() {
312
484
  });
313
485
  return;
314
486
  }
315
- printListResult(skills);
487
+ printListResult(renderer, skills);
316
488
  return;
317
489
  }
318
490
 
319
- if (effectiveCommand === "doctor") {
320
- const result = await runDoctor(cwd);
491
+ if (command === "doctor") {
492
+ const result = await services.runDoctor(cwd);
321
493
  if (flags.json) {
322
494
  renderer.writeJson({
323
495
  command: "doctor",
@@ -325,12 +497,23 @@ async function main() {
325
497
  });
326
498
  return;
327
499
  }
328
- printDoctorResult(result);
500
+ printDoctorResult(renderer, result);
329
501
  return;
330
502
  }
331
503
 
332
- if (effectiveCommand === "uninstall") {
333
- const result = await uninstallProject(cwd);
504
+ if (command === "uninstall") {
505
+ if (!flags.json && !flags.yes && Boolean(stdout?.isTTY)) {
506
+ const confirmed = await prompt.confirm({
507
+ message: "Remove the skilly-hand installation from this project?",
508
+ default: false
509
+ });
510
+ if (!confirmed) {
511
+ renderer.write(renderer.status("info", "Uninstall cancelled."));
512
+ return;
513
+ }
514
+ }
515
+
516
+ const result = await services.uninstallProject(cwd);
334
517
  if (flags.json) {
335
518
  renderer.writeJson({
336
519
  command: "uninstall",
@@ -338,12 +521,23 @@ async function main() {
338
521
  });
339
522
  return;
340
523
  }
341
- printUninstallResult(result);
524
+ printUninstallResult(renderer, result);
342
525
  return;
343
526
  }
344
527
 
345
- if (effectiveCommand === "install") {
346
- const result = await installProject({
528
+ if (command === "install") {
529
+ if (!flags.dryRun && !flags.json && !flags.yes && Boolean(stdout?.isTTY)) {
530
+ const confirmed = await prompt.confirm({
531
+ message: "Apply installation changes to this project?",
532
+ default: true
533
+ });
534
+ if (!confirmed) {
535
+ renderer.write(renderer.status("info", "Installation cancelled.", "No files were written."));
536
+ return;
537
+ }
538
+ }
539
+
540
+ const result = await services.installProject({
347
541
  cwd,
348
542
  agents: flags.agents,
349
543
  dryRun: flags.dryRun,
@@ -361,43 +555,109 @@ async function main() {
361
555
  return;
362
556
  }
363
557
 
364
- printInstallResult(result, flags);
558
+ printInstallResult(renderer, appVersion, result, flags);
365
559
  return;
366
560
  }
367
561
 
368
- throw new Error(`Unknown command: ${effectiveCommand}`);
562
+ throw new Error(`Unknown command: ${command}`);
369
563
  }
370
564
 
371
- const jsonRequested = process.argv.includes("--json");
565
+ export async function runCli({
566
+ argv = process.argv.slice(2),
567
+ stdout = process.stdout,
568
+ stderr = process.stderr,
569
+ env = process.env,
570
+ platform = process.platform,
571
+ prompt = createPromptAdapter(),
572
+ services: providedServices = {},
573
+ appVersion = version,
574
+ cwdResolver = process.cwd
575
+ } = {}) {
576
+ const renderer = createTerminalRenderer({ stdout, stderr, env, platform });
577
+ const services = createServices(providedServices);
578
+ const { command, flags } = parseArgs(argv);
372
579
 
373
- main().catch((error) => {
374
- const hint =
375
- error.message.startsWith("Unknown command:")
376
- ? "Run `npx skilly-hand --help` to see available commands."
377
- : error.message.startsWith("Unknown flag:") || error.message.startsWith("Missing value")
378
- ? "Check command flags with `npx skilly-hand --help`."
379
- : "Retry with `--verbose` for expanded context if needed.";
580
+ if (flags.help) {
581
+ if (flags.json) {
582
+ renderer.writeJson({
583
+ command: command || "install",
584
+ help: true,
585
+ usage: [
586
+ "npx skilly-hand",
587
+ "npx skilly-hand install",
588
+ "npx skilly-hand detect",
589
+ "npx skilly-hand list",
590
+ "npx skilly-hand doctor",
591
+ "npx skilly-hand uninstall"
592
+ ]
593
+ });
594
+ return;
595
+ }
380
596
 
381
- if (jsonRequested) {
382
- renderer.writeErrorJson({
383
- ok: false,
384
- error: {
385
- what: "skilly-hand command failed",
386
- why: error.message,
387
- hint
388
- }
389
- });
390
- process.exitCode = 1;
597
+ renderer.write(buildHelpText(renderer, appVersion));
391
598
  return;
392
599
  }
393
600
 
394
- renderer.writeError(
395
- renderer.error({
396
- what: "skilly-hand command failed",
397
- why: error.message,
398
- hint,
399
- exitCode: 1
400
- })
401
- );
402
- process.exitCode = 1;
403
- });
601
+ const cwd = path.resolve(flags.cwd || cwdResolver());
602
+
603
+ if (isInteractiveLauncherMode({ command, flags, stdout })) {
604
+ try {
605
+ await runInteractiveSession({
606
+ cwd,
607
+ renderer,
608
+ prompt,
609
+ services,
610
+ appVersion
611
+ });
612
+ return;
613
+ } catch (error) {
614
+ if (error?.name === "ExitPromptError") {
615
+ renderer.write(renderer.status("info", "Interactive session cancelled."));
616
+ return;
617
+ }
618
+ throw error;
619
+ }
620
+ }
621
+
622
+ const effectiveCommand = command || "install";
623
+ await runCommand({
624
+ command: effectiveCommand,
625
+ flags,
626
+ cwd,
627
+ stdout,
628
+ renderer,
629
+ prompt,
630
+ services,
631
+ appVersion
632
+ });
633
+ }
634
+
635
+ if (isExecutedDirectly(import.meta.url, process.argv[1])) {
636
+ const jsonRequested = process.argv.includes("--json");
637
+ const renderer = createTerminalRenderer();
638
+
639
+ runCli().catch((error) => {
640
+ if (jsonRequested) {
641
+ renderer.writeErrorJson({
642
+ ok: false,
643
+ error: {
644
+ what: "skilly-hand command failed",
645
+ why: error.message,
646
+ hint: buildErrorHint(error.message)
647
+ }
648
+ });
649
+ process.exitCode = 1;
650
+ return;
651
+ }
652
+
653
+ renderer.writeError(
654
+ renderer.error({
655
+ what: "skilly-hand command failed",
656
+ why: error.message,
657
+ hint: buildErrorHint(error.message),
658
+ exitCode: 1
659
+ })
660
+ );
661
+ process.exitCode = 1;
662
+ });
663
+ }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { copySkillTo, loadAllSkills, renderAgentsMarkdown, verifyCatalogFiles } from "../../catalog/src/index.js";
4
4
  import { detectProject, inspectProjectFiles } from "../../detectors/src/index.js";
5
5
 
6
- const DEFAULT_AGENTS = ["codex", "claude", "cursor", "gemini", "copilot"];
6
+ export const DEFAULT_AGENTS = ["codex", "claude", "cursor", "gemini", "copilot"];
7
7
  const MANAGED_MARKER = "<!-- Managed by skilly-hand.";
8
8
 
9
9
  function uniq(values) {
@@ -44,6 +44,37 @@ function parseTags(input) {
44
44
  return uniq((input || []).flatMap((value) => String(value).split(",")).map((value) => value.trim()).filter(Boolean));
45
45
  }
46
46
 
47
+ function parseSkillIds(input) {
48
+ return uniq((input || []).flatMap((value) => String(value).split(",")).map((value) => value.trim()).filter(Boolean));
49
+ }
50
+
51
+ export function resolveSkillSelectionByIds({ catalog, selectedSkillIds = [] }) {
52
+ const ids = parseSkillIds(selectedSkillIds);
53
+ const portableById = new Map(
54
+ catalog
55
+ .filter((skill) => skill.portable)
56
+ .map((skill) => [skill.id, skill])
57
+ );
58
+ const allById = new Map(catalog.map((skill) => [skill.id, skill]));
59
+
60
+ const invalid = [];
61
+ for (const id of ids) {
62
+ if (!allById.has(id)) {
63
+ invalid.push(`Unknown skill id: ${id}`);
64
+ continue;
65
+ }
66
+ if (!portableById.has(id)) {
67
+ invalid.push(`Skill is not portable: ${id}`);
68
+ }
69
+ }
70
+
71
+ if (invalid.length > 0) {
72
+ throw new Error(invalid.join("; "));
73
+ }
74
+
75
+ return ids.map((id) => portableById.get(id)).sort((a, b) => a.id.localeCompare(b.id));
76
+ }
77
+
47
78
  export function resolveSkillSelection({ catalog, detections, includeTags = [], excludeTags = [] }) {
48
79
  const coreSkills = catalog.filter((skill) => skill.tags.includes("core"));
49
80
  const requested = new Set(coreSkills.map((skill) => skill.id));
@@ -168,17 +199,20 @@ export async function installProject({
168
199
  agents,
169
200
  dryRun = false,
170
201
  includeTags = [],
171
- excludeTags = []
202
+ excludeTags = [],
203
+ selectedSkillIds
172
204
  }) {
173
205
  const selectedAgents = normalizeAgentList(agents);
174
206
  const catalog = await loadAllSkills();
175
207
  const detections = await detectProject(cwd);
176
- const skills = resolveSkillSelection({
177
- catalog,
178
- detections,
179
- includeTags: parseTags(includeTags),
180
- excludeTags: parseTags(excludeTags)
181
- });
208
+ const skills = selectedSkillIds !== undefined && selectedSkillIds !== null
209
+ ? resolveSkillSelectionByIds({ catalog, selectedSkillIds })
210
+ : resolveSkillSelection({
211
+ catalog,
212
+ detections,
213
+ includeTags: parseTags(includeTags),
214
+ excludeTags: parseTags(excludeTags)
215
+ });
182
216
  const plan = buildInstallPlan({ cwd, detections, skills, agents: selectedAgents });
183
217
 
184
218
  if (dryRun) {
@@ -20,7 +20,7 @@ const LOGO_ASCII = [
20
20
 
21
21
  export function getBrand() {
22
22
  return {
23
- name: "autoskills",
23
+ name: "skilly-hand",
24
24
  tagline: "portable AI skill orchestration",
25
25
  hint: "npx skilly-hand --help",
26
26
  logo: {