skills-doctor 0.3.0 → 0.4.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/README.md +26 -10
  3. package/bin/skills-doctor.js +2 -1
  4. package/dist/cli/commands/scan.d.ts +2 -0
  5. package/dist/cli/commands/scan.d.ts.map +1 -1
  6. package/dist/cli/commands/scan.js +104 -45
  7. package/dist/cli/index.d.ts +5 -1
  8. package/dist/cli/index.d.ts.map +1 -1
  9. package/dist/cli/index.js +38 -3
  10. package/dist/cli/utils/handoff-to-agent.d.ts.map +1 -1
  11. package/dist/cli/utils/handoff-to-agent.js +26 -10
  12. package/dist/domain/build-handoff-prompt.d.ts.map +1 -1
  13. package/dist/domain/build-handoff-prompt.js +6 -9
  14. package/dist/domain/build-report.d.ts.map +1 -1
  15. package/dist/domain/build-report.js +8 -2
  16. package/dist/domain/calculate-score.d.ts +4 -1
  17. package/dist/domain/calculate-score.d.ts.map +1 -1
  18. package/dist/domain/calculate-score.js +5 -1
  19. package/dist/domain/group-findings.d.ts +8 -0
  20. package/dist/domain/group-findings.d.ts.map +1 -0
  21. package/dist/domain/group-findings.js +30 -0
  22. package/dist/domain/parse-skill.d.ts.map +1 -1
  23. package/dist/domain/parse-skill.js +9 -6
  24. package/dist/domain/rule-catalog.d.ts +199 -0
  25. package/dist/domain/rule-catalog.d.ts.map +1 -0
  26. package/dist/domain/rule-catalog.js +230 -0
  27. package/dist/domain/rules/quality.d.ts +7 -1
  28. package/dist/domain/rules/quality.d.ts.map +1 -1
  29. package/dist/domain/rules/quality.js +103 -19
  30. package/dist/domain/scan-skills.d.ts +2 -1
  31. package/dist/domain/scan-skills.d.ts.map +1 -1
  32. package/dist/domain/scan-skills.js +101 -25
  33. package/dist/domain/summarize-findings.d.ts +6 -1
  34. package/dist/domain/summarize-findings.d.ts.map +1 -1
  35. package/dist/domain/summarize-findings.js +18 -3
  36. package/dist/domain/write-findings-directory.d.ts.map +1 -1
  37. package/dist/domain/write-findings-directory.js +13 -11
  38. package/dist/index.d.ts +3 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -1
  41. package/package.json +5 -4
  42. package/skills/skills-doctor/SKILL.md +23 -0
package/CHANGELOG.md CHANGED
@@ -6,7 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- *No changes yet.*
9
+ ## [0.4.0] - 2026-06-18
10
+
11
+ ### Added
12
+
13
+ - Exposed the rule catalog as structured public API metadata.
14
+ - Added opt-in `--fail-on` and `--min-score` scan quality gates for automation.
15
+ - Included the agent-facing `skills/skills-doctor` wrapper in the npm package.
16
+ - Added injectable resource and eval existence checks for `validateQualityRules`.
17
+ - Added a public API and `schemaVersion: 1` report schema reference.
18
+ - Tested the declared Node runtime floor in CI and aligned the package engine range to Node 22.13+.
19
+
20
+ ### Changed
21
+
22
+ - Made non-interactive scans fail on ambiguous local/global or Claude/Codex root choices instead of scanning all detected roots.
23
+ - Added bounded concurrent `SKILL.md` reads while preserving deterministic scan order.
24
+ - Reused shared grouped-finding indexes for report summaries and grouped output.
25
+ - Made the manual release checklist version-aware and aligned with tag-derived release notes.
26
+ - Replaced the stale reusable CLI spec with a Skills Doctor-specific architecture map.
27
+
28
+ ### Fixed
29
+
30
+ - Restored the documented `missing-skill` and `invalid-frontmatter` rule behavior so the structured rule catalog matches scanner findings.
31
+ - Stopped trailing sentence punctuation from creating false missing-resource warnings for valid skill resource references.
32
+ - Wrote repair handoff reports and prompts before stopping when no local `claude` or `codex` agent is available.
33
+ - Recognized normal `--help` mentions when checking script help guidance.
34
+ - Prevented long per-skill handoff report filenames from colliding after truncation.
35
+ - Returned JSON error reports for parse-level CLI failures when `--json` is set.
36
+ - Made the local `bun run dev` script execute the same CLI bin path used by packaged runs.
37
+ - Rejected symlinked skill resource references that resolve outside the skill directory.
38
+ - Preserved custom-root discovery diagnostics in scan reports when interactive scans add a missing custom skills path.
39
+ - Reflected blocking diagnostic failures in scan scores so diagnostic-only failures no longer report a perfect score.
40
+ - Reported unreadable direct-child `SKILL.md` entries as blocking diagnostics while continuing to scan other skills.
41
+ - Kept repair handoff prompts available inline when writing `handoff-prompt.md` fails after report-directory creation.
42
+ - Kept the release workflow on token-backed `bun publish` until npm trusted publishing is configured for the package.
43
+
44
+ ### Tests
45
+
46
+ - Tightened rule-catalog synchronization coverage so docs-only, catalog-only, and emitted-rule drift fail together.
47
+ - Covered script help guidance warnings for existing script references.
48
+ - Added packaged CLI smoke coverage for JSON stdout and blocking-scan exit behavior.
49
+
50
+ ## [0.3.1] - 2026-06-16
51
+
52
+ ### Added
53
+
54
+ - Added line numbers to quality-rule findings where a specific source line can be resolved.
55
+
56
+ ### Fixed
57
+
58
+ - Hid repair handoff subset options that do not match any findings (for warning-only and advice-only scans).
59
+ - Made CLI module import safe by removing side-effect execution and routing runtime entry through `bin/skills-doctor.js`.
10
60
 
