33 */
44
55import { resolve , relative , isAbsolute , join , dirname } from 'path' ;
6- import { realpath , access , constants } from 'fs/promises' ;
6+ import { realpath , access , readdir , stat , constants } from 'fs/promises' ;
77import { existsSync } from 'fs' ;
88import { SkillLinter } from '../../core/linter.js' ;
99import { loadConfig , mergeWithDefaults } from '../../config/loader.js' ;
@@ -20,6 +20,12 @@ export interface LintOptions {
2020 format ?: string ;
2121 output ?: string ;
2222 structure ?: boolean ;
23+ size ?: boolean ;
24+ references ?: boolean ;
25+ links ?: boolean ;
26+ keywords ?: boolean ;
27+ harness ?: boolean ;
28+ // Backward compat CLI flags
2329 triggering ?: boolean ;
2430 performance ?: boolean ;
2531 integration ?: boolean ;
@@ -43,11 +49,37 @@ export async function lintCommand(
4349 }
4450
4551 // Validate skill path for security (prevents path traversal attacks)
46- const resolvedPath = await validateSkillPath ( skillPath ) ;
52+ const resolvedPaths = await resolveSkillPaths ( skillPath ) ;
4753 const config = await buildConfig ( options ) ;
4854 const formatter = getFormatter ( config . formatters . default , config . formatters . options . colors ) ;
4955 const isMachineFormat = config . formatters . default !== 'text' ;
5056
57+ if ( resolvedPaths . length > 1 ) {
58+ // Multi-skill mode
59+ if ( ! isMachineFormat ) {
60+ Logger . start ( `Linting ${ resolvedPaths . length } skills in ${ skillPath } ` ) ;
61+ }
62+
63+ const linter = new SkillLinter ( config ) ;
64+ let allPassed = true ;
65+
66+ for ( const resolvedPath of resolvedPaths ) {
67+ const result = await linter . lint ( resolvedPath , config ) ;
68+ const output = formatter . format ( result ) ;
69+ console . log ( output ) ;
70+ if ( ! result . passed ) allPassed = false ;
71+ }
72+
73+ if ( options . output ) {
74+ Logger . document ( `Multi-skill report: use --format json for combined output` ) ;
75+ }
76+
77+ return allPassed ? 0 : 1 ;
78+ }
79+
80+ // Single-skill mode
81+ const resolvedPath = resolvedPaths [ 0 ] ;
82+
5183 if ( ! isMachineFormat ) {
5284 Logger . start ( `Linting ${ resolvedPath } ` ) ;
5385 }
@@ -73,6 +105,58 @@ export async function lintCommand(
73105 }
74106}
75107
108+ /**
109+ * Resolve one or more SKILL.md paths from the given input path.
110+ * If the path is a directory containing multiple skill subdirectories, returns all of them.
111+ * Otherwise delegates to validateSkillPath for a single path.
112+ */
113+ async function resolveSkillPaths ( skillPath : string ) : Promise < string [ ] > {
114+ // SECURITY: Sanitize path first
115+ let sanitized : string ;
116+ try {
117+ sanitized = sanitizePath ( skillPath ) ;
118+ } catch ( error ) {
119+ throw new Error ( `Invalid skill path: ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
120+ }
121+
122+ const workspaceRoot = await findGitRoot ( ) || process . cwd ( ) ;
123+ const resolved = resolve ( workspaceRoot , sanitized ) ;
124+
125+ if ( ! existsSync ( resolved ) ) {
126+ throw new Error ( `Skill path does not exist: ${ skillPath } ` ) ;
127+ }
128+
129+ // If it's a file, treat as single skill
130+ const stats = await stat ( resolved ) ;
131+ if ( stats . isFile ( ) ) {
132+ return [ await validateSkillPath ( skillPath ) ] ;
133+ }
134+
135+ // If it's a directory, check for SKILL.md directly inside
136+ const directSkill = join ( resolved , 'SKILL.md' ) ;
137+ if ( existsSync ( directSkill ) ) {
138+ return [ await validateSkillPath ( skillPath ) ] ;
139+ }
140+
141+ // Multi-skill mode: scan subdirectories for SKILL.md files
142+ const entries = await readdir ( resolved , { withFileTypes : true } ) ;
143+ const skillPaths : string [ ] = [ ] ;
144+
145+ for ( const entry of entries ) {
146+ if ( ! entry . isDirectory ( ) ) continue ;
147+ const subSkill = join ( resolved , entry . name , 'SKILL.md' ) ;
148+ if ( existsSync ( subSkill ) ) {
149+ skillPaths . push ( subSkill ) ;
150+ }
151+ }
152+
153+ if ( skillPaths . length === 0 ) {
154+ throw new Error ( `No SKILL.md files found in directory: ${ skillPath } ` ) ;
155+ }
156+
157+ return skillPaths ;
158+ }
159+
76160/**
77161 * Validate skill path for security and correctness
78162 * Prevents path traversal attacks and ensures path points to valid SKILL.md
@@ -165,13 +249,22 @@ async function buildConfig(options: LintOptions): Promise<LintConfig> {
165249 // CLI flags override file config
166250 const overrides : Record < string , unknown > = { } ;
167251
168- if ( options . structure !== undefined || options . triggering !== undefined ||
169- options . performance !== undefined || options . integration !== undefined ) {
252+ const hasScenarioFlag = options . structure !== undefined || options . size !== undefined ||
253+ options . references !== undefined || options . links !== undefined ||
254+ options . keywords !== undefined || options . harness !== undefined ||
255+ options . triggering !== undefined || options . performance !== undefined ||
256+ options . integration !== undefined ;
257+
258+ if ( hasScenarioFlag ) {
170259 overrides . scenarios = {
171260 structure : options . structure ?? fileConfig . scenarios . structure ,
172- triggering : options . triggering ?? fileConfig . scenarios . triggering ,
173- performance : options . performance ?? fileConfig . scenarios . performance ,
174- integration : options . integration ?? fileConfig . scenarios . integration ,
261+ size : ( options . size ?? options . performance ) ?? fileConfig . scenarios . size ,
262+ references : options . references ?? fileConfig . scenarios . references ,
263+ links : options . links !== undefined
264+ ? { enabled : options . links , checkExternal : fileConfig . scenarios . links ?. checkExternal ?? false }
265+ : fileConfig . scenarios . links ,
266+ keywords : ( options . keywords ?? options . triggering ) ?? fileConfig . scenarios . keywords ,
267+ harness : ( options . harness ?? options . integration ) ?? fileConfig . scenarios . harness ,
175268 } ;
176269 }
177270
0 commit comments