prjct-cli 0.37.1 → 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.
@@ -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) {
@@ -5,19 +5,22 @@
5
5
  * - Claude Code (CLI)
6
6
  * - Gemini CLI (CLI)
7
7
  * - Cursor IDE (GUI, project-level config)
8
+ * - Windsurf IDE (GUI, project-level config)
8
9
  *
9
10
  * Key discovery: Skills use identical SKILL.md format for CLI providers.
10
11
  * Cursor uses .mdc files with frontmatter for rules.
12
+ * Windsurf uses .md files with YAML frontmatter for rules.
11
13
  *
12
14
  * @see https://geminicli.com/docs/cli/gemini-md/
13
15
  * @see https://geminicli.com/docs/cli/skills/
14
16
  * @see https://cursor.com/docs/context/rules
17
+ * @see https://docs.windsurf.com/windsurf/cascade/memories
15
18
  */
16
19
 
17
20
  /**
18
21
  * Supported AI provider names
19
22
  */
20
- export type AIProviderName = 'claude' | 'gemini' | 'cursor'
23
+ export type AIProviderName = 'claude' | 'gemini' | 'cursor' | 'antigravity' | 'windsurf'
21
24
 
22
25
  /**
23
26
  * Command format for each provider
@@ -122,6 +125,20 @@ export interface CursorProjectDetection {
122
125
  projectRoot?: string
123
126
  }
124
127
 
128
+ /**
129
+ * Result of Windsurf project detection
130
+ */
131
+ export interface WindsurfProjectDetection {
132
+ /** Whether .windsurf/ directory exists in project */
133
+ detected: boolean
134
+
135
+ /** Whether prjct router is installed */
136
+ routerInstalled: boolean
137
+
138
+ /** Project root path */
139
+ projectRoot?: string
140
+ }
141
+
125
142
  /**
126
143
  * Provider-aware branding configuration
127
144
  */
@@ -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