skills-doctor 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
8
8
 
9
9
  *No changes yet.*
10
10
 
11
+ ## [0.3.1] - 2026-06-16
12
+
13
+ ### Added
14
+
15
+ - Added line numbers to quality-rule findings where a specific source line can be resolved.
16
+
17
+ ### Fixed
18
+
19
+ - Hid repair handoff subset options that do not match any findings (for warning-only and advice-only scans).
20
+ - Made CLI module import safe by removing side-effect execution and routing runtime entry through `bin/skills-doctor.js`.
21
+
22
+ ## [0.3.0] - 2026-06-16
23
+
24
+ ### Added
25
+
26
+ - Kept the interactive review menu available after viewing grouped and/or error findings so users can still launch repair in the same session.
27
+ - Added support for selecting a custom skills directory during interactive scans even when standard Claude/Codex roots are already detected.
28
+
29
+ ### Fixed
30
+
31
+ - Ignored direct skill-root child directories that do not contain `SKILL.md` instead of reporting a blocking `missing-skill` finding.
32
+ - Classified missing referenced assets under the `assets` finding category instead of falling back to another resource category.
33
+
11
34
  ## [0.2.0] - 2026-06-16
12
35
 
13
36
  ### Added
package/README.md CHANGED
@@ -57,9 +57,10 @@ It also detects these global user-level roots:
57
57
  When local and global roots both exist, the interactive CLI first asks whether
58
58
  to scan local project skills, global/root skills, or both. When both Claude and
59
59
  Codex/agents roots exist in the selected scope, it asks whether to scan Claude,
60
- Codex/agents, or both. When no known root exists, it prompts for a custom skills
61
- directory. Non-interactive runs use conservative defaults and fail with a clear
62
- user error when a required choice cannot be made.
60
+ Codex/agents, or both. If you already have standard roots detected, it also lets
61
+ 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.
63
64
 
64
65
  ## What It Checks
65
66
 
