@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +15 -0
- package/README.md +406 -0
- package/bin/lpm.js +334 -0
- package/index.d.ts +131 -0
- package/index.js +31 -0
- package/lib/api.js +324 -0
- package/lib/commands/add.js +1217 -0
- package/lib/commands/audit.js +283 -0
- package/lib/commands/cache.js +209 -0
- package/lib/commands/check-name.js +112 -0
- package/lib/commands/config.js +174 -0
- package/lib/commands/doctor.js +142 -0
- package/lib/commands/info.js +215 -0
- package/lib/commands/init.js +146 -0
- package/lib/commands/install.js +217 -0
- package/lib/commands/login.js +547 -0
- package/lib/commands/logout.js +94 -0
- package/lib/commands/marketplace-compare.js +164 -0
- package/lib/commands/marketplace-earnings.js +89 -0
- package/lib/commands/mcp-setup.js +363 -0
- package/lib/commands/open.js +82 -0
- package/lib/commands/outdated.js +291 -0
- package/lib/commands/pool-stats.js +100 -0
- package/lib/commands/publish.js +707 -0
- package/lib/commands/quality.js +211 -0
- package/lib/commands/remove.js +82 -0
- package/lib/commands/run.js +14 -0
- package/lib/commands/search.js +143 -0
- package/lib/commands/setup.js +92 -0
- package/lib/commands/skills.js +863 -0
- package/lib/commands/token-rotate.js +25 -0
- package/lib/commands/whoami.js +129 -0
- package/lib/config.js +240 -0
- package/lib/constants.js +190 -0
- package/lib/ecosystem.js +501 -0
- package/lib/editors.js +215 -0
- package/lib/import-rewriter.js +364 -0
- package/lib/install-targets/mcp-server.js +245 -0
- package/lib/install-targets/vscode-extension.js +178 -0
- package/lib/install-targets.js +82 -0
- package/lib/integrity.js +179 -0
- package/lib/lpm-config-prompts.js +102 -0
- package/lib/lpm-config.js +408 -0
- package/lib/project-utils.js +152 -0
- package/lib/quality/checks.js +654 -0
- package/lib/quality/display.js +139 -0
- package/lib/quality/score.js +115 -0
- package/lib/quality/swift-checks.js +447 -0
- package/lib/safe-path.js +180 -0
- package/lib/secure-store.js +288 -0
- package/lib/swift-project.js +637 -0
- package/lib/ui.js +40 -0
- 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
|
+
}
|