prjct-cli 0.39.0 → 0.41.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 +38 -0
- package/core/agentic/orchestrator-executor.ts +23 -6
- package/core/integrations/jira/cache.ts +57 -0
- package/core/integrations/jira/index.ts +16 -11
- package/core/integrations/jira/service.ts +244 -0
- package/core/integrations/linear/cache.ts +68 -0
- package/core/integrations/linear/index.ts +15 -1
- package/core/integrations/linear/service.ts +260 -0
- package/core/services/skill-installer.ts +431 -0
- package/core/services/skill-lock.ts +132 -0
- package/core/services/skill-service.ts +34 -24
- package/core/types/services.ts +6 -1
- package/dist/bin/prjct.mjs +17 -6
- package/dist/core/infrastructure/setup.js +192 -238
- package/package.json +1 -1
- package/templates/commands/done.md +24 -1
- package/templates/commands/jira.md +91 -139
- package/templates/commands/linear.md +81 -130
- package/templates/commands/skill.md +174 -25
- package/templates/commands/task.md +66 -2
- package/templates/mcp-config.json +4 -20
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear Service Layer
|
|
3
|
+
* Wraps LinearProvider with caching for improved performance.
|
|
4
|
+
* All operations are cached with 5-minute TTL.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { linearProvider } from './client'
|
|
8
|
+
import {
|
|
9
|
+
issueCache,
|
|
10
|
+
assignedIssuesCache,
|
|
11
|
+
teamsCache,
|
|
12
|
+
projectsCache,
|
|
13
|
+
clearLinearCache,
|
|
14
|
+
getLinearCacheStats,
|
|
15
|
+
} from './cache'
|
|
16
|
+
import type {
|
|
17
|
+
Issue,
|
|
18
|
+
CreateIssueInput,
|
|
19
|
+
UpdateIssueInput,
|
|
20
|
+
FetchOptions,
|
|
21
|
+
LinearConfig,
|
|
22
|
+
} from '../issue-tracker/types'
|
|
23
|
+
|
|
24
|
+
export class LinearService {
|
|
25
|
+
private initialized = false
|
|
26
|
+
private userId: string | null = null
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if service is ready
|
|
30
|
+
*/
|
|
31
|
+
isReady(): boolean {
|
|
32
|
+
return this.initialized && linearProvider.isConfigured()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Initialize the service with config
|
|
37
|
+
* Must be called before any operations
|
|
38
|
+
*/
|
|
39
|
+
async initialize(config: LinearConfig): Promise<void> {
|
|
40
|
+
if (this.initialized) return
|
|
41
|
+
|
|
42
|
+
await linearProvider.initialize(config)
|
|
43
|
+
this.initialized = true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initialize from API key directly
|
|
48
|
+
* Convenience method for simple setup
|
|
49
|
+
*/
|
|
50
|
+
async initializeFromApiKey(apiKey: string, teamId?: string): Promise<void> {
|
|
51
|
+
const config: LinearConfig = {
|
|
52
|
+
enabled: true,
|
|
53
|
+
provider: 'linear',
|
|
54
|
+
apiKey,
|
|
55
|
+
defaultTeamId: teamId,
|
|
56
|
+
syncOn: { task: true, done: true, ship: true },
|
|
57
|
+
enrichment: { enabled: true, updateProvider: true },
|
|
58
|
+
}
|
|
59
|
+
await this.initialize(config)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get issues assigned to current user (cached)
|
|
64
|
+
*/
|
|
65
|
+
async fetchAssignedIssues(options?: FetchOptions): Promise<Issue[]> {
|
|
66
|
+
this.ensureInitialized()
|
|
67
|
+
|
|
68
|
+
const cacheKey = `assigned:${this.userId || 'me'}`
|
|
69
|
+
const cached = assignedIssuesCache.get(cacheKey)
|
|
70
|
+
if (cached) {
|
|
71
|
+
return cached
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const issues = await linearProvider.fetchAssignedIssues(options)
|
|
75
|
+
assignedIssuesCache.set(cacheKey, issues)
|
|
76
|
+
|
|
77
|
+
// Also cache individual issues
|
|
78
|
+
for (const issue of issues) {
|
|
79
|
+
issueCache.set(`issue:${issue.id}`, issue)
|
|
80
|
+
issueCache.set(`issue:${issue.externalId}`, issue)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return issues
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get issues from a team (cached)
|
|
88
|
+
*/
|
|
89
|
+
async fetchTeamIssues(teamId: string, options?: FetchOptions): Promise<Issue[]> {
|
|
90
|
+
this.ensureInitialized()
|
|
91
|
+
|
|
92
|
+
const cacheKey = `team:${teamId}`
|
|
93
|
+
const cached = assignedIssuesCache.get(cacheKey)
|
|
94
|
+
if (cached) {
|
|
95
|
+
return cached
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const issues = await linearProvider.fetchTeamIssues(teamId, options)
|
|
99
|
+
assignedIssuesCache.set(cacheKey, issues)
|
|
100
|
+
|
|
101
|
+
// Also cache individual issues
|
|
102
|
+
for (const issue of issues) {
|
|
103
|
+
issueCache.set(`issue:${issue.id}`, issue)
|
|
104
|
+
issueCache.set(`issue:${issue.externalId}`, issue)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return issues
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get a single issue by ID or identifier (cached)
|
|
112
|
+
* Accepts UUID or identifier like "PRJ-123"
|
|
113
|
+
*/
|
|
114
|
+
async fetchIssue(id: string): Promise<Issue | null> {
|
|
115
|
+
this.ensureInitialized()
|
|
116
|
+
|
|
117
|
+
// Check cache first
|
|
118
|
+
const cacheKey = `issue:${id}`
|
|
119
|
+
const cached = issueCache.get(cacheKey)
|
|
120
|
+
if (cached) {
|
|
121
|
+
return cached
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const issue = await linearProvider.fetchIssue(id)
|
|
125
|
+
if (issue) {
|
|
126
|
+
// Cache by both ID and externalId
|
|
127
|
+
issueCache.set(`issue:${issue.id}`, issue)
|
|
128
|
+
issueCache.set(`issue:${issue.externalId}`, issue)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return issue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create a new issue (invalidates assigned cache)
|
|
136
|
+
*/
|
|
137
|
+
async createIssue(input: CreateIssueInput): Promise<Issue> {
|
|
138
|
+
this.ensureInitialized()
|
|
139
|
+
|
|
140
|
+
const issue = await linearProvider.createIssue(input)
|
|
141
|
+
|
|
142
|
+
// Cache the new issue
|
|
143
|
+
issueCache.set(`issue:${issue.id}`, issue)
|
|
144
|
+
issueCache.set(`issue:${issue.externalId}`, issue)
|
|
145
|
+
|
|
146
|
+
// Invalidate assigned issues cache (new issue may be assigned)
|
|
147
|
+
assignedIssuesCache.clear()
|
|
148
|
+
|
|
149
|
+
return issue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Update an issue (invalidates cache for that issue)
|
|
154
|
+
*/
|
|
155
|
+
async updateIssue(id: string, input: UpdateIssueInput): Promise<Issue> {
|
|
156
|
+
this.ensureInitialized()
|
|
157
|
+
|
|
158
|
+
const issue = await linearProvider.updateIssue(id, input)
|
|
159
|
+
|
|
160
|
+
// Update cache
|
|
161
|
+
issueCache.set(`issue:${issue.id}`, issue)
|
|
162
|
+
issueCache.set(`issue:${issue.externalId}`, issue)
|
|
163
|
+
|
|
164
|
+
return issue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Mark issue as in progress (invalidates cache)
|
|
169
|
+
*/
|
|
170
|
+
async markInProgress(id: string): Promise<void> {
|
|
171
|
+
this.ensureInitialized()
|
|
172
|
+
|
|
173
|
+
await linearProvider.markInProgress(id)
|
|
174
|
+
|
|
175
|
+
// Invalidate caches
|
|
176
|
+
issueCache.delete(`issue:${id}`)
|
|
177
|
+
assignedIssuesCache.clear()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Mark issue as done (invalidates cache)
|
|
182
|
+
*/
|
|
183
|
+
async markDone(id: string): Promise<void> {
|
|
184
|
+
this.ensureInitialized()
|
|
185
|
+
|
|
186
|
+
await linearProvider.markDone(id)
|
|
187
|
+
|
|
188
|
+
// Invalidate caches
|
|
189
|
+
issueCache.delete(`issue:${id}`)
|
|
190
|
+
assignedIssuesCache.clear()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Add a comment to an issue
|
|
195
|
+
*/
|
|
196
|
+
async addComment(id: string, body: string): Promise<void> {
|
|
197
|
+
this.ensureInitialized()
|
|
198
|
+
await linearProvider.addComment(id, body)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get available teams (cached)
|
|
203
|
+
*/
|
|
204
|
+
async getTeams(): Promise<Array<{ id: string; name: string; key?: string }>> {
|
|
205
|
+
this.ensureInitialized()
|
|
206
|
+
|
|
207
|
+
const cached = teamsCache.get('teams')
|
|
208
|
+
if (cached) {
|
|
209
|
+
return cached
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const teams = await linearProvider.getTeams()
|
|
213
|
+
teamsCache.set('teams', teams)
|
|
214
|
+
return teams
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get available projects (cached)
|
|
219
|
+
*/
|
|
220
|
+
async getProjects(): Promise<Array<{ id: string; name: string }>> {
|
|
221
|
+
this.ensureInitialized()
|
|
222
|
+
|
|
223
|
+
const cached = projectsCache.get('projects')
|
|
224
|
+
if (cached) {
|
|
225
|
+
return cached
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const projects = await linearProvider.getProjects()
|
|
229
|
+
projectsCache.set('projects', projects)
|
|
230
|
+
return projects
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Clear all caches
|
|
235
|
+
*/
|
|
236
|
+
clearCache(): void {
|
|
237
|
+
clearLinearCache()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get cache statistics for debugging
|
|
242
|
+
*/
|
|
243
|
+
getCacheStats() {
|
|
244
|
+
return getLinearCacheStats()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Ensure service is initialized
|
|
249
|
+
*/
|
|
250
|
+
private ensureInitialized(): void {
|
|
251
|
+
if (!this.initialized) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
'Linear service not initialized. Call linearService.initialize() first or run `p. linear setup`.'
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Singleton instance
|
|
260
|
+
export const linearService = new LinearService()
|
|
@@ -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
|