@@ -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();
@@ -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,CA6FpB,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;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"}
@@ -39,17 +39,26 @@ export const scanAction = async (directory, flags, options = {}) => {
39
39
  if (skipPrompts) {
40
40
  throw new CliInputError("No .claude/skills or .agents/skills root was found. Re-run interactively or add a supported skills root.");
41
41
  }
42
- const customRoot = await prompts.input("Skills directory path", ".");
43
- const custom = await discoverSkillRoots({
42
+ roots = await selectCustomRoot({
44
43
  cwd,
45
44
  homeDir: options.homeDir,
46
- customRoots: [{ rootPath: customRoot, ecosystem: "custom" }],
45
+ prompts,
46
+ roots,
47
47
  });
48
- roots = custom.roots;
49
48
  }
50
49
  else if (!skipPrompts) {
51
- roots = await selectRootScopes(roots, prompts);
52
- roots = await selectRoots(roots, prompts);
50
+ roots = await selectRootScopes({
51
+ roots,
52
+ prompts,
53
+ cwd,
54
+ homeDir: options.homeDir,
55
+ });
56
+ roots = await selectRoots({
57
+ roots,
58
+ prompts,
59
+ cwd,
60
+ homeDir: options.homeDir,
61
+ });
53
62
  }
54
63
  if (roots.length === 0) {
55
64
  throw new CliInputError("No readable skills root was selected.");
@@ -96,57 +105,126 @@ export const scanAction = async (directory, flags, options = {}) => {
96
105
  process.exitCode = resolveScanExitCode(finalReport);
97
106
  return finalReport;
98
107
  };
99
- const selectRoots = async (roots, prompts) => {
100
- const hasClaude = roots.some((root) => root.ecosystem === "claude");
101
- const hasCodex = roots.some((root) => root.ecosystem === "codex");
108
+ const selectRoots = async (input) => {
109
+ const { prompts, cwd, homeDir, roots } = input;
110
+ const customRoots = roots.filter((root) => root.source === "custom");
111
+ const standardRoots = roots.filter((root) => root.source !== "custom");
112
+ const hasClaude = standardRoots.some((root) => root.ecosystem === "claude");
113
+ const hasCodex = standardRoots.some((root) => root.ecosystem === "codex");
102
114
  if (!hasClaude || !hasCodex)
103
115
  return roots;
104
116
  const selection = await prompts.select("Choose skills folder to scan", [
105
117
  { name: "Both", value: "all" },
106
118
  { name: "Claude (.claude/skills)", value: "claude" },
107
119
  { name: "Codex/agents (.agents/skills)", value: "codex" },
120
+ { name: "Add custom skills path", value: "custom" },
108
121
  ]);
109
122
  if (selection === "all")
110
123
  return roots;
111
- return roots.filter((root) => root.ecosystem === selection);
124
+ if (selection === "custom") {
125
+ return selectCustomRoot({
126
+ cwd,
127
+ homeDir,
128
+ prompts,
129
+ roots,
130
+ });
131
+ }
132
+ return [...standardRoots.filter((root) => root.ecosystem === selection), ...customRoots];
112
133
  };
113
- const selectRootScopes = async (roots, prompts) => {
134
+ const selectRootScopes = async (input) => {
135
+ const { prompts, cwd, homeDir, roots } = input;
114
136
  const hasLocal = roots.some((root) => root.source === "local");
115
137
  const hasGlobal = roots.some((root) => root.source === "global");
116
- if (!hasLocal || !hasGlobal)
138
+ const hasBothScopes = hasLocal && hasGlobal;
139
+ if (!hasLocal && !hasGlobal)
117
140
  return roots;
118
- const selection = await prompts.select("Choose skills scope to scan", [
119
- { name: "Both local project and global/root skills", value: "all" },
120
- { name: "Local project skills (./.claude/skills, ./.agents/skills)", value: "local" },
121
- { name: "Global/root skills (~/.claude/skills, ~/.agents/skills)", value: "global" },
122
- ]);
141
+ const allLabel = hasBothScopes
142
+ ? "Both local project and global/root skills"
143
+ : "Detected skills root";
144
+ const choices = [
145
+ { name: allLabel, value: "all" },
146
+ ];
147
+ if (hasBothScopes) {
148
+ choices.push({
149
+ name: "Local project skills (./.claude/skills, ./.agents/skills)",
150
+ value: "local",
151
+ });
152
+ choices.push({
153
+ name: "Global/root skills (~/.claude/skills, ~/.agents/skills)",
154
+ value: "global",
155
+ });
156
+ }
157
+ choices.push({ name: "Add custom skills path", value: "custom" });
158
+ if (choices.length <= 1) {
159
+ return roots;
160
+ }
161
+ const selection = await prompts.select("Choose skills scope to scan", choices);
162
+ if (selection === "custom") {
163
+ return selectCustomRoot({
164
+ cwd,
165
+ homeDir,
166
+ prompts,
167
+ roots,
168
+ });
169
+ }
123
170
  if (selection === "all")
124
171
  return roots;
125
- return roots.filter((root) => root.source === selection);
172
+ if (selection === "claude") {
173
+ return roots.filter((root) => root.ecosystem === "claude");
174
+ }
175
+ if (selection === "codex") {
176
+ return roots.filter((root) => root.ecosystem === "codex");
177
+ }
178
+ if (selection === "local" || selection === "global") {
179
+ return roots.filter((root) => root.source === selection);
180
+ }
181
+ return roots;
182
+ };
183
+ const selectCustomRoot = async (input) => {
184
+ const customRoot = await input.prompts.input("Skills directory path", ".");
185
+ const custom = await discoverSkillRoots({
186
+ cwd: input.cwd,
187
+ homeDir: input.homeDir,
188
+ customRoots: [{ rootPath: customRoot, ecosystem: "custom" }],
189
+ });
190
+ return mergeRoots(input.roots, custom.roots);
191
+ };
192
+ const mergeRoots = (existingRoots, additionalRoots) => {
193
+ const merged = new Map();
194
+ for (const root of existingRoots) {
195
+ merged.set(root.rootPath, root);
196
+ }
197
+ for (const root of additionalRoots) {
198
+ if (!merged.has(root.rootPath)) {
199
+ merged.set(root.rootPath, root);
200
+ }
201
+ }
202
+ return [...merged.values()];
126
203
  };
127
204
  const reviewFindings = async (report, input) => {
128
205
  const { prompts, write } = input;
129
- const action = await prompts.select("Next step", [
130
- { name: "Fix skills with Claude or Codex", value: "repair" },
131
- ...(report.errorCount > 0 ? [{ name: "View errors", value: "errors" }] : []),
132
- { name: "View all findings", value: "all" },
133
- { name: "View findings by skill", value: "by-skill" },
134
- { name: "Exit", value: "exit" },
135
- ]);
136
- if (action === "exit")
137
- return;
138
- if (action === "repair") {
139
- return runRepairAgentFlow(report, input);
140
- }
141
- const selectedFindings = action === "errors"
142
- ? report.findings.filter((finding) => finding.severity === "error")
143
- : report.findings;
144
- if (action === "by-skill") {
145
- write(renderFindingsBySkill(selectedFindings));
146
- return;
147
- }
148
- write(renderFindings(selectedFindings));
149
- return undefined;
206
+ while (true) {
207
+ const action = await prompts.select("Next step", [
208
+ { name: "Fix skills with Claude or Codex", value: "repair" },
209
+ ...(report.errorCount > 0 ? [{ name: "View errors", value: "errors" }] : []),
210
+ { name: "View all findings", value: "all" },
211
+ { name: "View findings by skill", value: "by-skill" },
212
+ { name: "Exit", value: "exit" },
213
+ ]);
214
+ if (action === "exit")
215
+ return;
216
+ if (action === "repair") {
217
+ return runRepairAgentFlow(report, input);
218
+ }
219
+ const selectedFindings = action === "errors"
220
+ ? report.findings.filter((finding) => finding.severity === "error")
221
+ : report.findings;
222
+ if (action === "by-skill") {
223
+ write(renderFindingsBySkill(selectedFindings));
224
+ continue;
225
+ }
226
+ write(renderFindings(selectedFindings));
227
+ }
150
228
  };
151
229
  const runRepairAgentFlow = async (report, input) => {
152
230
  try {
@@ -1,4 +1,5 @@
1
1
  import { Command } from "commander";
2
2
  export declare const buildProgram: () => Command;
3
3
  export declare const main: (argv?: readonly string[]) => Promise<void>;
4
+ export declare const runCli: (argv?: readonly string[]) => Promise<void>;
4
5
  //# 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":"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;AAEF,eAAO,MAAM,MAAM,GAAU,OAAM,SAAS,MAAM,EAAiB,KAAG,OAAO,CAAC,IAAI,CAEjF,CAAC"}
package/dist/cli/index.js CHANGED
@@ -30,4 +30,6 @@ export const main = async (argv = process.argv) => {
30
30
  process.stdin.unref?.();
31
31
  }
32
32
  };
33
- main().catch(handleCliError);
33
+ export const runCli = async (argv = process.argv) => {
34
+ await main(argv).catch(handleCliError);
35
+ };
@@ -34,12 +34,20 @@ export const prepareRepairHandoff = async (input) => {
34
34
  };
35
35
  };
36
36
  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
- ]);
37
+ const choices = [];
38
+ if (report.errorCount > 0) {
39
+ choices.push({ name: "Blocking errors only", value: "errors" });
40
+ }
41
+ if (report.errorCount + report.warningCount > 0) {
42
+ choices.push({ name: "Blocking errors and warnings", value: "errors-and-warnings" });
43
+ }
44
+ if (report.findingCount > 0) {
45
+ choices.push({ name: "All findings", value: "all" });
46
+ }
47
+ if (report.skills.some((skill) => skill.findingCount > 0)) {
48
+ choices.push({ name: "Selected skills", value: "selected-skills" });
49
+ }
50
+ const subset = await prompts.select("Choose findings to repair", choices);
43
51
  if (subset === "errors") {
44
52
  return report.findings.filter((finding) => finding.severity === "error");
45
53
  }
@@ -23,17 +23,19 @@ const validateSkillQuality = async (skill) => {
23
23
  const findings = [];
24
24
  const frontmatter = skill.parseResult.frontmatter;
25
25
  const description = readString(frontmatter.data.description) ?? "";
26
- const body = frontmatter.body.trim();
26
+ const body = frontmatter.body;
27
+ const frontMatterLineCount = frontmatter.raw.split(/\r?\n/).length;
27
28
  findings.push(...validateDescription(skill, description));
28
- findings.push(...validateBody(skill, body));
29
- findings.push(...validateProgressiveDisclosure(skill));
30
- findings.push(...(await validateResources(skill, body)));
29
+ findings.push(...validateBody(skill, body, frontMatterLineCount));
30
+ findings.push(...validateProgressiveDisclosure(skill, body, frontMatterLineCount));
31
+ findings.push(...(await validateResources(skill, body, frontMatterLineCount)));
31
32
  findings.push(...(await validateEvals(skill, body)));
32
33
  return findings;
33
34
  };
34
35
  const validateDescription = (skill, description) => {
35
36
  const findings = [];
36
37
  const normalized = description.trim();
38
+ const line = findContentLine(skill.content, /^\s*description\s*:/i);
37
39
  if (normalized.length > 0 && !TRIGGER_PATTERN.test(normalized)) {
38
40
  findings.push(createFinding(skill, {
39
41
  ruleId: "weak-description-trigger",
@@ -42,6 +44,7 @@ const validateDescription = (skill, description) => {
42
44
  title: "Description lacks a clear activation trigger",
43
45
  message: "The description should explain when an agent should use this skill.",
44
46
  suggestion: 'Use imperative phrasing such as "Use this skill when..." and include concrete task contexts.',
47
+ line,
45
48
  }));
46
49
  }
47
50
  if (VAGUE_DESCRIPTION_PATTERN.test(normalized)) {
@@ -52,6 +55,7 @@ const validateDescription = (skill, description) => {
52
55
  title: "Description is too vague",
53
56
  message: "A short generic description is unlikely to trigger reliably or explain the skill's scope.",
54
57
  suggestion: "Describe what the skill does, when to use it, and important adjacent cases.",
58
+ line,
55
59
  }));
56
60
  }
57
61
  if (IMPLEMENTATION_DESCRIPTION_PATTERN.test(normalized) &&
@@ -63,11 +67,12 @@ const validateDescription = (skill, description) => {
63
67
  title: "Description focuses on implementation",
64
68
  message: "Descriptions should match user intent rather than the skill's internal mechanics.",
65
69
  suggestion: "Rewrite the description around the task the user is trying to accomplish.",
70
+ line,
66
71
  }));
67
72
  }
68
73
  return findings;
69
74
  };