11
61
  ## [0.3.0] - 2026-06-16
12
62
 
package/README.md CHANGED
@@ -40,6 +40,8 @@ Use the `skills-doctor` skill when people ask for an agent workflow to:
40
40
 
41
41
  The CLI is the primary source of rule logic and output shape.
42
42
  The skill wrapper is a convenience entrypoint so agents can discover and invoke the same scanner from within skill workflows.
43
+ When installed from npm, the wrapper is shipped at `node_modules/skills-doctor/skills/skills-doctor/SKILL.md`.
44
+ Copy or reference that file from an agent skill directory when you want an agent to discover the workflow, but keep scans delegated to `bunx skills-doctor@latest` or the installed `skills-doctor` binary.
43
45
 
44
46
  ## What It Scans
45
47
 
@@ -59,8 +61,9 @@ to scan local project skills, global/root skills, or both. When both Claude and
59
61
  Codex/agents roots exist in the selected scope, it asks whether to scan Claude,
60
62
  Codex/agents, or both. If you already have standard roots detected, it also lets
61
63
  you add a custom skills directory path in the same interactive flow. Non-interactive
62
- runs use conservative defaults and fail with a clear user error when a required
63
- choice cannot be made.
64
+ runs scan only a single unambiguous detected root. If multiple local/global
65
+ scopes or multiple ecosystems are detected, they fail with a clear user error
66
+ instead of guessing.
64
67
 
65
68
  ## What It Checks
66
69
 
@@ -77,15 +80,17 @@ the Agent Skills standards from <https://agentskills.io/home>, including:
77
80
  - divergent same-name skills across Claude and Codex/agents roots
78
81
 
79
82
  See `docs/RULES.md` for a full rule catalog, severity, and intended rationale.
83
+ Programmatic consumers can import `ruleCatalog` for the same metadata as structured data.
80
84
 
81
85
  Findings are grouped as blocking errors, warnings, and advisory improvements.
82
86
  The human summary opens with a score header showing a face, `0` to `100` score,
83
87
  label, and proportional terminal bar. The score starts at 100 and deducts 1.5
84
88
  points for each distinct error rule and 0.75 points for each distinct warning
85
- rule; repeated findings from the same rule do not increase the penalty. Advisory
86
- findings are counted in the report but do not affect the score. Score labels are
87
- `Great` for 75 or higher, `Needs work` for 50 through 74, and `Critical` below
88
- 50.
89
+ rule; each distinct blocking diagnostic code is scored like an error rule.
90
+ Repeated findings from the same rule do not increase the penalty. Advisory
91
+ findings and warning diagnostics are counted in the report but do not affect the
92
+ score. Score labels are `Great` for 75 or higher, `Needs work` for 50 through
93
+ 74, and `Critical` below 50.
89
94
 
90
95
  ## Interactive Repair Flow
91
96
 
@@ -119,10 +124,14 @@ Use JSON output for automation:
119
124
  skills-doctor --json
120
125
  skills-doctor --json --json-compact
121
126
  skills-doctor --yes --json
127
+ skills-doctor --yes --json --fail-on warning
128
+ skills-doctor --yes --json --min-score 95
122
129
  ```
123
130
 
124
131
  JSON mode writes one machine-readable report to stdout and suppresses prompts
125
132
  and spinners. Human logs and expected errors stay out of stdout.
133
+ By default, the exit code fails only for blocking errors and error diagnostics.
134
+ Use `--fail-on warning`, `--fail-on advice`, or `--min-score <number>` for stricter CI gates.
126
135
 
127
136
  ## Programmatic API
128
137
 
@@ -143,6 +152,7 @@ const report = buildScanReport({
143
152
  ```
