prjct-cli 1.5.1 → 1.6.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,78 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0] - 2026-02-06
4
+
5
+ ### Features
6
+
7
+ - super context for agents — skills.sh, proactive codebase context, effort/model (#121)
8
+
9
+
10
+ ## [1.6.0] - 2026-02-06
11
+
12
+ ### Features
13
+
14
+ - **Skills.sh auto-install**: During `prjct sync`, skills from skills.sh are automatically installed for generated agents. Real packages like `anthropics/skills/frontend-design`, `obra/superpowers/systematic-debugging`, and `obra/superpowers/test-driven-development` are mapped per agent domain.
15
+ - **Proactive codebase context**: The orchestrator now gathers real context before agent execution — git state, relevant files (scored by task relevance), code signatures from top files, and recently changed files. Agents start with a complete briefing instead of exploring first.
16
+ - **Effort/model metadata wiring**: Agent frontmatter `effort` and `model` fields are now extracted and injected into prompts, enabling per-agent reasoning depth control.
17
+
18
+ ### Improved
19
+
20
+ - **Skill loading warnings**: Missing skills now log visible warnings with the agent that needs them and a hint to run `prjct sync`
21
+ - **Skill content in prompts**: Increased skill content truncation from 1000 to 2000 characters for richer context
22
+ - **Skill mappings v3**: Updated `skill-mappings.json` from generic names to real installable skills.sh packages
23
+
24
+ ### Implementation Details
25
+
26
+ - `sync-service.ts`: New `autoInstallSkills()` method reads `skill-mappings.json`, checks if each skill is installed, and calls `skillInstaller.install()` for missing ones
27
+ - `orchestrator-executor.ts`: New `gatherRealContext()` calls `findRelevantFiles()`, `getRecentFiles()`, and `extractSignatures()` in parallel to build a proactive briefing
28
+ - `prompt-builder.ts`: New "CODEBASE CONTEXT" section with git state, relevant files table, code signatures, and recently changed files; plus effort/model per agent
29
+ - `agent-loader.ts`: New `extractFrontmatterMeta()` parses YAML frontmatter for effort/model fields
30
+ - `agentic.ts`: New `RealCodebaseContext` interface; `LoadedAgent` extended with `effort?` and `model?`
31
+
32
+ ### Test Plan
33
+
34
+ #### For QA
35
+ 1. Run `prjct sync` — verify skills auto-install (check `~/.claude/skills/`)
36
+ 2. Run `p. task "test"` — verify prompt includes git state, relevant files, signatures, effort/model
37
+ 3. Verify warnings for missing skills
38
+ 4. Build and all 416 tests pass
39
+
40
+ #### For Users
41
+ **What changed:** Agents receive proactive codebase context before starting work. Skills auto-install during sync.
42
+ **How to use:** No action needed — automatic during `prjct sync` and `p. task`.
43
+ **Breaking changes:** None
44
+
45
+ ## [1.5.2] - 2026-02-06
46
+
47
+ ### Improved
48
+
49
+ - **TTY detection for CLI spinners**: Spinner, step, and progress animations now detect non-TTY environments (CI/CD, Claude Code, piped output) and print a single static line instead of animating
50
+
51
+ ### Implementation Details
52
+
53
+ - Added `process.stdout.isTTY` guard to `spin()`, `step()`, and `progress()` in `core/utils/output.ts`
54
+ - Non-TTY environments get a single line with `\n` instead of `setInterval` with `\r` carriage returns
55
+ - Made `clear()` a no-op in non-TTY (the `\r` + spaces trick doesn't work outside terminals)
56
+ - Updated test for `stop()` to handle non-TTY behavior in test runner
57
+
58
+ ### Learnings
59
+
60
+ - `process.stdout.isTTY` is the reliable built-in way to detect interactive terminals in Node.js
61
+ - Test suites (bun test) also run as non-TTY, so test assertions need to account for both paths
62
+
63
+ ### Test Plan
64
+
65
+ #### For QA
66
+ 1. Run `prjct sync --yes` in an interactive terminal — spinner should animate normally
67
+ 2. Run `prjct sync --yes > out.txt` — output should show a single static line, no repeated frames
68
+ 3. Run inside Claude Code Bash tool — output should be clean, no spinner noise
69
+ 4. Verify `step()` and `progress()` behave the same way in both environments
70
+
71
+ #### For Users
72
+ **What changed:** CLI spinners no longer produce garbage output in non-interactive terminals
73
+ **How to use:** No action needed — automatic TTY detection
74
+ **Breaking changes:** None
75
+
3
76
  ## [1.5.1] - 2026-02-06
4
77
 
5
78
  ### Refactoring
@@ -124,7 +124,13 @@ describe('Output Module', () => {
124
124
  stdoutWriteSpy.mockClear()
125
125
  out.stop()
126
126
 
127
- expect(stdoutWriteSpy).toHaveBeenCalled()
127
+ // In TTY, stop() writes a clear sequence; in non-TTY, spinner doesn't
128
+ // use setInterval so stop() is a no-op (clear skips in non-TTY)
129
+ if (process.stdout.isTTY) {
130
+ expect(stdoutWriteSpy).toHaveBeenCalled()
131
+ } else {
132
+ expect(stdoutWriteSpy).not.toHaveBeenCalled()
133
+ }
128
134
  })
129
135
 
130
136
  it('should be safe to call multiple times', () => {
@@ -13,16 +13,29 @@
13
13
  * @version 1.0.0
14
14
  */
15
15
 
16
+ import { exec as execCallback } from 'node:child_process'
16
17
  import fs from 'node:fs/promises'
17
18
  import os from 'node:os'
18
19
  import path from 'node:path'
20
+ import { promisify } from 'node:util'
21
+ import { findRelevantFiles } from '../context-tools/files-tool'
22
+ import { getRecentFiles } from '../context-tools/recent-tool'
23
+ import { extractSignatures } from '../context-tools/signatures-tool'
19
24
  import configManager from '../infrastructure/config-manager'
20
25
  import pathManager from '../infrastructure/path-manager'
21
26
  import { stateStorage } from '../storage'
22
- import type { LoadedAgent, LoadedSkill, OrchestratorContext, OrchestratorSubtask } from '../types'
27
+ import type {
28
+ LoadedAgent,
29
+ LoadedSkill,
30
+ OrchestratorContext,
31
+ OrchestratorSubtask,
32
+ RealCodebaseContext,
33
+ } from '../types'
23
34
  import { isNotFoundError } from '../types/fs'
24
35
  import { parseFrontmatter } from './template-loader'
25
36
 
37
+ const execAsync = promisify(execCallback)
38
+
26
39
  // =============================================================================
27
40
  // Domain Detection Keywords
28
41
  // =============================================================================
@@ -201,10 +214,13 @@ export class OrchestratorExecutor {
201
214
  // Step 5: Load skills from agent frontmatter
202
215
  const skills = await this.loadSkills(agents)
203
216
 
204
- // Step 6: Determine if fragmentation is needed
217
+ // Step 6: Gather real codebase context proactively
218
+ const realContext = await this.gatherRealContext(taskDescription, projectPath)
219
+
220
+ // Step 7: Determine if fragmentation is needed
205
221
  const requiresFragmentation = this.shouldFragment(domains, taskDescription)
206
222
 
207
- // Step 7: Create subtasks if fragmentation is required
223
+ // Step 8: Create subtasks if fragmentation is required
208
224
  let subtasks: OrchestratorSubtask[] | null = null
209
225
  if (requiresFragmentation && command === 'task') {
210
226
  subtasks = await this.createSubtasks(taskDescription, domains, agents, projectId)
@@ -222,6 +238,100 @@ export class OrchestratorExecutor {
222
238
  ecosystem: repoAnalysis?.ecosystem || 'unknown',
223
239
  conventions: repoAnalysis?.conventions || [],
224
240
  },
241
+ realContext,
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Gather real codebase context proactively.
247
+ *
248
+ * Calls existing context tools (files-tool, recent-tool, signatures-tool)
249
+ * to build a briefing so the agent doesn't need to explore first.
250
+ */
251
+ private async gatherRealContext(
252
+ taskDescription: string,
253
+ projectPath: string
254
+ ): Promise<RealCodebaseContext | undefined> {
255
+ try {
256
+ // Run git state + relevant files + recent files in parallel
257
+ const [gitResult, filesResult, recentResult] = await Promise.all([
258
+ this.getGitState(projectPath),
259
+ findRelevantFiles(taskDescription, projectPath, { maxFiles: 10, minScore: 0.15 }),
260
+ getRecentFiles(projectPath, { commits: 10, maxFiles: 10 }),
261
+ ])
262
+
263
+ // Extract signatures from top 3 relevant files
264
+ const topFiles = filesResult.files.slice(0, 3)
265
+ const signatureResults = await Promise.all(
266
+ topFiles.map(async (f) => {
267
+ try {
268
+ const result = await extractSignatures(f.path, projectPath)
269
+ if (result.signatures.length === 0) return null
270
+ const sigContent = result.signatures
271
+ .map((s) => `${s.exported ? 'export ' : ''}${s.type} ${s.name}: ${s.signature}`)
272
+ .join('\n')
273
+ return { path: f.path, content: sigContent }
274
+ } catch {
275
+ return null
276
+ }
277
+ })
278
+ )
279
+
280
+ return {
281
+ gitBranch: gitResult.branch,
282
+ gitStatus: gitResult.status,
283
+ relevantFiles: filesResult.files.map((f) => ({
284
+ path: f.path,
285
+ score: Math.round(f.score * 100),
286
+ reason: f.reasons.join(', '),
287
+ })),
288
+ recentFiles: recentResult.hotFiles.slice(0, 5).map((f) => ({
289
+ path: f.path,
290
+ lastChanged: f.lastChanged,
291
+ changes: f.changes,
292
+ })),
293
+ signatures: signatureResults.filter(
294
+ (s): s is { path: string; content: string } => s !== null
295
+ ),
296
+ }
297
+ } catch {
298
+ // Non-critical — return undefined if context gathering fails
299
+ return undefined
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Get current git state (branch + short status)
305
+ */
306
+ private async getGitState(projectPath: string): Promise<{ branch: string; status: string }> {
307
+ try {
308
+ const [branchResult, statusResult] = await Promise.all([
309
+ execAsync('git branch --show-current', { cwd: projectPath }),
310
+ execAsync('git status --porcelain', { cwd: projectPath }),
311
+ ])
312
+
313
+ const branch = branchResult.stdout.trim() || 'main'
314
+ const lines = statusResult.stdout.trim().split('\n').filter(Boolean)
315
+
316
+ let modified = 0
317
+ let untracked = 0
318
+ let staged = 0
319
+ for (const line of lines) {
320
+ const code = line.substring(0, 2)
321
+ if (code.startsWith('??')) untracked++
322
+ else if (code[0] !== ' ' && code[0] !== '?') staged++
323
+ else modified++
324
+ }
325
+
326
+ const parts: string[] = []
327
+ if (staged > 0) parts.push(`${staged} staged`)
328
+ if (modified > 0) parts.push(`${modified} modified`)
329
+ if (untracked > 0) parts.push(`${untracked} untracked`)
330
+ const status = parts.length > 0 ? parts.join(', ') : 'clean'
331
+
332
+ return { branch, status }
333
+ } catch {
334
+ return { branch: 'unknown', status: 'git unavailable' }
225
335
  }
226
336
  }
227
337
 
@@ -365,6 +475,8 @@ export class OrchestratorExecutor {
365
475
  content: body,
366
476
  skills: frontmatter.skills || [],
367
477
  filePath,
478
+ effort: frontmatter.effort as LoadedAgent['effort'],
479
+ model: frontmatter.model as string | undefined,
368
480
  }
369
481
  } catch {
370
482
  // Try next variation
@@ -409,16 +521,18 @@ export class OrchestratorExecutor {
409
521
  async loadSkills(agents: LoadedAgent[]): Promise<LoadedSkill[]> {
410
522
  const skillsDir = path.join(os.homedir(), '.claude', 'skills')
411
523
 
412
- // Collect unique skill names from all agents
413
- const uniqueSkillNames = new Set<string>()
524
+ // Collect unique skill names from all agents, tracking which agents need them
525
+ const skillToAgents = new Map<string, string[]>()
414
526
  for (const agent of agents) {
415
527
  for (const skillName of agent.skills) {
416
- uniqueSkillNames.add(skillName)
528
+ const existing = skillToAgents.get(skillName) || []
529
+ existing.push(agent.name)
530
+ skillToAgents.set(skillName, existing)
417
531
  }
418
532
  }
419
533
 
420
534
  // Load all skills in parallel
421
- const skillPromises = Array.from(uniqueSkillNames).map(
535
+ const skillPromises = Array.from(skillToAgents.keys()).map(
422
536
  async (skillName): Promise<LoadedSkill | null> => {
423
537
  // Check both patterns: flat file and subdirectory (ecosystem standard)
424
538
  const flatPath = path.join(skillsDir, `${skillName}.md`)
@@ -434,7 +548,11 @@ export class OrchestratorExecutor {
434
548
  const content = await fs.readFile(flatPath, 'utf-8')
435
549
  return { name: skillName, content, filePath: flatPath }
436
550
  } catch {
437
- // Skill not found - not an error, just skip
551
+ // Skill not found log warning with agent context
552
+ const agentNames = skillToAgents.get(skillName) || []
553
+ console.warn(
554
+ `⚠ Skill "${skillName}" not installed (needed by: ${agentNames.join(', ')}). Run \`prjct sync\` to auto-install.`
555
+ )
438
556
  return null
439
557
  }
440
558
  }
@@ -459,6 +459,8 @@ class PromptBuilder {
459
459
  parts.push('### LOADED AGENTS (Project-Specific Specialists)\n\n')
460
460
  for (const agent of orchestratorContext.agents) {
461
461
  parts.push(`#### Agent: ${agent.name} (${agent.domain})\n`)
462
+ if (agent.effort) parts.push(`Effort: ${agent.effort}\n`)
463
+ if (agent.model) parts.push(`Model: ${agent.model}\n`)
462
464
  if (agent.skills.length > 0) {
463
465
  parts.push(`Skills: ${agent.skills.join(', ')}\n`)
464
466
  }
@@ -476,15 +478,50 @@ class PromptBuilder {
476
478
  parts.push('### LOADED SKILLS (From Agent Frontmatter)\n\n')
477
479
  for (const skill of orchestratorContext.skills) {
478
480
  parts.push(`#### Skill: ${skill.name}\n`)
479
- // Include first 1000 chars of skill content
481
+ // Include first 2000 chars of skill content
480
482
  const truncatedContent =
481
- skill.content.length > 1000
482
- ? `${skill.content.substring(0, 1000)}\n... (truncated)`
483
+ skill.content.length > 2000
484
+ ? `${skill.content.substring(0, 2000)}\n... (truncated)`
483
485
  : skill.content
484
486
  parts.push(`\`\`\`markdown\n${truncatedContent}\n\`\`\`\n\n`)
485
487
  }
486
488
  }
487
489
 
490
+ // Inject real codebase context (proactively gathered)
491
+ if (orchestratorContext.realContext) {
492
+ const rc = orchestratorContext.realContext
493
+ parts.push('### CODEBASE CONTEXT (Real — gathered proactively)\n\n')
494
+
495
+ parts.push(`**Git State**: Branch \`${rc.gitBranch}\` | ${rc.gitStatus}\n\n`)
496
+
497
+ if (rc.relevantFiles.length > 0) {
498
+ parts.push('**Relevant Files** (scored by task relevance):\n')
499
+ parts.push('| Score | File | Why |\n')
500
+ parts.push('|-------|------|-----|\n')
501
+ for (const f of rc.relevantFiles.slice(0, 8)) {
502
+ parts.push(`| ${f.score} | ${f.path} | ${f.reason} |\n`)
503
+ }
504
+ parts.push('\n')
505
+ }
506
+
507
+ if (rc.signatures.length > 0) {
508
+ parts.push('**Code Signatures** (top files):\n')
509
+ for (const sig of rc.signatures) {
510
+ parts.push(`\`\`\`typescript\n// ${sig.path}\n${sig.content}\n\`\`\`\n`)
511
+ }
512
+ parts.push('\n')
513
+ }
514
+
515
+ if (rc.recentFiles.length > 0) {
516
+ parts.push('**Recently Changed**: ')
517
+ const recentSummary = rc.recentFiles
518
+ .slice(0, 5)
519
+ .map((f) => `${f.path} (${f.lastChanged})`)
520
+ .join(', ')
521
+ parts.push(`${recentSummary}\n\n`)
522
+ }
523
+ }
524
+
488
525
  // Inject subtasks if fragmented
489
526
  if (orchestratorContext.requiresFragmentation && orchestratorContext.subtasks) {
490
527
  parts.push('### SUBTASKS (Execute in Order)\n\n')
@@ -36,7 +36,7 @@ export const AI_TOOLS: Record<string, AIToolConfig> = {
36
36
  name: 'Claude Code',
37
37
  outputFile: 'CLAUDE.md',
38
38
  outputPath: 'global',
39
- maxTokens: 3000,
39
+ maxTokens: 6000,
40
40
  format: 'detailed',
41
41
  description: 'Anthropic Claude Code CLI',
42
42
  },
@@ -516,6 +516,11 @@ export class AnalysisCommands extends PrjctCommandsBase {
516
516
  const skillWord = result.skills.length === 1 ? 'skill' : 'skills'
517
517
  generatedItems.push(`${result.skills.length} ${skillWord}`)
518
518
  }
519
+ const installed = result.skillsInstalled?.filter((s) => s.status === 'installed') || []
520
+ if (installed.length > 0) {
521
+ const word = installed.length === 1 ? 'skill' : 'skills'
522
+ generatedItems.push(`${installed.length} ${word} auto-installed`)
523
+ }
519
524
 
520
525
  out.section('Generated')
521
526
  out.list(generatedItems, { bullet: '✓' })
@@ -40,6 +40,7 @@ const MODEL_PRICING = {
40
40
  'claude-sonnet-4.5': { input: 0.003, output: 0.015 }, // $3/$15 per M
41
41
  'claude-haiku-4.5': { input: 0.001, output: 0.005 }, // $1/$5 per M
42
42
  'claude-opus-4': { input: 0.015, output: 0.075 }, // $15/$75 per M (legacy)
43
+ 'claude-opus-4-6': { input: 0.015, output: 0.075 }, // $15/$75 per M
43
44
  // OpenAI
44
45
  'gpt-4o': { input: 0.0025, output: 0.01 }, // $2.50/$10 per M
45
46
  'gpt-4-turbo': { input: 0.01, output: 0.03 }, // $10/$30 per M
@@ -78,6 +79,7 @@ export function countTokens(text: string): number {
78
79
  const BREAKDOWN_MODELS: ModelName[] = [
79
80
  'claude-sonnet-4.5',
80
81
  'claude-opus-4.5',
82
+ 'claude-opus-4-6',
81
83
  'gpt-4o',
82
84
  'gemini-1.5-pro',
83
85
  ]
@@ -25,6 +25,8 @@ interface Agent {
25
25
  role: string | null
26
26
  domain: string
27
27
  skills: string[]
28
+ effort?: 'low' | 'medium' | 'high' | 'max'
29
+ model?: string
28
30
  modified: Date
29
31
  /** Source of this agent: 'file' (generated) or 'hierarchical' (AGENTS.md) */
30
32
  source?: 'file' | 'hierarchical'
@@ -69,7 +71,8 @@ class AgentLoader {
69
71
  const agentPath = path.join(this.agentsDir, `${agentName}.md`)
70
72
  const content = await fs.readFile(agentPath, 'utf-8')
71
73
 
72
- // Parse agent metadata from content
74
+ // Parse agent metadata from content (including frontmatter)
75
+ const { effort, model } = this.extractFrontmatterMeta(content)
73
76
  const agent: Agent = {
74
77
  name: agentName,
75
78
  content,
@@ -77,6 +80,8 @@ class AgentLoader {
77
80
  role: this.extractRole(content),
78
81
  domain: this.extractDomain(content),
79
82
  skills: this.extractSkills(content),
83
+ effort,
84
+ model,
80
85
  modified: (await fs.stat(agentPath)).mtime,
81
86
  }
82
87
 
@@ -203,6 +208,35 @@ class AgentLoader {
203
208
  return skills
204
209
  }
205
210
 
211
+ /**
212
+ * Extract effort and model from YAML frontmatter
213
+ */
214
+ private extractFrontmatterMeta(content: string): {
215
+ effort?: 'low' | 'medium' | 'high' | 'max'
216
+ model?: string
217
+ } {
218
+ const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/)
219
+ if (!frontmatterMatch) return {}
220
+
221
+ const fm = frontmatterMatch[1]
222
+ const result: { effort?: 'low' | 'medium' | 'high' | 'max'; model?: string } = {}
223
+
224
+ const effortMatch = fm.match(/^effort:\s*(.+)$/m)
225
+ if (effortMatch) {
226
+ const val = effortMatch[1].trim().replace(/['"]/g, '') as 'low' | 'medium' | 'high' | 'max'
227
+ if (['low', 'medium', 'high', 'max'].includes(val)) {
228
+ result.effort = val
229
+ }
230
+ }
231
+
232
+ const modelMatch = fm.match(/^model:\s*(.+)$/m)
233
+ if (modelMatch) {
234
+ result.model = modelMatch[1].trim().replace(/['"]/g, '')
235
+ }
236
+
237
+ return result
238
+ }
239
+
206
240
  /**
207
241
  * Get agents directory path
208
242
  */
@@ -29,11 +29,14 @@ export interface SelectedContext {
29
29
  }
30
30
  }
31
31
 
32
+ /** Default token budget for context selection (increased for 200K+ context models) */
33
+ const DEFAULT_TOKEN_BUDGET = 80_000
34
+
32
35
  export interface ContextSelectionOptions {
33
36
  maxFiles?: number // Max files to return (default: 50)
34
37
  minScore?: number // Min relevance score (default: 30)
35
38
  includeGeneral?: boolean // Include 'general' domain files (default: true)
36
- tokenBudget?: number // Max estimated tokens (default: 50000)
39
+ tokenBudget?: number // Max estimated tokens (default: 80000)
37
40
  }
38
41
 
39
42
  // ============================================================================
@@ -173,7 +176,7 @@ export class ContextSelector {
173
176
  const maxFiles = options.maxFiles || 50
174
177
  const minScore = options.minScore || 30
175
178
  const includeGeneral = options.includeGeneral !== false
176
- const tokenBudget = options.tokenBudget || 50000
179
+ const tokenBudget = options.tokenBudget || DEFAULT_TOKEN_BUDGET
177
180
 
178
181
  // Load index and categories
179
182
  const [index, domainsData, categoriesCache] = await Promise.all([
@@ -351,7 +354,7 @@ export class ContextSelector {
351
354
  ): SelectedContext {
352
355
  const maxFiles = options.maxFiles || 50
353
356
  const minScore = options.minScore || 30
354
- const tokenBudget = options.tokenBudget || 50000
357
+ const tokenBudget = options.tokenBudget || DEFAULT_TOKEN_BUDGET
355
358
 
356
359
  // Filter and sort by score
357
360
  const filteredFiles = files.filter((f) => f.score >= minScore).sort((a, b) => b.score - a.score)
@@ -36,6 +36,8 @@ export interface HierarchicalAgent {
36
36
  sources: string[]
37
37
  /** Whether this agent was overridden at some level */
38
38
  wasOverridden: boolean
39
+ /** Effort level hint for Claude's adaptive reasoning depth */
40
+ effort?: 'low' | 'medium' | 'high' | 'max'
39
41
  }
40
42
 
41
43
  export interface AgentResolutionResult {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { exec } from 'node:child_process'
19
19
  import fs from 'node:fs/promises'
20
+ import os from 'node:os'
20
21
  import path from 'node:path'
21
22
  import { promisify } from 'node:util'
22
23
  import {
@@ -26,15 +27,18 @@ import {
26
27
  type ProjectContext,
27
28
  resolveToolIds,
28
29
  } from '../ai-tools'
30
+ import { getErrorMessage } from '../errors'
29
31
  import commandInstaller from '../infrastructure/command-installer'
30
32
  import configManager from '../infrastructure/config-manager'
31
33
  import pathManager from '../infrastructure/path-manager'
32
34
  import { metricsStorage } from '../storage/metrics-storage'
33
35
  import { type ContextSources, defaultSources, type SourceInfo } from '../utils/citations'
34
36
  import dateHelper from '../utils/date-helper'
37
+ import log from '../utils/logger'
35
38
  import { ContextFileGenerator } from './context-generator'
36
39
  import type { SyncDiff } from './diff-generator'
37
40
  import { localStateGenerator } from './local-state-generator'
41
+ import { skillInstaller } from './skill-installer'
38
42
  import { type StackDetection, StackDetector } from './stack-detector'
39
43
  import { syncVerifier, type VerificationReport } from './sync-verifier'
40
44
 
@@ -105,6 +109,7 @@ interface SyncResult {
105
109
  stack: StackDetection
106
110
  agents: AgentInfo[]
107
111
  skills: { agent: string; skill: string }[]
112
+ skillsInstalled: { name: string; agent: string; status: 'installed' | 'skipped' | 'error' }[]
108
113
  contextFiles: string[]
109
114
  aiTools: AIToolResult[]
110
115
  syncMetrics?: SyncMetrics
@@ -176,6 +181,7 @@ class SyncService {
176
181
  stack: this.emptyStack(),
177
182
  agents: [],
178
183
  skills: [],
184
+ skillsInstalled: [],
179
185
  contextFiles: [],
180
186
  aiTools: [],
181
187
  error: 'No prjct project. Run p. init first.',
@@ -203,6 +209,7 @@ class SyncService {
203
209
  // 4. Generate all files (depends on gathered data)
204
210
  const agents = await this.generateAgents(stack, stats)
205
211
  const skills = this.configureSkills(agents)
212
+ const skillsInstalled = await this.autoInstallSkills(agents)
206
213
  const sources = this.buildSources(stats, commands)
207
214
  const contextFiles = await this.generateContextFiles(git, stats, commands, agents, sources)
208
215
 
@@ -274,6 +281,7 @@ class SyncService {
274
281
  stack,
275
282
  agents,
276
283
  skills,
284
+ skillsInstalled,
277
285
  contextFiles,
278
286
  aiTools: aiToolResults.map((r) => ({
279
287
  toolId: r.toolId,
@@ -294,6 +302,7 @@ class SyncService {
294
302
  stack: this.emptyStack(),
295
303
  agents: [],
296
304
  skills: [],
305
+ skillsInstalled: [],
297
306
  contextFiles: [],
298
307
  aiTools: [],
299
308
  error: (error as Error).message,
@@ -800,6 +809,109 @@ You are the ${name} expert for this project. Apply best practices for the detect
800
809
  return skills
801
810
  }
802
811
 
812
+ // ==========================================================================
813
+ // SKILL AUTO-INSTALLATION
814
+ // ==========================================================================
815
+
816
+ /**
817
+ * Auto-install skills from skill-mappings.json for generated agents.
818
+ * Reads the mapping, checks which packages are needed, and installs missing ones.
819
+ */
820
+ private async autoInstallSkills(
821
+ agents: AgentInfo[]
822
+ ): Promise<{ name: string; agent: string; status: 'installed' | 'skipped' | 'error' }[]> {
823
+ const results: { name: string; agent: string; status: 'installed' | 'skipped' | 'error' }[] = []
824
+
825
+ try {
826
+ // Load skill mappings
827
+ const mappingsPath = path.join(
828
+ __dirname,
829
+ '..',
830
+ '..',
831
+ 'templates',
832
+ 'config',
833
+ 'skill-mappings.json'
834
+ )
835
+ const mappingsContent = await fs.readFile(mappingsPath, 'utf-8')
836
+ const mappings = JSON.parse(mappingsContent)
837
+ const agentToSkillMap = mappings.agentToSkillMap || {}
838
+
839
+ // Collect all packages to install, grouped by agent
840
+ const packagesToInstall: { pkg: string; agent: string }[] = []
841
+ for (const agent of agents) {
842
+ const mapping = agentToSkillMap[agent.name]
843
+ if (mapping?.packages) {
844
+ for (const pkg of mapping.packages) {
845
+ packagesToInstall.push({ pkg, agent: agent.name })
846
+ }
847
+ }
848
+ }
849
+
850
+ if (packagesToInstall.length === 0) return results
851
+
852
+ // Install each package (check if already installed first)
853
+ const skillsDir = path.join(os.homedir(), '.claude', 'skills')
854
+ for (const { pkg, agent } of packagesToInstall) {
855
+ // Extract skill name from package path (e.g., "anthropics/skills/frontend-design" -> "frontend-design")
856
+ const skillName = pkg.split('/').pop() || pkg
857
+
858
+ // Check if already installed
859
+ const subdirPath = path.join(skillsDir, skillName, 'SKILL.md')
860
+ const flatPath = path.join(skillsDir, `${skillName}.md`)
861
+
862
+ let alreadyInstalled = false
863
+ try {
864
+ await fs.access(subdirPath)
865
+ alreadyInstalled = true
866
+ } catch {
867
+ try {
868
+ await fs.access(flatPath)
869
+ alreadyInstalled = true
870
+ } catch {
871
+ // Not installed
872
+ }
873
+ }
874
+
875
+ if (alreadyInstalled) {
876
+ results.push({ name: skillName, agent, status: 'skipped' })
877
+ continue
878
+ }
879
+
880
+ // Install via skillInstaller (supports owner/repo format)
881
+ try {
882
+ // Parse package as owner/repo or owner/repo@skill format
883
+ // "anthropics/skills/frontend-design" -> owner=anthropics, repo=skills, skill=frontend-design
884
+ const parts = pkg.split('/')
885
+ let installSource: string
886
+ if (parts.length === 3) {
887
+ // owner/repo/skill -> owner/repo@skill
888
+ installSource = `${parts[0]}/${parts[1]}@${parts[2]}`
889
+ } else {
890
+ installSource = pkg
891
+ }
892
+
893
+ const installResult = await skillInstaller.install(installSource)
894
+ if (installResult.installed.length > 0) {
895
+ results.push({ name: skillName, agent, status: 'installed' })
896
+ log.info(`Installed skill: ${skillName} for agent: ${agent}`)
897
+ } else if (installResult.errors.length > 0) {
898
+ results.push({ name: skillName, agent, status: 'error' })
899
+ log.debug(`Failed to install skill ${skillName}`, { errors: installResult.errors })
900
+ } else {
901
+ results.push({ name: skillName, agent, status: 'skipped' })
902
+ }
903
+ } catch (error) {
904
+ results.push({ name: skillName, agent, status: 'error' })
905
+ log.debug(`Skill install error for ${skillName}`, { error: getErrorMessage(error) })
906
+ }
907
+ }
908
+ } catch (error) {
909
+ log.debug('Skill auto-installation failed (non-critical)', { error: getErrorMessage(error) })
910
+ }
911
+
912
+ return results
913
+ }
914
+
803
915
  // ==========================================================================
804
916
  // CONTEXT FILE GENERATION
805
917
  // ==========================================================================