prjct-cli 0.39.0 → 0.40.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 CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.40.0] - 2026-01-28
4
+
5
+ ### Feature: Enhanced Skill System
6
+
7
+ Inspired by vercel-labs/skills, prjct-cli now supports remote skill installation, version tracking via lock file, and full ecosystem-standard SKILL.md subdirectory format across all skill directories.
8
+
9
+ **New: Remote Skill Installation (`p. skill add`)**
10
+ - Install skills from GitHub repos: `p. skill add owner/repo`
11
+ - Install specific skill: `p. skill add owner/repo@skill-name`
12
+ - Install from local directory: `p. skill add ./path`
13
+ - Automatic source metadata injection (`_prjct` frontmatter block)
14
+
15
+ **New: Skill Lock File**
16
+ - Tracks installed skills at `~/.prjct-cli/skills/.skill-lock.json`
17
+ - Records source URL, commit SHA, install timestamp
18
+ - Enables update detection via `p. skill check`
19
+
20
+ **New: Additional Subcommands**
21
+ - `p. skill add <source>` — Install from GitHub or local path
22
+ - `p. skill remove <name>` — Uninstall a skill
23
+ - `p. skill init <name>` — Scaffold a new skill template
24
+ - `p. skill check` — Detect available updates
25
+
26
+ **Improved: Full SKILL.md Subdirectory Support**
27
+ - All skill directories now support both `{name}.md` (flat) and `{name}/SKILL.md` (subdirectory) formats
28
+ - Previously only provider dirs (`~/.claude/skills/`) checked subdirectories
29
+ - Orchestrator executor now checks both patterns when loading agent skills
30
+
31
+ **New Files:**
32
+ - `core/services/skill-installer.ts` — Remote installation service
33
+ - `core/services/skill-lock.ts` — Lock file management
34
+
35
+ **Modified:**
36
+ - `core/types/services.ts` — Extended `SkillMetadata` with `sourceUrl`, `sourceType`, `installedAt`, `sha`; added `'remote'` source type
37
+ - `core/services/skill-service.ts` — Unified SKILL.md discovery in all dirs
38
+ - `core/agentic/orchestrator-executor.ts` — Both path patterns for skill loading
39
+ - `templates/commands/skill.md` — New subcommands documentation
40
+
3
41
  ## [0.39.0] - 2026-01-24
4
42
 
5
43
  ### Feature: Windsurf IDE Support (PRJ-66)
@@ -344,18 +344,35 @@ export class OrchestratorExecutor {
344
344
  // Skip if already loaded
345
345
  if (loadedSkillNames.has(skillName)) continue
346
346
 
347
- const skillPath = path.join(skillsDir, `${skillName}.md`)
347
+ // Check both patterns: flat file and subdirectory (ecosystem standard)
348
+ const flatPath = path.join(skillsDir, `${skillName}.md`)
349
+ const subdirPath = path.join(skillsDir, skillName, 'SKILL.md')
350
+
351
+ let content: string | null = null
352
+ let resolvedPath = flatPath
353
+
354
+ // Prefer subdirectory format (ecosystem standard)
348
355
  try {
349
- const content = await fs.readFile(skillPath, 'utf-8')
356
+ content = await fs.readFile(subdirPath, 'utf-8')
357
+ resolvedPath = subdirPath
358
+ } catch {
359
+ // Fall back to flat file
360
+ try {
361
+ content = await fs.readFile(flatPath, 'utf-8')
362
+ resolvedPath = flatPath
363
+ } catch {
364
+ // Skill not found - not an error, just skip
365
+ console.warn(`Skill not found: ${skillName}`)
366
+ }
367
+ }
368
+
369
+ if (content) {
350
370
  skills.push({
351
371
  name: skillName,
352
372
  content,
353
- filePath: skillPath,
373
+ filePath: resolvedPath,
354
374
  })
355
375
  loadedSkillNames.add(skillName)
356
- } catch {
357
- // Skill not found - not an error, just skip
358
- console.warn(`Skill not found: ${skillName}`)
359
376
  }
360
377
  }