144
153
 
145
154
  The CLI remains the primary interface for interactive repair workflows.
155
+ See `docs/API.md` for supported exports and the `schemaVersion: 1` report schema.
146
156
 
147
157
  ## Exit Codes
148
158
 
@@ -162,15 +172,21 @@ agent then follows its own configuration.
162
172
  Before tagging a release:
163
173
 
164
174
  ```bash
175
+ VERSION=<x.y.z>
165
176
  bun run verify
166
177
  bun run pack:dry-run
167
- node scripts/extract-release-notes.mjs 0.1.0
178
+ node scripts/extract-release-notes.mjs "$VERSION"
168
179
  ```
169
180
 
170
181
  Then:
171
182
 
172
183
  1. Update `package.json` version.
173
184
  2. Move changelog entries from `Unreleased` into `## [x.y.z] - YYYY-MM-DD`.
174
- 3. Commit the release prep.
175
- 4. Tag `v<x.y.z>`.
176
- 5. Push the tag to trigger the release workflow.
185
+ 3. Confirm `node scripts/extract-release-notes.mjs "$VERSION"` prints the intended notes.
186
+ 4. Commit the release prep.
187
+ 5. Tag `v<x.y.z>`.
188
+ 6. Ensure the `NPM_TOKEN` repository secret can publish the npm package.
189
+ 7. Push the tag to trigger the release workflow.
190
+
191
+ The release workflow derives the same version from the pushed tag with
192
+ `node scripts/extract-release-notes.mjs "${GITHUB_REF_NAME#v}"`.
@@ -10,4 +10,5 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
10
10
  }
11
11
  }
12
12
 
13
- await import("../dist/cli/index.js");
13
+ const { runCli } = await import("../dist/cli/index.js");
14
+ await runCli();
@@ -6,6 +6,8 @@ export type ScanFlags = {
6
6
  readonly json?: boolean;
7
7
  readonly jsonCompact?: boolean;
8
8
  readonly yes?: boolean;
9
+ readonly failOn?: string | undefined;
10
+ readonly minScore?: string | undefined;
9
11
  };
