@lpm-registry/cli 0.2.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 (54) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +15 -0
  3. package/README.md +406 -0
  4. package/bin/lpm.js +334 -0
  5. package/index.d.ts +131 -0
  6. package/index.js +31 -0
  7. package/lib/api.js +324 -0
  8. package/lib/commands/add.js +1217 -0
  9. package/lib/commands/audit.js +283 -0
  10. package/lib/commands/cache.js +209 -0
  11. package/lib/commands/check-name.js +112 -0
  12. package/lib/commands/config.js +174 -0
  13. package/lib/commands/doctor.js +142 -0
  14. package/lib/commands/info.js +215 -0
  15. package/lib/commands/init.js +146 -0
  16. package/lib/commands/install.js +217 -0
  17. package/lib/commands/login.js +547 -0
  18. package/lib/commands/logout.js +94 -0
  19. package/lib/commands/marketplace-compare.js +164 -0
  20. package/lib/commands/marketplace-earnings.js +89 -0
  21. package/lib/commands/mcp-setup.js +363 -0
  22. package/lib/commands/open.js +82 -0
  23. package/lib/commands/outdated.js +291 -0
  24. package/lib/commands/pool-stats.js +100 -0
  25. package/lib/commands/publish.js +707 -0
  26. package/lib/commands/quality.js +211 -0
  27. package/lib/commands/remove.js +82 -0
  28. package/lib/commands/run.js +14 -0
  29. package/lib/commands/search.js +143 -0
  30. package/lib/commands/setup.js +92 -0
  31. package/lib/commands/skills.js +863 -0
  32. package/lib/commands/token-rotate.js +25 -0
  33. package/lib/commands/whoami.js +129 -0
  34. package/lib/config.js +240 -0
  35. package/lib/constants.js +190 -0
  36. package/lib/ecosystem.js +501 -0
  37. package/lib/editors.js +215 -0
  38. package/lib/import-rewriter.js +364 -0
  39. package/lib/install-targets/mcp-server.js +245 -0
  40. package/lib/install-targets/vscode-extension.js +178 -0
  41. package/lib/install-targets.js +82 -0
  42. package/lib/integrity.js +179 -0
  43. package/lib/lpm-config-prompts.js +102 -0
  44. package/lib/lpm-config.js +408 -0
  45. package/lib/project-utils.js +152 -0
  46. package/lib/quality/checks.js +654 -0
  47. package/lib/quality/display.js +139 -0
  48. package/lib/quality/score.js +115 -0
  49. package/lib/quality/swift-checks.js +447 -0
  50. package/lib/safe-path.js +180 -0
  51. package/lib/secure-store.js +288 -0
  52. package/lib/swift-project.js +637 -0
  53. package/lib/ui.js +40 -0
  54. package/package.json +74 -0