70
- const validateBody = (skill, body) => {
75
+ const validateBody = (skill, body, frontMatterLineCount) => {
71
76
  const findings = [];
72
77
  if (PLACEHOLDER_PATTERN.test(body)) {
73
78
  findings.push(createFinding(skill, {
@@ -77,6 +82,7 @@ const validateBody = (skill, body) => {
77
82
  title: "Body contains placeholder text",
78
83
  message: "A skill body should contain complete reusable instructions, not placeholders.",
79
84
  suggestion: "Replace placeholders with concrete workflow steps, gotchas, examples, or validation guidance.",
85
+ line: findBodyLine(frontMatterLineCount, body, PLACEHOLDER_PATTERN),
80
86
  }));
81
87
  }
82
88
  if (GENERIC_BODY_PATTERN.test(body)) {
@@ -87,6 +93,7 @@ const validateBody = (skill, body) => {
87
93
  title: "Body appears generic",
88
94
  message: "The body uses generic advice that does not add skill-specific expertise.",
89
95
  suggestion: "Replace generic phrasing with concrete project or domain procedures the agent would not already know.",
96
+ line: findBodyLine(frontMatterLineCount, body, GENERIC_BODY_PATTERN),
90
97
  }));
91
98
  }
92
99
  if (!WORKFLOW_STEP_PATTERN.test(body)) {
@@ -97,6 +104,7 @@ const validateBody = (skill, body) => {
97
104
  title: "Body lacks concrete workflow structure",
98
105
  message: "The body does not appear to include headings, ordered steps, or checklist items.",
99
106
  suggestion: "Add a concise workflow, gotchas section, examples, or validation loop.",
107
+ line: findFirstBodyLine(body, frontMatterLineCount),
100
108
  }));
