@skilly-hand/skilly-hand 0.3.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.
@@ -1,13 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
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";
3
6
  import { loadAllSkills } from "../../catalog/src/index.js";
4
- 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";
5
14
  import { createTerminalRenderer } from "../../core/src/terminal.js";
6
15
  import { detectProject } from "../../detectors/src/index.js";
7
16
 
8
- const renderer = createTerminalRenderer();
17
+ const require = createRequire(import.meta.url);
18
+ const { version } = require("../../../package.json");
9
19
 
10
- function parseArgs(argv) {
20
+ function isExecutedDirectly(metaUrl, argv1) {
21
+ if (!argv1) return false;
22
+ return metaUrl === pathToFileURL(argv1).href;
23
+ }
24
+
25
+ export function parseArgs(argv) {
11
26
  const args = [...argv];
12
27
  const positional = [];
13
28
  const flags = {
@@ -50,9 +65,10 @@ function parseArgs(argv) {
50
65
  return { command: positional[0], flags };
51
66
  }
52
67
 
53
- function buildHelpText() {
68
+ function buildHelpText(renderer, appVersion) {
54
69
  const usage = renderer.section("Usage", renderer.list([
55
- "npx skilly-hand [install]",
70
+ "npx skilly-hand # interactive launcher when running in a TTY",
71
+ "npx skilly-hand install",
56
72
  "npx skilly-hand detect",
57
73
  "npx skilly-hand list",
58
74
  "npx skilly-hand doctor",
@@ -62,7 +78,7 @@ function buildHelpText() {
62
78
  const flags = renderer.section("Flags", renderer.list([
63
79
  "--dry-run Show install plan without writing files",
64
80
  "--json Emit stable JSON output for automation",
65
- "--yes, -y Reserved for future non-interactive confirmations",
81
+ "--yes, -y Skip install/uninstall confirmations",
66
82
  "--verbose, -v Reserved for future debug detail",
67
83
  "--agent, -a <name> codex|claude|cursor|gemini|copilot (repeatable)",
68
84
  "--cwd <path> Project root (defaults to current directory)",
@@ -72,65 +88,22 @@ function buildHelpText() {
72
88
  ], { bullet: "-" }));
73
89
 
74
90
  const examples = renderer.section("Examples", renderer.list([
91
+ "npx skilly-hand",
75
92
  "npx skilly-hand install --dry-run",
76
93
  "npx skilly-hand detect --json",
77
94
  "npx skilly-hand install --agent codex --agent claude",
78
- "npx skilly-hand list --include workflow"
95
+ "npx skilly-hand uninstall --yes"
79
96
  ], { bullet: "-" }));
80
97
 
81
98
  return renderer.joinBlocks([
82
- renderer.status("info", "skilly-hand", "Portable AI skill orchestration for coding assistants."),
99
+ renderer.banner(appVersion),
83
100
  usage,
84
101
  flags,
85
102
  examples
86
103
  ]);
87
104
  }
88
105
 
89
- function detectionRows(detections) {
90
- return detections.map((item) => ({
91
- technology: item.technology,
92
- confidence: item.confidence.toFixed(2),
93
- reasons: item.reasons.join("; "),
94
- recommended: item.recommendedSkillIds.join(", ")
95
- }));
96
- }
97
-
98
- function renderDetections(detections) {
99
- if (detections.length === 0) {
100
- return renderer.status("warn", "No technology signals were detected.", "Only core skills will be selected.");
101
- }
102
-
103
- return renderer.table(
104
- [
105
- { key: "technology", header: "Technology" },
106
- { key: "confidence", header: "Confidence" },
107
- { key: "reasons", header: "Reasons" },
108
- { key: "recommended", header: "Recommended Skills" }
109
- ],
110
- detectionRows(detections)
111
- );
112
- }
113
-
114
- function renderSkillTable(skills) {
115
- if (skills.length === 0) {
116
- return renderer.status("warn", "No skills selected.");
117
- }
118
-
119
- return renderer.table(
120
- [
121
- { key: "id", header: "Skill ID" },
122
- { key: "title", header: "Title" },
123
- { key: "tags", header: "Tags" }
124
- ],
125
- skills.map((skill) => ({
126
- id: skill.id,
127
- title: skill.title,
128
- tags: skill.tags.join(", ")
129
- }))
130
- );
131
- }
132
-
133
- function printInstallResult(result, flags) {
106
+ function printInstallResult(renderer, appVersion, result, flags) {
134
107
  const mode = flags.dryRun ? "dry-run" : "apply";
135
108
  const preflight = renderer.section(
136
109
  "Install Preflight",
@@ -144,8 +117,30 @@ function printInstallResult(result, flags) {
144
117
  ])
145
118
  );
146
119
 
147
- const detections = renderer.section("Detected Technologies", renderDetections(result.plan.detections));
148
- const skills = renderer.section("Skill Plan", renderSkillTable(result.plan.skills));
120
+ const detections = renderer.section(
121
+ "Detected Technologies",
122
+ result.plan.detections.length > 0
123
+ ? renderer.detectionGrid(result.plan.detections)
124
+ : renderer.status("warn", "No technology signals were detected.", "Only core skills will be selected.")
125
+ );
126
+
127
+ const skills = renderer.section(
128
+ "Skill Plan",
129
+ result.plan.skills.length > 0
130
+ ? renderer.table(
131
+ [
132
+ { key: "id", header: "Skill ID" },
133
+ { key: "title", header: "Title" },
134
+ { key: "tags", header: "Tags" }
135
+ ],
136
+ result.plan.skills.map((skill) => ({
137
+ id: skill.id,
138
+ title: skill.title,
139
+ tags: skill.tags.join(", ")
140
+ }))
141
+ )
142
+ : renderer.status("warn", "No skills selected.")
143
+ );
149
144
 
150
145
  const status = result.applied
151
146
  ? renderer.status("success", "Installation completed.", "Managed files and symlinks are in place.")
@@ -153,19 +148,19 @@ function printInstallResult(result, flags) {
153
148
 
154
149
  const nextSteps = result.applied
155
150
  ? renderer.nextSteps([
156
- "Review generated AGENTS and assistant instruction files.",
157
- "Run `npx skilly-hand doctor` to validate installation health.",
158
- "Use `npx skilly-hand uninstall` to restore backed-up files if needed."
159
- ])
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
+ ])
160
155
  : renderer.nextSteps([
161
- "Run `npx skilly-hand install` to apply this plan.",
162
- "Adjust `--include` and `--exclude` tags to tune skill selection."
163
- ]);
156
+ "Run `npx skilly-hand install` to apply this plan.",
157
+ "Adjust `--include` and `--exclude` tags to tune skill selection."
158
+ ]);
164
159
 
165
- renderer.write(renderer.joinBlocks([preflight, detections, skills, status, nextSteps]));
160
+ renderer.write(renderer.joinBlocks([renderer.banner(appVersion), preflight, detections, skills, status, nextSteps]));
166
161
  }
167
162
 
168
- function printDetectResult(cwd, detections) {
163
+ function printDetectResult(renderer, cwd, detections) {
169
164
  const summary = renderer.section(
170
165
  "Detection Summary",
171
166
  renderer.kv([
@@ -174,11 +169,17 @@ function printDetectResult(cwd, detections) {
174
169
  ])
175
170
  );
176
171
 
177
- const details = renderer.section("Findings", renderDetections(detections));
178
- renderer.write(renderer.joinBlocks([summary, details]));
172
+ const findings = renderer.section(
173
+ "Findings",
174
+ detections.length > 0
175
+ ? renderer.detectionGrid(detections)
176
+ : renderer.status("warn", "No technology signals were detected.", "Only core skills will be selected.")
177
+ );
178
+
179
+ renderer.write(renderer.joinBlocks([summary, findings]));
179
180
  }
180
181
 
181
- function printListResult(skills) {
182
+ function printListResult(renderer, skills) {
182
183
  const summary = renderer.section(
183
184
  "Catalog Summary",
184
185
  renderer.kv([["Skills available", String(skills.length)]])
@@ -205,10 +206,8 @@ function printListResult(skills) {
205
206
  renderer.write(renderer.joinBlocks([summary, table]));
206
207
  }
207
208
 
208
- function printDoctorResult(result) {
209
- const health = result.installed
210
- ? renderer.status("success", "Installation detected.")
211
- : renderer.status("warn", "No installation detected.");
209
+ function printDoctorResult(renderer, result) {
210
+ const badge = renderer.healthBadge(result.installed);
212
211
 
213
212
  const summary = renderer.section(
214
213
  "Doctor Summary",
@@ -250,10 +249,10 @@ function printDoctorResult(result) {
250
249
  )
251
250
  );
252
251
 
253
- renderer.write(renderer.joinBlocks([health, summary, lock, issues, probes]));
252
+ renderer.write(renderer.joinBlocks([badge, summary, lock, issues, probes]));
254
253
  }
255
254
 
256
- function printUninstallResult(result) {
255
+ function printUninstallResult(renderer, result) {
257
256
  if (result.removed) {
258
257
  renderer.write(
259
258
  renderer.joinBlocks([
@@ -275,34 +274,193 @@ function printUninstallResult(result) {
275
274
  );
276
275
  }
277
276
 
278
- async function main() {
279
- 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
+ }
280
286
 
281
- if (flags.help) {
282
- if (flags.json) {
283
- renderer.writeJson({
284
- command: command || "install",
285
- help: true,
286
- usage: [
287
- "npx skilly-hand [install]",
288
- "npx skilly-hand detect",
289
- "npx skilly-hand list",
290
- "npx skilly-hand doctor",
291
- "npx skilly-hand uninstall"
292
- ]
293
- });
294
- return;
295
- }
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
+ });
296
347
 
297
- renderer.write(buildHelpText());
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
+ });
360
+
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."));
298
368
  return;
299
369
  }
300
370
 
301
- const cwd = path.resolve(flags.cwd || process.cwd());
302
- 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
+ }
303
411
 
304
- if (effectiveCommand === "detect") {
305
- const detections = await detectProject(cwd);
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
+ }
434
+
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);
306
464
  if (flags.json) {
307
465
  renderer.writeJson({
308
466
  command: "detect",
@@ -312,12 +470,12 @@ async function main() {
312
470
  });
313
471
  return;
314
472
  }
315
- printDetectResult(cwd, detections);
473
+ printDetectResult(renderer, cwd, detections);
316
474
  return;
317
475
  }
318
476
 
319
- if (effectiveCommand === "list") {
320
- const skills = await loadAllSkills();
477
+ if (command === "list") {
478
+ const skills = await services.loadAllSkills();
321
479
  if (flags.json) {
322
480
  renderer.writeJson({
323
481
  command: "list",
@@ -326,12 +484,12 @@ async function main() {
326
484
  });
327
485
  return;
328
486
  }
329
- printListResult(skills);
487
+ printListResult(renderer, skills);
330
488
  return;
331
489
  }
332
490
 
333
- if (effectiveCommand === "doctor") {
334
- const result = await runDoctor(cwd);
491
+ if (command === "doctor") {
492
+ const result = await services.runDoctor(cwd);
335
493
  if (flags.json) {
336
494
  renderer.writeJson({
337
495
  command: "doctor",
@@ -339,12 +497,23 @@ async function main() {
339
497
  });
340
498
  return;
341
499
  }
342
- printDoctorResult(result);
500
+ printDoctorResult(renderer, result);
343
501
  return;
344
502
  }
345
503
 
346
- if (effectiveCommand === "uninstall") {
347
- 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);
348
517
  if (flags.json) {
349
518
  renderer.writeJson({
350
519
  command: "uninstall",
@@ -352,12 +521,23 @@ async function main() {
352
521
  });
353
522
  return;
354
523
  }
355
- printUninstallResult(result);
524
+ printUninstallResult(renderer, result);
356
525
  return;
357
526
  }
358
527
 
359
- if (effectiveCommand === "install") {
360
- 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({
361
541
  cwd,
362
542
  agents: flags.agents,
363
543
  dryRun: flags.dryRun,
@@ -375,43 +555,109 @@ async function main() {
375
555
  return;
376
556
  }
377
557
 
378
- printInstallResult(result, flags);
558
+ printInstallResult(renderer, appVersion, result, flags);
379
559
  return;
380
560
  }
381
561
 
382
- throw new Error(`Unknown command: ${effectiveCommand}`);
562
+ throw new Error(`Unknown command: ${command}`);
383
563
  }
384
564
 
385
- 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);
386
579
 
387
- main().catch((error) => {
388
- const hint =
389
- error.message.startsWith("Unknown command:")
390
- ? "Run `npx skilly-hand --help` to see available commands."
391
- : error.message.startsWith("Unknown flag:") || error.message.startsWith("Missing value")
392
- ? "Check command flags with `npx skilly-hand --help`."
393
- : "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
+ }
394
596
 
395
- if (jsonRequested) {
396
- renderer.writeErrorJson({
397
- ok: false,
398
- error: {
399
- what: "skilly-hand command failed",
400
- why: error.message,
401
- hint
402
- }
403
- });
404
- process.exitCode = 1;
597
+ renderer.write(buildHelpText(renderer, appVersion));
405
598
  return;
406
599
  }
407
600
 
408
- renderer.writeError(
409
- renderer.error({
410
- what: "skilly-hand command failed",
411
- why: error.message,
412
- hint,
413
- exitCode: 1
414
- })
415
- );
416
- process.exitCode = 1;
417
- });
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
+ }