@@ -0,0 +1,863 @@
1
+ /**
2
+ * `lpm skills` - Manage Agent Skills for LPM packages.
3
+ *
4
+ * Subcommands:
5
+ * validate - Validate .lpm/skills/ files in current directory
6
+ * install - Fetch and install skills from registry
7
+ * list - List available skills for installed packages
8
+ * clean - Remove locally installed skills
9
+ */
10
+
11
+ import fs from "node:fs"
12
+ import path from "node:path"
13
+ import chalk from "chalk"
14
+ import { get } from "../api.js"
15
+ import { createSpinner, log, printHeader } from "../ui.js"
16
+
17
+ // ============================================================================
18
+ // Shared constants and utilities
19
+ // ============================================================================
20
+
21
+ const SKILLS_DIR = ".lpm/skills"
22
+ const SKILLS_PATTERN = /\.md$/
23
+
24
+ // Same limits as server-side validation
25
+ const MAX_SKILL_SIZE = 15 * 1024
26
+ const MAX_TOTAL_SIZE = 100 * 1024
27
+ const MAX_SKILLS_COUNT = 10
28
+ const MIN_CONTENT_LENGTH = 100
29
+
30
+ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
31
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/
32
+
33
+ const BLOCKED_PATTERNS = [
34
+ { pattern: /curl\s+.*\|\s*(ba)?sh/i, category: "shell-injection" },
35
+ { pattern: /wget\s+.*\|\s*(ba)?sh/i, category: "shell-injection" },
36
+ { pattern: /\beval\s*\(/i, category: "shell-injection" },
37
+ { pattern: /child_process/i, category: "shell-injection" },
38
+ {
39
+ pattern: /process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i,
40
+ category: "env-exfiltration",
41
+ },
42
+ {
43
+ pattern: /ignore\s+(?:all\s+)?previous\s+instructions/i,
44
+ category: "prompt-injection",
45
+ },
46
+ { pattern: /you\s+are\s+now\s+/i, category: "prompt-injection" },
47
+ { pattern: /\[INST\]/i, category: "prompt-injection" },
48
+ { pattern: /<<SYS>>/i, category: "prompt-injection" },
49
+ {
50
+ pattern: /forget\s+(?:all\s+)?(?:your\s+)?(?:previous\s+)?instructions/i,
51
+ category: "prompt-injection",
52
+ },
53
+ { pattern: /fs\.(unlink|rmdir|rm)Sync?\s*\(/i, category: "fs-attack" },
54
+ { pattern: /\brimraf\b/i, category: "fs-attack" },
55
+ { pattern: /rm\s+-rf\s+\//i, category: "fs-attack" },
56
+ ]
57
+
58
+ /**
59
+ * Parse simple YAML frontmatter (no external dependency).
60
+ */
61
+ function parseSimpleYaml(yamlStr) {
62
+ const result = {}
63
+ const lines = yamlStr.split("\n")
64
+ let currentKey = null
65
+ let inArray = false
66
+
67
+ for (const line of lines) {
68
+ const trimmed = line.trim()
69
+ if (!trimmed || trimmed.startsWith("#")) continue
70
+
71
+ if (inArray && trimmed.startsWith("- ")) {
72
+ const value = trimmed
73
+ .slice(2)
74
+ .trim()
75
+ .replace(/^["']|["']$/g, "")
76
+ if (currentKey && Array.isArray(result[currentKey])) {
77
+ result[currentKey].push(value)
78
+ }
79
+ continue
80
+ }
81
+
82
+ const colonIndex = trimmed.indexOf(":")
83
+ if (colonIndex > 0) {
84
+ const key = trimmed.slice(0, colonIndex).trim()
85
+ const value = trimmed.slice(colonIndex + 1).trim()
86
+
87
+ currentKey = key
88
+ inArray = false
89
+
90
+ if (!value) {
91
+ result[key] = []
92
+ inArray = true
93
+ } else if (value.startsWith("[") && value.endsWith("]")) {
94
+ result[key] = value
95
+ .slice(1, -1)
96
+ .split(",")
97
+ .map(s => s.trim().replace(/^["']|["']$/g, ""))
98
+ .filter(Boolean)
99
+ } else {
100
+ result[key] = value.replace(/^["']|["']$/g, "")
101
+ inArray = false
102
+ }
103
+ }
104
+ }
105
+
106
+ return result
107
+ }
108
+
109
+ /**
110
+ * Parse a skill file into structured data.
111
+ */
112
+ function parseSkill(rawContent, filePath) {
113
+ const sizeBytes = Buffer.byteLength(rawContent, "utf-8")
114
+
115
+ const match = rawContent.match(FRONTMATTER_REGEX)
116
+ if (!match) {
117
+ return {
118
+ skill: null,
119
+ error: `${filePath}: Missing or invalid YAML frontmatter (must start with --- and end with ---)`,
120
+ }
121
+ }
122
+
123
+ const [, yamlStr, markdownBody] = match
124
+
125
+ let frontmatter
126
+ try {
127
+ frontmatter = parseSimpleYaml(yamlStr)
128
+ } catch {
129
+ return {
130
+ skill: null,
131
+ error: `${filePath}: Failed to parse YAML frontmatter`,
132
+ }
133
+ }
134
+
135
+ if (!frontmatter.name || typeof frontmatter.name !== "string") {
136
+ return {
137
+ skill: null,
138
+ error: `${filePath}: Missing required "name" field in frontmatter`,
139
+ }
140
+ }
141
+
142
+ if (!frontmatter.description || typeof frontmatter.description !== "string") {
143
+ return {
144
+ skill: null,
145
+ error: `${filePath}: Missing required "description" field in frontmatter`,
146
+ }
147
+ }
148
+
149
+ if (!NAME_PATTERN.test(frontmatter.name)) {
150
+ return {
151
+ skill: null,
152
+ error: `${filePath}: Skill name "${frontmatter.name}" must be lowercase letters, numbers, and hyphens only`,
153
+ }
154
+ }
155
+
156
+ const globs = frontmatter.globs
157
+ if (globs && !Array.isArray(globs)) {
158
+ return {
159
+ skill: null,
160
+ error: `${filePath}: "globs" field must be an array of strings`,
161
+ }
162
+ }
163
+
164
+ const content = markdownBody.trim()
165
+
166
+ return {
167
+ skill: {
168
+ name: frontmatter.name,
169
+ description: frontmatter.description,
170
+ globs: globs && globs.length > 0 ? globs : null,
171
+ content,
172
+ rawContent,
173
+ sizeBytes,
174
+ },
175
+ error: null,
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Validate a parsed skill against size and security constraints.
181
+ */
182
+ function validateSkill(skill, filePath) {
183
+ const errors = []
184
+
185
+ if (skill.sizeBytes > MAX_SKILL_SIZE) {
186
+ errors.push(
187
+ `${filePath}: File size ${(skill.sizeBytes / 1024).toFixed(1)}KB exceeds maximum ${MAX_SKILL_SIZE / 1024}KB`,
188
+ )
189
+ }
190
+
191
+ if (skill.content.length < MIN_CONTENT_LENGTH) {
192
+ errors.push(
193
+ `${filePath}: Content is too short (${skill.content.length} chars, minimum ${MIN_CONTENT_LENGTH})`,
194
+ )
195
+ }
196
+
197
+ if (skill.description.length < 10) {
198
+ errors.push(`${filePath}: Description is too short (minimum 10 characters)`)
199
+ }
200
+
201
+ if (skill.description.length > 500) {
202
+ errors.push(`${filePath}: Description is too long (maximum 500 characters)`)
203
+ }
204
+
205
+ for (const { pattern, category } of BLOCKED_PATTERNS) {
206
+ if (pattern.test(skill.rawContent)) {
207
+ errors.push(
208
+ `${filePath}: Blocked pattern detected (${category}): ${pattern.source}`,
209
+ )
210
+ }
211
+ }
212
+
213
+ return { valid: errors.length === 0, errors }
214
+ }
215
+
216
+ /**
217
+ * Validate a batch of skills (count, total size, duplicates).
218
+ */
219
+ function validateBatch(skills) {
220
+ const errors = []
221
+
222
+ if (skills.length > MAX_SKILLS_COUNT) {
223
+ errors.push(
224
+ `Too many skills (${skills.length}), maximum is ${MAX_SKILLS_COUNT}`,
225
+ )
226
+ }
227
+
228
+ const totalSize = skills.reduce((sum, s) => sum + s.sizeBytes, 0)
229
+ if (totalSize > MAX_TOTAL_SIZE) {
230
+ errors.push(
231
+ `Total skills size ${(totalSize / 1024).toFixed(1)}KB exceeds maximum ${MAX_TOTAL_SIZE / 1024}KB`,
232
+ )
233
+ }
234
+
235
+ const names = new Set()
236
+ for (const skill of skills) {
237
+ if (names.has(skill.name)) {
238
+ errors.push(`Duplicate skill name: "${skill.name}"`)
239
+ }
240
+ names.add(skill.name)
241
+ }
242
+
243
+ return { valid: errors.length === 0, errors }
244
+ }
245
+
246
+ /**
247
+ * Read and find all .md files in the .lpm/skills/ directory.
248
+ */
249
+ function findSkillFiles(baseDir) {
250
+ const skillsDir = path.join(baseDir, SKILLS_DIR)
251
+ if (!fs.existsSync(skillsDir)) {
252
+ return { dir: skillsDir, files: [] }
253
+ }
254
+
255
+ const entries = fs.readdirSync(skillsDir)
256
+ const files = entries
257
+ .filter(f => SKILLS_PATTERN.test(f))
258
+ .map(f => path.join(skillsDir, f))
259
+
260
+ return { dir: skillsDir, files }
261
+ }
262
+
263
+ /**
264
+ * Ensure ".lpm" is in package.json "files" array.
265
+ * Returns "added" if it was added, "already-present" if it was already there,
266
+ * "missing-files-field" if there's no "files" field (npm includes everything).
267
+ */
268
+ function ensureLpmInFiles() {
269
+ const packageJsonPath = path.resolve(process.cwd(), "package.json")
270
+ if (!fs.existsSync(packageJsonPath)) return "missing-files-field"
271
+
272
+ const raw = fs.readFileSync(packageJsonPath, "utf-8")
273
+ let pkg
274
+ try {
275
+ pkg = JSON.parse(raw)
276
+ } catch {
277
+ return "missing-files-field"
278
+ }
279
+
280
+ // No "files" field means npm includes everything by default
281
+ if (!Array.isArray(pkg.files)) return "missing-files-field"
282
+
283
+ // Check if .lpm is already included (exact or glob)
284
+ const hasLpm = pkg.files.some(
285
+ f => f === ".lpm" || f === ".lpm/" || f === ".lpm/**",
286
+ )
287
+ if (hasLpm) return "already-present"
288
+
289
+ // Add ".lpm" to the files array
290
+ pkg.files.push(".lpm")
291
+
292
+ // Preserve formatting: detect indent from the original file
293
+ const indentMatch = raw.match(/^(\s+)"/)
294
+ const indent = indentMatch ? indentMatch[1] : "\t"
295
+ fs.writeFileSync(
296
+ packageJsonPath,
297
+ `${JSON.stringify(pkg, null, indent)}\n`,
298
+ "utf-8",
299
+ )
300
+
301
+ return "added"
302
+ }
303
+
304
+ /**
305
+ * Read LPM packages from the nearest package.json.
306
+ */
307
+ function getLpmDependencies() {
308
+ const packageJsonPath = path.resolve(process.cwd(), "package.json")
309
+ if (!fs.existsSync(packageJsonPath)) return []
310
+
311
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
312
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
313
+
314
+ return Object.entries(allDeps)
315
+ .filter(([name]) => name.startsWith("@lpm.dev/"))
316
+ .map(([name, version]) => ({
317
+ fullName: name,
318
+ shortName: name.replace("@lpm.dev/", ""),
319
+ version: version.replace(/^[\^~>=<]+/, ""),
320
+ }))
321
+ }
322
+
323
+ /**
324
+ * Parse a package name input (accepts "owner.pkg" or "@lpm.dev/owner.pkg").
325
+ */
326
+ function parsePkgName(input) {
327
+ let cleaned = input
328
+ if (cleaned.startsWith("@lpm.dev/")) {
329
+ cleaned = cleaned.replace("@lpm.dev/", "")
330
+ }
331
+ const dotIndex = cleaned.indexOf(".")
332
+ if (dotIndex === -1 || dotIndex === 0 || dotIndex === cleaned.length - 1) {
333
+ return null
334
+ }
335
+ return cleaned
336
+ }
337
+
338
+ // ============================================================================
339
+ // Subcommand: validate
340
+ // ============================================================================
341
+
342
+ /**
343
+ * lpm skills validate
344
+ * Validates .lpm/skills/ files in the current directory.
345
+ */
346
+ export async function skillsValidate(options = {}) {
347
+ const isJson = options.json
348
+
349
+ if (!isJson) printHeader()
350
+
351
+ const { dir, files } = findSkillFiles(process.cwd())
352
+
353
+ if (files.length === 0) {
354
+ if (isJson) {
355
+ console.log(
356
+ JSON.stringify({
357
+ valid: false,
358
+ skills: [],
359
+ errors: [`No skill files found in ${SKILLS_DIR}/`],
360
+ }),
361
+ )
362
+ } else {
363
+ log.warn(`No skill files found in ${SKILLS_DIR}/`)
364
+ console.log(
365
+ chalk.dim(
366
+ ` Create .lpm/skills/*.md files to add Agent Skills to your package.`,
367
+ ),
368
+ )
369
+ console.log(chalk.dim(` See: https://lpm.dev/docs/packages/skills`))
370
+ }
371
+ return
372
+ }
373
+
374
+ if (!isJson) {
375
+ console.log(
376
+ ` Validating ${files.length} skill file${files.length !== 1 ? "s" : ""} in ${dir}\n`,
377
+ )
378
+ }
379
+
380
+ const parsed = []
381
+ const allErrors = []
382
+
383
+ for (const filePath of files) {
384
+ const rawContent = fs.readFileSync(filePath, "utf-8")
385
+ const relativePath = path.relative(process.cwd(), filePath)
386
+ const { skill, error } = parseSkill(rawContent, relativePath)
387
+
388
+ if (error) {
389
+ allErrors.push(error)
390
+ if (!isJson) {
391
+ console.log(` ${chalk.red("✗")} ${relativePath}`)
392
+ console.log(` ${chalk.red(error)}`)
393
+ }
394
+ continue
395
+ }
396
+
397
+ const validation = validateSkill(skill, relativePath)
398
+ if (!validation.valid) {
399
+ allErrors.push(...validation.errors)
400
+ if (!isJson) {
401
+ console.log(` ${chalk.red("✗")} ${relativePath}`)
402
+ for (const err of validation.errors) {
403
+ console.log(` ${chalk.red(err)}`)
404
+ }
405
+ }
406
+ } else {
407
+ parsed.push(skill)
408
+ if (!isJson) {
409
+ const globInfo = skill.globs
410
+ ? chalk.dim(
411
+ ` (${skill.globs.length} glob${skill.globs.length !== 1 ? "s" : ""})`,
412
+ )
413
+ : ""
414
+ console.log(` ${chalk.green("✓")} ${relativePath}${globInfo}`)
415
+ console.log(` ${chalk.dim(skill.description)}`)
416
+ }
417
+ }
418
+ }
419
+
420
+ // Batch validation
421
+ if (parsed.length > 0) {
422
+ const batchResult = validateBatch(parsed)
423
+ if (!batchResult.valid) {
424
+ allErrors.push(...batchResult.errors)
425
+ if (!isJson) {
426
+ console.log("")
427
+ for (const err of batchResult.errors) {
428
+ console.log(` ${chalk.red("✗")} ${err}`)
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ const isValid = allErrors.length === 0 && parsed.length > 0
435
+
436
+ if (isJson) {
437
+ console.log(
438
+ JSON.stringify(
439
+ {
440
+ valid: isValid,
441
+ skills: parsed.map(s => ({
442
+ name: s.name,
443
+ description: s.description,
444
+ globs: s.globs,
445
+ sizeBytes: s.sizeBytes,
446
+ })),
447
+ errors: allErrors,
448
+ },
449
+ null,
450
+ 2,
451
+ ),
452
+ )
453
+ return
454
+ }
455
+
456
+ console.log("")
457
+ if (isValid) {
458
+ const totalSize = parsed.reduce((sum, s) => sum + s.sizeBytes, 0)
459
+ log.success(
460
+ `${parsed.length} skill${parsed.length !== 1 ? "s" : ""} valid (${(totalSize / 1024).toFixed(1)}KB total)`,
461
+ )
462
+ console.log(
463
+ chalk.dim(
464
+ ` Quality impact: +7 pts (has-skills)${parsed.length >= 3 ? " +3 pts (comprehensive)" : ""}`,
465
+ ),
466
+ )
467
+
468
+ // Check if .lpm is in package.json files field
469
+ const fixed = ensureLpmInFiles()
470
+ if (fixed === "added") {
471
+ console.log("")
472
+ log.success(
473
+ `Added ".lpm" to package.json "files" field so skills are included in the tarball.`,
474
+ )
475
+ } else if (fixed === "missing-files-field") {
476
+ // No "files" field means npm includes everything - skills are included
477
+ } else if (fixed === "already-present") {
478
+ // Already configured correctly
479
+ }
480
+ } else {
481
+ log.error(
482
+ `Validation failed with ${allErrors.length} error${allErrors.length !== 1 ? "s" : ""}.`,
483
+ )
484
+ }
485
+ console.log("")
486
+ }
487
+
488
+ // ============================================================================
489
+ // Subcommand: install
490
+ // ============================================================================
491
+
492
+ /**
493
+ * lpm skills install [package]
494
+ * Fetches and installs skills from the registry.
495
+ */
496
+ export async function skillsInstall(packageInput, options = {}) {
497
+ const isJson = options.json
498
+
499
+ if (!isJson) printHeader()
500
+
501
+ let packages = []
502
+
503
+ if (packageInput) {
504
+ const cleaned = parsePkgName(packageInput)
505
+ if (!cleaned) {
506
+ if (isJson) {
507
+ console.log(
508
+ JSON.stringify({
509
+ error: "Invalid package name format. Use: owner.package-name",
510
+ }),
511
+ )
512
+ } else {
513
+ log.error("Invalid package name format. Use: owner.package-name")
514
+ }
515
+ process.exit(1)
516
+ }
517
+ packages = [{ shortName: cleaned, fullName: `@lpm.dev/${cleaned}` }]
518
+ } else {
519
+ // Read from package.json
520
+ packages = getLpmDependencies()
521
+ if (packages.length === 0) {
522
+ if (isJson) {
523
+ console.log(
524
+ JSON.stringify({
525
+ installed: 0,
526
+ packages: [],
527
+ errors: ["No @lpm.dev/* packages found in package.json."],
528
+ }),
529
+ )
530
+ } else {
531
+ log.info("No @lpm.dev/* packages found in package.json.")
532
+ }
533
+ return
534
+ }
535
+ }
536
+
537
+ const spinner = isJson
538
+ ? null
539
+ : createSpinner(
540
+ `Fetching skills for ${packages.length} package${packages.length !== 1 ? "s" : ""}...`,
541
+ ).start()
542
+
543
+ const results = []
544
+ const errors = []
545
+
546
+ for (const pkg of packages) {
547
+ try {
548
+ const response = await get(
549
+ `/skills?name=${encodeURIComponent(pkg.shortName)}${pkg.version ? `&version=${encodeURIComponent(pkg.version)}` : ""}`,
550
+ )
551
+
552
+ if (!response.ok) {
553
+ if (response.status === 404) {
554
+ results.push({ name: pkg.fullName, skills: [], status: "not-found" })
555
+ continue
556
+ }
557
+ const data = await response.json().catch(() => ({}))
558
+ errors.push(
559
+ `${pkg.fullName}: ${data.error || `HTTP ${response.status}`}`,
560
+ )
561
+ continue
562
+ }
563
+
564
+ const data = await response.json()
565
+
566
+ if (!data.skills || data.skills.length === 0) {
567
+ results.push({ name: pkg.fullName, skills: [], status: "no-skills" })
568
+ continue
569
+ }
570
+
571
+ // Write skills to .lpm/skills/{owner}.{package-name}/
572
+ const targetDir = path.join(process.cwd(), SKILLS_DIR, pkg.shortName)
573
+ fs.mkdirSync(targetDir, { recursive: true })
574
+
575
+ for (const skill of data.skills) {
576
+ const fileName = `${skill.name}.md`
577
+ const content = skill.rawContent || skill.content
578
+ fs.writeFileSync(path.join(targetDir, fileName), content, "utf-8")
579
+ }
580
+
581
+ results.push({
582
+ name: pkg.fullName,
583
+ skills: data.skills.map(s => s.name),
584
+ status: "installed",
585
+ path: targetDir,
586
+ })
587
+ } catch (err) {
588
+ errors.push(`${pkg.fullName}: ${err.message}`)
589
+ }
590
+ }
591
+
592
+ // Ensure .lpm/skills/ is in .gitignore
593
+ ensureGitignore()
594
+
595
+ if (spinner) spinner.stop()
596
+
597
+ if (isJson) {
598
+ console.log(
599
+ JSON.stringify(
600
+ {
601
+ installed: results.filter(r => r.status === "installed").length,
602
+ packages: results,
603
+ errors,
604
+ },
605
+ null,
606
+ 2,
607
+ ),
608
+ )
609
+ return
610
+ }
611
+
612
+ // Print results
613
+ const installed = results.filter(r => r.status === "installed")
614
+ const noSkills = results.filter(
615
+ r => r.status === "no-skills" || r.status === "not-found",
616
+ )
617
+
618
+ if (installed.length > 0) {
619
+ console.log("")
620
+ for (const r of installed) {
621
+ log.success(
622
+ `${r.name}: ${r.skills.length} skill${r.skills.length !== 1 ? "s" : ""} installed`,
623
+ )
624
+ for (const name of r.skills) {
625
+ console.log(chalk.dim(` ${name}.md`))
626
+ }
627
+ }
628
+ }
629
+
630
+ if (noSkills.length > 0) {
631
+ console.log("")
632
+ for (const r of noSkills) {
633
+ log.dim(` ${r.name}: no skills available`)
634
+ }
635
+ }
636
+
637
+ if (errors.length > 0) {
638
+ console.log("")
639
+ for (const err of errors) {
640
+ log.error(err)
641
+ }
642
+ }
643
+
644
+ if (installed.length > 0) {
645
+ console.log("")
646
+ console.log(chalk.dim(` Skills saved to ${SKILLS_DIR}/`))
647
+ printWiringInstructions()
648
+ }
649
+
650
+ console.log("")
651
+ }
652
+
653
+ /**
654
+ * Print instructions for wiring skills into AI tools.
655
+ */
656
+ function printWiringInstructions() {
657
+ const skillsPath = path.join(process.cwd(), SKILLS_DIR)
658
+ if (!fs.existsSync(skillsPath)) return
659
+
660
+ console.log("")
661
+ console.log(chalk.dim(" To use these skills with your AI coding tool:"))
662
+ console.log("")
663
+ console.log(chalk.dim(" Claude Code:"))
664
+ console.log(
665
+ chalk.dim(
666
+ ` Add to CLAUDE.md: "See ${SKILLS_DIR}/ for package-specific guidelines"`,
667
+ ),
668
+ )
669
+ console.log("")
670
+ console.log(chalk.dim(" Cursor:"))
671
+ console.log(
672
+ chalk.dim(` Copy files to .cursor/rules/ or reference in .cursorrules`),
673
+ )
674
+ }
675
+
676
+ /**
677
+ * Ensure .lpm/skills/ is listed in .gitignore.
678
+ */
679
+ function ensureGitignore() {
680
+ const gitignorePath = path.join(process.cwd(), ".gitignore")
681
+
682
+ if (!fs.existsSync(gitignorePath)) return
683
+
684
+ const content = fs.readFileSync(gitignorePath, "utf-8")
685
+ if (content.includes(".lpm/skills")) return
686
+
687
+ fs.appendFileSync(
688
+ gitignorePath,
689
+ "\n# LPM Agent Skills (fetched from registry)\n.lpm/skills/\n",
690
+ )
691
+ }
692
+
693
+ // ============================================================================
694
+ // Subcommand: list
695
+ // ============================================================================
696
+
697
+ /**
698
+ * lpm skills list
699
+ * Lists available skills for installed @lpm.dev/* packages.
700
+ */
701
+ export async function skillsList(options = {}) {
702
+ const isJson = options.json
703
+
704
+ if (!isJson) printHeader()
705
+
706
+ const packages = getLpmDependencies()
707
+
708
+ if (packages.length === 0) {
709
+ if (isJson) {
710
+ console.log(JSON.stringify({ packages: [] }))
711
+ } else {
712
+ log.info("No @lpm.dev/* packages found in package.json.")
713
+ }
714
+ return
715
+ }
716
+
717
+ const spinner = isJson
718
+ ? null
719
+ : createSpinner(
720
+ `Checking skills for ${packages.length} package${packages.length !== 1 ? "s" : ""}...`,
721
+ ).start()
722
+
723
+ const results = []
724
+
725
+ for (const pkg of packages) {
726
+ try {
727
+ const response = await get(
728
+ `/skills?name=${encodeURIComponent(pkg.shortName)}${pkg.version ? `&version=${encodeURIComponent(pkg.version)}` : ""}`,
729
+ )
730
+
731
+ if (!response.ok) {
732
+ results.push({
733
+ name: pkg.fullName,
734
+ version: pkg.version,
735
+ skillsCount: 0,
736
+ installed: false,
737
+ })
738
+ continue
739
+ }
740
+
741
+ const data = await response.json()
742
+
743
+ // Check if installed locally
744
+ const localDir = path.join(process.cwd(), SKILLS_DIR, pkg.shortName)
745
+ const installed = fs.existsSync(localDir)
746
+
747
+ results.push({
748
+ name: pkg.fullName,
749
+ version: data.version || pkg.version,
750
+ skillsCount: data.skillsCount || 0,
751
+ skills: (data.skills || []).map(s => s.name),
752
+ installed,
753
+ })
754
+ } catch {
755
+ results.push({
756
+ name: pkg.fullName,
757
+ version: pkg.version,
758
+ skillsCount: 0,
759
+ installed: false,
760
+ })
761
+ }
762
+ }
763
+
764
+ if (spinner) spinner.stop()
765
+
766
+ if (isJson) {
767
+ console.log(JSON.stringify({ packages: results }, null, 2))
768
+ return
769
+ }
770
+
771
+ console.log("")
772
+
773
+ const withSkills = results.filter(r => r.skillsCount > 0)
774
+ const withoutSkills = results.filter(r => r.skillsCount === 0)
775
+
776
+ if (withSkills.length > 0) {
777
+ console.log(chalk.bold(" Packages with Agent Skills:\n"))
778
+ for (const r of withSkills) {
779
+ const installedBadge = r.installed
780
+ ? chalk.green(" [installed]")
781
+ : chalk.dim(" [not installed]")
782
+ console.log(
783
+ ` ${r.name}@${r.version} ${chalk.cyan(`${r.skillsCount} skill${r.skillsCount !== 1 ? "s" : ""}`)}${installedBadge}`,
784
+ )
785
+ }
786
+ }
787
+
788
+ if (withoutSkills.length > 0) {
789
+ if (withSkills.length > 0) console.log("")
790
+ console.log(chalk.dim(" No skills available:"))
791
+ for (const r of withoutSkills) {
792
+ console.log(chalk.dim(` ${r.name}@${r.version}`))
793
+ }
794
+ }
795
+
796
+ if (withSkills.length > 0) {
797
+ const notInstalled = withSkills.filter(r => !r.installed)
798
+ if (notInstalled.length > 0) {
799
+ console.log("")
800
+ console.log(
801
+ chalk.dim(
802
+ ` Run ${chalk.cyan("lpm skills install")} to download all available skills.`,
803
+ ),
804
+ )
805
+ }
806
+ }
807
+
808
+ console.log("")
809
+ }
810
+
811
+ // ============================================================================
812
+ // Subcommand: clean
813
+ // ============================================================================
814
+
815
+ /**
816
+ * lpm skills clean
817
+ * Removes locally installed skills (.lpm/skills/ directory).
818
+ */
819
+ export async function skillsClean(options = {}) {
820
+ const isJson = options.json
821
+
822
+ if (!isJson) printHeader()
823
+
824
+ const skillsPath = path.join(process.cwd(), SKILLS_DIR)
825
+
826
+ if (!fs.existsSync(skillsPath)) {
827
+ if (isJson) {
828
+ console.log(
829
+ JSON.stringify({
830
+ cleaned: false,
831
+ message: "No .lpm/skills/ directory found.",
832
+ }),
833
+ )
834
+ } else {
835
+ log.info("No .lpm/skills/ directory found. Nothing to clean.")
836
+ }
837
+ return
838
+ }
839
+
840
+ // Count files before removing
841
+ let fileCount = 0
842
+ const countFiles = dir => {
843
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
844
+ if (entry.isDirectory()) {
845
+ countFiles(path.join(dir, entry.name))
846
+ } else {
847
+ fileCount++
848
+ }
849
+ }
850
+ }
851
+ countFiles(skillsPath)
852
+
853
+ fs.rmSync(skillsPath, { recursive: true, force: true })
854
+
855
+ if (isJson) {
856
+ console.log(JSON.stringify({ cleaned: true, filesRemoved: fileCount }))
857
+ } else {
858
+ log.success(
859
+ `Removed ${SKILLS_DIR}/ (${fileCount} file${fileCount !== 1 ? "s" : ""})`,
860
+ )
861
+ console.log("")
862
+ }
863
+ }