101
109
  }
102
110
  if (TOOL_MENU_PATTERN.test(body)) {
@@ -107,6 +115,7 @@ const validateBody = (skill, body) => {
107
115
  title: "Body presents a tool menu without a default",
108
116
  message: "Skills should provide defaults rather than long menus of equal options.",
109
117
  suggestion: "Pick a default tool and explain when to use a fallback.",
118
+ line: findBodyLine(frontMatterLineCount, body, TOOL_MENU_PATTERN),
110
119
  }));
111
120
  }
112
121
  if (DESTRUCTIVE_PATTERN.test(body) && !SAFETY_PATTERN.test(body)) {
@@ -117,11 +126,12 @@ const validateBody = (skill, body) => {
117
126
  title: "Destructive operation lacks safety guidance",
118
127
  message: "Destructive, release, migration, or deploy guidance should include validation, preview, backup, or confirmation steps.",
119
128
  suggestion: "Add a dry-run, validation, backup, or explicit confirmation requirement before the destructive action.",
129
+ line: findBodyLine(frontMatterLineCount, body, DESTRUCTIVE_PATTERN),
120
130
  }));
121
131
  }
122
132
  return findings;
123
133
  };
124
- const validateProgressiveDisclosure = (skill) => {
134
+ const validateProgressiveDisclosure = (skill, body, frontMatterLineCount) => {
125
135
  const findings = [];
126
136
  const lineCount = skill.content.split(/\r?\n/).length;
127
137
  const tokenEstimate = estimateTokens(skill.content);
@@ -153,11 +163,12 @@ const validateProgressiveDisclosure = (skill) => {
153
163
  title: "Resource reference lacks a load trigger",
154
164
  message: "The skill references a resource directory generically instead of naming the file and when to load it.",
155
165
  suggestion: 'Use specific guidance such as "Read references/api-errors.md if the API returns a non-200 status."',
166
+ line: findBodyLine(frontMatterLineCount, body, GENERIC_REFERENCE_PATTERN),
156
167
  }));
