@docker-doctor/cli 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,32 +28,37 @@ Works with any project that uses Docker.
28
28
  npx @docker-doctor/cli@latest
29
29
  ```
30
30
 
31
- ### 2. Browse rules
31
+ ### 2. Install for agents
32
+
33
+ Once you have an audit, install the skill so your coding agent learns the `/docker-doctor` triage workflow and can fix the issues for you:
32
34
 
33
35
  ```bash
34
- npx @docker-doctor/cli@latest rules list
35
- npx @docker-doctor/cli@latest rules explain docker-doctor/no-root-user
36
+ npx @docker-doctor/cli@latest install
36
37
  ```
37
38
 
38
- ### 3. Run in CI
39
+ Works with Claude Code, Cursor, Codex, OpenCode, and many more. After an interactive scan finds issues, Docker Doctor also offers to hand them straight to an agent detected on your machine.
39
40
 
40
- Docker Doctor walks you through setting up a GitHub Actions workflow after your first scan:
41
+ [Rules reference →](https://docker-doctor.vercel.app/docs/reference/rules)
41
42
 
42
- ```bash
43
- npx @docker-doctor/cli@latest
44
- ```
43
+ ### 3. Run in CI
44
+
45
+ The GitHub Action (`PunGrumpy/docker-doctor`) scans every pull request and posts a sticky summary comment — advisory by default, with an opt-in gate. See the [GitHub Actions guide](https://docker-doctor.vercel.app/docs/guides/github-actions) for setup, inputs, and gating.
45
46
 
46
47
  ### 4. Configure
47
48
 
48
- ```js
49
+ ```ts
49
50
  // docker-doctor.config.ts
51
+ import type { DockerDoctorConfig } from "@docker-doctor/cli";
52
+
50
53
  export default {
51
54
  rules: {
52
55
  "docker-doctor/no-root-user": "error",
53
56
  },
54
- };
57
+ } satisfies DockerDoctorConfig;
55
58
  ```
56
59
 