10
12
  export type ScanActionOptions = {
11
13
  readonly cwd?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/scan.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAU/D,OAAO,KAAK,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM5F,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGhF,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAEzE,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACjC,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,QAAQ,CAAC,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;IACzD,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,WAAW,CAAC,EAAE,mBAAmB,CAAC;CAC5C,CAAC;AAMF,eAAO,MAAM,UAAU,GACrB,WAAW,MAAM,EACjB,OAAO,SAAS,EAChB,UAAS,iBAAsB,KAC9B,OAAO,CAAC,UAAU,CAsGpB,CAAC"}
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/scan.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAgB/D,OAAO,KAAK,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM5F,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGhF,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAEzE,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACjC,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,QAAQ,CAAC,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;IACzD,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,WAAW,CAAC,EAAE,mBAAmB,CAAC;CAC5C,CAAC;AAUF,eAAO,MAAM,UAAU,GACrB,WAAW,MAAM,EACjB,OAAO,SAAS,EAChB,UAAS,iBAAsB,KAC9B,OAAO,CAAC,UAAU,CAkHpB,CAAC"}
@@ -2,8 +2,9 @@ import path from "node:path";
2
2
  import { buildScanReport } from "../../domain/build-report.js";
3
3
  import { compareFindings, renderPostHandoffSummary } from "../../domain/compare-findings.js";
4
4
  import { discoverSkillRoots } from "../../domain/discover-skill-roots.js";
5
+ import { groupFindingsByKey } from "../../domain/group-findings.js";
5
6
  import { scanSkillRoots } from "../../domain/scan-skills.js";
6
- import { renderHumanSummary, resolveScanExitCode } from "../../domain/summarize-findings.js";
7
+ import { renderHumanSummary, resolveScanExitCode, } from "../../domain/summarize-findings.js";
7
8
  import { CliInputError } from "../utils/handle-error.js";
8
9
  import { prepareRepairHandoff } from "../utils/handoff-to-agent.js";
9
10
  import { enableJsonMode, writeJsonReport } from "../utils/json-mode.js";
@@ -14,6 +15,7 @@ import { shouldSkipPrompts } from "../utils/should-skip-prompts.js";
14
15
  import { createSpinner } from "../utils/spinner.js";
15
16
  export const scanAction = async (directory, flags, options = {}) => {
16
17
  const cwd = path.resolve(options.cwd ?? process.cwd(), directory);
18
+ const gateOptions = resolveGateOptions(flags);
17
19
  const prompts = options.prompts ?? inquirerPromptAdapter;
18
20
  const writeStdout = options.writeStdout ?? ((message) => process.stdout.write(message));
19
21
  const writeStderr = options.writeStderr ?? ((message) => process.stderr.write(message));
@@ -35,36 +37,46 @@ export const scanAction = async (directory, flags, options = {}) => {
35
37
  }
36
38
  const discovered = await spinner.run("Finding local skill roots...", () => discoverSkillRoots({ cwd, homeDir: options.homeDir }));
37
39
  let roots = discovered.roots;
40
+ const diagnostics = [...discovered.diagnostics];
38
41
  if (roots.length === 0) {
39
42
  if (skipPrompts) {
40
43
  throw new CliInputError("No .claude/skills or .agents/skills root was found. Re-run interactively or add a supported skills root.");
41
44
  }
42
- roots = await selectCustomRoot({
45
+ const selection = await selectCustomRoot({
43
46
  cwd,
44
47
  homeDir: options.homeDir,
45
48
  prompts,
46
49
  roots,
47
50
  });
51
+ roots = selection.roots;
52
+ diagnostics.push(...selection.diagnostics);
53
+ }
54
+ else if (skipPrompts) {
55
+ assertNonInteractiveRootSelectionIsUnambiguous(roots);
48
56
  }
49
57
  else if (!skipPrompts) {
50
- roots = await selectRootScopes({
58
+ const scopeSelection = await selectRootScopes({
51
59
  roots,
52
60
  prompts,
53
61
  cwd,
54
62
  homeDir: options.homeDir,
55
63
  });
56
- roots = await selectRoots({
64
+ roots = scopeSelection.roots;
65
+ diagnostics.push(...scopeSelection.diagnostics);
66
+ const rootSelection = await selectRoots({
57
67
  roots,
58
68
  prompts,
59
69
  cwd,
60
70
  homeDir: options.homeDir,
61
71
  });
72
+ roots = rootSelection.roots;
73
+ diagnostics.push(...rootSelection.diagnostics);
62
74
  }
63
75
  if (roots.length === 0) {
64
76
  throw new CliInputError("No readable skills root was selected.");
65
77
  }
66
78
  const startedAt = now();
67
- const scan = await spinner.run("Scanning skills...", () => scanSkillRoots({ roots }));
79
+ const scan = await spinner.run("Scanning skills...", () => scanSkillRoots({ roots, diagnostics }));
68
80
  const elapsedMilliseconds = Math.max(0, Math.round(now() - startedAt));
69
81
  const report = buildScanReport({
70
82
  version: options.version ?? "0.0.0",
@@ -102,9 +114,40 @@ export const scanAction = async (directory, flags, options = {}) => {
102
114
  })) ?? report;
103
115
  }
104
116
  }
105
- process.exitCode = resolveScanExitCode(finalReport);
117
+ process.exitCode = resolveScanExitCode(finalReport, gateOptions);
106
118
  return finalReport;
107
119
  };
120
+ const resolveGateOptions = (flags) => ({
121
+ failOn: parseFailOnSeverity(flags.failOn),
122
+ minScore: parseMinScore(flags.minScore),
123
+ });
124
+ const parseFailOnSeverity = (value) => {
125
+ if (value === undefined)
126
+ return undefined;
127
+ if (value === "error" || value === "warning" || value === "advice")
128
+ return value;
129
+ throw new CliInputError("Invalid --fail-on value. Use one of: error, warning, advice.");
130
+ };
131
+ const parseMinScore = (value) => {
132
+ if (value === undefined)
133
+ return undefined;
134
+ const score = Number(value);
135
+ if (!Number.isFinite(score) || score < 0 || score > 100) {
136
+ throw new CliInputError("Invalid --min-score value. Use a number from 0 to 100.");
137
+ }
138
+ return score;
139
+ };
140
+ const assertNonInteractiveRootSelectionIsUnambiguous = (roots) => {
141
+ const standardRoots = roots.filter((root) => root.source !== "custom");
142
+ const standardSources = new Set(standardRoots.map((root) => root.source));
143
+ if (standardSources.has("local") && standardSources.has("global")) {
144
+ throw new CliInputError("Multiple local and global skills roots were found. Re-run interactively to choose which scope to scan.");
145
+ }
146
+ const standardEcosystems = new Set(standardRoots.map((root) => root.ecosystem));
147
+ if (standardEcosystems.has("claude") && standardEcosystems.has("codex")) {
148
+ throw new CliInputError("Multiple Claude and Codex/agents skills roots were found. Re-run interactively to choose which ecosystem to scan.");
149
+ }
150
+ };
108
151
  const selectRoots = async (input) => {
109
152
  const { prompts, cwd, homeDir, roots } = input;
110
153
  const customRoots = roots.filter((root) => root.source === "custom");
@@ -112,7 +155,7 @@ const selectRoots = async (input) => {
112
155
  const hasClaude = standardRoots.some((root) => root.ecosystem === "claude");
113
156
  const hasCodex = standardRoots.some((root) => root.ecosystem === "codex");
114
157
  if (!hasClaude || !hasCodex)
115
- return roots;
158
+ return { roots, diagnostics: [] };
116
159
  const selection = await prompts.select("Choose skills folder to scan", [
117
160
  { name: "Both", value: "all" },
118
161
  { name: "Claude (.claude/skills)", value: "claude" },
@@ -120,7 +163,7 @@ const selectRoots = async (input) => {
120
163
  { name: "Add custom skills path", value: "custom" },
121
164
  ]);
122
165
  if (selection === "all")
123
- return roots;
166
+ return { roots, diagnostics: [] };
124
167
  if (selection === "custom") {
125
168
  return selectCustomRoot({
126
169
  cwd,
@@ -129,7 +172,10 @@ const selectRoots = async (input) => {
129
172
  roots,
130
173
  });
131
174
  }
132
- return [...standardRoots.filter((root) => root.ecosystem === selection), ...customRoots];
175
+ return {
176
+ roots: [...standardRoots.filter((root) => root.ecosystem === selection), ...customRoots],
177
+ diagnostics: [],
178
+ };
133
179
  };
134
180
  const selectRootScopes = async (input) => {
135
181
  const { prompts, cwd, homeDir, roots } = input;
@@ -137,7 +183,7 @@ const selectRootScopes = async (input) => {
137
183
  const hasGlobal = roots.some((root) => root.source === "global");
138
184
  const hasBothScopes = hasLocal && hasGlobal;
139
185
  if (!hasLocal && !hasGlobal)
140
- return roots;
186
+ return { roots, diagnostics: [] };
141
187
  const allLabel = hasBothScopes
142
188
  ? "Both local project and global/root skills"
143
189
  : "Detected skills root";
@@ -156,7 +202,7 @@ const selectRootScopes = async (input) => {
156
202
  }
157
203
  choices.push({ name: "Add custom skills path", value: "custom" });
158
204
  if (choices.length <= 1) {
159
- return roots;
205
+ return { roots, diagnostics: [] };
160
206
  }
161
207
  const selection = await prompts.select("Choose skills scope to scan", choices);
162
208
  if (selection === "custom") {
@@ -168,17 +214,17 @@ const selectRootScopes = async (input) => {
168
214
  });
169
215
  }
170
216
  if (selection === "all")
171
- return roots;
217
+ return { roots, diagnostics: [] };
172
218
  if (selection === "claude") {
173
- return roots.filter((root) => root.ecosystem === "claude");
219
+ return { roots: roots.filter((root) => root.ecosystem === "claude"), diagnostics: [] };
174
220
  }
175
221
  if (selection === "codex") {
176
- return roots.filter((root) => root.ecosystem === "codex");
222
+ return { roots: roots.filter((root) => root.ecosystem === "codex"), diagnostics: [] };
177
223
  }
178
224
  if (selection === "local" || selection === "global") {
179
- return roots.filter((root) => root.source === selection);
225
+ return { roots: roots.filter((root) => root.source === selection), diagnostics: [] };
180
226
  }
181
- return roots;
227
+ return { roots, diagnostics: [] };
182
228
  };
183
229
  const selectCustomRoot = async (input) => {
184
230
  const customRoot = await input.prompts.input("Skills directory path", ".");
@@ -187,7 +233,10 @@ const selectCustomRoot = async (input) => {
187
233
  homeDir: input.homeDir,
188
234
  customRoots: [{ rootPath: customRoot, ecosystem: "custom" }],
189
235
  });
190
- return mergeRoots(input.roots, custom.roots);
236
+ return {
237
+ roots: mergeRoots(input.roots, custom.roots),
238
+ diagnostics: custom.diagnostics,
239
+ };
191
240
  };
192
241
  const mergeRoots = (existingRoots, additionalRoots) => {
193
242
  const merged = new Map();
@@ -228,34 +277,35 @@ const reviewFindings = async (report, input) => {
228
277
  };
229
278
  const runRepairAgentFlow = async (report, input) => {
230
279
  try {
231
- const agent = await chooseRepairAgent({
232
- prompts: input.prompts,
233
- isAvailable: input.isRepairAgentAvailable,
234
- });
235
- if (agent === undefined) {
236
- input.write("Repair handoff cancelled.\n");
237
- return undefined;
238
- }
239
280
  const handoff = await prepareRepairHandoff({
240
281
  report,
241
282
  prompts: input.prompts,
242
283
  outputRoot: input.repairReportOutputRoot,
243
284
  timestamp: input.repairReportTimestamp,
244
285
  });
245
- input.write(`Selected ${agent.displayName}.\n`);
246
- input.write(`Launch preview: ${formatRepairAgentPreview(agent.id)}\n`);
247
- if (handoff.reportDirectory !== undefined) {
248
- input.write(`Report directory: ${handoff.reportDirectory}\n`);
249
- }
250
- if (handoff.promptPath !== undefined) {
251
- input.write(`Repair prompt: ${handoff.promptPath}\n`);
286
+ let agent;
287
+ try {
288
+ agent = await chooseRepairAgent({
289
+ prompts: input.prompts,
290
+ isAvailable: input.isRepairAgentAvailable,
291
+ });
252
292
  }
253
- else {
254
- input.write(`Repair prompt:\n${handoff.prompt}\n`);
293
+ catch (error) {
294
+ if (error instanceof CliInputError) {
295
+ writeRepairHandoffSummary(handoff, input.write);
296
+ input.write(`${error.message}\n`);
297
+ return undefined;
298
+ }
299
+ throw error;
255
300
  }
256
- if (handoff.reportWriteError !== undefined) {
257
- input.write(`Report write failed: ${handoff.reportWriteError.message}\n`);
301
+ if (agent === undefined) {
302
+ writeRepairHandoffSummary(handoff, input.write);
303
+ input.write("Repair handoff cancelled.\n");
304
+ return undefined;
258
305
  }
306
+ input.write(`Selected ${agent.displayName}.\n`);
307
+ input.write(`Launch preview: ${formatRepairAgentPreview(agent.id)}\n`);
308
+ writeRepairHandoffSummary(handoff, input.write);
259
309
  const shouldLaunch = await input.prompts.confirm(`Launch ${agent.displayName} now?`, false);
260
310
  if (!shouldLaunch) {
261
311
  input.write("Agent launch cancelled.\n");
@@ -301,19 +351,28 @@ const runRepairAgentFlow = async (report, input) => {
301
351
  throw error;
302
352
  }
303
353
  };
354
+ const writeRepairHandoffSummary = (handoff, write) => {
355
+ if (handoff.reportDirectory !== undefined) {
356
+ write(`Report directory: ${handoff.reportDirectory}\n`);
357
+ }
358
+ if (handoff.promptPath !== undefined) {
359
+ write(`Repair prompt: ${handoff.promptPath}\n`);
360
+ }
361
+ else {
362
+ write(`Repair prompt:\n${handoff.prompt}\n`);
363
+ }
364
+ if (handoff.reportWriteError !== undefined) {
365
+ write(`Report write failed: ${handoff.reportWriteError.message}\n`);
366
+ }
367
+ };
304
368
  const renderFindings = (findings) => `${findings
305
369
  .map((finding) => `[${finding.severity}] ${finding.ruleId} ${finding.skillName ?? finding.skillPath}\n${finding.message}\nSuggestion: ${finding.suggestion}`)
306
370
  .join("\n\n")}\n`;
307
371
  const renderFindingsBySkill = (findings) => {
308
- const groups = new Map();
309
- for (const finding of findings) {
310
- const key = finding.skillName ?? finding.skillPath;
311
- groups.set(key, [...(groups.get(key) ?? []), finding]);
312
- }
313
- return [...groups.entries()]
314
- .map(([skillName, skillFindings]) => {
315
- const lines = [`${skillName}:`];
316
- lines.push(...skillFindings.map((finding) => `- [${finding.severity}] ${finding.ruleId}`));
372
+ return groupFindingsByKey(findings, (finding) => finding.skillName ?? finding.skillPath)
373
+ .map((group) => {
374
+ const lines = [`${group.key}:`];
375
+ lines.push(...group.findings.map((finding) => `- [${finding.severity}] ${finding.ruleId}`));
317
376
  return lines.join("\n");
318
377
  })
319
378
  .join("\n\n")
@@ -1,4 +1,8 @@
1
1
  import { Command } from "commander";
2
- export declare const buildProgram: () => Command;
2
+ export type BuildProgramOptions = {
3
+ readonly jsonMode?: boolean;
4
+ };
5
+ export declare const buildProgram: (options?: BuildProgramOptions) => Command;
3
6
  export declare const main: (argv?: readonly string[]) => Promise<void>;
7
+ export declare const runCli: (argv?: readonly string[]) => Promise<void>;
4
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAKpC,eAAO,MAAM,YAAY,QAAO,OAmB/B,CAAC;AAEF,eAAO,MAAM,IAAI,GAAU,OAAM,SAAS,MAAM,EAAiB,KAAG,OAAO,CAAC,IAAI,CAY/E,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAMpC,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,UAAS,mBAAwB,KAAG,OAkChE,CAAC;AAEF,eAAO,MAAM,IAAI,GAAU,OAAM,SAAS,MAAM,EAAiB,KAAG,OAAO,CAAC,IAAI,CAiB/E,CAAC;AAEF,eAAO,MAAM,MAAM,GAAU,OAAM,SAAS,MAAM,EAAiB,KAAG,OAAO,CAAC,IAAI,CAEjF,CAAC"}
package/dist/cli/index.js CHANGED
@@ -1,8 +1,10 @@
1
+ import path from "node:path";
1
2
  import { Command } from "commander";
2
3
  import packageJson from "../../package.json" with { type: "json" };
3
4
  import { scanAction } from "./commands/scan.js";
4
5
  import { handleCliError } from "./utils/handle-error.js";
5
- export const buildProgram = () => {
6
+ import { enableJsonMode } from "./utils/json-mode.js";
7
+ export const buildProgram = (options = {}) => {
6
8
  const program = new Command()
7
9
  .name("skills-doctor")
8
10
  .description("Scan Agent Skills and report quality issues.")
@@ -10,13 +12,25 @@ export const buildProgram = () => {
10
12
  .argument("[directory]", "directory to scan from", ".")
11
13
  .option("--json", "output one machine-readable JSON report")
12
14
  .option("--json-compact", "with --json, omit indentation")
15
+ .option("--fail-on <severity>", "fail on findings at or above severity: error, warning, advice")
16
+ .option("--min-score <number>", "fail when the scan score is below this threshold")
13
17
  .option("-y, --yes", "skip prompts and use conservative defaults")
14
18
  .action(async (directory, flags) => {
15
19
  await scanAction(directory, flags);
16
20
  });
21
+ if (options.jsonMode) {
22
+ program.exitOverride();
23
+ program.configureOutput({
24
+ writeErr: () => { },
25
+ });
26
+ }
17
27
  return program;
18
28
  };
19
29
  export const main = async (argv = process.argv) => {
30
+ const preParseJsonMode = resolvePreParseJsonMode(argv);
31
+ if (preParseJsonMode !== undefined) {
32
+ enableJsonMode(preParseJsonMode);
33
+ }
20
34
  process.on("SIGINT", () => process.exit(130));
21
35
  process.on("SIGTERM", () => process.exit(143));
22
36
  process.stdout.on("error", (error) => {
@@ -24,10 +38,31 @@ export const main = async (argv = process.argv) => {
24
38
  process.exit(0);
25
39
  });
26
40
  try {
27
- await buildProgram().parseAsync([...argv]);
41
+ await buildProgram({ jsonMode: preParseJsonMode !== undefined }).parseAsync([...argv]);
28
42
  }
29
43
  finally {
30
44
  process.stdin.unref?.();
31
45
  }
32
46
  };
33
- main().catch(handleCliError);
47
+ export const runCli = async (argv = process.argv) => {
48
+ await main(argv).catch(handleCliError);
49
+ };
50
+ const resolvePreParseJsonMode = (argv) => {
51
+ const userArgs = argv.slice(2);
52
+ if (!userArgs.includes("--json"))
53
+ return undefined;
54
+ return {
55
+ compact: userArgs.includes("--json-compact"),
56
+ directory: path.resolve(process.cwd(), findDirectoryArg(userArgs) ?? "."),
57
+ };
58
+ };
59
+ const findDirectoryArg = (args) => args.find((arg, index) => isDirectoryArg(args, arg, index));
60
+ const VALUE_FLAGS = new Set(["--fail-on", "--min-score"]);
61
+ const isDirectoryArg = (args, arg, index) => {
62
+ if (arg === "--")
63
+ return false;
64
+ if (arg.startsWith("-"))
65
+ return false;
66
+ const previous = args[index - 1];
67
+ return previous === undefined || !VALUE_FLAGS.has(previous);
68
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"handoff-to-agent.d.ts","sourceRoot":"","sources":["../../../src/cli/utils/handoff-to-agent.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAC/D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAKrD,OAAO,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAC;AAElF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,qBAAqB,GAAG,KAAK,GAAG,iBAAiB,CAAC;AAE/F,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,sBAAsB,GAAG,SAAS,CAAC;CACrE,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAC/B,OAAO,yBAAyB,KAC/B,OAAO,CAAC,qBAAqB,CAgC/B,CAAC"}
1
+ {"version":3,"file":"handoff-to-agent.d.ts","sourceRoot":"","sources":["../../../src/cli/utils/handoff-to-agent.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAC/D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAKrD,OAAO,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAC;AAElF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,qBAAqB,GAAG,KAAK,GAAG,iBAAiB,CAAC;AAE/F,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,sBAAsB,GAAG,SAAS,CAAC;CACrE,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAC/B,OAAO,yBAAyB,KAC/B,OAAO,CAAC,qBAAqB,CAsC/B,CAAC"}
@@ -21,25 +21,40 @@ export const prepareRepairHandoff = async (input) => {
21
21
  reportDirectory: reportResult.result?.directory,
22
22
  });
23
23
  let promptPath;
24
+ let promptWriteError;
24
25
  if (reportResult.result !== undefined) {
25
- promptPath = path.join(reportResult.result.directory, "handoff-prompt.md");
26
- await writeFile(promptPath, `${prompt}\n`);
26
+ const targetPromptPath = path.join(reportResult.result.directory, "handoff-prompt.md");
27
+ try {
28
+ await writeFile(targetPromptPath, `${prompt}\n`);
29
+ promptPath = targetPromptPath;
30
+ }
31
+ catch (error) {
32
+ promptWriteError = normalizeError(error);
33
+ }
27
34
  }
28
35
  return {
29
36
  findings,
30
37
  prompt,
31
38
  reportDirectory: reportResult.result?.directory,
32
39
  promptPath,
33
- reportWriteError: reportResult.error,
40
+ reportWriteError: reportResult.error ?? promptWriteError,
34
41
  };
35
42
  };
36
43
  const chooseRepairFindings = async (report, prompts) => {
37
- const subset = await prompts.select("Choose findings to repair", [
38
- { name: "Blocking errors only", value: "errors" },
39
- { name: "Blocking errors and warnings", value: "errors-and-warnings" },
40
- { name: "All findings", value: "all" },
41
- { name: "Selected skills", value: "selected-skills" },
42
- ]);
44
+ const choices = [];
45
+ if (report.errorCount > 0) {
46
+ choices.push({ name: "Blocking errors only", value: "errors" });
47
+ }
48
+ if (report.errorCount + report.warningCount > 0) {
49
+ choices.push({ name: "Blocking errors and warnings", value: "errors-and-warnings" });
50
+ }
51
+ if (report.findingCount > 0) {
52
+ choices.push({ name: "All findings", value: "all" });
53
+ }
54
+ if (report.skills.some((skill) => skill.findingCount > 0)) {
55
+ choices.push({ name: "Selected skills", value: "selected-skills" });
56
+ }
57
+ const subset = await prompts.select("Choose findings to repair", choices);
43
58
  if (subset === "errors") {
44
59
  return report.findings.filter((finding) => finding.severity === "error");
45
60
  }
@@ -74,6 +89,7 @@ const tryWriteFindingsDirectory = async (input) => {
74
89
  };
75
90
  }
76
91
  catch (error) {
77
- return { error: error instanceof Error ? error : new Error(String(error)) };
92
+ return { error: normalizeError(error) };
78
93
  }
79
94
  };
95
+ const normalizeError = (error) => error instanceof Error ? error : new Error(String(error));
@@ -1 +1 @@
1
- {"version":3,"file":"build-handoff-prompt.d.ts","sourceRoot":"","sources":["../../src/domain/build-handoff-prompt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,OAAO,EAAa,MAAM,YAAY,CAAC;AAKrD,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,OAAO,uBAAuB,KAAG,MAmEnE,CAAC"}
1
+ {"version":3,"file":"build-handoff-prompt.d.ts","sourceRoot":"","sources":["../../src/domain/build-handoff-prompt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,OAAO,KAAK,EAAE,OAAO,EAAa,MAAM,YAAY,CAAC;AAKrD,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,OAAO,uBAAuB,KAAG,MAmEnE,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { groupFindingsByKey } from "./group-findings.js";
1
2
  const MAX_INLINE_GROUPS = 5;
2
3
  const MAX_INLINE_FINDINGS_PER_GROUP = 3;
3
4
  export const buildHandoffPrompt = (input) => {
@@ -38,15 +39,11 @@ export const buildHandoffPrompt = (input) => {
38
39
  };
39
40
  const formatRoots = (roots) => roots.map((root) => `${root.ecosystem}: ${root.rootPath}`).join("; ");
40
41
  const groupFindingsBySkill = (findings) => {
41
- const groups = new Map();
42
- for (const finding of findings) {
43
- groups.set(finding.skillPath, [...(groups.get(finding.skillPath) ?? []), finding]);
44
- }
45
- return [...groups.entries()]
46
- .map(([skillPath, skillFindings]) => ({
47
- skillPath,
48
- skillLabel: skillFindings[0]?.skillName ?? skillPath,
49
- findings: sortFindings(skillFindings),
42
+ return groupFindingsByKey(findings, (finding) => finding.skillPath)
43
+ .map((group) => ({
44
+ skillPath: group.key,
45
+ skillLabel: group.findings[0]?.skillName ?? group.key,
46
+ findings: sortFindings(group.findings),
50
47
  }))
51
48
  .sort((left, right) => severityScore(right.findings[0]) - severityScore(left.findings[0]) ||
52
49
  right.findings.length - left.findings.length ||