157
168
  }
158
169
  return findings;
159
170
  };
160
- const validateResources = async (skill, body) => {
171
+ const validateResources = async (skill, body, frontMatterLineCount) => {
161
172
  const findings = [];
162
173
  const referencedPaths = [...new Set(skill.content.match(RESOURCE_REFERENCE_PATTERN) ?? [])];
163
174
  for (const referencePath of referencedPaths) {
@@ -169,6 +180,7 @@ const validateResources = async (skill, body) => {
169
180
  title: "Resource reference escapes the skill directory",
170
181
  message: "The skill references a resource outside the skill directory. Resource references must remain inside scripts/, references/, or assets/ for this skill.",
171
182
  suggestion: "Use a path rooted inside the skill (for example references/file.md) without '..' segments.",
183
+ line: findReferenceLine(skill.content, referencePath),
172
184
  }));
173
185
  continue;
174
186
  }
@@ -181,6 +193,7 @@ const validateResources = async (skill, body) => {
181
193
  title: "Referenced resource does not exist",
182
194
  message: `The skill references ${referencePath}, but that path does not exist inside the skill directory.`,
183
195
  suggestion: "Create the referenced file or remove the stale reference.",
196
+ line: findReferenceLine(skill.content, referencePath),
184
197
  }));
185
198
  continue;
