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.
@@ -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