diff --git a/.changeset/agent-handoff-and-install.md b/.changeset/agent-handoff-and-install.md new file mode 100644 index 0000000..cb8d7e5 --- /dev/null +++ b/.changeset/agent-handoff-and-install.md @@ -0,0 +1,5 @@ +--- +"@docker-doctor/cli": patch +--- + +Add coding-agent integration: a `docker-doctor install` command that installs the bundled agent skill for any agent-install-supported coding agent (Claude Code, Cursor, Codex, OpenCode, and more), and a post-scan handoff that replaces the old "View rules list" prompt — when a scan finds issues, the CLI now offers to launch a detected agent (`claude`, `codex`, `cursor-agent`) with the issues as its prompt, or copy that prompt to the clipboard. Handoffs write the full report to `.docker-doctor/` (auto-gitignored) and install the skill for the chosen agent. diff --git a/.gitignore b/.gitignore index 984fea5..cc2d86f 100644 --- a/.gitignore +++ b/.gitignore @@ -224,3 +224,6 @@ generated # Next.js next-env.d.ts + +# Bundled agent skill (copied from skills/docker-doctor at build time) +packages/docker-doctor/skill/ diff --git a/apps/web/content/docs/guides/coding-agents.mdx b/apps/web/content/docs/guides/coding-agents.mdx new file mode 100644 index 0000000..4729eab --- /dev/null +++ b/apps/web/content/docs/guides/coding-agents.mdx @@ -0,0 +1,50 @@ +--- +title: Coding Agents +description: Install the Docker Doctor skill for your coding agent and hand scan results straight to it. +--- + +Docker Doctor is built to work with AI coding agents. There are two pieces: an installable **agent skill** that teaches your agent the `/docker-doctor` triage workflow, and a post-scan **handoff** that sends the issues Docker Doctor just found straight to an agent on your machine. + +## Install the skill + +```bash +npx @docker-doctor/cli@latest install +``` + +In a terminal this opens a multi-select of coding agents — agents already detected on your machine are pre-selected — and copies the skill into each agent's project-level skills directory (for example `.claude/skills/docker-doctor/` for Claude Code). + +Works with Claude Code, Cursor, Codex, OpenCode, and every other agent supported by [agent-install](https://www.npmjs.com/package/agent-install). + +### Non-interactive install + +In scripts or CI, pass the agent ids explicitly: + +```bash +npx @docker-doctor/cli@latest install --agent claude-code cursor +``` + +Any `agent-install` agent id is accepted. Unknown ids fail with the full list of valid ones. + +## Hand issues to an agent after a scan + +When an interactive scan finds issues, Docker Doctor asks what to do next: + +``` +✔ What would you like to do next? +❯ Claude Code + Copy prompt to clipboard + Skip +``` + +- **<agent name>** — shown for each agent CLI found on your `PATH` (`claude`, `codex`, `cursor-agent`). Docker Doctor installs the skill for that agent, then launches it in your terminal with a prompt describing every issue, its fix, and the affected files. The agent runs in its auto-approve mode so it can fix the issues end-to-end. +- **Copy prompt to clipboard** — copies the same prompt so you can paste it into any agent or chat. +- **Skip** — do nothing. + +## The `.docker-doctor/` report directory + +Before handing off, Docker Doctor writes the full scan results to `.docker-doctor/` in your project root: + +- `diagnostics.json` — the same report as `--json` +- one `.txt` per rule with the fix recipe and every affected file + +The prompt references this directory so the agent can read past the inline summary. Docker Doctor adds `.docker-doctor/` to your `.gitignore` automatically (only if the project is a git repository and the entry is missing). diff --git a/apps/web/content/docs/guides/meta.json b/apps/web/content/docs/guides/meta.json index 32ffb66..a858141 100644 --- a/apps/web/content/docs/guides/meta.json +++ b/apps/web/content/docs/guides/meta.json @@ -1,4 +1,4 @@ { "title": "Guides", - "pages": ["getting-started"] + "pages": ["getting-started", "coding-agents"] } diff --git a/apps/web/content/docs/reference/cli.mdx b/apps/web/content/docs/reference/cli.mdx index 1dc7da0..337aab6 100644 --- a/apps/web/content/docs/reference/cli.mdx +++ b/apps/web/content/docs/reference/cli.mdx @@ -39,6 +39,22 @@ npx @docker-doctor/cli@latest . --json > report.json | `--score` | Non-zero if the score is below 50 | | `--json` | Non-zero if any `error`-severity diagnostic is present | +## Install for agents + +Install the Docker Doctor agent skill for your coding agents (interactive multi-select in a TTY): + +```bash +npx @docker-doctor/cli@latest install +``` + +Non-interactive runs must name the agents: + +```bash +npx @docker-doctor/cli@latest install --agent claude-code cursor +``` + +See the [Coding Agents guide](/docs/guides/coding-agents) for details. + ## Rules subcommands List every built-in rule, its category, default severity, and description: @@ -56,3 +72,5 @@ npx @docker-doctor/cli@latest rules explain docker-doctor/no-root-user ## Interactive mode When run in a TTY without `--score` or `--json`, docker-doctor shows a spinner while scanning and, after printing results, offers to scaffold a `.github/workflows/docker-doctor.yml` CI workflow for you. + +If the scan found issues, it then offers to hand them to a coding agent detected on your machine — launching the agent with the issues as its prompt — or to copy that prompt to your clipboard. See the [Coding Agents guide](/docs/guides/coding-agents). diff --git a/bun.lock b/bun.lock index 352cc07..c887dd8 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "docker-doctor": "dist/cli.mjs", }, "dependencies": { + "agent-install": "0.0.8", "chalk": "^5.4.1", "commander": "^15.0.0", "yaml": "^2.7.0", @@ -985,7 +986,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], + "agent-install": ["agent-install@0.0.8", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-x/AxHAzJx788UnM3MfjPIpIda8swS+x5Dz9wTt9MfteWzQXLgAtWuYU21FKj4rfwZa4Ru8R+xJIhxBnZBNsR+w=="], "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -2361,6 +2362,8 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "react-doctor/agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], + "react-doctor/oxlint": ["oxlint@1.66.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.66.0", "@oxlint/binding-android-arm64": "1.66.0", "@oxlint/binding-darwin-arm64": "1.66.0", "@oxlint/binding-darwin-x64": "1.66.0", "@oxlint/binding-freebsd-x64": "1.66.0", "@oxlint/binding-linux-arm-gnueabihf": "1.66.0", "@oxlint/binding-linux-arm-musleabihf": "1.66.0", "@oxlint/binding-linux-arm64-gnu": "1.66.0", "@oxlint/binding-linux-arm64-musl": "1.66.0", "@oxlint/binding-linux-ppc64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-musl": "1.66.0", "@oxlint/binding-linux-s390x-gnu": "1.66.0", "@oxlint/binding-linux-x64-gnu": "1.66.0", "@oxlint/binding-linux-x64-musl": "1.66.0", "@oxlint/binding-openharmony-arm64": "1.66.0", "@oxlint/binding-win32-arm64-msvc": "1.66.0", "@oxlint/binding-win32-ia32-msvc": "1.66.0", "@oxlint/binding-win32-x64-msvc": "1.66.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw=="], "react-doctor/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -2589,6 +2592,8 @@ "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "react-doctor/agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "react-doctor/oxlint/@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.66.0", "", { "os": "android", "cpu": "arm" }, "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw=="], "react-doctor/oxlint/@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.66.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg=="], diff --git a/packages/docker-doctor/README.md b/packages/docker-doctor/README.md index 0d24d73..e9e2ff2 100644 --- a/packages/docker-doctor/README.md +++ b/packages/docker-doctor/README.md @@ -28,13 +28,18 @@ Works with any project that uses Docker. npx @docker-doctor/cli@latest ``` -### 2. Browse rules +### 2. Install for agents + +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: ```bash -npx @docker-doctor/cli@latest rules list -npx @docker-doctor/cli@latest rules explain docker-doctor/no-root-user +npx @docker-doctor/cli@latest install ``` +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. + +[Rules reference →](https://docker-doctor.vercel.app/docs/reference/rules) + ### 3. Run in CI Docker Doctor walks you through setting up a GitHub Actions workflow after your first scan: diff --git a/packages/docker-doctor/package.json b/packages/docker-doctor/package.json index a138fbe..9424fa7 100644 --- a/packages/docker-doctor/package.json +++ b/packages/docker-doctor/package.json @@ -31,6 +31,7 @@ }, "files": [ "dist", + "skill", "LICENSE" ], "type": "module", @@ -56,9 +57,10 @@ "dev": "NODE_OPTIONS='--max-old-space-size=4096' tsdown --watch", "test": "bun test", "typecheck": "tsc --noEmit", - "clean": "git clean -xdf .turbo node_modules dist" + "clean": "git clean -xdf .turbo node_modules dist skill" }, "dependencies": { + "agent-install": "0.0.8", "chalk": "^5.4.1", "commander": "^15.0.0", "yaml": "^2.7.0" diff --git a/packages/docker-doctor/src/agents/clipboard.ts b/packages/docker-doctor/src/agents/clipboard.ts new file mode 100644 index 0000000..2c979c8 --- /dev/null +++ b/packages/docker-doctor/src/agents/clipboard.ts @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; + +interface ClipboardCommand { + command: string; + args: string[]; +} + +const getClipboardCommands = (): ClipboardCommand[] => { + if (process.platform === "darwin") { + return [{ args: [], command: "pbcopy" }]; + } + if (process.platform === "win32") { + return [{ args: [], command: "clip" }]; + } + return [ + { args: [], command: "wl-copy" }, + { args: ["-selection", "clipboard"], command: "xclip" }, + { args: ["--clipboard", "--input"], command: "xsel" }, + ]; +}; + +const tryCopy = ({ command, args }: ClipboardCommand, text: string) => + /* eslint-disable promise/avoid-new */ + new Promise((resolve) => { + const child = spawn(command, args, { + stdio: ["pipe", "ignore", "ignore"], + }); + child.once("error", () => { + resolve(false); + }); + child.once("exit", (code) => { + resolve(code === 0); + }); + child.stdin.end(text); + }); +/* eslint-enable promise/avoid-new */ + +const tryCommands = async ( + commands: ClipboardCommand[], + text: string +): Promise => { + const [first, ...rest] = commands; + if (!first) { + return false; + } + if (await tryCopy(first, text)) { + return true; + } + return tryCommands(rest, text); +}; + +// Best-effort: tries each platform clipboard tool in order (they're fallbacks, +// so the attempts are sequential). Returns false when none worked — the +// caller prints the payload instead. +export const copyToClipboard = (text: string): Promise => + tryCommands(getClipboardCommands(), text); diff --git a/packages/docker-doctor/src/agents/diagnostics-dir.ts b/packages/docker-doctor/src/agents/diagnostics-dir.ts new file mode 100644 index 0000000..72bee7b --- /dev/null +++ b/packages/docker-doctor/src/agents/diagnostics-dir.ts @@ -0,0 +1,114 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { Diagnostic, JsonReport } from "@docker-doctor/core"; + +export const DIAGNOSTICS_DIR_NAME = ".docker-doctor"; + +const UNSAFE_FILE_CHARS = /[^a-z0-9-]+/giu; + +const ruleFileName = (rule: string): string => { + const shortKey = rule.split("/").at(-1) ?? rule; + return `${shortKey.replace(UNSAFE_FILE_CHARS, "-")}.txt`; +}; + +export const groupDiagnosticsByRule = ( + diagnostics: Diagnostic[] +): Map => { + const groups = new Map(); + for (const diagnostic of diagnostics) { + const group = groups.get(diagnostic.rule); + if (group) { + group.push(diagnostic); + } else { + groups.set(diagnostic.rule, [diagnostic]); + } + } + return groups; +}; + +// Writes the full scan results into /.docker-doctor/ — diagnostics.json +// (the same shape as `--json`) plus one .txt per rule — so the handed-off +// agent can read past the inline prompt. Recreated fresh on every handoff. +export const writeDiagnosticsDirectory = async ( + diagnostics: Diagnostic[], + report: JsonReport, + rootDir: string +): Promise => { + const dir = path.join(rootDir, DIAGNOSTICS_DIR_NAME); + await fs.rm(dir, { force: true, recursive: true }); + await fs.mkdir(dir, { recursive: true }); + + await fs.writeFile( + path.join(dir, "diagnostics.json"), + JSON.stringify(report, null, 2), + "utf-8" + ); + + const writes: Promise[] = []; + for (const [rule, ruleDiagnostics] of groupDiagnosticsByRule(diagnostics)) { + const [first] = ruleDiagnostics; + const lines = [ + `${rule} (${first.severity})`, + first.message, + `Fix: ${first.help}`, + "", + ...ruleDiagnostics.map( + (d) => `${d.file}${d.line === undefined ? "" : `:${d.line}`}` + ), + "", + ]; + writes.push( + fs.writeFile( + path.join(dir, ruleFileName(rule)), + lines.join("\n"), + "utf-8" + ) + ); + } + await Promise.all(writes); +}; + +// Keeps the scan output out of version control: appends `.docker-doctor/` to +// the project's .gitignore when it isn't covered yet. Only creates a new +// .gitignore when the project is actually a git repository. +export const ensureGitignoreEntry = async (rootDir: string): Promise => { + const gitignorePath = path.join(rootDir, ".gitignore"); + + let existing: string | null = null; + try { + existing = await fs.readFile(gitignorePath, "utf-8"); + } catch { + existing = null; + } + + if (existing !== null) { + const isIgnored = existing + .split(/\r?\n/u) + .some((line) => + [ + DIAGNOSTICS_DIR_NAME, + `${DIAGNOSTICS_DIR_NAME}/`, + `/${DIAGNOSTICS_DIR_NAME}`, + `/${DIAGNOSTICS_DIR_NAME}/`, + ].includes(line.trim()) + ); + if (isIgnored) { + return; + } + const separator = existing.endsWith("\n") || existing === "" ? "" : "\n"; + await fs.writeFile( + gitignorePath, + `${existing}${separator}${DIAGNOSTICS_DIR_NAME}/\n`, + "utf-8" + ); + return; + } + + try { + await fs.access(path.join(rootDir, ".git")); + } catch { + return; + } + await fs.writeFile(gitignorePath, `${DIAGNOSTICS_DIR_NAME}/\n`, "utf-8"); +}; diff --git a/packages/docker-doctor/src/agents/handoff-payload.ts b/packages/docker-doctor/src/agents/handoff-payload.ts new file mode 100644 index 0000000..b08489e --- /dev/null +++ b/packages/docker-doctor/src/agents/handoff-payload.ts @@ -0,0 +1,77 @@ +import type { Diagnostic } from "@docker-doctor/core"; +import { findRule } from "@docker-doctor/core"; + +import { + DIAGNOSTICS_DIR_NAME, + groupDiagnosticsByRule, +} from "./diagnostics-dir"; + +const MAX_FILES_PER_RULE = 5; + +const SEVERITY_RANK: Record = { + error: 0, + info: 2, + warning: 1, +}; + +const SEVERITY_LABEL: Record = { + error: "ERROR", + info: "INFO", + warning: "WARN", +}; + +export interface HandoffPayloadInput { + diagnostics: Diagnostic[]; + projectName: string; +} + +// The prompt handed to the chosen agent: every rule group inline (docker +// projects rarely have hundreds of findings), errors first, each with its fix +// recipe and affected files, plus a pointer to the full on-disk report. +export const buildHandoffPayload = (input: HandoffPayloadInput): string => { + const groups = [ + ...groupDiagnosticsByRule(input.diagnostics).entries(), + ].toSorted(([, a], [, b]) => { + const rankDelta = + SEVERITY_RANK[a[0].severity] - SEVERITY_RANK[b[0].severity]; + return rankDelta === 0 ? b.length - a.length : rankDelta; + }); + + const issueWord = groups.length === 1 ? "issue" : "issues"; + const lines: string[] = [ + `Fix the ${groups.length} Docker Doctor ${issueWord} in ${input.projectName}.`, + "", + ]; + + for (const [index, [rule, ruleDiagnostics]] of groups.entries()) { + const [first] = ruleDiagnostics; + const category = findRule(rule)?.category ?? "General"; + const countBadge = + ruleDiagnostics.length > 1 ? ` (×${ruleDiagnostics.length})` : ""; + lines.push( + `${index + 1}. ${SEVERITY_LABEL[first.severity]} ${category}: ${first.message} [${rule}]${countBadge}`, + ` Fix: ${first.help}` + ); + const files = [...new Set(ruleDiagnostics.map((d) => d.file))]; + for (const file of files.slice(0, MAX_FILES_PER_RULE)) { + const firstSite = ruleDiagnostics.find( + (d) => d.file === file && d.line !== undefined + ); + lines.push(` - ${file}${firstSite ? `:${firstSite.line}` : ""}`); + } + const remaining = files.length - MAX_FILES_PER_RULE; + if (remaining > 0) { + lines.push(` - +${remaining} more files`); + } + } + + 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." + ); + + return lines.join("\n"); +}; diff --git a/packages/docker-doctor/src/agents/is-command-available.ts b/packages/docker-doctor/src/agents/is-command-available.ts new file mode 100644 index 0000000..c1752bf --- /dev/null +++ b/packages/docker-doctor/src/agents/is-command-available.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; + +const WINDOWS_EXTENSIONS = [".exe", ".cmd", ".bat"]; + +export const isCommandAvailable = (command: string): boolean => { + const pathValue = process.env.PATH ?? ""; + const extensions = process.platform === "win32" ? WINDOWS_EXTENSIONS : [""]; + + for (const dir of pathValue.split(path.delimiter)) { + if (dir === "") { + continue; + } + for (const extension of extensions) { + try { + fs.accessSync(path.join(dir, command + extension), fs.constants.X_OK); + return true; + } catch { + // Not in this directory — keep looking. + } + } + } + return false; +}; diff --git a/packages/docker-doctor/src/agents/launch-agent.ts b/packages/docker-doctor/src/agents/launch-agent.ts new file mode 100644 index 0000000..505d71b --- /dev/null +++ b/packages/docker-doctor/src/agents/launch-agent.ts @@ -0,0 +1,27 @@ +import { spawn } from "node:child_process"; + +import type { LaunchableAgentId } from "./launchable-agents"; +import { AGENT_AUTO_FLAGS, AGENT_BINARIES } from "./launchable-agents"; + +// Launches the agent's CLI with the prompt as its initial argument, handing it +// this terminal. Resolves true when the agent process exits normally, false +// when it could not be started (caller falls back to the clipboard path). +export const launchAgent = ( + agentId: LaunchableAgentId, + prompt: string +): Promise => + /* eslint-disable promise/avoid-new */ + new Promise((resolve) => { + const child = spawn( + AGENT_BINARIES[agentId], + [...AGENT_AUTO_FLAGS[agentId], prompt], + { stdio: "inherit" } + ); + child.once("error", () => { + resolve(false); + }); + child.once("exit", () => { + resolve(true); + }); + }); +/* eslint-enable promise/avoid-new */ diff --git a/packages/docker-doctor/src/agents/launchable-agents.ts b/packages/docker-doctor/src/agents/launchable-agents.ts new file mode 100644 index 0000000..6807e0f --- /dev/null +++ b/packages/docker-doctor/src/agents/launchable-agents.ts @@ -0,0 +1,38 @@ +import { isCommandAvailable } from "./is-command-available"; + +export type LaunchableAgentId = "claude-code" | "codex" | "cursor"; + +export const LAUNCHABLE_AGENT_IDS: readonly LaunchableAgentId[] = [ + "claude-code", + "codex", + "cursor", +]; + +// CLI agents we can hand off to by launching their binary with the prompt as +// the initial argument, inheriting this terminal — the agent takes over the +// TTY and control returns here when it exits. +export const AGENT_BINARIES: Record = { + "claude-code": "claude", + codex: "codex", + cursor: "cursor-agent", +}; + +// Each agent's skip-approvals flag. The handoff exists so the agent can fix +// the issues end-to-end; the user opted in by picking it from the menu. +export const AGENT_AUTO_FLAGS: Record = { + "claude-code": ["--dangerously-skip-permissions"], + codex: ["--yolo"], + cursor: ["--force"], +}; + +// Launchable = the agent's CLI binary is on PATH. Windows is excluded: npm +// installs .cmd shims there that `spawn` can only run through a shell, and a +// shell mangles the multi-line prompt — those users get the clipboard path. +export const detectLaunchableAgents = (): LaunchableAgentId[] => { + if (process.platform === "win32") { + return []; + } + return LAUNCHABLE_AGENT_IDS.filter((agentId) => + isCommandAvailable(AGENT_BINARIES[agentId]) + ); +}; diff --git a/packages/docker-doctor/src/agents/skill-install.ts b/packages/docker-doctor/src/agents/skill-install.ts new file mode 100644 index 0000000..ed61323 --- /dev/null +++ b/packages/docker-doctor/src/agents/skill-install.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { SkillAgentType, SkillInstallResult } from "agent-install"; +import { SKILL_MANIFEST_FILE, installSkillsFromSource } from "agent-install"; + +const moduleDir = import.meta.dirname; + +// The published package ships the skill at /skill (copied from +// skills/docker-doctor at build time); when running from the repo the source +// itself is the fallback. Returns null when neither exists. +export const getSkillSourceDirectory = (): string | null => { + const candidates = [ + // dist/cli.mjs → package root/skill/docker-doctor + path.resolve(moduleDir, "../skill/docker-doctor"), + // src/agents/*.ts in the monorepo → repo root/skills/docker-doctor + path.resolve(moduleDir, "../../../../skills/docker-doctor"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, SKILL_MANIFEST_FILE))) { + return candidate; + } + } + return null; +}; + +// Copies the bundled docker-doctor skill into each agent's project-level +// skills dir so the agent already knows the /docker-doctor triage workflow. +// Best-effort: returns null when the bundled skill is missing or the install +// throws — callers treat that as "skill not installed", never as a failure. +export const installSkillForAgents = async ( + agents: SkillAgentType[], + projectRoot: string +): Promise => { + const source = getSkillSourceDirectory(); + if (!source) { + return null; + } + try { + return await installSkillsFromSource({ + agents, + cwd: projectRoot, + mode: "copy", + source, + }); + } catch { + return null; + } +}; diff --git a/packages/docker-doctor/src/cli.ts b/packages/docker-doctor/src/cli.ts index f4028a7..4b78353 100644 --- a/packages/docker-doctor/src/cli.ts +++ b/packages/docker-doctor/src/cli.ts @@ -4,7 +4,7 @@ import path from "node:path"; import readline from "node:readline"; import { setTimeout } from "node:timers/promises"; -import type { Diagnostic, RuleSeverity } from "@docker-doctor/core"; +import type { Diagnostic, JsonReport, RuleSeverity } from "@docker-doctor/core"; import { discoverProject, parseDockerfile, @@ -17,10 +17,32 @@ import { findRule, toJsonReport, } from "@docker-doctor/core"; +import type { SkillAgentType } from "agent-install"; +import { + detectInstalledSkillAgents, + getSkillAgentConfig, + getSkillAgentTypes, + isSkillAgentType, +} from "agent-install"; import chalk from "chalk"; import { Command } from "commander"; import packageJson from "../package.json" with { type: "json" }; +import { copyToClipboard } from "./agents/clipboard"; +import { + ensureGitignoreEntry, + writeDiagnosticsDirectory, +} from "./agents/diagnostics-dir"; +import { buildHandoffPayload } from "./agents/handoff-payload"; +import { launchAgent } from "./agents/launch-agent"; +import { + AGENT_BINARIES, + detectLaunchableAgents, +} from "./agents/launchable-agents"; +import { + getSkillSourceDirectory, + installSkillForAgents, +} from "./agents/skill-install"; import { formatTerminal } from "./formatters/terminal"; interface KeypressKey { @@ -213,7 +235,192 @@ const askSelect = ( /* eslint-enable promise/avoid-new */ }; -const runInteractiveWizard = async (): Promise => { +interface MultiSelectOption { + label: string; + selected: boolean; +} + +const askMultiSelect = ( + question: string, + options: MultiSelectOption[] +): Promise => { + const isRaw = process.stdin.isTTY; + if (!isRaw) { + return Promise.resolve( + options.flatMap((option, i) => (option.selected ? [i] : [])) + ); + } + + /* eslint-disable promise/avoid-new */ + return new Promise((resolve) => { + let index = 0; + const selected = options.map((option) => option.selected); + const lineCount = options.length + 2; + + readline.emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdin.resume(); + + // Hide cursor during prompt + process.stdout.write("\u001B[?25l"); + + const render = (firstTime = false) => { + if (!firstTime) { + process.stdout.write(`\u001B[${lineCount}A\r`); + } + + process.stdout.write( + `\r\u001B[K ${chalk.green("✔")} ${chalk.bold(question)}\n` + ); + + let i = 0; + for (const option of options) { + const isCursor = i === index; + const cursor = isCursor ? chalk.cyan("❯ ") : " "; + const box = selected[i] ? chalk.cyan("[x]") : chalk.dim("[ ]"); + let text = chalk.dim(option.label); + if (isCursor) { + text = chalk.cyan.bold(option.label); + } else if (selected[i]) { + text = option.label; + } + process.stdout.write(`\r\u001B[K${cursor}${box} ${text}\n`); + i += 1; + } + process.stdout.write( + `\r\u001B[K ${chalk.dim("space to toggle · enter to confirm")}\n` + ); + }; + + render(true); + + const handleKeypress = (str: string, key: KeypressKey) => { + const cleanup = () => { + process.stdin.removeListener("keypress", handleKeypress); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + process.stdout.write("\u001B[?25h"); + }; + + if (key.name === "up" || key.name === "k") { + index = (index - 1 + options.length) % options.length; + render(); + } else if (key.name === "down" || key.name === "j") { + index = (index + 1) % options.length; + render(); + } else if (key.name === "space" || str === " ") { + selected[index] = !selected[index]; + render(); + } else if ( + key.name === "return" || + key.name === "enter" || + str === "\r" || + str === "\n" + ) { + cleanup(); + const chosen = options.flatMap((option, i) => + selected[i] ? [option.label] : [] + ); + // Overwrite the prompt with a one-line summary + process.stdout.write(`\u001B[${lineCount}A\r\u001B[K`); + process.stdout.write( + ` ${chalk.green("✔")} ${chalk.bold(question)} › ${chosen.length > 0 ? chalk.cyan(chosen.join(", ")) : chalk.dim("none")}\n` + ); + for (let i = 0; i < lineCount - 1; i += 1) { + process.stdout.write("\r\u001B[K\n"); + } + process.stdout.write(`\u001B[${lineCount - 1}A`); + resolve(options.flatMap((_, i) => (selected[i] ? [i] : []))); + } else if (key.ctrl && key.name === "c") { + cleanup(); + process.stdout.write("\n"); + process.exit(130); + } + }; + + process.stdin.on("keypress", handleKeypress); + }); + /* eslint-enable promise/avoid-new */ +}; + +const printAgentPrompt = (payload: string): void => { + console.log(`\n${chalk.dim("──── Agent prompt ────")}`); + console.log(payload); + console.log(chalk.dim("──────────────────────")); +}; + +interface WizardContext { + diagnostics: Diagnostic[]; + report: JsonReport; + rootDir: string; +} + +// getSkillAgentConfig rejects the synthetic "universal" id at the type level. +const agentDisplayName = (agent: SkillAgentType): string => + agent === "universal" ? "Universal" : getSkillAgentConfig(agent).displayName; + +// Post-scan handoff: offer to send the findings to a coding agent detected on +// this machine (launching it with the issues as its prompt), or copy the +// prompt for any other agent. Only reached when the scan found something. +const runAgentHandoff = async (context: WizardContext): Promise => { + const launchable = detectLaunchableAgents(); + const options = [ + ...launchable.map((agentId) => agentDisplayName(agentId)), + "Copy prompt to clipboard", + "Skip", + ]; + const skipIndex = options.length - 1; + const clipboardIndex = options.length - 2; + + const choice = await askSelect("What would you like to do next?", options); + if (choice === skipIndex) { + return; + } + + await writeDiagnosticsDirectory( + context.diagnostics, + context.report, + context.rootDir + ); + await ensureGitignoreEntry(context.rootDir); + + const payload = buildHandoffPayload({ + diagnostics: context.diagnostics, + projectName: path.basename(context.rootDir), + }); + + if (choice === clipboardIndex) { + const copied = await copyToClipboard(payload); + if (copied) { + console.log( + `\n ${chalk.green("✔")} Prompt copied — paste it into any agent or chat.` + ); + } else { + printAgentPrompt(payload); + } + return; + } + + const agentId = launchable[choice]; + const installResult = await installSkillForAgents([agentId], context.rootDir); + if (installResult && installResult.installed.length > 0) { + console.log( + `\n ${chalk.green("✔")} Installed the docker-doctor skill for ${agentDisplayName(agentId)}` + ); + } + console.log(`\n Handing off to ${agentDisplayName(agentId)}...\n`); + const launched = await launchAgent(agentId, payload); + if (!launched) { + console.log( + ` ${chalk.yellow("⚠")} Couldn't launch ${AGENT_BINARIES[agentId]}. Here's the prompt instead:` + ); + printAgentPrompt(payload); + } +}; + +const runInteractiveWizard = async (context: WizardContext): Promise => { try { const addGhActions = await askConfirm( "Add Docker Doctor to GitHub Actions?" @@ -249,19 +456,10 @@ jobs: ); } - const nextChoice = await askSelect("What would you like to do next?", [ - "View rules list", - "Skip", - ]); - - if (nextChoice === 0) { - console.log(`\n ${chalk.bold("Available Rules:")}`); - for (const r of allRules) { - console.log( - ` - ${chalk.cyan(r.key)}: ${r.message} (${chalk.dim(r.category)})` - ); - } + if (context.diagnostics.length === 0) { + return; } + await runAgentHandoff(context); } catch { // Ignore prompt errors } @@ -507,7 +705,11 @@ program ); if (process.stdout.isTTY && process.stdin.isTTY) { - await runInteractiveWizard(); + await runInteractiveWizard({ + diagnostics: filteredDiagnostics, + report: toJsonReport(filteredDiagnostics, score, label, project), + rootDir, + }); } process.exitCode = hasErrors ? 1 : 0; } finally { @@ -523,6 +725,101 @@ program } }); +// Curated picker entries shown alongside whatever agents are detected on this +// machine — any other agent-install id still works via --agent. +const CURATED_INSTALL_AGENTS: SkillAgentType[] = [ + "claude-code", + "codex", + "cursor", + "opencode", +]; + +const resolveInstallAgents = async ( + requested: string[] | undefined +): Promise => { + if (requested && requested.length > 0) { + const invalid = requested.filter((agent) => !isSkillAgentType(agent)); + if (invalid.length > 0) { + console.error(`Unknown agent id(s): ${invalid.join(", ")}`); + console.error( + `Valid ids: ${getSkillAgentTypes() + .filter((agent) => agent !== "universal") + .join(", ")}` + ); + return null; + } + return requested.filter((agent) => isSkillAgentType(agent)); + } + + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + console.error( + "Non-interactive run: pass --agent (e.g. --agent claude-code cursor)." + ); + return null; + } + + const installedAgents = await detectInstalledSkillAgents(); + const detected = installedAgents.filter((agent) => agent !== "universal"); + const choices = [...new Set([...detected, ...CURATED_INSTALL_AGENTS])]; + const detectedSet = new Set(detected); + const picked = await askMultiSelect( + "Which coding agents should get the docker-doctor skill?", + choices.map((agent) => ({ + label: agentDisplayName(agent), + selected: detectedSet.has(agent), + })) + ); + return picked.map((i) => choices[i]); +}; + +program + .command("install") + .description("install the Docker Doctor agent skill for your coding agents") + .option( + "-a, --agent ", + "agent id(s) to install for (e.g. claude-code codex cursor)" + ) + .action(async (options: { agent?: string[] }) => { + const source = getSkillSourceDirectory(); + if (!source) { + console.error( + "Bundled skill not found — this looks like a broken installation." + ); + process.exit(1); + } + + const agents = await resolveInstallAgents(options.agent); + if (agents === null) { + process.exit(1); + } + if (agents.length === 0) { + console.log("Nothing selected — skipped."); + return; + } + + const result = await installSkillForAgents(agents, process.cwd()); + if (!result) { + console.error("Failed to install the skill."); + process.exit(1); + } + for (const installed of result.installed) { + console.log( + ` ${chalk.green("✔")} ${agentDisplayName(installed.agent)} → ${installed.path}` + ); + } + for (const failed of result.failed) { + console.log( + ` ${chalk.red("✖")} ${agentDisplayName(failed.agent)}: ${failed.error}` + ); + } + if (result.installed.length > 0) { + console.log( + `\n The agent can now run ${chalk.cyan("/docker-doctor")} to scan and triage this project.` + ); + } + process.exitCode = result.failed.length > 0 ? 1 : 0; + }); + // Rules subcommand group const rules = program .command("rules") diff --git a/packages/docker-doctor/tsdown.config.ts b/packages/docker-doctor/tsdown.config.ts index 7080d74..be38589 100644 --- a/packages/docker-doctor/tsdown.config.ts +++ b/packages/docker-doctor/tsdown.config.ts @@ -5,6 +5,9 @@ export default defineConfig({ js: "#!/usr/bin/env node", }, clean: true, + // Ship the agent skill with the package so `docker-doctor install` and the + // post-scan handoff can copy it into agents' skills dirs. + copy: [{ from: "../../skills/docker-doctor", to: "skill" }], deps: { alwaysBundle: ["@docker-doctor/core", "chalk"], },