186
199
  }
@@ -192,6 +205,7 @@ const validateResources = async (skill, body) => {
192
205
  title: "Script reference lacks help guidance",
193
206
  message: "Script instructions should document usage or mention --help so agents can learn the interface.",
194
207
  suggestion: "Add a short usage example and document that the script supports --help.",
208
+ line: findReferenceLine(skill.content, referencePath),
195
209
  }));
196
210
  }
197
211
  }
@@ -203,6 +217,7 @@ const validateResources = async (skill, body) => {
203
217
  title: "Script guidance appears interactive",
204
218
  message: "Agents need non-interactive scripts that accept flags, stdin, files, or environment variables.",
205
219
  suggestion: "Replace interactive prompts with command-line flags and clear errors for missing inputs.",
220
+ line: findBodyLine(frontMatterLineCount, body, INTERACTIVE_SCRIPT_PATTERN),
206
221
  }));
207
222
  }
208
223
  if (UNPINNED_RUNNER_PATTERN.test(body)) {
@@ -213,6 +228,7 @@ const validateResources = async (skill, body) => {
213
228
  title: "Package-runner command is not version-pinned",
214
229
  message: "One-off package-runner commands should pin versions when reproducibility matters.",
215
230
  suggestion: "Use a versioned command such as npx eslint@9 or uvx ruff@0.8.0.",
231
+ line: findBodyLine(frontMatterLineCount, body, UNPINNED_RUNNER_PATTERN),
216
232
  }));
217
233
  }
218
234
  return findings;
@@ -285,8 +301,35 @@ const createFinding = (skill, input) => ({
285
301
  skillName: skill.parseResult.ok
286
302
  ? readString(skill.parseResult.frontmatter.data.name)
287
303
  : skill.directoryName,
304
+ line: input.line,
288
305
  agentRepairable: true,
289
306
  });
307
+ const findContentLine = (content, pattern) => {
308
+ const linePattern = typeof pattern === "string"
309
+ ? new RegExp(escapeRegExp(pattern))
310
+ : new RegExp(pattern.source, pattern.flags.replace(/g/g, ""));
311
+ const lines = content.split(/\r?\n/);
312
+ for (const [index, line] of lines.entries()) {
313
+ if (linePattern.test(line)) {
314
+ return index + 1;
315
+ }
316
+ }
317
+ return undefined;
318
+ };
319
+ const findBodyLine = (frontMatterLineCount, body, pattern) => {
320
+ const bodyLine = findContentLine(body, pattern);
321
+ if (bodyLine === undefined)
322
+ return undefined;
323
+ return frontMatterLineCount + 2 + bodyLine;
324
+ };
325
+ const findReferenceLine = (content, referencePath) => findContentLine(content, referencePath);
326
+ const findFirstBodyLine = (body, frontMatterLineCount) => {
327
+ const lines = body.split(/\r?\n/);
328
+ if (lines.length === 0)
329
+ return undefined;
330
+ return frontMatterLineCount + 2 + 1;
331
+ };
332
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
290
333
  const readString = (value) => typeof value === "string" ? value : undefined;
291
334
  const estimateTokens = (content) => Math.ceil(content.split(/\s+/).filter(Boolean).length * 1.35);