60
+ Prefer YAML? `docker-doctor.config.yaml` works too, with editor autocomplete via `# yaml-language-server: $schema=https://docker-doctor.vercel.app/schema.json`. A `defineConfig` helper is also exported for projects with `@docker-doctor/cli` installed — see the [configuration docs](https://docker-doctor.vercel.app/docs/reference/configuration).
61
+
57
62
  ## How the score works
58
63
 
59
64
  Every scan produces a 0-100 health score alongside a label (`Excellent 🏆`, `Good ✅`, `Needs Work ⚠️`, `Critical 🚨`).
package/dist/cli.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_src = require('./src-TEhFWhpk.cjs');
2
+ const require_src = require('./src-Dz3RukyR.cjs');
3
3
  let node_fs_promises = require("node:fs/promises");
4
4
  node_fs_promises = require_src.__toESM(node_fs_promises, 1);
5
5
  let node_path = require("node:path");
@@ -9,11 +9,15 @@ node_os = require_src.__toESM(node_os, 1);
9
9
  let node_readline = require("node:readline");
10
10
  node_readline = require_src.__toESM(node_readline, 1);
11
11
  let node_timers_promises = require("node:timers/promises");
12
+ let agent_install = require("agent-install");
12
13
  let node_process = require("node:process");
13
14
  node_process = require_src.__toESM(node_process, 1);
14
15
  let node_tty = require("node:tty");
15
16
  node_tty = require_src.__toESM(node_tty, 1);
16
17
  let commander = require("commander");
18
+ let node_child_process = require("node:child_process");
19
+ let node_fs = require("node:fs");
20
+ node_fs = require_src.__toESM(node_fs, 1);
17
21
 
18
22
  //#region ../../node_modules/.bun/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
19
23
  const ANSI_BACKGROUND_OFFSET = 10;
@@ -420,6 +424,234 @@ Object.defineProperties(createChalk.prototype, styles);
420
424
  const chalk = createChalk();
421
425
  const chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
422
426
 
427
+ //#endregion
428
+ //#region src/agents/clipboard.ts
429
+ const getClipboardCommands = () => {
430
+ if (process.platform === "darwin") return [{
431
+ args: [],
432
+ command: "pbcopy"
433
+ }];
434
+ if (process.platform === "win32") return [{
435
+ args: [],
436
+ command: "clip"
437
+ }];
438
+ return [
439
+ {
440
+ args: [],
441
+ command: "wl-copy"
442
+ },
443
+ {
444
+ args: ["-selection", "clipboard"],
445
+ command: "xclip"
446
+ },
447
+ {
448
+ args: ["--clipboard", "--input"],
449
+ command: "xsel"
450
+ }
451
+ ];
452
+ };
453
+ const tryCopy = ({ command, args }, text) => new Promise((resolve) => {
454
+ const child = (0, node_child_process.spawn)(command, args, { stdio: [
455
+ "pipe",
456
+ "ignore",
457
+ "ignore"
458
+ ] });
459
+ child.once("error", () => {
460
+ resolve(false);
461
+ });
462
+ child.once("exit", (code) => {
463
+ resolve(code === 0);
464
+ });
465
+ child.stdin.end(text);
466
+ });
467
+ const tryCommands = async (commands, text) => {
468
+ const [first, ...rest] = commands;
469
+ if (!first) return false;
470
+ if (await tryCopy(first, text)) return true;
471
+ return tryCommands(rest, text);
472
+ };
473
+ const copyToClipboard = (text) => tryCommands(getClipboardCommands(), text);
474
+
475
+ //#endregion
476
+ //#region src/agents/diagnostics-dir.ts
477
+ const DIAGNOSTICS_DIR_NAME = ".docker-doctor";
478
+ const UNSAFE_FILE_CHARS = /[^a-z0-9-]+/giu;
479
+ const ruleFileName = (rule) => {
480
+ return `${(rule.split("/").at(-1) ?? rule).replace(UNSAFE_FILE_CHARS, "-")}.txt`;
481
+ };
482
+ const groupDiagnosticsByRule = (diagnostics) => {
483
+ const groups = /* @__PURE__ */ new Map();
484
+ for (const diagnostic of diagnostics) {
485
+ const group = groups.get(diagnostic.rule);
486
+ if (group) group.push(diagnostic);
487
+ else groups.set(diagnostic.rule, [diagnostic]);
488
+ }
489
+ return groups;
490
+ };
491
+ const writeDiagnosticsDirectory = async (diagnostics, report, rootDir) => {
492
+ const dir = node_path.default.join(rootDir, DIAGNOSTICS_DIR_NAME);
493
+ await node_fs_promises.default.rm(dir, {
494
+ force: true,
495
+ recursive: true
496
+ });
497
+ await node_fs_promises.default.mkdir(dir, { recursive: true });
498
+ await node_fs_promises.default.writeFile(node_path.default.join(dir, "diagnostics.json"), JSON.stringify(report, null, 2), "utf-8");
499
+ const writes = [];
500
+ for (const [rule, ruleDiagnostics] of groupDiagnosticsByRule(diagnostics)) {
501
+ const [first] = ruleDiagnostics;
502
+ const lines = [
503
+ `${rule} (${first.severity})`,
504
+ first.message,
505
+ `Fix: ${first.help}`,
506
+ "",
507
+ ...ruleDiagnostics.map((d) => `${d.file}${d.line === void 0 ? "" : `:${d.line}`}`),
508
+ ""
509
+ ];
510
+ writes.push(node_fs_promises.default.writeFile(node_path.default.join(dir, ruleFileName(rule)), lines.join("\n"), "utf-8"));
511
+ }
512
+ await Promise.all(writes);
513
+ };
514
+ const ensureGitignoreEntry = async (rootDir) => {
515
+ const gitignorePath = node_path.default.join(rootDir, ".gitignore");
516
+ let existing = null;
517
+ try {
518
+ existing = await node_fs_promises.default.readFile(gitignorePath, "utf-8");
519
+ } catch {
520
+ existing = null;
521
+ }
522
+ if (existing !== null) {
523
+ if (existing.split(/\r?\n/u).some((line) => [
524
+ ".docker-doctor",
525
+ `${".docker-doctor"}/`,
526
+ `/${".docker-doctor"}`,
527
+ `/${".docker-doctor"}/`
528
+ ].includes(line.trim()))) return;
529
+ const separator = existing.endsWith("\n") || existing === "" ? "" : "\n";
530
+ await node_fs_promises.default.writeFile(gitignorePath, `${existing}${separator}${DIAGNOSTICS_DIR_NAME}/\n`, "utf-8");
531
+ return;
532
+ }
533
+ try {
534
+ await node_fs_promises.default.access(node_path.default.join(rootDir, ".git"));
535
+ } catch {
536
+ return;
537
+ }
538
+ await node_fs_promises.default.writeFile(gitignorePath, `${DIAGNOSTICS_DIR_NAME}/\n`, "utf-8");
539
+ };
540
+
541
+ //#endregion
542
+ //#region src/agents/handoff-payload.ts
543
+ const MAX_FILES_PER_RULE = 5;
544
+ const SEVERITY_RANK = {
545
+ error: 0,
546
+ info: 2,
547
+ warning: 1
548
+ };
549
+ const SEVERITY_LABEL = {
550
+ error: "ERROR",
551
+ info: "INFO",
552
+ warning: "WARN"
553
+ };
554
+ const buildHandoffPayload = (input) => {
555
+ const groups = [...groupDiagnosticsByRule(input.diagnostics).entries()].toSorted(([, a], [, b]) => {
556
+ const rankDelta = SEVERITY_RANK[a[0].severity] - SEVERITY_RANK[b[0].severity];
557
+ return rankDelta === 0 ? b.length - a.length : rankDelta;
558
+ });
559
+ const issueWord = groups.length === 1 ? "issue" : "issues";
560
+ const lines = [`Fix the ${groups.length} Docker Doctor ${issueWord} in ${input.projectName}.`, ""];
561
+ for (const [index, [rule, ruleDiagnostics]] of groups.entries()) {
562
+ const [first] = ruleDiagnostics;
563
+ const category = require_src.findRule(rule)?.category ?? "General";
564
+ const countBadge = ruleDiagnostics.length > 1 ? ` (×${ruleDiagnostics.length})` : "";
565
+ lines.push(`${index + 1}. ${SEVERITY_LABEL[first.severity]} ${category}: ${first.message} [${rule}]${countBadge}`, ` Fix: ${first.help}`);
566
+ const files = [...new Set(ruleDiagnostics.map((d) => d.file))];
567
+ for (const file of files.slice(0, MAX_FILES_PER_RULE)) {
568
+ const firstSite = ruleDiagnostics.find((d) => d.file === file && d.line !== void 0);
569
+ lines.push(` - ${file}${firstSite ? `:${firstSite.line}` : ""}`);
570
+ }
571
+ const remaining = files.length - MAX_FILES_PER_RULE;
572
+ if (remaining > 0) lines.push(` - +${remaining} more files`);
573
+ }
574
+ lines.push("", `Full report (diagnostics.json + a .txt per rule): ${DIAGNOSTICS_DIR_NAME}/`, "", "Read each file and fix the root cause — don't suppress or silence the rule.", "When you're done, re-run `npx @docker-doctor/cli@latest .` and confirm the score improved and no errors remain.");
575
+ return lines.join("\n");
576
+ };
577
+
578
+ //#endregion
579
+ //#region src/agents/is-command-available.ts
580
+ const WINDOWS_EXTENSIONS = [
581
+ ".exe",
582
+ ".cmd",
583
+ ".bat"
584
+ ];
585
+ const isCommandAvailable = (command) => {
586
+ const pathValue = process.env.PATH ?? "";
587
+ const extensions = process.platform === "win32" ? WINDOWS_EXTENSIONS : [""];
588
+ for (const dir of pathValue.split(node_path.default.delimiter)) {
589
+ if (dir === "") continue;
590
+ for (const extension of extensions) try {
591
+ node_fs.default.accessSync(node_path.default.join(dir, command + extension), node_fs.default.constants.X_OK);
592
+ return true;
593
+ } catch {}
594
+ }
595
+ return false;
596
+ };
597
+
598
+ //#endregion
599
+ //#region src/agents/launchable-agents.ts
600
+ const LAUNCHABLE_AGENT_IDS = [
601
+ "claude-code",
602
+ "codex",
603
+ "cursor"
604
+ ];
605
+ const AGENT_BINARIES = {
606
+ "claude-code": "claude",
607
+ codex: "codex",
608
+ cursor: "cursor-agent"
609
+ };
610
+ const AGENT_AUTO_FLAGS = {
611
+ "claude-code": ["--dangerously-skip-permissions"],
612
+ codex: ["--yolo"],
613
+ cursor: ["--force"]
614
+ };
615
+ const detectLaunchableAgents = () => {
616
+ if (process.platform === "win32") return [];
617
+ return LAUNCHABLE_AGENT_IDS.filter((agentId) => isCommandAvailable(AGENT_BINARIES[agentId]));
618
+ };
619
+
620
+ //#endregion
621
+ //#region src/agents/launch-agent.ts
622
+ const launchAgent = (agentId, prompt) => new Promise((resolve) => {
623
+ const child = (0, node_child_process.spawn)(AGENT_BINARIES[agentId], [...AGENT_AUTO_FLAGS[agentId], prompt], { stdio: "inherit" });
624
+ child.once("error", () => {
625
+ resolve(false);
626
+ });
627
+ child.once("exit", () => {
628
+ resolve(true);
629
+ });
630
+ });
631
+
632
+ //#endregion
633
+ //#region src/agents/skill-install.ts
634
+ const moduleDir = __dirname;
635
+ const getSkillSourceDirectory = () => {
636
+ const candidates = [node_path.default.resolve(moduleDir, "../skill/docker-doctor"), node_path.default.resolve(moduleDir, "../../../../skills/docker-doctor")];
637
+ for (const candidate of candidates) if (node_fs.default.existsSync(node_path.default.join(candidate, agent_install.SKILL_MANIFEST_FILE))) return candidate;
638
+ return null;
639
+ };
640
+ const installSkillForAgents = async (agents, projectRoot) => {
641
+ const source = getSkillSourceDirectory();
642
+ if (!source) return null;
643
+ try {
644
+ return await (0, agent_install.installSkillsFromSource)({
645
+ agents,
646
+ cwd: projectRoot,
647
+ mode: "copy",
648
+ source
649
+ });
650
+ } catch {
651
+ return null;
652
+ }
653
+ };
654
+
423
655
  //#endregion
424
656
  //#region src/formatters/terminal.ts
425
657
  const printCodeFrame = (content, line, severityColor) => {
@@ -696,37 +928,131 @@ const askSelect = (question, options, defaultIndex = 0) => {
696
928
  process.stdin.on("keypress", handleKeypress);
697
929
  });
698
930
  };
