prjct-cli 1.1.0 → 1.2.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,676 @@
1
+ /**
2
+ * HooksService - Git hooks integration for auto-sync
3
+ *
4
+ * Manages git hooks that automatically sync prjct context on
5
+ * commit and checkout. Supports multiple hook managers:
6
+ * - lefthook
7
+ * - husky
8
+ * - direct .git/hooks/ scripts
9
+ *
10
+ * @see PRJ-128
11
+ * @module services/hooks-service
12
+ */
13
+
14
+ import { execSync } from 'node:child_process'
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import chalk from 'chalk'
18
+ import configManager from '../infrastructure/config-manager'
19
+ import out from '../utils/output'
20
+
21
+ // ============================================================================
22
+ // TYPES
23
+ // ============================================================================
24
+
25
+ export type HookStrategy = 'lefthook' | 'husky' | 'direct'
26
+ export type HookName = 'post-commit' | 'post-checkout'
27
+
28
+ interface HookConfig {
29
+ enabled: boolean
30
+ strategy: HookStrategy
31
+ hooks: HookName[]
32
+ installedAt?: string
33
+ }
34
+
35
+ interface HooksStatusResult {
36
+ installed: boolean
37
+ strategy: HookStrategy | null
38
+ hooks: Array<{
39
+ name: HookName
40
+ installed: boolean
41
+ path: string
42
+ }>
43
+ detectedManagers: HookStrategy[]
44
+ }
45
+
46
+ interface HooksInstallResult {
47
+ success: boolean
48
+ strategy: HookStrategy
49
+ hooksInstalled: HookName[]
50
+ error?: string
51
+ }
52
+
53
+ // ============================================================================
54
+ // HOOK SCRIPT TEMPLATES
55
+ // ============================================================================
56
+
57
+ /**
58
+ * Shell script for post-commit hook
59
+ * Runs prjct sync in quiet mode with rate limiting
60
+ */
61
+ function getPostCommitScript(): string {
62
+ return `#!/bin/sh
63
+ # prjct auto-sync hook (post-commit)
64
+ # Syncs project context after each commit
65
+ # Installed by: prjct hooks install
66
+
67
+ # Rate limit: skip if synced within last 30 seconds
68
+ LOCK_FILE="\${TMPDIR:-/tmp}/prjct-sync-$(pwd | md5sum 2>/dev/null | cut -d' ' -f1 || md5 -q -s "$(pwd)").lock"
69
+ if [ -f "$LOCK_FILE" ]; then
70
+ LOCK_AGE=$(( $(date +%s) - $(stat -f%m "$LOCK_FILE" 2>/dev/null || stat -c%Y "$LOCK_FILE" 2>/dev/null || echo 0) ))
71
+ if [ "$LOCK_AGE" -lt 30 ]; then
72
+ exit 0
73
+ fi
74
+ fi
75
+
76
+ # Run sync in background, suppress all output
77
+ if command -v prjct >/dev/null 2>&1; then
78
+ touch "$LOCK_FILE"
79
+ prjct sync --quiet --yes >/dev/null 2>&1 &
80
+ fi
81
+
82
+ exit 0
83
+ `
84
+ }
85
+
86
+ /**
87
+ * Shell script for post-checkout hook
88
+ * Syncs project context after branch switch
89
+ */
90
+ function getPostCheckoutScript(): string {
91
+ return `#!/bin/sh
92
+ # prjct auto-sync hook (post-checkout)
93
+ # Syncs project context after branch switch
94
+ # Installed by: prjct hooks install
95
+
96
+ # Only run on branch checkout (not file checkout)
97
+ # $3 is the checkout type flag: 1 = branch, 0 = file
98
+ if [ "$3" != "1" ]; then
99
+ exit 0
100
+ fi
101
+
102
+ # Skip if old and new refs are the same (no actual branch change)
103
+ if [ "$1" = "$2" ]; then
104
+ exit 0
105
+ fi
106
+
107
+ # Rate limit: skip if synced within last 30 seconds
108
+ LOCK_FILE="\${TMPDIR:-/tmp}/prjct-sync-$(pwd | md5sum 2>/dev/null | cut -d' ' -f1 || md5 -q -s "$(pwd)").lock"
109
+ if [ -f "$LOCK_FILE" ]; then
110
+ LOCK_AGE=$(( $(date +%s) - $(stat -f%m "$LOCK_FILE" 2>/dev/null || stat -c%Y "$LOCK_FILE" 2>/dev/null || echo 0) ))
111
+ if [ "$LOCK_AGE" -lt 30 ]; then
112
+ exit 0
113
+ fi
114
+ fi
115
+
116
+ # Run sync in background, suppress all output
117
+ if command -v prjct >/dev/null 2>&1; then
118
+ touch "$LOCK_FILE"
119
+ prjct sync --quiet --yes >/dev/null 2>&1 &
120
+ fi
121
+
122
+ exit 0
123
+ `
124
+ }
125
+
126
+ // ============================================================================
127
+ // HOOK MANAGER DETECTION
128
+ // ============================================================================
129
+
130
+ /**
131
+ * Detect which hook managers are available in the project
132
+ */
133
+ function detectHookManagers(projectPath: string): HookStrategy[] {
134
+ const detected: HookStrategy[] = []
135
+
136
+ // Check for lefthook
137
+ if (
138
+ fs.existsSync(path.join(projectPath, 'lefthook.yml')) ||
139
+ fs.existsSync(path.join(projectPath, 'lefthook.yaml'))
140
+ ) {
141
+ detected.push('lefthook')
142
+ }
143
+
144
+ // Check for husky
145
+ if (
146
+ fs.existsSync(path.join(projectPath, '.husky')) ||
147
+ fs.existsSync(path.join(projectPath, '.husky', '_'))
148
+ ) {
149
+ detected.push('husky')
150
+ }
151
+
152
+ // Direct .git/hooks is always available if it's a git repo
153
+ if (fs.existsSync(path.join(projectPath, '.git'))) {
154
+ detected.push('direct')
155
+ }
156
+
157
+ return detected
158
+ }
159
+
160
+ /**
161
+ * Select the best hook strategy based on what's available
162
+ */
163
+ function selectStrategy(detected: HookStrategy[]): HookStrategy {
164
+ // Prefer managed hook tools over direct
165
+ if (detected.includes('lefthook')) return 'lefthook'
166
+ if (detected.includes('husky')) return 'husky'
167
+ return 'direct'
168
+ }
169
+
170
+ // ============================================================================
171
+ // INSTALLATION STRATEGIES
172
+ // ============================================================================
173
+
174
+ /**
175
+ * Install hooks via lefthook (append to existing config)
176
+ */
177
+ function installLefthook(projectPath: string, hooks: HookName[]): boolean {
178
+ const configFile = fs.existsSync(path.join(projectPath, 'lefthook.yml'))
179
+ ? 'lefthook.yml'
180
+ : 'lefthook.yaml'
181
+ const configPath = path.join(projectPath, configFile)
182
+
183
+ let content = fs.readFileSync(configPath, 'utf-8')
184
+
185
+ for (const hook of hooks) {
186
+ const sectionName = hook // e.g. "post-commit"
187
+ const commandName = `prjct-sync-${hook}`
188
+
189
+ // Check if already configured
190
+ if (content.includes(commandName)) {
191
+ continue
192
+ }
193
+
194
+ const hookBlock = `
195
+ ${sectionName}:
196
+ commands:
197
+ ${commandName}:
198
+ run: prjct sync --quiet --yes
199
+ fail_text: "prjct sync failed (non-blocking)"
200
+ `
201
+
202
+ // If the hook section already exists, add command to it
203
+ const sectionRegex = new RegExp(`^${sectionName}:\\s*$`, 'm')
204
+ if (sectionRegex.test(content)) {
205
+ // Insert command into existing section
206
+ content = content.replace(
207
+ sectionRegex,
208
+ `${sectionName}:\n commands:\n ${commandName}:\n run: prjct sync --quiet --yes\n fail_text: "prjct sync failed (non-blocking)"`
209
+ )
210
+ } else {
211
+ // Append new section
212
+ content = content.trimEnd() + '\n' + hookBlock
213
+ }
214
+ }
215
+
216
+ fs.writeFileSync(configPath, content, 'utf-8')
217
+ return true
218
+ }
219
+
220
+ /**
221
+ * Install hooks via husky
222
+ */
223
+ function installHusky(projectPath: string, hooks: HookName[]): boolean {
224
+ const huskyDir = path.join(projectPath, '.husky')
225
+
226
+ for (const hook of hooks) {
227
+ const hookPath = path.join(huskyDir, hook)
228
+ const script = hook === 'post-commit' ? getPostCommitScript() : getPostCheckoutScript()
229
+
230
+ if (fs.existsSync(hookPath)) {
231
+ // Append to existing hook if not already present
232
+ const existing = fs.readFileSync(hookPath, 'utf-8')
233
+ if (existing.includes('prjct sync')) {
234
+ continue
235
+ }
236
+ fs.appendFileSync(hookPath, '\n# prjct auto-sync\nprjct sync --quiet --yes &\n')
237
+ } else {
238
+ fs.writeFileSync(hookPath, script, { mode: 0o755 })
239
+ }
240
+ }
241
+
242
+ return true
243
+ }
244
+
245
+ /**
246
+ * Install hooks directly into .git/hooks/
247
+ */
248
+ function installDirect(projectPath: string, hooks: HookName[]): boolean {
249
+ const hooksDir = path.join(projectPath, '.git', 'hooks')
250
+
251
+ if (!fs.existsSync(hooksDir)) {
252
+ fs.mkdirSync(hooksDir, { recursive: true })
253
+ }
254
+
255
+ for (const hook of hooks) {
256
+ const hookPath = path.join(hooksDir, hook)
257
+ const script = hook === 'post-commit' ? getPostCommitScript() : getPostCheckoutScript()
258
+
259
+ if (fs.existsSync(hookPath)) {
260
+ const existing = fs.readFileSync(hookPath, 'utf-8')
261
+ if (existing.includes('prjct sync')) {
262
+ continue // Already installed
263
+ }
264
+ // Append to existing hook
265
+ fs.appendFileSync(hookPath, '\n# prjct auto-sync\n' + script.split('\n').slice(1).join('\n'))
266
+ } else {
267
+ fs.writeFileSync(hookPath, script, { mode: 0o755 })
268
+ }
269
+ }
270
+
271
+ return true
272
+ }
273
+
274
+ // ============================================================================
275
+ // UNINSTALL STRATEGIES
276
+ // ============================================================================
277
+
278
+ function uninstallLefthook(projectPath: string): boolean {
279
+ const configFile = fs.existsSync(path.join(projectPath, 'lefthook.yml'))
280
+ ? 'lefthook.yml'
281
+ : 'lefthook.yaml'
282
+ const configPath = path.join(projectPath, configFile)
283
+
284
+ if (!fs.existsSync(configPath)) return false
285
+
286
+ let content = fs.readFileSync(configPath, 'utf-8')
287
+
288
+ // Remove prjct-sync commands
289
+ content = content.replace(/\s*prjct-sync-[\w-]+:[\s\S]*?(?=\n\S|\n*$)/g, '')
290
+
291
+ // Clean up empty sections
292
+ content = content.replace(/^(post-commit|post-checkout):\s*commands:\s*$/gm, '')
293
+
294
+ fs.writeFileSync(configPath, content.trimEnd() + '\n', 'utf-8')
295
+ return true
296
+ }
297
+
298
+ function uninstallHusky(projectPath: string): boolean {
299
+ const huskyDir = path.join(projectPath, '.husky')
300
+
301
+ for (const hook of ['post-commit', 'post-checkout'] as HookName[]) {
302
+ const hookPath = path.join(huskyDir, hook)
303
+ if (!fs.existsSync(hookPath)) continue
304
+
305
+ const content = fs.readFileSync(hookPath, 'utf-8')
306
+ if (!content.includes('prjct sync')) continue
307
+
308
+ // Remove prjct lines
309
+ const cleaned = content
310
+ .split('\n')
311
+ .filter((line) => !line.includes('prjct sync') && !line.includes('prjct auto-sync'))
312
+ .join('\n')
313
+
314
+ if (cleaned.trim() === '#!/bin/sh' || cleaned.trim() === '#!/usr/bin/env sh') {
315
+ // Hook is now empty, remove it
316
+ fs.unlinkSync(hookPath)
317
+ } else {
318
+ fs.writeFileSync(hookPath, cleaned, { mode: 0o755 })
319
+ }
320
+ }
321
+
322
+ return true
323
+ }
324
+
325
+ function uninstallDirect(projectPath: string): boolean {
326
+ const hooksDir = path.join(projectPath, '.git', 'hooks')
327
+
328
+ for (const hook of ['post-commit', 'post-checkout'] as HookName[]) {
329
+ const hookPath = path.join(hooksDir, hook)
330
+ if (!fs.existsSync(hookPath)) continue
331
+
332
+ const content = fs.readFileSync(hookPath, 'utf-8')
333
+ if (!content.includes('prjct sync')) continue
334
+
335
+ if (content.includes('Installed by: prjct hooks install')) {
336
+ // Entirely ours, remove it
337
+ fs.unlinkSync(hookPath)
338
+ } else {
339
+ // Shared hook, just remove our lines
340
+ const cleaned = content
341
+ .split('\n')
342
+ .filter((line) => !line.includes('prjct sync') && !line.includes('prjct auto-sync'))
343
+ .join('\n')
344
+ fs.writeFileSync(hookPath, cleaned, { mode: 0o755 })
345
+ }
346
+ }
347
+
348
+ return true
349
+ }
350
+
351
+ // ============================================================================
352
+ // HOOKS SERVICE
353
+ // ============================================================================
354
+
355
+ class HooksService {
356
+ /**
357
+ * Install git hooks for auto-sync
358
+ */
359
+ async install(
360
+ projectPath: string,
361
+ options: { strategy?: HookStrategy; hooks?: HookName[] } = {}
362
+ ): Promise<HooksInstallResult> {
363
+ const hooks: HookName[] = options.hooks || ['post-commit', 'post-checkout']
364
+
365
+ // Detect available managers
366
+ const detected = detectHookManagers(projectPath)
367
+
368
+ if (detected.length === 0) {
369
+ return {
370
+ success: false,
371
+ strategy: 'direct',
372
+ hooksInstalled: [],
373
+ error: 'Not a git repository. Run "git init" first.',
374
+ }
375
+ }
376
+
377
+ const strategy = options.strategy || selectStrategy(detected)
378
+
379
+ try {
380
+ let success = false
381
+
382
+ switch (strategy) {
383
+ case 'lefthook':
384
+ success = installLefthook(projectPath, hooks)
385
+ break
386
+ case 'husky':
387
+ success = installHusky(projectPath, hooks)
388
+ break
389
+ case 'direct':
390
+ success = installDirect(projectPath, hooks)
391
+ break
392
+ }
393
+
394
+ if (success) {
395
+ // Save hook config to project.json
396
+ await this.saveHookConfig(projectPath, {
397
+ enabled: true,
398
+ strategy,
399
+ hooks,
400
+ installedAt: new Date().toISOString(),
401
+ })
402
+ }
403
+
404
+ return {
405
+ success,
406
+ strategy,
407
+ hooksInstalled: success ? hooks : [],
408
+ }
409
+ } catch (error) {
410
+ return {
411
+ success: false,
412
+ strategy,
413
+ hooksInstalled: [],
414
+ error: (error as Error).message,
415
+ }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Uninstall git hooks
421
+ */
422
+ async uninstall(projectPath: string): Promise<{ success: boolean; error?: string }> {
423
+ try {
424
+ // Read current config to determine strategy
425
+ const config = await this.getHookConfig(projectPath)
426
+ const strategy = config?.strategy || 'direct'
427
+
428
+ let success = false
429
+
430
+ switch (strategy) {
431
+ case 'lefthook':
432
+ success = uninstallLefthook(projectPath)
433
+ break
434
+ case 'husky':
435
+ success = uninstallHusky(projectPath)
436
+ break
437
+ case 'direct':
438
+ success = uninstallDirect(projectPath)
439
+ break
440
+ }
441
+
442
+ // Clear hook config
443
+ if (success) {
444
+ await this.saveHookConfig(projectPath, {
445
+ enabled: false,
446
+ strategy,
447
+ hooks: [],
448
+ })
449
+ }
450
+
451
+ return { success }
452
+ } catch (error) {
453
+ return { success: false, error: (error as Error).message }
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Get hook installation status
459
+ */
460
+ async status(projectPath: string): Promise<HooksStatusResult> {
461
+ const detected = detectHookManagers(projectPath)
462
+ const config = await this.getHookConfig(projectPath)
463
+
464
+ const hookNames: HookName[] = ['post-commit', 'post-checkout']
465
+ const hooks = hookNames.map((name) => ({
466
+ name,
467
+ installed: this.isHookInstalled(projectPath, name, config?.strategy || null),
468
+ path: this.getHookPath(projectPath, name, config?.strategy || null),
469
+ }))
470
+
471
+ return {
472
+ installed: hooks.some((h) => h.installed),
473
+ strategy: config?.strategy || null,
474
+ hooks,
475
+ detectedManagers: detected,
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Run the hooks CLI command
481
+ */
482
+ async run(projectPath: string, subcommand: string): Promise<number> {
483
+ const projectId = await configManager.getProjectId(projectPath)
484
+
485
+ if (!projectId) {
486
+ console.error('No prjct project found. Run "prjct init" first.')
487
+ return 1
488
+ }
489
+
490
+ switch (subcommand) {
491
+ case 'install':
492
+ return this.runInstall(projectPath)
493
+ case 'uninstall':
494
+ return this.runUninstall(projectPath)
495
+ case 'status':
496
+ return this.runStatus(projectPath)
497
+ default:
498
+ return this.runStatus(projectPath)
499
+ }
500
+ }
501
+
502
+ // ==========================================================================
503
+ // CLI SUBCOMMANDS
504
+ // ==========================================================================
505
+
506
+ private async runInstall(projectPath: string): Promise<number> {
507
+ out.start()
508
+ out.section('Git Hooks Installation')
509
+
510
+ const detected = detectHookManagers(projectPath)
511
+ const strategy = selectStrategy(detected)
512
+
513
+ console.log(` Strategy: ${chalk.cyan(strategy)}`)
514
+ console.log(` Hooks: ${chalk.dim('post-commit, post-checkout')}`)
515
+ console.log('')
516
+
517
+ const result = await this.install(projectPath, { strategy })
518
+
519
+ if (result.success) {
520
+ out.done(`Hooks installed via ${result.strategy}`)
521
+ console.log('')
522
+ for (const hook of result.hooksInstalled) {
523
+ console.log(` ${chalk.green('✓')} ${hook}`)
524
+ }
525
+ console.log('')
526
+ console.log(chalk.dim(' Context will auto-sync on commit and branch switch.'))
527
+ console.log(chalk.dim(' Remove with: prjct hooks uninstall'))
528
+ } else {
529
+ out.fail(result.error || 'Failed to install hooks')
530
+ }
531
+
532
+ console.log('')
533
+ out.end()
534
+ return result.success ? 0 : 1
535
+ }
536
+
537
+ private async runUninstall(projectPath: string): Promise<number> {
538
+ out.start()
539
+ out.section('Git Hooks Removal')
540
+
541
+ const result = await this.uninstall(projectPath)
542
+
543
+ if (result.success) {
544
+ out.done('Hooks removed')
545
+ } else {
546
+ out.fail(result.error || 'Failed to remove hooks')
547
+ }
548
+
549
+ console.log('')
550
+ out.end()
551
+ return result.success ? 0 : 1
552
+ }
553
+
554
+ private async runStatus(projectPath: string): Promise<number> {
555
+ out.start()
556
+ out.section('Git Hooks Status')
557
+
558
+ const status = await this.status(projectPath)
559
+
560
+ if (status.installed) {
561
+ console.log(` Status: ${chalk.green('Active')}`)
562
+ console.log(` Strategy: ${chalk.cyan(status.strategy)}`)
563
+ } else {
564
+ console.log(` Status: ${chalk.dim('Not installed')}`)
565
+ }
566
+
567
+ console.log('')
568
+ for (const hook of status.hooks) {
569
+ const icon = hook.installed ? chalk.green('✓') : chalk.dim('○')
570
+ const label = hook.installed ? hook.name : chalk.dim(hook.name)
571
+ console.log(` ${icon} ${label}`)
572
+ }
573
+
574
+ if (status.detectedManagers.length > 0) {
575
+ console.log('')
576
+ console.log(` ${chalk.dim('Available managers:')} ${status.detectedManagers.join(', ')}`)
577
+ }
578
+
579
+ if (!status.installed) {
580
+ console.log('')
581
+ console.log(chalk.dim(' Install with: prjct hooks install'))
582
+ }
583
+
584
+ console.log('')
585
+ out.end()
586
+ return 0
587
+ }
588
+
589
+ // ==========================================================================
590
+ // HELPERS
591
+ // ==========================================================================
592
+
593
+ private isHookInstalled(
594
+ projectPath: string,
595
+ hook: HookName,
596
+ strategy: HookStrategy | null
597
+ ): boolean {
598
+ if (strategy === 'lefthook') {
599
+ const configFile = fs.existsSync(path.join(projectPath, 'lefthook.yml'))
600
+ ? 'lefthook.yml'
601
+ : 'lefthook.yaml'
602
+ const configPath = path.join(projectPath, configFile)
603
+ if (!fs.existsSync(configPath)) return false
604
+ const content = fs.readFileSync(configPath, 'utf-8')
605
+ return content.includes(`prjct-sync-${hook}`)
606
+ }
607
+
608
+ if (strategy === 'husky') {
609
+ const hookPath = path.join(projectPath, '.husky', hook)
610
+ if (!fs.existsSync(hookPath)) return false
611
+ return fs.readFileSync(hookPath, 'utf-8').includes('prjct sync')
612
+ }
613
+
614
+ // Direct
615
+ const hookPath = path.join(projectPath, '.git', 'hooks', hook)
616
+ if (!fs.existsSync(hookPath)) return false
617
+ return fs.readFileSync(hookPath, 'utf-8').includes('prjct sync')
618
+ }
619
+
620
+ private getHookPath(projectPath: string, hook: HookName, strategy: HookStrategy | null): string {
621
+ if (strategy === 'lefthook') {
622
+ return fs.existsSync(path.join(projectPath, 'lefthook.yml'))
623
+ ? 'lefthook.yml'
624
+ : 'lefthook.yaml'
625
+ }
626
+ if (strategy === 'husky') {
627
+ return `.husky/${hook}`
628
+ }
629
+ return `.git/hooks/${hook}`
630
+ }
631
+
632
+ private async getHookConfig(projectPath: string): Promise<HookConfig | null> {
633
+ const projectId = await configManager.getProjectId(projectPath)
634
+ if (!projectId) return null
635
+
636
+ try {
637
+ const projectJsonPath = path.join(
638
+ process.env.HOME || '',
639
+ '.prjct-cli',
640
+ 'projects',
641
+ projectId,
642
+ 'project.json'
643
+ )
644
+ if (!fs.existsSync(projectJsonPath)) return null
645
+ const project = JSON.parse(fs.readFileSync(projectJsonPath, 'utf-8'))
646
+ return project.hooks || null
647
+ } catch {
648
+ return null
649
+ }
650
+ }
651
+
652
+ private async saveHookConfig(projectPath: string, config: HookConfig): Promise<void> {
653
+ const projectId = await configManager.getProjectId(projectPath)
654
+ if (!projectId) return
655
+
656
+ try {
657
+ const projectJsonPath = path.join(
658
+ process.env.HOME || '',
659
+ '.prjct-cli',
660
+ 'projects',
661
+ projectId,
662
+ 'project.json'
663
+ )
664
+ if (!fs.existsSync(projectJsonPath)) return
665
+
666
+ const project = JSON.parse(fs.readFileSync(projectJsonPath, 'utf-8'))
667
+ project.hooks = config
668
+ fs.writeFileSync(projectJsonPath, JSON.stringify(project, null, 2))
669
+ } catch {
670
+ // Non-fatal
671
+ }
672
+ }
673
+ }
674
+
675
+ export const hooksService = new HooksService()
676
+ export default { hooksService }
@@ -195,23 +195,35 @@ export class StalenessChecker {
195
195
  lines.push('CLAUDE.md status: ✓ Fresh')
196
196
  }
197
197
 
198
- lines.push('─────────────────────────────')
199
-
198
+ // Build key-value table content
199
+ const details: string[] = []
200
200
  if (status.lastSyncCommit) {
201
- lines.push(`Last sync: ${status.lastSyncCommit}`)
201
+ details.push(`Last sync: ${status.lastSyncCommit}`)
202
202
  }
203
203
  if (status.currentCommit) {
204
- lines.push(`Current: ${status.currentCommit}`)
204
+ details.push(`Current: ${status.currentCommit}`)
205
205
  }
206
206
  if (status.commitsSinceSync > 0) {
207
- lines.push(`Commits since: ${status.commitsSinceSync}`)
207
+ details.push(`Commits since: ${status.commitsSinceSync}`)
208
208
  }
209
209
  if (status.daysSinceSync > 0) {
210
- lines.push(`Days since: ${status.daysSinceSync}`)
210
+ details.push(`Days since: ${status.daysSinceSync}`)
211
211
  }
212
212
  if (status.changedFiles.length > 0) {
213
- lines.push(`Files changed: ${status.changedFiles.length}`)
213
+ details.push(`Files changed: ${status.changedFiles.length}`)
214
214
  }
215
+
216
+ // Wrap details in a box
217
+ if (details.length > 0) {
218
+ const maxLen = Math.max(...details.map((l) => l.length))
219
+ const border = '─'.repeat(maxLen + 2)
220
+ lines.push(`┌${border}┐`)
221
+ for (const detail of details) {
222
+ lines.push(`│ ${detail.padEnd(maxLen)} │`)
223
+ }
224
+ lines.push(`└${border}┘`)
225
+ }
226
+
215
227
  if (status.significantChanges.length > 0) {
216
228
  lines.push(``)
217
229
  lines.push(`Significant changes:`)