292
335
  const exists = async (targetPath) => {
@@ -303,7 +346,7 @@ const resourceCategory = (referencePath) => {
303
346
  return "references";
304
347
  if (referencePath.startsWith("scripts/"))
305
348
  return "scripts";
306
- return "progressive-disclosure";
349
+ return "assets";
307
350
  };
308
351
  const isNonTrivialSkill = (body) => body.length > 500 || WORKFLOW_STEP_PATTERN.test(body) || RESOURCE_REFERENCE_PATTERN.test(body);
309
352
  const hasParentTraversal = (referencePath) => referencePath.split(/[\\/]+/).includes("..");
@@ -1 +1 @@
1
- {"version":3,"file":"scan-skills.d.ts","sourceRoot":"","sources":["../../src/domain/scan-skills.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAuB,UAAU,EAAe,SAAS,EAAE,MAAM,YAAY,CAAC;AAE1F,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;CACtC,CAAC;AAEF,eAAO,MAAM,cAAc,GAAU,OAAO,mBAAmB,KAAG,OAAO,CAAC,UAAU,CAmDnF,CAAC"}
1
+ {"version":3,"file":"scan-skills.d.ts","sourceRoot":"","sources":["../../src/domain/scan-skills.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAuB,UAAU,EAAe,SAAS,EAAE,MAAM,YAAY,CAAC;AAE1F,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;CACtC,CAAC;AAEF,eAAO,MAAM,cAAc,GAAU,OAAO,mBAAmB,KAAG,OAAO,CAAC,UAAU,CAkDnF,CAAC"}
@@ -2,7 +2,7 @@ import { readdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parseSkillContent } from "./parse-skill.js";
4
4
  import { validateQualityRules } from "./rules/quality.js";
5
- import { buildMissingSkillFinding, validateStructuralRules } from "./rules/structural.js";
5
+ import { validateStructuralRules } from "./rules/structural.js";
6
6
  export const scanSkillRoots = async (input) => {
7
7
  const skills = [];
8
8
  const diagnostics = [];
@@ -24,7 +24,6 @@ export const scanSkillRoots = async (input) => {
24
24
  const skillPath = path.join(skillDir, "SKILL.md");
25
25
  const content = await readFile(skillPath, "utf8").catch(() => null);
26
26
  if (content === null) {
27
- findings.push(buildMissingSkillFinding({ root, skillDir }));
28
27
  continue;
29
28
  }
30
29
  skills.push({
@@ -21,7 +21,7 @@ export type ParseFailure = {
21
21
  readonly message: string;
22
22
  };
23
23
  export type FindingSeverity = "error" | "warning" | "advice";
24
- export type FindingCategory = "frontmatter" | "description" | "body-quality" | "progressive-disclosure" | "references" | "scripts" | "evals" | "portability" | "cross-ecosystem";
24
+ export type FindingCategory = "frontmatter" | "description" | "body-quality" | "progressive-disclosure" | "references" | "assets" | "scripts" | "evals" | "portability" | "cross-ecosystem";
25
25
  export type Finding = {
26
26
  readonly ruleId: string;
27
27
  readonly severity: FindingSeverity;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/domain/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/domain/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAC;AAE3D,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;CAChD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAE9D,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE7D,MAAM,MAAM,eAAe,GACvB,aAAa,GACb,aAAa,GACb,cAAc,GACd,wBAAwB,GACxB,YAAY,GACZ,QAAQ,GACR,SAAS,GACT,OAAO,GACP,aAAa,GACb,iBAAiB,CAAC;AAEtB,MAAM,MAAM,OAAO,GAAG;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,WAAW,GACnB;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,WAAW,EAAE,iBAAiB,CAAC;CACzC,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;CAC9B,CAAC;AAEN,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;IACxC,QAAQ,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;CACvC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-doctor",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Interactive CLI that scans and repairs Agent Skills quality issues.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.13",