699
- const runInteractiveWizard = async () => {
931
+ const askMultiSelect = (question, options) => {
932
+ if (!process.stdin.isTTY) return Promise.resolve(options.flatMap((option, i) => option.selected ? [i] : []));
933
+ return new Promise((resolve) => {
934
+ let index = 0;
935
+ const selected = options.map((option) => option.selected);
936
+ const lineCount = options.length + 2;
937
+ node_readline.default.emitKeypressEvents(process.stdin);
938
+ process.stdin.setRawMode(true);
939
+ process.stdin.resume();
940
+ process.stdout.write("\x1B[?25l");
941
+ const render = (firstTime = false) => {
942
+ if (!firstTime) process.stdout.write(`\u001B[${lineCount}A\r`);
943
+ process.stdout.write(`\r\u001B[K ${chalk.green("✔")} ${chalk.bold(question)}\n`);
944
+ let i = 0;
945
+ for (const option of options) {
946
+ const isCursor = i === index;
947
+ const cursor = isCursor ? chalk.cyan("❯ ") : " ";
948
+ const box = selected[i] ? chalk.cyan("[x]") : chalk.dim("[ ]");
949
+ let text = chalk.dim(option.label);
950
+ if (isCursor) text = chalk.cyan.bold(option.label);
951
+ else if (selected[i]) text = option.label;
952
+ process.stdout.write(`\r\u001B[K${cursor}${box} ${text}\n`);
953
+ i += 1;
954
+ }
955
+ process.stdout.write(`\r\u001B[K ${chalk.dim("space to toggle · enter to confirm")}\n`);
956
+ };
957
+ render(true);
958
+ const handleKeypress = (str, key) => {
959
+ const cleanup = () => {
960
+ process.stdin.removeListener("keypress", handleKeypress);
961
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
962
+ process.stdin.pause();
963
+ process.stdout.write("\x1B[?25h");
964
+ };
965
+ if (key.name === "up" || key.name === "k") {
966
+ index = (index - 1 + options.length) % options.length;
967
+ render();
968
+ } else if (key.name === "down" || key.name === "j") {
969
+ index = (index + 1) % options.length;
970
+ render();
971
+ } else if (key.name === "space" || str === " ") {
972
+ selected[index] = !selected[index];
973
+ render();
974
+ } else if (key.name === "return" || key.name === "enter" || str === "\r" || str === "\n") {
975
+ cleanup();
976
+ const chosen = options.flatMap((option, i) => selected[i] ? [option.label] : []);
977
+ process.stdout.write(`\u001B[${lineCount}A\r\u001B[K`);
978
+ process.stdout.write(` ${chalk.green("✔")} ${chalk.bold(question)} › ${chosen.length > 0 ? chalk.cyan(chosen.join(", ")) : chalk.dim("none")}\n`);
979
+ for (let i = 0; i < lineCount - 1; i += 1) process.stdout.write("\r\x1B[K\n");
980
+ process.stdout.write(`\u001B[${lineCount - 1}A`);
981
+ resolve(options.flatMap((_, i) => selected[i] ? [i] : []));
982
+ } else if (key.ctrl && key.name === "c") {
983
+ cleanup();
984
+ process.stdout.write("\n");
985
+ process.exit(130);
986
+ }
987
+ };
988
+ process.stdin.on("keypress", handleKeypress);
989
+ });
990
+ };
991
+ const printAgentPrompt = (payload) => {
992
+ console.log(`\n${chalk.dim("──── Agent prompt ────")}`);
993
+ console.log(payload);
994
+ console.log(chalk.dim("──────────────────────"));
995
+ };
996
+ const agentDisplayName = (agent) => agent === "universal" ? "Universal" : (0, agent_install.getSkillAgentConfig)(agent).displayName;
997
+ const runAgentHandoff = async (context) => {
998
+ const launchable = detectLaunchableAgents();
999
+ const options = [
1000
+ ...launchable.map((agentId) => agentDisplayName(agentId)),
1001
+ "Copy prompt to clipboard",
1002
+ "Skip"
1003
+ ];
1004
+ const skipIndex = options.length - 1;
1005
+ const clipboardIndex = options.length - 2;
1006
+ const choice = await askSelect("What would you like to do next?", options);
1007
+ if (choice === skipIndex) return;
1008
+ await writeDiagnosticsDirectory(context.diagnostics, context.report, context.rootDir);
1009
+ await ensureGitignoreEntry(context.rootDir);
1010
+ const payload = buildHandoffPayload({
1011
+ diagnostics: context.diagnostics,
1012
+ projectName: node_path.default.basename(context.rootDir)
1013
+ });
1014
+ if (choice === clipboardIndex) {
1015
+ if (await copyToClipboard(payload)) console.log(`\n ${chalk.green("✔")} Prompt copied — paste it into any agent or chat.`);
1016
+ else printAgentPrompt(payload);
1017
+ return;
1018
+ }
1019
+ const agentId = launchable[choice];
1020
+ const installResult = await installSkillForAgents([agentId], context.rootDir);
1021
+ if (installResult && installResult.installed.length > 0) console.log(`\n ${chalk.green("✔")} Installed the docker-doctor skill for ${agentDisplayName(agentId)}`);
1022
+ console.log(`\n Handing off to ${agentDisplayName(agentId)}...\n`);
1023
+ if (!await launchAgent(agentId, payload)) {
1024
+ console.log(` ${chalk.yellow("⚠")} Couldn't launch ${AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
1025
+ printAgentPrompt(payload);
1026
+ }
1027
+ };
1028
+ const runInteractiveWizard = async (context) => {
700
1029
  try {
701
1030
  if (await askConfirm("Add Docker Doctor to GitHub Actions?")) {
702
1031
  const workflowDir = node_path.default.resolve(".github/workflows");
703
1032
  await node_fs_promises.default.mkdir(workflowDir, { recursive: true });
704
1033
  const workflowPath = node_path.default.join(workflowDir, "docker-doctor.yml");
705
- await node_fs_promises.default.writeFile(workflowPath, `name: Docker Doctor Scan
1034
+ const [actionMajor] = require_src.package_default.version.split(".");
1035
+ const workflowYaml = `name: Docker Doctor
706
1036
  on:
707
- push:
708
- branches: [ main, master ]
709
1037
  pull_request:
710
- branches: [ main, master ]
1038
+ permissions:
1039
+ contents: read
1040
+ pull-requests: write
1041
+ issues: write
711
1042
  jobs:
712
1043
  docker-doctor:
713
1044
  runs-on: ubuntu-latest
714
1045
  steps:
715
- - uses: actions/checkout@v4
716
- - name: Setup Bun
717
- uses: oven-sh/setup-bun@v2
718
- - name: Install dependencies
719
- run: bun install
720
- - name: Run docker-doctor
721
- run: bunx docker-doctor .
722
- `, "utf-8");
1046
+ - uses: actions/checkout@v5
1047
+ - uses: PunGrumpy/docker-doctor@v${actionMajor}
1048
+ `;
1049
+ await node_fs_promises.default.writeFile(workflowPath, workflowYaml, "utf-8");
723
1050
  console.log(`\n ${chalk.green("✨")} Created ${chalk.cyan(".github/workflows/docker-doctor.yml")}!`);
724
- console.log(` Scan every pull request to prevent new Docker issues while you fix the backlog.`);
725
- }
726
- if (await askSelect("What would you like to do next?", ["View rules list", "Skip"]) === 0) {
727
- console.log(`\n ${chalk.bold("Available Rules:")}`);
728
- for (const r of require_src.allRules) console.log(` - ${chalk.cyan(r.key)}: ${r.message} (${chalk.dim(r.category)})`);
1051
+ console.log(` Every pull request gets a scan and a sticky summary comment advisory by default.`);
1052
+ console.log(` Inputs and gating: ${chalk.cyan("https://docker-doctor.vercel.app/docs/guides/github-actions")}`);
729
1053
  }
1054
+ if (context.diagnostics.length === 0) return;
1055
+ await runAgentHandoff(context);
730
1056
  } catch {}
731
1057
  };
732
1058
  const runRulesEngine = async (rootDir, project, rulesConfig, projectFilesList, fileContents, options, setStatus) => {
@@ -859,7 +1185,11 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
859
1185
  }
860
1186
  await formatTerminal(filteredDiagnostics, score, label, project, options.verbose, fileContents);
861
1187
  const hasErrors = filteredDiagnostics.some((d) => d.severity === "error");
862
- if (process.stdout.isTTY && process.stdin.isTTY) await runInteractiveWizard();
1188
+ if (process.stdout.isTTY && process.stdin.isTTY) await runInteractiveWizard({
1189
+ diagnostics: filteredDiagnostics,
1190
+ report: require_src.toJsonReport(filteredDiagnostics, score, label, project),
1191
+ rootDir
1192
+ });
863
1193
  process.exitCode = hasErrors ? 1 : 0;
864
1194
  } finally {
865
1195
  if (spinnerInterval !== null) {
@@ -873,6 +1203,55 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
873
1203
  process.exit(1);
874
1204
  }
875
1205
  });
1206
+ const CURATED_INSTALL_AGENTS = [
1207
+ "claude-code",
1208
+ "codex",
1209
+ "cursor",
1210
+ "opencode"
1211
+ ];
1212
+ const resolveInstallAgents = async (requested) => {
1213
+ if (requested && requested.length > 0) {
1214
+ const invalid = requested.filter((agent) => !(0, agent_install.isSkillAgentType)(agent));
1215
+ if (invalid.length > 0) {
1216
+ console.error(`Unknown agent id(s): ${invalid.join(", ")}`);
1217
+ console.error(`Valid ids: ${(0, agent_install.getSkillAgentTypes)().filter((agent) => agent !== "universal").join(", ")}`);
1218
+ return null;
1219
+ }
1220
+ return requested.filter((agent) => (0, agent_install.isSkillAgentType)(agent));
1221
+ }
1222
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
1223
+ console.error("Non-interactive run: pass --agent <id...> (e.g. --agent claude-code cursor).");
1224
+ return null;
1225
+ }
1226
+ const detected = (await (0, agent_install.detectInstalledSkillAgents)()).filter((agent) => agent !== "universal");
1227
+ const choices = [.../* @__PURE__ */ new Set([...detected, ...CURATED_INSTALL_AGENTS])];
1228
+ const detectedSet = new Set(detected);
1229
+ return (await askMultiSelect("Which coding agents should get the docker-doctor skill?", choices.map((agent) => ({
1230
+ label: agentDisplayName(agent),
1231
+ selected: detectedSet.has(agent)
1232
+ })))).map((i) => choices[i]);
1233
+ };
1234
+ program.command("install").description("install the Docker Doctor agent skill for your coding agents").option("-a, --agent <agents...>", "agent id(s) to install for (e.g. claude-code codex cursor)").action(async (options) => {
1235
+ if (!getSkillSourceDirectory()) {
1236
+ console.error("Bundled skill not found — this looks like a broken installation.");
1237
+ process.exit(1);
1238
+ }
1239
+ const agents = await resolveInstallAgents(options.agent);
1240
+ if (agents === null) process.exit(1);
1241
+ if (agents.length === 0) {
1242
+ console.log("Nothing selected — skipped.");
1243
+ return;
1244
+ }
1245
+ const result = await installSkillForAgents(agents, process.cwd());
1246
+ if (!result) {
1247
+ console.error("Failed to install the skill.");
1248
+ process.exit(1);
1249
+ }
1250
+ for (const installed of result.installed) console.log(` ${chalk.green("✔")} ${agentDisplayName(installed.agent)} → ${installed.path}`);
1251
+ for (const failed of result.failed) console.log(` ${chalk.red("✖")} ${agentDisplayName(failed.agent)}: ${failed.error}`);
1252
+ if (result.installed.length > 0) console.log(`\n The agent can now run ${chalk.cyan("/docker-doctor")} to scan and triage this project.`);
1253
+ process.exitCode = result.failed.length > 0 ? 1 : 0;
1254
+ });
876
1255
  const rules = program.command("rules").description("manage and list configuration rules");
877
1256
  rules.command("list").description("list all available rules").action(() => {
878
1257
  console.log("\nAvailable Rules:");