361
378
  }
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Skill Installer Service
3
+ *
4
+ * Installs skills from remote sources (GitHub repos, local paths).
5
+ * Follows the ecosystem standard of {name}/SKILL.md subdirectory format.
6
+ *
7
+ * Supported sources:
8
+ * - owner/repo — clone from GitHub, discover all SKILL.md files
9
+ * - owner/repo@skill-name — install specific skill from repo
10
+ * - ./local-path — install from local directory
11
+ *
12
+ * @version 1.0.0
13
+ */
14
+
15
+ import fs from 'fs/promises'
16
+ import path from 'path'
17
+ import os from 'os'
18
+ import { promisify } from 'util'
19
+ import { exec as execCallback } from 'child_process'
20
+ import { glob } from 'glob'
21
+
22
+ import { skillLock } from './skill-lock'
23
+ import type { SkillLockEntry } from './skill-lock'
24
+
25
+ const exec = promisify(execCallback)
26
+
27
+ // =============================================================================
28
+ // Types
29
+ // =============================================================================
30
+
31
+ export interface ParsedSource {
32
+ type: 'github' | 'local'
33
+ owner?: string
34
+ repo?: string
35
+ skillName?: string
36
+ localPath?: string
37
+ url: string
38
+ }
39
+
40
+ export interface InstalledSkill {
41
+ name: string
42
+ filePath: string
43
+ source: ParsedSource
44
+ sha?: string
45
+ }
46
+
47
+ export interface InstallResult {
48
+ installed: InstalledSkill[]
49
+ skipped: string[]
50
+ errors: string[]
51
+ }
52
+
53
+ // =============================================================================
54
+ // Source Parsing
55
+ // =============================================================================
56
+
57
+ /**
58
+ * Parse a source string into a structured source object
59
+ *
60
+ * Formats:
61
+ * - "owner/repo" → GitHub repo, install all skills
62
+ * - "owner/repo@skill-name" → GitHub repo, specific skill
63
+ * - "./path" or "/path" → Local directory
64
+ */
65
+ export function parseSource(source: string): ParsedSource {
66
+ // Local path
67
+ if (source.startsWith('./') || source.startsWith('/') || source.startsWith('~')) {
68
+ const resolvedPath = source.startsWith('~')
69
+ ? path.join(os.homedir(), source.slice(1))
70
+ : path.resolve(source)
71
+ return {
72
+ type: 'local',
73
+ localPath: resolvedPath,
74
+ url: resolvedPath,
75
+ }
76
+ }
77
+
78
+ // GitHub: owner/repo@skill-name
79
+ const atIndex = source.indexOf('@')
80
+ if (atIndex > 0) {
81
+ const repoPath = source.slice(0, atIndex)
82
+ const skillName = source.slice(atIndex + 1)
83
+ const [owner, repo] = repoPath.split('/')
84
+ if (owner && repo) {
85
+ return {
86
+ type: 'github',
87
+ owner,
88
+ repo,
89
+ skillName,
90
+ url: `https://github.com/${owner}/${repo}`,
91
+ }
92
+ }
93
+ }
94
+
95
+ // GitHub: owner/repo
96
+ const parts = source.split('/')
97
+ if (parts.length === 2 && parts[0] && parts[1]) {
98
+ return {
99
+ type: 'github',
100
+ owner: parts[0],
101
+ repo: parts[1],
102
+ url: `https://github.com/${parts[0]}/${parts[1]}`,
103
+ }
104
+ }
105
+
106
+ throw new Error(`Invalid source format: "${source}". Expected "owner/repo", "owner/repo@skill-name", or "./local-path"`)
107
+ }
108
+
109
+ // =============================================================================
110
+ // Skill Discovery
111
+ // =============================================================================
112
+
113
+ /**
114
+ * Discover skills in a directory by scanning for SKILL.md files
115
+ */
116
+ async function discoverSkills(dir: string): Promise<Array<{ name: string; filePath: string }>> {
117
+ const skills: Array<{ name: string; filePath: string }> = []
118
+
119
+ // Pattern 1: {dir}/SKILL.md (root-level skill)
120
+ try {
121
+ const rootSkill = path.join(dir, 'SKILL.md')
122
+ await fs.access(rootSkill)
123
+ const dirName = path.basename(dir)
124
+ skills.push({ name: dirName, filePath: rootSkill })
125
+ } catch {
126
+ // No root SKILL.md
127
+ }
128
+
129
+ // Pattern 2: {dir}/*/SKILL.md (subdirectory skills)
130
+ const subdirSkills = await glob('*/SKILL.md', { cwd: dir, absolute: true })
131
+ for (const filePath of subdirSkills) {
132
+ const name = path.basename(path.dirname(filePath))
133
+ // Avoid duplicate if already found as root
134
+ if (!skills.some(s => s.name === name)) {
135
+ skills.push({ name, filePath })
136
+ }
137
+ }
138
+
139
+ // Pattern 3: {dir}/skills/*/SKILL.md (nested skills directory)
140
+ const nestedSkills = await glob('skills/*/SKILL.md', { cwd: dir, absolute: true })
141
+ for (const filePath of nestedSkills) {
142
+ const name = path.basename(path.dirname(filePath))
143
+ if (!skills.some(s => s.name === name)) {
144
+ skills.push({ name, filePath })
145
+ }
146
+ }
147
+
148
+ return skills
149
+ }
150
+
151
+ // =============================================================================
152
+ // Frontmatter Injection
153
+ // =============================================================================
154
+
155
+ /**
156
+ * Add _prjct source tracking metadata to a skill's frontmatter
157
+ */
158
+ function injectSourceMetadata(
159
+ content: string,
160
+ source: ParsedSource,
161
+ sha?: string
162
+ ): string {
163
+ const now = new Date().toISOString()
164
+ const prjctBlock = [
165
+ `_prjct:`,
166
+ ` sourceUrl: ${source.url}`,
167
+ ` sourceType: ${source.type}`,
168
+ ` installedAt: ${now}`,
169
+ ]
170
+ if (sha) {
171
+ prjctBlock.push(` sha: ${sha}`)
172
+ }
173
+
174
+ // Check if file has existing frontmatter
175
+ const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---/
176
+ const match = content.match(frontmatterRegex)
177
+
178
+ if (match) {
179
+ // Remove existing _prjct block if present
180
+ let frontmatter = match[1]
181
+ frontmatter = frontmatter.replace(/\n?_prjct:[\s\S]*?(?=\n[a-zA-Z]|\n---|\s*$)/g, '')
182
+
183
+ // Append _prjct block
184
+ const updatedFrontmatter = frontmatter.trimEnd() + '\n' + prjctBlock.join('\n')
185
+ return content.replace(frontmatterRegex, `---\n${updatedFrontmatter}\n---`)
186
+ }
187
+
188
+ // No frontmatter — add one
189
+ return `---\n${prjctBlock.join('\n')}\n---\n\n${content}`
190
+ }
191
+
192
+ // =============================================================================
193
+ // Installation
194
+ // =============================================================================
195
+
196
+ /**
197
+ * Get the default install directory for skills
198
+ */
199
+ function getInstallDir(): string {
200
+ return path.join(os.homedir(), '.claude', 'skills')
201
+ }
202
+
203
+ /**
204
+ * Install a single skill file to the target directory
205
+ */
206
+ async function installSkillFile(
207
+ sourcePath: string,
208
+ name: string,
209
+ source: ParsedSource,
210
+ sha?: string
211
+ ): Promise<InstalledSkill> {
212
+ const installDir = getInstallDir()
213
+ const targetDir = path.join(installDir, name)
214
+ const targetPath = path.join(targetDir, 'SKILL.md')
215
+
216
+ // Read source content
217
+ const content = await fs.readFile(sourcePath, 'utf-8')
218
+
219
+ // Inject source metadata
220
+ const enrichedContent = injectSourceMetadata(content, source, sha)
221
+
222
+ // Write to target (ecosystem standard: {name}/SKILL.md)
223
+ await fs.mkdir(targetDir, { recursive: true })
224
+ await fs.writeFile(targetPath, enrichedContent, 'utf-8')
225
+
226
+ return {
227
+ name,
228
+ filePath: targetPath,
229
+ source,
230
+ sha,
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Install skills from a GitHub repository
236
+ */
237
+ async function installFromGitHub(source: ParsedSource): Promise<InstallResult> {
238
+ const result: InstallResult = { installed: [], skipped: [], errors: [] }
239
+
240
+ // Create temp directory
241
+ const tmpDir = path.join(os.tmpdir(), `prjct-skill-${Date.now()}`)
242
+
243
+ try {
244
+ // Clone with depth 1 for speed
245
+ const cloneUrl = `https://github.com/${source.owner}/${source.repo}.git`
246
+ await exec(`git clone --depth 1 ${cloneUrl} ${tmpDir}`, { timeout: 60_000 })
247
+
248
+ // Get the commit SHA
249
+ let sha: string | undefined
250
+ try {
251
+ const { stdout } = await exec('git rev-parse HEAD', { cwd: tmpDir, timeout: 5_000 })
252
+ sha = stdout.trim()
253
+ } catch {
254
+ // Non-critical
255
+ }
256
+
257
+ // Discover skills in the cloned repo
258
+ const discoveredSkills = await discoverSkills(tmpDir)
259
+
260
+ if (discoveredSkills.length === 0) {
261
+ result.errors.push(`No SKILL.md files found in ${source.owner}/${source.repo}`)
262
+ return result
263
+ }
264
+
265
+ // Filter to specific skill if requested
266
+ const skillsToInstall = source.skillName
267
+ ? discoveredSkills.filter(s => s.name === source.skillName)
268
+ : discoveredSkills
269
+
270
+ if (source.skillName && skillsToInstall.length === 0) {
271
+ result.errors.push(`Skill "${source.skillName}" not found in ${source.owner}/${source.repo}`)
272
+ return result
273
+ }
274
+
275
+ // Install each skill
276
+ for (const skill of skillsToInstall) {
277
+ try {
278
+ const installed = await installSkillFile(skill.filePath, skill.name, source, sha)
279
+
280
+ // Update lock file
281
+ const lockEntry: SkillLockEntry = {
282
+ name: skill.name,
283
+ source: {
284
+ type: 'github',
285
+ url: `${source.owner}/${source.repo}`,
286
+ sha,
287
+ },
288
+ installedAt: new Date().toISOString(),
289
+ filePath: installed.filePath,
290
+ }
291
+ await skillLock.addEntry(lockEntry)
292
+
293
+ result.installed.push(installed)
294
+ } catch (error) {
295
+ result.errors.push(`Failed to install ${skill.name}: ${(error as Error).message}`)
296
+ }
297
+ }
298
+ } finally {
299
+ // Clean up temp directory
300
+ try {
301
+ await fs.rm(tmpDir, { recursive: true, force: true })
302
+ } catch {
303
+ // Best effort cleanup
304
+ }
305
+ }
306
+
307
+ return result
308
+ }
309
+
310
+ /**
311
+ * Install skills from a local directory
312
+ */
313
+ async function installFromLocal(source: ParsedSource): Promise<InstallResult> {
314
+ const result: InstallResult = { installed: [], skipped: [], errors: [] }
315
+ const localPath = source.localPath!
316
+
317
+ try {
318
+ await fs.access(localPath)
319
+ } catch {
320
+ result.errors.push(`Local path not found: ${localPath}`)
321
+ return result
322
+ }
323
+
324
+ const stat = await fs.stat(localPath)
325
+
326
+ if (stat.isFile()) {
327
+ // Single SKILL.md file
328
+ const name = path.basename(path.dirname(localPath))
329
+ try {
330
+ const installed = await installSkillFile(localPath, name, source)
331
+ const lockEntry: SkillLockEntry = {
332
+ name,
333
+ source: { type: 'local', url: localPath },
334
+ installedAt: new Date().toISOString(),
335
+ filePath: installed.filePath,
336
+ }
337
+ await skillLock.addEntry(lockEntry)
338
+ result.installed.push(installed)
339
+ } catch (error) {
340
+ result.errors.push(`Failed to install from ${localPath}: ${(error as Error).message}`)
341
+ }
342
+ } else {
343
+ // Directory — discover skills
344
+ const discoveredSkills = await discoverSkills(localPath)
345
+
346
+ if (discoveredSkills.length === 0) {
347
+ result.errors.push(`No SKILL.md files found in ${localPath}`)
348
+ return result
349
+ }
350
+
351
+ for (const skill of discoveredSkills) {
352
+ try {
353
+ const installed = await installSkillFile(skill.filePath, skill.name, source)
354
+ const lockEntry: SkillLockEntry = {
355
+ name: skill.name,
356
+ source: { type: 'local', url: localPath },
357
+ installedAt: new Date().toISOString(),
358
+ filePath: installed.filePath,
359
+ }
360
+ await skillLock.addEntry(lockEntry)
361
+ result.installed.push(installed)
362
+ } catch (error) {
363
+ result.errors.push(`Failed to install ${skill.name}: ${(error as Error).message}`)
364
+ }
365
+ }
366
+ }
367
+
368
+ return result
369
+ }
370
+
371
+ /**
372
+ * Remove an installed skill
373
+ */
374
+ async function remove(name: string): Promise<boolean> {
375
+ const installDir = getInstallDir()
376
+
377
+ // Remove subdirectory format
378
+ const subdirPath = path.join(installDir, name)
379
+ try {
380
+ await fs.rm(subdirPath, { recursive: true, force: true })
381
+ } catch {
382
+ // May not exist in subdir format
383
+ }
384
+
385
+ // Remove flat file format
386
+ const flatPath = path.join(installDir, `${name}.md`)
387
+ try {
388
+ await fs.rm(flatPath, { force: true })
389
+ } catch {
390
+ // May not exist in flat format
391
+ }
392
+
393
+ // Remove from lock file
394
+ return skillLock.removeEntry(name)
395
+ }
396
+
397
+ // =============================================================================
398
+ // Main Install Function
399
+ // =============================================================================
400
+
401
+ /**
402
+ * Install skills from a source string
403
+ *
404
+ * @param source - Source string (e.g., "owner/repo", "owner/repo@skill", "./path")
405
+ * @returns Installation result with installed, skipped, and error lists
406
+ */
407
+ async function install(sourceStr: string): Promise<InstallResult> {
408
+ const source = parseSource(sourceStr)
409
+
410
+ switch (source.type) {
411
+ case 'github':
412
+ return installFromGitHub(source)
413
+ case 'local':
414
+ return installFromLocal(source)
415
+ default:
416
+ return {
417
+ installed: [],
418
+ skipped: [],
419
+ errors: [`Unsupported source type: ${source.type}`],
420
+ }
421
+ }
422
+ }
423
+
424
+ export const skillInstaller = {
425
+ install,
426
+ remove,
427
+ parseSource,
428
+ getInstallDir,
429
+ }
430
+
431
+ export default skillInstaller
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Skill Lock File Service
3
+ *
4
+ * Manages a lock file that tracks remotely-installed skills for
5
+ * reproducibility and update detection.
6
+ *
7
+ * Lock file location: ~/.prjct-cli/skills/.skill-lock.json
8
+ *
9
+ * @version 1.0.0
10
+ */
11
+
12
+ import fs from 'fs/promises'
13
+ import path from 'path'
14
+ import os from 'os'
15
+
16
+ // =============================================================================
17
+ // Types
18
+ // =============================================================================
19
+
20
+ export interface SkillLockSource {
21
+ type: 'github' | 'local' | 'registry'
22
+ url: string
23
+ sha?: string
24
+ }
25
+
26
+ export interface SkillLockEntry {
27
+ name: string
28
+ source: SkillLockSource
29
+ installedAt: string
30
+ filePath: string
31
+ }
32
+
33
+ export interface SkillLockFile {
34
+ version: 1
35
+ generatedAt: string
36
+ skills: Record<string, SkillLockEntry>
37
+ }
38
+
39
+ // =============================================================================
40
+ // Lock File Service
41
+ // =============================================================================
42
+
43
+ const LOCK_FILE_NAME = '.skill-lock.json'
44
+
45
+ function getLockFilePath(): string {
46
+ return path.join(os.homedir(), '.prjct-cli', 'skills', LOCK_FILE_NAME)
47
+ }
48
+
49
+ function createEmptyLockFile(): SkillLockFile {
50
+ return {
51
+ version: 1,
52
+ generatedAt: new Date().toISOString(),
53
+ skills: {},
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Read the lock file, returning an empty lock file if it doesn't exist
59
+ */
60
+ async function read(): Promise<SkillLockFile> {
61
+ try {
62
+ const content = await fs.readFile(getLockFilePath(), 'utf-8')
63
+ return JSON.parse(content) as SkillLockFile
64
+ } catch {
65
+ return createEmptyLockFile()
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Write the lock file to disk
71
+ */
72
+ async function write(lockFile: SkillLockFile): Promise<void> {
73
+ const lockPath = getLockFilePath()
74
+ await fs.mkdir(path.dirname(lockPath), { recursive: true })
75
+ lockFile.generatedAt = new Date().toISOString()
76
+ await fs.writeFile(lockPath, JSON.stringify(lockFile, null, 2), 'utf-8')
77
+ }
78
+
79
+ /**
80
+ * Add or update a skill entry in the lock file
81
+ */
82
+ async function addEntry(entry: SkillLockEntry): Promise<void> {
83
+ const lockFile = await read()
84
+ lockFile.skills[entry.name] = entry
85
+ await write(lockFile)
86
+ }
87
+
88
+ /**
89
+ * Remove a skill entry from the lock file
90
+ */
91
+ async function removeEntry(name: string): Promise<boolean> {
92
+ const lockFile = await read()
93
+ if (!(name in lockFile.skills)) return false
94
+ delete lockFile.skills[name]
95
+ await write(lockFile)
96
+ return true
97
+ }
98
+
99
+ /**
100
+ * Get a single skill entry
101
+ */
102
+ async function getEntry(name: string): Promise<SkillLockEntry | null> {
103
+ const lockFile = await read()
104
+ return lockFile.skills[name] || null
105
+ }
106
+
107
+ /**
108
+ * Get all entries
109
+ */
110
+ async function getAll(): Promise<Record<string, SkillLockEntry>> {
111
+ const lockFile = await read()
112
+ return lockFile.skills
113
+ }
114
+
115
+ /**
116
+ * Get the lock file path (for display purposes)
117
+ */
118
+ function getPath(): string {
119
+ return getLockFilePath()
120
+ }
121
+
122
+ export const skillLock = {
123
+ read,
124
+ write,
125
+ addEntry,
126
+ removeEntry,
127
+ getEntry,
128
+ getAll,
129
+ getPath,
130
+ }
131
+
132
+ export default skillLock
@@ -83,9 +83,9 @@ class SkillService {
83
83
  /**
84
84
  * Get all skill directories in order of priority
85
85
  */
86
- private getSkillDirs(projectPath?: string, provider?: AIProviderName): Array<{ dir: string; source: Skill['source']; isProviderSkill?: boolean }> {
86
+ private getSkillDirs(projectPath?: string, provider?: AIProviderName): Array<{ dir: string; source: Skill['source'] }> {
87
87
  const homeDir = process.env.HOME || process.env.USERPROFILE || '~'
88
- const dirs: Array<{ dir: string; source: Skill['source']; isProviderSkill?: boolean }> = []
88
+ const dirs: Array<{ dir: string; source: Skill['source'] }> = []
89
89
 
90
90
  // Project skills (highest priority)
91
91
  if (projectPath) {
@@ -96,11 +96,11 @@ class SkillService {
96
96
  // Both use SKILL.md format, so skills are compatible
97
97
  if (provider) {
98
98
  const providerDir = provider === 'gemini' ? '.gemini' : '.claude'
99
- dirs.push({ dir: path.join(homeDir, providerDir, 'skills'), source: 'global', isProviderSkill: true })
99
+ dirs.push({ dir: path.join(homeDir, providerDir, 'skills'), source: 'global' })
100
100
  } else {
101
101
  // Check both providers if no specific one is set
102
- dirs.push({ dir: path.join(homeDir, '.claude', 'skills'), source: 'global', isProviderSkill: true })
103
- dirs.push({ dir: path.join(homeDir, '.gemini', 'skills'), source: 'global', isProviderSkill: true })
102
+ dirs.push({ dir: path.join(homeDir, '.claude', 'skills'), source: 'global' })
103
+ dirs.push({ dir: path.join(homeDir, '.gemini', 'skills'), source: 'global' })
104
104
  }
105
105
 
106
106
  // prjct global skills
@@ -123,6 +123,9 @@ class SkillService {
123
123
  const id = fileToSkillId(filePath)
124
124
  const name = (metadata.name as string) || id
125
125
 
126
+ // Extract _prjct source tracking metadata if present
127
+ const prjctMeta = metadata._prjct as Record<string, unknown> | undefined
128
+
126
129
  return {
127
130
  id,
128
131
  name,
@@ -136,6 +139,12 @@ class SkillService {
136
139
  agent: metadata.agent as string,
137
140
  tags: metadata.tags as string[],
138
141
  version: metadata.version as string,
142
+ category: metadata.category as string,
143
+ author: metadata.author as string,
144
+ sourceUrl: prjctMeta?.sourceUrl as string,
145
+ sourceType: prjctMeta?.sourceType as SkillMetadata['sourceType'],
146
+ installedAt: prjctMeta?.installedAt as string,
147
+ sha: prjctMeta?.sha as string,
139
148
  },
140
149
  }
141
150
  } catch (_error) {
@@ -151,27 +160,27 @@ class SkillService {
151
160
  this.skills.clear()
152
161
  const dirs = this.getSkillDirs(projectPath, provider)
153
162
 
154
- for (const { dir, source, isProviderSkill } of dirs) {
163
+ for (const { dir, source } of dirs) {
155
164
  try {
156
- if (isProviderSkill) {
157
- // Provider skills use SKILL.md in subdirectories
158
- // e.g., ~/.claude/skills/my-skill/SKILL.md
159
- const skillDirs = await glob('*/SKILL.md', { cwd: dir, absolute: true })
160
- for (const file of skillDirs) {
161
- const skill = await this.loadSkill(file, source)
162
- if (skill && !this.skills.has(skill.id)) {
163
- this.skills.set(skill.id, skill)
164
- }
165
+ // Check both patterns in ALL skill directories:
166
+ // 1. Flat files: {dir}/{name}.md
167
+ // 2. Subdirectories: {dir}/{name}/SKILL.md (ecosystem standard)
168
+ const flatFiles = await glob('*.md', { cwd: dir, absolute: true })
169
+ const subdirFiles = await glob('*/SKILL.md', { cwd: dir, absolute: true })
170
+
171
+ // Load subdirectory skills first (ecosystem standard takes priority within same dir)
172
+ for (const file of subdirFiles) {
173
+ const skill = await this.loadSkill(file, source)
174
+ if (skill && !this.skills.has(skill.id)) {
175
+ this.skills.set(skill.id, skill)
165
176
  }
166
- } else {
167
- // Regular .md files in directory
168
- const files = await glob('*.md', { cwd: dir, absolute: true })
169
- for (const file of files) {
170
- const skill = await this.loadSkill(file, source)
171
- if (skill && !this.skills.has(skill.id)) {
172
- // Don't override higher priority skills
173
- this.skills.set(skill.id, skill)
174
- }
177
+ }
178
+
179
+ // Then flat files (don't override subdirectory version)
180
+ for (const file of flatFiles) {
181
+ const skill = await this.loadSkill(file, source)
182
+ if (skill && !this.skills.has(skill.id)) {
183
+ this.skills.set(skill.id, skill)
175
184
  }
176
185
  }
177
186
  } catch (_error) {
@@ -256,6 +265,7 @@ class SkillService {
256
265
  project: [],
257
266
  global: [],
258
267
  builtin: [],
268
+ remote: [],
259
269
  }
260
270
 
261
271
  for (const skill of skills) {
@@ -28,6 +28,11 @@ export interface SkillMetadata {
28
28
  version?: string
29
29
  category?: string
30
30
  author?: string
31
+ // Ecosystem compatibility fields
32
+ sourceUrl?: string
33
+ sourceType?: 'github' | 'local' | 'builtin' | 'registry'
34
+ installedAt?: string
35
+ sha?: string
31
36
  }
32
37
 
33
38
  export interface Skill {
@@ -35,7 +40,7 @@ export interface Skill {
35
40
  name: string
36
41
  description: string
37
42
  content: string
38
- source: 'project' | 'global' | 'builtin'
43
+ source: 'project' | 'global' | 'builtin' | 'remote'
39
44
  filePath: string
40
45
  metadata: SkillMetadata
41
46
  path?: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {
@@ -1,22 +1,26 @@
1
1
  ---
2
- allowed-tools: [Read, Glob]
2
+ allowed-tools: [Read, Glob, Bash]
3
3
  description: 'List, search, and invoke skills'
4
4
  timestamp-rule: 'None'
5
- architecture: 'Skill discovery and execution'
5
+ architecture: 'Skill discovery, installation, and execution'
6
6
  ---
7
7
 
8
8
  # /p:skill - Skill Management
9
9
 
10
- List, search, and invoke reusable skills.
10
+ List, search, install, and invoke reusable skills.
11
11
 
12
12
  ## Usage
13
13
 
14
14
  ```
15
- /p:skill # List all skills
16
- /p:skill list # List all skills
17
- /p:skill search <query> # Search skills
18
- /p:skill show <id> # Show skill details
19
- /p:skill invoke <id> # Invoke a skill
15
+ /p:skill # List all skills
16
+ /p:skill list # List all skills
17
+ /p:skill search <query> # Search skills
18
+ /p:skill show <id> # Show skill details
19
+ /p:skill invoke <id> # Invoke a skill
20
+ /p:skill add <source> # Install skill from remote source
21
+ /p:skill remove <name> # Remove an installed skill
22
+ /p:skill init <name> # Scaffold a new skill
23
+ /p:skill check # Check for available updates
20
24
  ```
21
25
 
22
26
  ## Flow
@@ -24,11 +28,14 @@ List, search, and invoke reusable skills.
24
28
  ### List Skills (`/p:skill` or `/p:skill list`)
25
29
 
26
30
  1. Load skills from all sources:
27
- - Project: `.prjct/skills/*.md`
28
- - Global: `~/.prjct-cli/skills/*.md`
31
+ - Project: `.prjct/skills/*.md` and `.prjct/skills/*/SKILL.md`
32
+ - Provider: `~/.claude/skills/*/SKILL.md` and `~/.claude/skills/*.md`
33
+ - Global: `~/.prjct-cli/skills/*.md` and `~/.prjct-cli/skills/*/SKILL.md`
29
34
  - Built-in: `templates/skills/*.md`
30
35
 
31
- 2. Output grouped by source:
36
+ 2. Check lock file at `~/.prjct-cli/skills/.skill-lock.json` for source info
37
+
38
+ 3. Output grouped by source:
32
39
 
33
40
  ```
34
41
  ## Available Skills
@@ -36,13 +43,13 @@ List, search, and invoke reusable skills.
36
43
  ### Project Skills
37
44
  - **custom-deploy** - Deploy to staging server
38
45
 
39
- ### Global Skills
46
+ ### Global Skills (Provider)
47
+ - **frontend-design** - Create production-grade UIs [github: vercel-labs/skills]
40
48
  - **my-template** - Personal code template
41
49
 
42
50
  ### Built-in Skills
43
51
  - **code-review** - Review code for quality
44
52
  - **refactor** - Refactor code structure
45
- - **debug** - Systematic debugging
46
53
  ```
47
54
 
48
55
  ### Search Skills (`/p:skill search <query>`)
@@ -55,14 +62,17 @@ List, search, and invoke reusable skills.
55
62
 
56
63
  1. Load skill by ID
57
64
  2. Display metadata and content
65
+ 3. If remotely installed, show source tracking info
58
66
 
59
67
  ```
60
- ## Skill: code-review
68
+ ## Skill: frontend-design
61
69
 
62
- **Description:** Review code for quality
63
- **Source:** builtin
64
- **Tags:** review, quality, security
65
- **Agent:** general
70
+ **Description:** Create production-grade frontend interfaces
71
+ **Source:** global (github: vercel-labs/skills)
72
+ **Tags:** frontend, design, ui
73
+ **Version:** 1.0.0
74
+ **Installed:** 2026-01-28T12:00:00.000Z
75
+ **SHA:** abc123
66
76
 
67
77
  ### Content
68
78
  [Full skill prompt content]
@@ -74,9 +84,122 @@ List, search, and invoke reusable skills.
74
84
  2. Return skill content for execution
75
85
  3. The skill content becomes the prompt
76
86
 
87
+ ### Add Skill (`/p:skill add <source>`)
88
+
89
+ Install skills from remote sources.
90
+
91
+ **Supported source formats:**
92
+ - `owner/repo` — Clone GitHub repo, install all discovered skills
93
+ - `owner/repo@skill-name` — Install specific skill from GitHub repo
94
+ - `./local-path` — Install from local directory
95
+
96
+ **Install flow:**
97
+ 1. Parse source string
98
+ 2. For GitHub: `git clone --depth 1` to temp dir (60s timeout)
99
+ 3. Discover SKILL.md files (scans `*/SKILL.md` and `skills/*/SKILL.md`)
100
+ 4. Copy to `~/.claude/skills/{name}/SKILL.md` (ecosystem standard format)
101
+ 5. Add `_prjct` metadata block to frontmatter (sourceUrl, sha, installedAt)
102
+ 6. Update lock file at `~/.prjct-cli/skills/.skill-lock.json`
103
+ 7. Clean up temp dir
104
+
105
+ **Example:**
106
+ ```
107
+ p. skill add vercel-labs/skills
108
+ p. skill add my-org/custom-skills@api-designer
109
+ p. skill add ./my-local-skill
110
+ ```
111
+
112
+ **Output:**
113
+ ```
114
+ ✅ Installed 3 skills from vercel-labs/skills
115
+
116
+ - frontend-design → ~/.claude/skills/frontend-design/SKILL.md
117
+ - find-skills → ~/.claude/skills/find-skills/SKILL.md
118
+ - code-review → ~/.claude/skills/code-review/SKILL.md
119
+
120
+ Lock file updated: ~/.prjct-cli/skills/.skill-lock.json
121
+ ```
122
+
123
+ ### Remove Skill (`/p:skill remove <name>`)
124
+
125
+ 1. Remove skill directory from `~/.claude/skills/{name}/`
126
+ 2. Also remove flat file if it exists (`~/.claude/skills/{name}.md`)
127
+ 3. Remove entry from lock file
128
+ 4. Confirm removal
129
+
130
+ **Output:**
131
+ ```
132
+ ✅ Removed skill: frontend-design
133
+
134
+ Deleted: ~/.claude/skills/frontend-design/
135
+ Lock file updated.
136
+ ```
137
+
138
+ ### Init Skill (`/p:skill init <name>`)
139
+
140
+ Scaffold a new skill in the project.
141
+
142
+ 1. Create `.prjct/skills/{name}/SKILL.md` with template frontmatter
143
+ 2. Open for editing
144
+
145
+ **Template:**
146
+ ```markdown
147
+ ---
148
+ name: {name}
149
+ description: TODO - describe what this skill does
150
+ agent: general
151
+ tags: []
152
+ version: 1.0.0
153
+ author: {detected-author}
154
+ ---
155
+
156
+ # {Name} Skill
157
+
158
+ ## Purpose
159
+
160
+ Describe what this skill helps with.
161
+
162
+ ## Instructions
163
+
164
+ Step-by-step instructions for the AI agent...
165
+ ```
166
+
167
+ ### Check Updates (`/p:skill check`)
168
+
169
+ Compare lock file SHAs with remote repositories to detect available updates.
170
+
171
+ 1. Read lock file entries
172
+ 2. For each GitHub-sourced skill, run `git ls-remote` to get latest SHA
173
+ 3. Compare with stored SHA
174
+ 4. Report skills with available updates (no auto-update)
175
+
176
+ **Output:**
177
+ ```
178
+ ## Skill Update Check
179
+
180
+ - **frontend-design** (vercel-labs/skills) — Update available
181
+ Current: abc123 → Latest: def456
182
+ - **code-review** (vercel-labs/skills) — Up to date
183
+ - **my-local-skill** (local) — Skipped (local source)
184
+
185
+ 1 update available. Run `p. skill add <source>` to update.
186
+ ```
187
+
77
188
  ## Skill File Format
78
189
 
79
- Skills are markdown files with frontmatter:
190
+ Skills are markdown files with frontmatter. Two formats are supported:
191
+
192
+ ### Subdirectory Format (Ecosystem Standard)
193
+ ```
194
+ ~/.claude/skills/my-skill/SKILL.md
195
+ ```
196
+
197
+ ### Flat Format (Legacy)
198
+ ```
199
+ ~/.claude/skills/my-skill.md
200
+ ```
201
+
202
+ ### Frontmatter Schema
80
203
 
81
204
  ```markdown
82
205
  ---
@@ -85,6 +208,13 @@ description: What the skill does
85
208
  agent: general
86
209
  tags: [tag1, tag2]
87
210
  version: 1.0.0
211
+ author: Author Name
212
+ category: development
213
+ _prjct:
214
+ sourceUrl: https://github.com/owner/repo
215
+ sourceType: github
216
+ installedAt: 2026-01-28T12:00:00.000Z
217
+ sha: abc123
88
218
  ---
89
219
 
90
220
  # Skill Content
@@ -94,11 +224,30 @@ The actual prompt/instructions...
94
224
 
95
225
  ## Creating Custom Skills
96
226
 
97
- ### Project Skill
98
- Create `.prjct/skills/my-skill.md`
99
-
100
- ### Global Skill
101
- Create `~/.prjct-cli/skills/my-skill.md`
227
+ ### Project Skill (repo-specific)
228
+ Create `.prjct/skills/my-skill/SKILL.md` or `.prjct/skills/my-skill.md`
229
+
230
+ ### Global Skill (all projects)
231
+ Create `~/.claude/skills/my-skill/SKILL.md` or `~/.prjct-cli/skills/my-skill.md`
232
+
233
+ ## Lock File
234
+
235
+ Installed skills are tracked in `~/.prjct-cli/skills/.skill-lock.json`:
236
+
237
+ ```json
238
+ {
239
+ "version": 1,
240
+ "generatedAt": "2026-01-28T...",
241
+ "skills": {
242
+ "frontend-design": {
243
+ "name": "frontend-design",
244
+ "source": { "type": "github", "url": "vercel-labs/skills", "sha": "abc123" },
245
+ "installedAt": "2026-01-28T...",
246
+ "filePath": "~/.claude/skills/frontend-design/SKILL.md"
247
+ }
248
+ }
249
+ }
250
+ ```
102
251
 
103
252
  ## Output Format
104
253
 
@@ -106,5 +255,5 @@ Create `~/.prjct-cli/skills/my-skill.md`
106
255
  ## Skills ({count} total)
107
256
 
108
257
  ### {source}
109
- - **{name}** ({id}): {description}
258
+ - **{name}** ({id}): {description} [{sourceInfo}]
110
259
  ```