prjct-cli 0.56.0 → 0.57.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.57.0] - 2026-02-05
4
+
5
+ ### Features
6
+
7
+ - monorepo support with nested PRJCT.md inheritance (PRJ-118) (#94)
8
+
9
+
10
+ ## [0.57.0] - 2026-02-05
11
+
12
+ ### Features
13
+
14
+ - **Monorepo support (PRJ-118)**: Support nested PRJCT.md files for monorepo subdirectories
15
+ - Detect monorepos (pnpm, npm, yarn, lerna, nx, turborepo, rush)
16
+ - Discover packages with workspace patterns
17
+ - Nested PRJCT.md inheritance (deeper files take precedence)
18
+ - Per-package CLAUDE.md generation with merged context
19
+ - `prjct sync --package=<name>` for single package sync
20
+
21
+ ### Added
22
+
23
+ - `NestedContextResolver` service for PRJCT.md discovery and inheritance
24
+ - `detectMonorepo()` and `discoverMonorepoPackages()` in PathManager
25
+ - `generateMonorepoContexts()` in ContextFileGenerator
26
+
27
+ ## [0.56.1] - 2026-02-05
28
+
29
+ ### Bug Fixes
30
+
31
+ - **Context injection**: Fixed template paths in CLAUDE.md - now correctly points to `~/.claude/commands/p/` instead of `templates/commands/`
32
+ - **Agent loading**: Added clear instructions for loading domain agents before SMART commands (task, ship, bug, done)
33
+
3
34
  ## [0.56.0] - 2026-02-05
4
35
 
5
36
  ### Features
@@ -227,7 +227,13 @@ export class AnalysisCommands extends PrjctCommandsBase {
227
227
  */
228
228
  async sync(
229
229
  projectPath: string = process.cwd(),
230
- options: { aiTools?: string[]; preview?: boolean; yes?: boolean; json?: boolean } = {}
230
+ options: {
231
+ aiTools?: string[]
232
+ preview?: boolean
233
+ yes?: boolean
234
+ json?: boolean
235
+ package?: string
236
+ } = {}
231
237
  ): Promise<CommandResult> {
232
238
  try {
233
239
  const initResult = await this.ensureProjectInit(projectPath)
@@ -242,6 +248,45 @@ export class AnalysisCommands extends PrjctCommandsBase {
242
248
  const globalPath = pathManager.getGlobalProjectPath(projectId)
243
249
  const startTime = Date.now()
244
250
 
251
+ // Handle package-specific sync for monorepos
252
+ if (options.package) {
253
+ const monoInfo = await pathManager.detectMonorepo(projectPath)
254
+ if (!monoInfo.isMonorepo) {
255
+ return {
256
+ success: false,
257
+ error: 'Not a monorepo. --package flag only works in monorepos.',
258
+ }
259
+ }
260
+
261
+ const pkg = monoInfo.packages.find(
262
+ (p) => p.name === options.package || p.relativePath === options.package
263
+ )
264
+ if (!pkg) {
265
+ const available = monoInfo.packages.map((p) => p.name).join(', ')
266
+ return {
267
+ success: false,
268
+ error: `Package "${options.package}" not found. Available: ${available}`,
269
+ }
270
+ }
271
+
272
+ // Sync only the specified package
273
+ const result = await syncService.sync(projectPath, {
274
+ aiTools: options.aiTools,
275
+ packagePath: pkg.path,
276
+ packageName: pkg.name,
277
+ })
278
+
279
+ if (options.json) {
280
+ console.log(
281
+ JSON.stringify({ success: result.success, package: pkg.name, path: pkg.relativePath })
282
+ )
283
+ } else {
284
+ out.done(`Synced package: ${pkg.name}`)
285
+ }
286
+
287
+ return { success: result.success }
288
+ }
289
+
245
290
  // Generate diff preview if we have existing context
246
291
  const claudeMdPath = path.join(globalPath, 'context', 'CLAUDE.md')
247
292
  let existingContent: string | null = null
@@ -166,10 +166,15 @@ export const COMMANDS: CommandMeta[] = [
166
166
  name: 'sync',
167
167
  group: 'core',
168
168
  description: 'Sync project state and update workflow agents',
169
- usage: { claude: '/p:sync', terminal: 'prjct sync' },
169
+ usage: { claude: '/p:sync', terminal: 'prjct sync [--package=<name>]' },
170
170
  implemented: true,
171
171
  hasTemplate: true,
172
172
  requiresProject: true,
173
+ features: [
174
+ 'Monorepo support: --package=<name> for single package sync',
175
+ 'Nested PRJCT.md inheritance',
176
+ 'Per-package CLAUDE.md generation',
177
+ ],
173
178
  },
174
179
  {
175
180
  name: 'suggest',
@@ -184,7 +184,13 @@ class PrjctCommands {
184
184
 
185
185
  async sync(
186
186
  projectPath: string = process.cwd(),
187
- options: { aiTools?: string[]; preview?: boolean; yes?: boolean; json?: boolean } = {}
187
+ options: {
188
+ aiTools?: string[]
189
+ preview?: boolean
190
+ yes?: boolean
191
+ json?: boolean
192
+ package?: string
193
+ } = {}
188
194
  ): Promise<CommandResult> {
189
195
  return this.analysis.sync(projectPath, options)
190
196
  }
package/core/index.ts CHANGED
@@ -129,6 +129,7 @@ async function main(): Promise<void> {
129
129
  preview: options.preview === true,
130
130
  yes: options.yes === true,
131
131
  json: options.json === true,
132
+ package: options.package ? String(options.package) : undefined,
132
133
  }),
133
134
  start: () => commands.start(),
134
135
  // Context (for Claude templates)
@@ -13,10 +13,28 @@ import crypto from 'node:crypto'
13
13
  import fs from 'node:fs/promises'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
+ import { globSync } from 'glob'
16
17
  import type { SessionInfo } from '../types'
17
18
  import * as dateHelper from '../utils/date-helper'
18
19
  import * as fileHelper from '../utils/file-helper'
19
20
 
21
+ /**
22
+ * Monorepo detection result
23
+ */
24
+ export interface MonorepoInfo {
25
+ isMonorepo: boolean
26
+ type: 'pnpm' | 'npm' | 'yarn' | 'lerna' | 'nx' | 'rush' | 'turborepo' | null
27
+ rootPath: string
28
+ packages: MonorepoPackage[]
29
+ }
30
+
31
+ export interface MonorepoPackage {
32
+ name: string
33
+ path: string
34
+ relativePath: string
35
+ hasPrjctMd: boolean
36
+ }
37
+
20
38
  class PathManager {
21
39
  globalBaseDir: string
22
40
  globalProjectsDir: string
@@ -360,6 +378,207 @@ class PathManager {
360
378
  getContextPath(projectId: string): string {
361
379
  return path.join(this.getGlobalProjectPath(projectId), 'context')
362
380
  }
381
+
382
+ // ===========================================================================
383
+ // Monorepo Detection
384
+ // ===========================================================================
385
+
386
+ /**
387
+ * Detect if a project is a monorepo and get package information
388
+ */
389
+ async detectMonorepo(projectPath: string): Promise<MonorepoInfo> {
390
+ const result: MonorepoInfo = {
391
+ isMonorepo: false,
392
+ type: null,
393
+ rootPath: projectPath,
394
+ packages: [],
395
+ }
396
+
397
+ // Check for various monorepo configurations
398
+ const checks = [
399
+ { file: 'pnpm-workspace.yaml', type: 'pnpm' as const },
400
+ { file: 'lerna.json', type: 'lerna' as const },
401
+ { file: 'nx.json', type: 'nx' as const },
402
+ { file: 'rush.json', type: 'rush' as const },
403
+ { file: 'turbo.json', type: 'turborepo' as const },
404
+ ]
405
+
406
+ for (const check of checks) {
407
+ const filePath = path.join(projectPath, check.file)
408
+ if (await fileHelper.fileExists(filePath)) {
409
+ result.isMonorepo = true
410
+ result.type = check.type
411
+ break
412
+ }
413
+ }
414
+
415
+ // Check package.json for workspaces (npm/yarn)
416
+ if (!result.isMonorepo) {
417
+ const packageJsonPath = path.join(projectPath, 'package.json')
418
+ if (await fileHelper.fileExists(packageJsonPath)) {
419
+ try {
420
+ const content = await fs.readFile(packageJsonPath, 'utf-8')
421
+ const pkg = JSON.parse(content)
422
+ if (pkg.workspaces) {
423
+ result.isMonorepo = true
424
+ result.type = 'npm' // Could be yarn too, but npm is more generic
425
+ }
426
+ } catch {
427
+ // Invalid package.json, ignore
428
+ }
429
+ }
430
+ }
431
+
432
+ // If it's a monorepo, discover packages
433
+ if (result.isMonorepo) {
434
+ result.packages = await this.discoverMonorepoPackages(projectPath, result.type)
435
+ }
436
+
437
+ return result
438
+ }
439
+
440
+ /**
441
+ * Discover all packages in a monorepo
442
+ */
443
+ async discoverMonorepoPackages(
444
+ rootPath: string,
445
+ type: MonorepoInfo['type']
446
+ ): Promise<MonorepoPackage[]> {
447
+ const packages: MonorepoPackage[] = []
448
+ let patterns: string[] = []
449
+
450
+ try {
451
+ if (type === 'pnpm') {
452
+ // Read pnpm-workspace.yaml
453
+ const yaml = await fs.readFile(path.join(rootPath, 'pnpm-workspace.yaml'), 'utf-8')
454
+ // Simple YAML parsing for packages array
455
+ const match = yaml.match(/packages:\s*\n((?:\s*-\s*.+\n?)+)/)
456
+ if (match) {
457
+ patterns = match[1]
458
+ .split('\n')
459
+ .map((line) => line.replace(/^\s*-\s*['"]?|['"]?\s*$/g, ''))
460
+ .filter(Boolean)
461
+ }
462
+ } else if (type === 'npm' || type === 'lerna') {
463
+ // Read from package.json workspaces or lerna.json
464
+ const packageJsonPath = path.join(rootPath, 'package.json')
465
+ const content = await fs.readFile(packageJsonPath, 'utf-8')
466
+ const pkg = JSON.parse(content)
467
+ if (Array.isArray(pkg.workspaces)) {
468
+ patterns = pkg.workspaces
469
+ } else if (pkg.workspaces?.packages) {
470
+ patterns = pkg.workspaces.packages
471
+ }
472
+
473
+ // Also check lerna.json
474
+ if (type === 'lerna') {
475
+ const lernaPath = path.join(rootPath, 'lerna.json')
476
+ if (await fileHelper.fileExists(lernaPath)) {
477
+ const lernaContent = await fs.readFile(lernaPath, 'utf-8')
478
+ const lerna = JSON.parse(lernaContent)
479
+ if (lerna.packages) {
480
+ patterns = lerna.packages
481
+ }
482
+ }
483
+ }
484
+ } else if (type === 'nx') {
485
+ // NX uses apps/* and libs/* by default
486
+ patterns = ['apps/*', 'libs/*', 'packages/*']
487
+ } else if (type === 'turborepo') {
488
+ // Turborepo reads from package.json workspaces
489
+ const packageJsonPath = path.join(rootPath, 'package.json')
490
+ const content = await fs.readFile(packageJsonPath, 'utf-8')
491
+ const pkg = JSON.parse(content)
492
+ if (Array.isArray(pkg.workspaces)) {
493
+ patterns = pkg.workspaces
494
+ }
495
+ }
496
+
497
+ // If no patterns found, use common defaults
498
+ if (patterns.length === 0) {
499
+ patterns = ['packages/*', 'apps/*', 'libs/*']
500
+ }
501
+
502
+ // Expand glob patterns to find packages
503
+ for (const pattern of patterns) {
504
+ // Skip negation patterns for now
505
+ if (pattern.startsWith('!')) continue
506
+
507
+ const matches = globSync(pattern, {
508
+ cwd: rootPath,
509
+ absolute: false,
510
+ })
511
+
512
+ for (const match of matches) {
513
+ const packagePath = path.join(rootPath, match)
514
+ const packageJsonPath = path.join(packagePath, 'package.json')
515
+
516
+ // Only include directories with package.json
517
+ if (await fileHelper.fileExists(packageJsonPath)) {
518
+ try {
519
+ const content = await fs.readFile(packageJsonPath, 'utf-8')
520
+ const pkg = JSON.parse(content)
521
+ const prjctMdPath = path.join(packagePath, 'PRJCT.md')
522
+
523
+ packages.push({
524
+ name: pkg.name || path.basename(match),
525
+ path: packagePath,
526
+ relativePath: match,
527
+ hasPrjctMd: await fileHelper.fileExists(prjctMdPath),
528
+ })
529
+ } catch {
530
+ // Invalid package.json, skip
531
+ }
532
+ }
533
+ }
534
+ }
535
+ } catch {
536
+ // Error reading monorepo config, return empty
537
+ }
538
+
539
+ return packages
540
+ }
541
+
542
+ /**
543
+ * Check if current path is within a monorepo package
544
+ * Returns the package info if found, null otherwise
545
+ */
546
+ async findContainingPackage(
547
+ currentPath: string,
548
+ monoInfo: MonorepoInfo
549
+ ): Promise<MonorepoPackage | null> {
550
+ if (!monoInfo.isMonorepo) return null
551
+
552
+ const normalizedCurrent = path.resolve(currentPath)
553
+
554
+ for (const pkg of monoInfo.packages) {
555
+ const normalizedPkg = path.resolve(pkg.path)
556
+ if (normalizedCurrent.startsWith(normalizedPkg)) {
557
+ return pkg
558
+ }
559
+ }
560
+
561
+ return null
562
+ }
563
+
564
+ /**
565
+ * Find monorepo root from any subdirectory
566
+ * Walks up the directory tree looking for monorepo markers
567
+ */
568
+ async findMonorepoRoot(startPath: string): Promise<string | null> {
569
+ let currentPath = path.resolve(startPath)
570
+ const root = path.parse(currentPath).root
571
+
572
+ while (currentPath !== root) {
573
+ const monoInfo = await this.detectMonorepo(currentPath)
574
+ if (monoInfo.isMonorepo) {
575
+ return currentPath
576
+ }
577
+ currentPath = path.dirname(currentPath)
578
+ }
579
+
580
+ return null
581
+ }
363
582
  }
364
583
 
365
584
  const pathManager = new PathManager()
@@ -11,8 +11,10 @@
11
11
 
12
12
  import fs from 'node:fs/promises'
13
13
  import path from 'node:path'
14
+ import pathManager from '../infrastructure/path-manager'
14
15
  import dateHelper from '../utils/date-helper'
15
16
  import { mergePreservedSections, validatePreserveBlocks } from '../utils/preserve-sections'
17
+ import { NestedContextResolver } from './nested-context-resolver'
16
18
 
17
19
  // ============================================================================
18
20
  // TYPES
@@ -325,6 +327,135 @@ ${
325
327
 
326
328
  await this.writeWithPreservation(path.join(contextPath, 'shipped.md'), content)
327
329
  }
330
+
331
+ // ==========================================================================
332
+ // MONOREPO SUPPORT
333
+ // ==========================================================================
334
+
335
+ /**
336
+ * Generate CLAUDE.md files for each package in a monorepo
337
+ * Each package gets its own context file with inherited + package-specific rules
338
+ */
339
+ async generateMonorepoContexts(
340
+ git: GitData,
341
+ stats: ProjectStats,
342
+ commands: Commands,
343
+ agents: AgentInfo[]
344
+ ): Promise<string[]> {
345
+ const monoInfo = await pathManager.detectMonorepo(this.config.projectPath)
346
+
347
+ if (!monoInfo.isMonorepo) {
348
+ return []
349
+ }
350
+
351
+ const generatedFiles: string[] = []
352
+ const resolver = new NestedContextResolver(this.config.projectPath)
353
+ await resolver.initialize()
354
+
355
+ // Generate CLAUDE.md for each package that has PRJCT.md
356
+ for (const pkg of monoInfo.packages) {
357
+ if (!pkg.hasPrjctMd) continue
358
+
359
+ const resolvedCtx = await resolver.getPackageContext(pkg.name)
360
+ if (!resolvedCtx) continue
361
+
362
+ const content = await this.generatePackageClaudeMd(
363
+ pkg,
364
+ resolvedCtx,
365
+ git,
366
+ stats,
367
+ commands,
368
+ agents
369
+ )
370
+
371
+ // Write to the package directory
372
+ const claudePath = path.join(pkg.path, 'CLAUDE.md')
373
+ await this.writeWithPreservation(claudePath, content)
374
+ generatedFiles.push(path.relative(this.config.projectPath, claudePath))
375
+ }
376
+
377
+ return generatedFiles
378
+ }
379
+
380
+ /**
381
+ * Generate CLAUDE.md content for a specific package
382
+ */
383
+ private async generatePackageClaudeMd(
384
+ pkg: { name: string; path: string; relativePath: string },
385
+ resolvedCtx: { content: string; sources: string[]; overrides: string[] },
386
+ git: GitData,
387
+ stats: ProjectStats,
388
+ commands: Commands,
389
+ agents: AgentInfo[]
390
+ ): Promise<string> {
391
+ const workflowAgents = agents.filter((a) => a.type === 'workflow').map((a) => a.name)
392
+ const domainAgents = agents.filter((a) => a.type === 'domain').map((a) => a.name)
393
+
394
+ // Try to read package-specific info
395
+ let pkgVersion = stats.version
396
+ let pkgName = pkg.name
397
+ try {
398
+ const pkgJsonPath = path.join(pkg.path, 'package.json')
399
+ const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf-8'))
400
+ pkgVersion = pkgJson.version || stats.version
401
+ pkgName = pkgJson.name || pkg.name
402
+ } catch {
403
+ // Use defaults
404
+ }
405
+
406
+ return `# ${pkgName} - Package Rules
407
+ <!-- package: ${pkg.relativePath} -->
408
+ <!-- monorepo: ${stats.name} -->
409
+ <!-- Generated: ${dateHelper.getTimestamp()} -->
410
+ <!-- Sources: ${resolvedCtx.sources.join(' → ')} -->
411
+
412
+ ## THIS PACKAGE
413
+
414
+ **Name:** ${pkgName}
415
+ **Path:** ${pkg.relativePath}
416
+ **Version:** ${pkgVersion}
417
+ **Monorepo:** ${stats.name}
418
+
419
+ ---
420
+
421
+ ## INHERITED CONTEXT
422
+
423
+ ${resolvedCtx.content || '_No PRJCT.md rules defined_'}
424
+
425
+ ${resolvedCtx.overrides.length > 0 ? `\n**Overrides:** ${resolvedCtx.overrides.join(', ')}\n` : ''}
426
+
427
+ ---
428
+
429
+ ## COMMANDS
430
+
431
+ | Action | Command |
432
+ |--------|---------|
433
+ | Install | \`${commands.install}\` |
434
+ | Dev | \`${commands.dev}\` |
435
+ | Test | \`${commands.test}\` |
436
+ | Build | \`${commands.build}\` |
437
+
438
+ ---
439
+
440
+ ## PROJECT STATE
441
+
442
+ | Field | Value |
443
+ |-------|-------|
444
+ | Package | ${pkgName} |
445
+ | Monorepo | ${stats.name} |
446
+ | Branch | ${git.branch} |
447
+ | Ecosystem | ${stats.ecosystem} |
448
+
449
+ ---
450
+
451
+ ## AGENTS
452
+
453
+ Load from \`~/.prjct-cli/projects/${this.config.projectId}/agents/\`:
454
+
455
+ **Workflow**: ${workflowAgents.join(', ')}
456
+ **Domain**: ${domainAgents.join(', ') || 'none'}
457
+ `
458
+ }
328
459
  }
329
460
 
330
461
  export default ContextFileGenerator
@@ -32,6 +32,9 @@ export type { GitData } from './git-analyzer'
32
32
  // Git Analyzer - Extracted from sync-service (PRJ-85)
33
33
  export { GitAnalyzer, getEmptyGitData, gitAnalyzer } from './git-analyzer'
34
34
  export { MemoryService, memoryService } from './memory-service'
35
+ export type { ContextSection, NestedContext, ResolvedContext } from './nested-context-resolver'
36
+ // Nested Context Resolver - Monorepo PRJCT.md inheritance (PRJ-118)
37
+ export { NestedContextResolver } from './nested-context-resolver'
35
38
  export type { IndexOptions, RelevantContext, ScanResult } from './project-index'
36
39
  // Project Index - Persistent scanning with scoring
37
40
  export { createProjectIndexer, ProjectIndexer, RELEVANCE_THRESHOLD } from './project-index'