@plaited/development-skills 0.3.5

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 (40) hide show
  1. package/.claude/commands/lsp-analyze.md +66 -0
  2. package/.claude/commands/lsp-find.md +51 -0
  3. package/.claude/commands/lsp-hover.md +48 -0
  4. package/.claude/commands/lsp-refs.md +55 -0
  5. package/.claude/commands/scaffold-rules.md +221 -0
  6. package/.claude/commands/validate-skill.md +29 -0
  7. package/.claude/rules/accuracy.md +64 -0
  8. package/.claude/rules/bun-apis.md +80 -0
  9. package/.claude/rules/code-review.md +276 -0
  10. package/.claude/rules/git-workflow.md +66 -0
  11. package/.claude/rules/github.md +154 -0
  12. package/.claude/rules/testing.md +125 -0
  13. package/.claude/settings.local.json +47 -0
  14. package/.claude/skills/code-documentation/SKILL.md +47 -0
  15. package/.claude/skills/code-documentation/references/internal-templates.md +113 -0
  16. package/.claude/skills/code-documentation/references/maintenance.md +164 -0
  17. package/.claude/skills/code-documentation/references/public-api-templates.md +100 -0
  18. package/.claude/skills/code-documentation/references/type-documentation.md +116 -0
  19. package/.claude/skills/code-documentation/references/workflow.md +60 -0
  20. package/.claude/skills/scaffold-rules/SKILL.md +97 -0
  21. package/.claude/skills/typescript-lsp/SKILL.md +239 -0
  22. package/.claude/skills/validate-skill/SKILL.md +105 -0
  23. package/LICENSE +15 -0
  24. package/README.md +149 -0
  25. package/bin/cli.ts +109 -0
  26. package/package.json +57 -0
  27. package/src/lsp-analyze.ts +223 -0
  28. package/src/lsp-client.ts +400 -0
  29. package/src/lsp-find.ts +100 -0
  30. package/src/lsp-hover.ts +87 -0
  31. package/src/lsp-references.ts +83 -0
  32. package/src/lsp-symbols.ts +73 -0
  33. package/src/resolve-file-path.ts +28 -0
  34. package/src/scaffold-rules.ts +435 -0
  35. package/src/tests/fixtures/sample.ts +27 -0
  36. package/src/tests/lsp-client.spec.ts +180 -0
  37. package/src/tests/resolve-file-path.spec.ts +33 -0
  38. package/src/tests/scaffold-rules.spec.ts +286 -0
  39. package/src/tests/validate-skill.spec.ts +231 -0
  40. package/src/validate-skill.ts +492 -0
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Validate skill directories against AgentSkills specification.
4
+ *
5
+ * Usage: bun validate-skill.ts [paths...] [--json]
6
+ *
7
+ * @see https://agentskills.io/specification
8
+ */
9
+
10
+ import { basename, join } from 'node:path'
11
+ import { parseArgs } from 'node:util'
12
+ import { Glob } from 'bun'
13
+
14
+ /**
15
+ * Properties extracted from SKILL.md frontmatter.
16
+ */
17
+ type SkillProperties = {
18
+ name: string
19
+ description: string
20
+ license?: string
21
+ compatibility?: string
22
+ 'allowed-tools'?: string
23
+ metadata?: Record<string, string>
24
+ }
25
+
26
+ /**
27
+ * Result of validating a skill directory.
28
+ */
29
+ type ValidationResult = {
30
+ valid: boolean
31
+ path: string
32
+ errors: string[]
33
+ warnings: string[]
34
+ properties?: SkillProperties
35
+ }
36
+
37
+ /**
38
+ * Validation constants from AgentSkills specification
39
+ * @see https://agentskills.io/specification
40
+ */
41
+ const ALLOWED_FIELDS = new Set(['name', 'description', 'license', 'compatibility', 'allowed-tools', 'metadata'])
42
+ const REQUIRED_FIELDS = ['name', 'description'] as const
43
+ /** Must be lowercase alphanumeric with optional hyphens (no consecutive hyphens, no leading/trailing) */
44
+ const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
45
+ /** Spec: name must be 1-64 characters */
46
+ const MAX_NAME_LENGTH = 64
47
+ /** Spec: description must be 1-1024 characters */
48
+ const MAX_DESCRIPTION_LENGTH = 1024
49
+ /** Spec: compatibility must be 1-500 characters if provided */
50
+ const MAX_COMPATIBILITY_LENGTH = 500
51
+
52
+ /**
53
+ * Parse YAML frontmatter from SKILL.md content.
54
+ *
55
+ * @returns Tuple of [metadata, body] or throws on parse error
56
+ */
57
+ const parseFrontmatter = (content: string): [Record<string, unknown>, string] => {
58
+ const trimmed = content.trim()
59
+ if (!trimmed.startsWith('---')) {
60
+ throw new Error('SKILL.md must start with YAML frontmatter (---)')
61
+ }
62
+
63
+ const endIndex = trimmed.indexOf('---', 3)
64
+ if (endIndex === -1) {
65
+ throw new Error('YAML frontmatter not properly closed with ---')
66
+ }
67
+
68
+ const yamlContent = trimmed.slice(3, endIndex).trim()
69
+ const body = trimmed.slice(endIndex + 3).trim()
70
+
71
+ // Simple YAML parser for frontmatter (handles key: value and key: "value")
72
+ const metadata: Record<string, unknown> = {}
73
+ let currentKey: string | null = null
74
+ let inMetadata = false
75
+ const metadataObj: Record<string, string> = {}
76
+
77
+ for (const line of yamlContent.split('\n')) {
78
+ const trimmedLine = line.trim()
79
+ if (!trimmedLine) continue
80
+
81
+ // Check for metadata block
82
+ if (trimmedLine === 'metadata:') {
83
+ inMetadata = true
84
+ continue
85
+ }
86
+
87
+ // Handle metadata entries (indented key: value pairs)
88
+ if (inMetadata && line.startsWith(' ')) {
89
+ const metaMatch = trimmedLine.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/)
90
+ if (metaMatch?.[1] && metaMatch[2] !== undefined) {
91
+ const key = metaMatch[1]
92
+ const value = metaMatch[2]
93
+ metadataObj[key] = value.replace(/^["']|["']$/g, '')
94
+ }
95
+ continue
96
+ }
97
+
98
+ // Exit metadata block when encountering non-indented line
99
+ if (inMetadata && !line.startsWith(' ')) {
100
+ inMetadata = false
101
+ if (Object.keys(metadataObj).length > 0) {
102
+ metadata.metadata = { ...metadataObj }
103
+ }
104
+ }
105
+
106
+ // Parse regular key: value pairs
107
+ const match = trimmedLine.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/)
108
+ if (match?.[1]) {
109
+ const key = match[1]
110
+ const value = match[2] ?? ''
111
+ currentKey = key
112
+
113
+ // Handle multi-line strings (value on same line)
114
+ if (value) {
115
+ metadata[key] = value.replace(/^["']|["']$/g, '')
116
+ }
117
+ } else if (currentKey && trimmedLine) {
118
+ // Handle continuation of previous value
119
+ const prev = metadata[currentKey]
120
+ metadata[currentKey] = typeof prev === 'string' ? `${prev} ${trimmedLine}` : trimmedLine
121
+ }
122
+ }
123
+
124
+ // Capture any remaining metadata
125
+ if (Object.keys(metadataObj).length > 0 && !metadata.metadata) {
126
+ metadata.metadata = { ...metadataObj }
127
+ }
128
+
129
+ return [metadata, body]
130
+ }
131
+
132
+ /**
133
+ * Validate skill name according to AgentSkills specification.
134
+ */
135
+ const validateName = (name: unknown, dirName: string): string[] => {
136
+ const errors: string[] = []
137
+
138
+ if (typeof name !== 'string' || !name) {
139
+ errors.push("Field 'name' must be a non-empty string")
140
+ return errors
141
+ }
142
+
143
+ if (name.length > MAX_NAME_LENGTH) {
144
+ errors.push(`Skill name exceeds ${MAX_NAME_LENGTH} character limit`)
145
+ }
146
+
147
+ if (name !== name.toLowerCase()) {
148
+ errors.push('Skill name must be lowercase')
149
+ }
150
+
151
+ if (name.startsWith('-') || name.endsWith('-')) {
152
+ errors.push('Skill name cannot start or end with a hyphen')
153
+ }
154
+
155
+ if (name.includes('--')) {
156
+ errors.push('Skill name cannot contain consecutive hyphens')
157
+ }
158
+
159
+ if (!NAME_PATTERN.test(name)) {
160
+ errors.push('Skill name contains invalid characters (only lowercase alphanumeric and hyphens allowed)')
161
+ }
162
+
163
+ if (name !== dirName) {
164
+ errors.push(`Directory name '${dirName}' must match skill name '${name}'`)
165
+ }
166
+
167
+ return errors
168
+ }
169
+
170
+ /**
171
+ * Validate skill description according to AgentSkills specification.
172
+ */
173
+ const validateDescription = (description: unknown): string[] => {
174
+ const errors: string[] = []
175
+
176
+ if (typeof description !== 'string' || !description) {
177
+ errors.push("Field 'description' must be a non-empty string")
178
+ return errors
179
+ }
180
+
181
+ if (description.length > MAX_DESCRIPTION_LENGTH) {
182
+ errors.push(`Description exceeds ${MAX_DESCRIPTION_LENGTH} character limit`)
183
+ }
184
+
185
+ return errors
186
+ }
187
+
188
+ /**
189
+ * Validate optional compatibility field.
190
+ */
191
+ const validateCompatibility = (compatibility: unknown): string[] => {
192
+ const errors: string[] = []
193
+
194
+ if (compatibility === undefined) return errors
195
+
196
+ if (typeof compatibility !== 'string') {
197
+ errors.push("Field 'compatibility' must be a string")
198
+ return errors
199
+ }
200
+
201
+ if (compatibility.length > MAX_COMPATIBILITY_LENGTH) {
202
+ errors.push(`Compatibility exceeds ${MAX_COMPATIBILITY_LENGTH} character limit`)
203
+ }
204
+
205
+ return errors
206
+ }
207
+
208
+ /**
209
+ * Validate metadata fields are all strings.
210
+ */
211
+ const validateMetadata = (metadata: unknown): string[] => {
212
+ const errors: string[] = []
213
+
214
+ if (metadata === undefined) return errors
215
+
216
+ if (typeof metadata !== 'object' || metadata === null) {
217
+ errors.push("Field 'metadata' must be an object")
218
+ return errors
219
+ }
220
+
221
+ for (const [key, value] of Object.entries(metadata)) {
222
+ if (typeof value !== 'string') {
223
+ errors.push(`Metadata field '${key}' must be a string`)
224
+ }
225
+ }
226
+
227
+ return errors
228
+ }
229
+
230
+ /**
231
+ * Check for unexpected fields in frontmatter.
232
+ */
233
+ const validateFields = (metadata: Record<string, unknown>): string[] => {
234
+ const warnings: string[] = []
235
+
236
+ for (const key of Object.keys(metadata)) {
237
+ if (!ALLOWED_FIELDS.has(key)) {
238
+ warnings.push(`Unexpected field in frontmatter: '${key}'`)
239
+ }
240
+ }
241
+
242
+ return warnings
243
+ }
244
+
245
+ /**
246
+ * Find SKILL.md file in a directory (case-insensitive).
247
+ */
248
+ const findSkillMd = async (skillDir: string): Promise<string | null> => {
249
+ // Prefer uppercase SKILL.md
250
+ const upperPath = join(skillDir, 'SKILL.md')
251
+ if (await Bun.file(upperPath).exists()) {
252
+ return upperPath
253
+ }
254
+
255
+ // Fall back to lowercase
256
+ const lowerPath = join(skillDir, 'skill.md')
257
+ if (await Bun.file(lowerPath).exists()) {
258
+ return lowerPath
259
+ }
260
+
261
+ return null
262
+ }
263
+
264
+ /**
265
+ * Validate a single skill directory.
266
+ *
267
+ * @param skillDir - Path to the skill directory
268
+ * @returns Validation result with errors and warnings
269
+ */
270
+ const validateSkillDirectory = async (skillDir: string): Promise<ValidationResult> => {
271
+ const result: ValidationResult = {
272
+ valid: false,
273
+ path: skillDir,
274
+ errors: [],
275
+ warnings: [],
276
+ }
277
+
278
+ // Check directory exists
279
+ try {
280
+ const stat = await Bun.$`test -d ${skillDir}`.quiet()
281
+ if (stat.exitCode !== 0) {
282
+ result.errors.push(`Path is not a directory: ${skillDir}`)
283
+ return result
284
+ }
285
+ } catch {
286
+ result.errors.push(`Directory does not exist: ${skillDir}`)
287
+ return result
288
+ }
289
+
290
+ // Find SKILL.md
291
+ const skillMdPath = await findSkillMd(skillDir)
292
+ if (!skillMdPath) {
293
+ result.errors.push('Missing required file: SKILL.md')
294
+ return result
295
+ }
296
+
297
+ // Read and parse content
298
+ let content: string
299
+ try {
300
+ content = await Bun.file(skillMdPath).text()
301
+ } catch (error) {
302
+ result.errors.push(`Failed to read SKILL.md: ${error}`)
303
+ return result
304
+ }
305
+
306
+ // Parse frontmatter
307
+ let metadata: Record<string, unknown>
308
+ try {
309
+ ;[metadata] = parseFrontmatter(content)
310
+ } catch (error) {
311
+ result.errors.push(`Failed to parse frontmatter: ${error}`)
312
+ return result
313
+ }
314
+
315
+ // Check for required fields
316
+ for (const field of REQUIRED_FIELDS) {
317
+ if (!(field in metadata)) {
318
+ result.errors.push(`Missing required field in frontmatter: '${field}'`)
319
+ }
320
+ }
321
+
322
+ if (result.errors.length > 0) {
323
+ return result
324
+ }
325
+
326
+ // Validate individual fields
327
+ const dirName = basename(skillDir)
328
+
329
+ result.errors.push(...validateName(metadata.name, dirName))
330
+ result.errors.push(...validateDescription(metadata.description))
331
+ result.errors.push(...validateCompatibility(metadata.compatibility))
332
+ result.errors.push(...validateMetadata(metadata.metadata))
333
+ result.warnings.push(...validateFields(metadata))
334
+
335
+ // If valid, extract properties
336
+ if (result.errors.length === 0) {
337
+ result.valid = true
338
+ const props: SkillProperties = {
339
+ name: metadata.name as string,
340
+ description: metadata.description as string,
341
+ }
342
+ if (typeof metadata.license === 'string') props.license = metadata.license
343
+ if (typeof metadata.compatibility === 'string') props.compatibility = metadata.compatibility
344
+ if (typeof metadata['allowed-tools'] === 'string') props['allowed-tools'] = metadata['allowed-tools']
345
+ if (metadata.metadata && typeof metadata.metadata === 'object') {
346
+ props.metadata = metadata.metadata as Record<string, string>
347
+ }
348
+ result.properties = props
349
+ }
350
+
351
+ return result
352
+ }
353
+
354
+ /**
355
+ * Check if a path is an existing directory.
356
+ */
357
+ const isDirectory = async (path: string): Promise<boolean> => {
358
+ try {
359
+ const stat = await Bun.$`test -d ${path}`.quiet()
360
+ return stat.exitCode === 0
361
+ } catch {
362
+ return false
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Find all skill directories under a root path.
368
+ *
369
+ * @param rootDir - Root directory to search
370
+ * @returns Array of skill directory paths
371
+ */
372
+ const findSkillDirectories = async (rootDir: string): Promise<string[]> => {
373
+ // Check if directory exists before scanning
374
+ if (!(await isDirectory(rootDir))) {
375
+ return []
376
+ }
377
+
378
+ const skillDirs: string[] = []
379
+ const glob = new Glob('**/SKILL.md')
380
+
381
+ for await (const file of glob.scan({ cwd: rootDir, absolute: true })) {
382
+ // Get parent directory of SKILL.md
383
+ const skillDir = file.replace(/\/SKILL\.md$/i, '')
384
+ skillDirs.push(skillDir)
385
+ }
386
+
387
+ return skillDirs.sort()
388
+ }
389
+
390
+ /**
391
+ * Validate all skills under a root directory.
392
+ *
393
+ * @param rootDir - Root directory containing skill folders
394
+ * @returns Array of validation results
395
+ */
396
+ const validateSkills = async (rootDir: string): Promise<ValidationResult[]> => {
397
+ const skillDirs = await findSkillDirectories(rootDir)
398
+ const results: ValidationResult[] = []
399
+
400
+ for (const skillDir of skillDirs) {
401
+ results.push(await validateSkillDirectory(skillDir))
402
+ }
403
+
404
+ return results
405
+ }
406
+
407
+ /**
408
+ * Validate skill directories against AgentSkills specification
409
+ *
410
+ * @param args - Command line arguments
411
+ */
412
+ export const validateSkill = async (args: string[]) => {
413
+ const { values, positionals } = parseArgs({
414
+ args,
415
+ options: {
416
+ json: {
417
+ type: 'boolean',
418
+ default: false,
419
+ },
420
+ },
421
+ allowPositionals: true,
422
+ })
423
+
424
+ const cwd = process.cwd()
425
+ const searchPaths = positionals.length > 0 ? positionals : [join(cwd, '.claude/skills')]
426
+
427
+ const allResults: ValidationResult[] = []
428
+
429
+ for (const searchPath of searchPaths) {
430
+ const fullPath = searchPath.startsWith('/') ? searchPath : join(cwd, searchPath)
431
+
432
+ // Check if path is a skill directory or a directory containing skills
433
+ const skillMdPath = await findSkillMd(fullPath)
434
+
435
+ if (skillMdPath) {
436
+ // Direct skill directory
437
+ allResults.push(await validateSkillDirectory(fullPath))
438
+ } else {
439
+ // Directory containing skills
440
+ const results = await validateSkills(fullPath)
441
+ if (results.length > 0) {
442
+ allResults.push(...results)
443
+ } else {
444
+ // No nested skills found - validate path directly (will produce error)
445
+ allResults.push(await validateSkillDirectory(fullPath))
446
+ }
447
+ }
448
+ }
449
+
450
+ if (values.json) {
451
+ console.log(JSON.stringify(allResults, null, 2))
452
+ } else {
453
+ // Human-readable output
454
+ let hasErrors = false
455
+
456
+ for (const result of allResults) {
457
+ const relativePath = result.path.replace(cwd, '').replace(/^\//, '')
458
+
459
+ if (result.valid) {
460
+ console.log(`✓ ${relativePath}`)
461
+ } else {
462
+ console.log(`✗ ${relativePath}`)
463
+ hasErrors = true
464
+ }
465
+
466
+ for (const error of result.errors) {
467
+ console.log(` ERROR: ${error}`)
468
+ }
469
+
470
+ for (const warning of result.warnings) {
471
+ console.log(` WARN: ${warning}`)
472
+ }
473
+ }
474
+
475
+ if (allResults.length === 0) {
476
+ console.log('No skills found to validate')
477
+ } else {
478
+ const valid = allResults.filter((r) => r.valid).length
479
+ const total = allResults.length
480
+ console.log(`\n${valid}/${total} skills valid`)
481
+ }
482
+
483
+ if (hasErrors) {
484
+ process.exit(1)
485
+ }
486
+ }
487
+ }
488
+
489
+ // Keep executable entry point for direct execution
490
+ if (import.meta.main) {
491
+ await validateSkill(Bun.argv.slice(2))
492
+ }