prjct-cli 0.49.0 → 0.51.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,356 @@
1
+ /**
2
+ * Diff Generator for Sync Preview
3
+ *
4
+ * Generates human-readable diffs between old and new context files.
5
+ * Shows what will change before sync applies.
6
+ *
7
+ * @see PRJ-125
8
+ * @module services/diff-generator
9
+ */
10
+
11
+ import chalk from 'chalk'
12
+
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+
17
+ export interface DiffSection {
18
+ name: string
19
+ type: 'added' | 'modified' | 'removed' | 'unchanged'
20
+ before?: string
21
+ after?: string
22
+ lineCount: number
23
+ }
24
+
25
+ export interface PreservedInfo {
26
+ name: string
27
+ lineCount: number
28
+ }
29
+
30
+ export interface SyncDiff {
31
+ hasChanges: boolean
32
+ added: DiffSection[]
33
+ modified: DiffSection[]
34
+ removed: DiffSection[]
35
+ preserved: PreservedInfo[]
36
+ tokensBefore: number
37
+ tokensAfter: number
38
+ tokenDelta: number
39
+ }
40
+
41
+ export interface DiffOptions {
42
+ showFullDiff?: boolean
43
+ colorize?: boolean
44
+ }
45
+
46
+ // =============================================================================
47
+ // Constants
48
+ // =============================================================================
49
+
50
+ const CHARS_PER_TOKEN = 4
51
+
52
+ // =============================================================================
53
+ // Token Estimation
54
+ // =============================================================================
55
+
56
+ /**
57
+ * Estimate token count for content
58
+ */
59
+ export function estimateTokens(content: string): number {
60
+ return Math.ceil(content.length / CHARS_PER_TOKEN)
61
+ }
62
+
63
+ // =============================================================================
64
+ // Section Parsing
65
+ // =============================================================================
66
+
67
+ interface ParsedSection {
68
+ name: string
69
+ content: string
70
+ startLine: number
71
+ endLine: number
72
+ }
73
+
74
+ /**
75
+ * Parse markdown into sections based on headers
76
+ */
77
+ export function parseMarkdownSections(content: string): ParsedSection[] {
78
+ const lines = content.split('\n')
79
+ const sections: ParsedSection[] = []
80
+ let currentSection: ParsedSection | null = null
81
+
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i]
84
+ const headerMatch = line.match(/^(#{1,3})\s+(.+)$/)
85
+
86
+ if (headerMatch) {
87
+ // Save previous section
88
+ if (currentSection) {
89
+ currentSection.endLine = i - 1
90
+ sections.push(currentSection)
91
+ }
92
+
93
+ // Start new section
94
+ currentSection = {
95
+ name: headerMatch[2].trim(),
96
+ content: line,
97
+ startLine: i,
98
+ endLine: i,
99
+ }
100
+ } else if (currentSection) {
101
+ currentSection.content += `\n${line}`
102
+ }
103
+ }
104
+
105
+ // Save last section
106
+ if (currentSection) {
107
+ currentSection.endLine = lines.length - 1
108
+ sections.push(currentSection)
109
+ }
110
+
111
+ return sections
112
+ }
113
+
114
+ /**
115
+ * Check if content is within a preserve block
116
+ */
117
+ function isPreservedSection(content: string): boolean {
118
+ return content.includes('<!-- prjct:preserve')
119
+ }
120
+
121
+ // =============================================================================
122
+ // Diff Generation
123
+ // =============================================================================
124
+
125
+ /**
126
+ * Generate diff between old and new content
127
+ */
128
+ export function generateSyncDiff(oldContent: string, newContent: string): SyncDiff {
129
+ const oldSections = parseMarkdownSections(oldContent)
130
+ const newSections = parseMarkdownSections(newContent)
131
+
132
+ const diff: SyncDiff = {
133
+ hasChanges: false,
134
+ added: [],
135
+ modified: [],
136
+ removed: [],
137
+ preserved: [],
138
+ tokensBefore: estimateTokens(oldContent),
139
+ tokensAfter: estimateTokens(newContent),
140
+ tokenDelta: 0,
141
+ }
142
+
143
+ diff.tokenDelta = diff.tokensAfter - diff.tokensBefore
144
+
145
+ // Create maps for quick lookup
146
+ const oldMap = new Map(oldSections.map((s) => [s.name.toLowerCase(), s]))
147
+ const newMap = new Map(newSections.map((s) => [s.name.toLowerCase(), s]))
148
+
149
+ // Find preserved sections in old content
150
+ for (const section of oldSections) {
151
+ if (isPreservedSection(section.content)) {
152
+ diff.preserved.push({
153
+ name: section.name,
154
+ lineCount: section.content.split('\n').length,
155
+ })
156
+ }
157
+ }
158
+
159
+ // Find added and modified sections
160
+ for (const newSection of newSections) {
161
+ const key = newSection.name.toLowerCase()
162
+ const oldSection = oldMap.get(key)
163
+
164
+ if (!oldSection) {
165
+ // New section
166
+ diff.added.push({
167
+ name: newSection.name,
168
+ type: 'added',
169
+ after: newSection.content,
170
+ lineCount: newSection.content.split('\n').length,
171
+ })
172
+ diff.hasChanges = true
173
+ } else if (oldSection.content.trim() !== newSection.content.trim()) {
174
+ // Modified section (skip if preserved)
175
+ if (!isPreservedSection(oldSection.content)) {
176
+ diff.modified.push({
177
+ name: newSection.name,
178
+ type: 'modified',
179
+ before: oldSection.content,
180
+ after: newSection.content,
181
+ lineCount: newSection.content.split('\n').length,
182
+ })
183
+ diff.hasChanges = true
184
+ }
185
+ }
186
+ }
187
+
188
+ // Find removed sections
189
+ for (const oldSection of oldSections) {
190
+ const key = oldSection.name.toLowerCase()
191
+ if (!newMap.has(key) && !isPreservedSection(oldSection.content)) {
192
+ diff.removed.push({
193
+ name: oldSection.name,
194
+ type: 'removed',
195
+ before: oldSection.content,
196
+ lineCount: oldSection.content.split('\n').length,
197
+ })
198
+ diff.hasChanges = true
199
+ }
200
+ }
201
+
202
+ return diff
203
+ }
204
+
205
+ // =============================================================================
206
+ // Diff Formatting
207
+ // =============================================================================
208
+
209
+ /**
210
+ * Format diff for terminal display
211
+ */
212
+ export function formatDiffPreview(diff: SyncDiff, options: DiffOptions = {}): string {
213
+ const { colorize = true } = options
214
+ const lines: string[] = []
215
+
216
+ const green = colorize ? chalk.green : (s: string) => s
217
+ const red = colorize ? chalk.red : (s: string) => s
218
+ const yellow = colorize ? chalk.yellow : (s: string) => s
219
+ const dim = colorize ? chalk.dim : (s: string) => s
220
+ const bold = colorize ? chalk.bold : (s: string) => s
221
+
222
+ if (!diff.hasChanges) {
223
+ lines.push(dim('No changes detected (context is up to date)'))
224
+ return lines.join('\n')
225
+ }
226
+
227
+ lines.push('')
228
+ lines.push(bold('📋 Changes to context files:'))
229
+ lines.push('')
230
+
231
+ // Added sections
232
+ if (diff.added.length > 0) {
233
+ for (const section of diff.added) {
234
+ lines.push(green(`+ │ + ${section.name} (new)`))
235
+ }
236
+ }
237
+
238
+ // Modified sections
239
+ if (diff.modified.length > 0) {
240
+ for (const section of diff.modified) {
241
+ lines.push(yellow(`~ │ ${section.name} (modified)`))
242
+ }
243
+ }
244
+
245
+ // Removed sections
246
+ if (diff.removed.length > 0) {
247
+ for (const section of diff.removed) {
248
+ lines.push(red(`- │ - ${section.name} (removed)`))
249
+ }
250
+ }
251
+
252
+ // Preserved sections
253
+ if (diff.preserved.length > 0) {
254
+ lines.push('')
255
+ lines.push(dim(' ## Your Customizations'))
256
+ for (const section of diff.preserved) {
257
+ lines.push(dim(` │ ✓ ${section.name} (${section.lineCount} lines preserved)`))
258
+ }
259
+ }
260
+
261
+ // Summary
262
+ lines.push('')
263
+ lines.push(dim('────────────────────────────────'))
264
+
265
+ const summaryParts: string[] = []
266
+ if (diff.added.length > 0) summaryParts.push(green(`+${diff.added.length} added`))
267
+ if (diff.modified.length > 0) summaryParts.push(yellow(`~${diff.modified.length} modified`))
268
+ if (diff.removed.length > 0) summaryParts.push(red(`-${diff.removed.length} removed`))
269
+
270
+ lines.push(`Summary: ${summaryParts.join(', ') || 'no changes'}`)
271
+
272
+ // Token delta
273
+ const tokenSign = diff.tokenDelta >= 0 ? '+' : ''
274
+ const tokenColor = diff.tokenDelta >= 0 ? green : red
275
+ lines.push(
276
+ `Tokens: ${diff.tokensBefore.toLocaleString()} → ${diff.tokensAfter.toLocaleString()} (${tokenColor(tokenSign + diff.tokenDelta.toLocaleString())})`
277
+ )
278
+
279
+ lines.push('')
280
+
281
+ return lines.join('\n')
282
+ }
283
+
284
+ /**
285
+ * Format full git-style diff
286
+ */
287
+ export function formatFullDiff(diff: SyncDiff, options: DiffOptions = {}): string {
288
+ const { colorize = true } = options
289
+ const lines: string[] = []
290
+
291
+ const green = colorize ? chalk.green : (s: string) => s
292
+ const red = colorize ? chalk.red : (s: string) => s
293
+ const cyan = colorize ? chalk.cyan : (s: string) => s
294
+ const dim = colorize ? chalk.dim : (s: string) => s
295
+
296
+ // Added sections
297
+ for (const section of diff.added) {
298
+ lines.push(cyan(`@@ +${section.name} @@`))
299
+ if (section.after) {
300
+ for (const line of section.after.split('\n')) {
301
+ lines.push(green(`+ ${line}`))
302
+ }
303
+ }
304
+ lines.push('')
305
+ }
306
+
307
+ // Modified sections
308
+ for (const section of diff.modified) {
309
+ lines.push(cyan(`@@ ${section.name} @@`))
310
+ if (section.before) {
311
+ for (const line of section.before.split('\n').slice(0, 5)) {
312
+ lines.push(red(`- ${line}`))
313
+ }
314
+ if (section.before.split('\n').length > 5) {
315
+ lines.push(dim(` ... ${section.before.split('\n').length - 5} more lines`))
316
+ }
317
+ }
318
+ if (section.after) {
319
+ for (const line of section.after.split('\n').slice(0, 5)) {
320
+ lines.push(green(`+ ${line}`))
321
+ }
322
+ if (section.after.split('\n').length > 5) {
323
+ lines.push(dim(` ... ${section.after.split('\n').length - 5} more lines`))
324
+ }
325
+ }
326
+ lines.push('')
327
+ }
328
+
329
+ // Removed sections
330
+ for (const section of diff.removed) {
331
+ lines.push(cyan(`@@ -${section.name} @@`))
332
+ if (section.before) {
333
+ for (const line of section.before.split('\n').slice(0, 5)) {
334
+ lines.push(red(`- ${line}`))
335
+ }
336
+ if (section.before.split('\n').length > 5) {
337
+ lines.push(dim(` ... ${section.before.split('\n').length - 5} more lines`))
338
+ }
339
+ }
340
+ lines.push('')
341
+ }
342
+
343
+ return lines.join('\n')
344
+ }
345
+
346
+ // =============================================================================
347
+ // Exports
348
+ // =============================================================================
349
+
350
+ export default {
351
+ generateSyncDiff,
352
+ formatDiffPreview,
353
+ formatFullDiff,
354
+ estimateTokens,
355
+ parseMarkdownSections,
356
+ }
@@ -31,6 +31,7 @@ import pathManager from '../infrastructure/path-manager'
31
31
  import { metricsStorage } from '../storage/metrics-storage'
32
32
  import dateHelper from '../utils/date-helper'
33
33
  import { ContextFileGenerator } from './context-generator'
34
+ import type { SyncDiff } from './diff-generator'
34
35
  import { type StackDetection, StackDetector } from './stack-detector'
35
36
 
36
37
  const execAsync = promisify(exec)
@@ -104,10 +105,15 @@ interface SyncResult {
104
105
  aiTools: AIToolResult[]
105
106
  syncMetrics?: SyncMetrics
106
107
  error?: string
108
+ // Preview mode fields
109
+ isPreview?: boolean
110
+ previewDiff?: SyncDiff
107
111
  }
108
112
 
109
113
  interface SyncOptions {
110
114
  aiTools?: string[] // AI tools to generate context for (default: claude, cursor)
115
+ preview?: boolean // If true, return diff without applying changes
116
+ skipConfirmation?: boolean // If true, apply changes without confirmation (--yes flag)
111
117
  }
112
118
 
113
119
  // ============================================================================
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Minimal Output System for prjct-cli
2
+ * Unified Output System for prjct-cli
3
3
  * Spinner while working → Single line result
4
4
  * With prjct branding
5
5
  *
6
6
  * Supports --quiet mode for CI/CD and scripting
7
+ *
8
+ * @see PRJ-130
7
9
  */
8
10
 
9
11
  import chalk from 'chalk'
@@ -14,6 +16,22 @@ import { getError } from './error-messages'
14
16
  const _FRAMES = branding.spinner.frames
15
17
  const SPEED = branding.spinner.speed
16
18
 
19
+ /**
20
+ * Centralized icons for consistent output
21
+ */
22
+ export const ICONS = {
23
+ success: chalk.green('✓'),
24
+ fail: chalk.red('✗'),
25
+ warn: chalk.yellow('⚠'),
26
+ info: chalk.blue('ℹ'),
27
+ debug: chalk.dim('🔧'),
28
+ bullet: chalk.dim('•'),
29
+ arrow: chalk.dim('→'),
30
+ check: chalk.green('✓'),
31
+ cross: chalk.red('✗'),
32
+ spinner: chalk.cyan('◐'),
33
+ } as const
34
+
17
35
  let interval: ReturnType<typeof setInterval> | null = null
18
36
  let frame = 0
19
37
 
@@ -57,6 +75,12 @@ interface Output {
57
75
  fail(msg: string): Output
58
76
  failWithHint(error: ErrorWithHint | ErrorCode): Output
59
77
  warn(msg: string): Output
78
+ info(msg: string): Output
79
+ debug(msg: string): Output
80
+ success(msg: string, metrics?: OutputMetrics): Output
81
+ list(items: string[], options?: { bullet?: string; indent?: number }): Output
82
+ table(rows: Array<Record<string, string | number>>, options?: { header?: boolean }): Output
83
+ box(title: string, content: string): Output
60
84
  stop(): Output
61
85
  step(current: number, total: number, msg: string): Output
62
86
  progress(current: number, total: number, msg?: string): Output
@@ -99,7 +123,7 @@ const out: Output = {
99
123
  suffix = chalk.dim(` [${parts.join(' | ')}]`)
100
124
  }
101
125
  }
102
- console.log(`${chalk.green('✓')} ${truncate(msg, 50)}${suffix}`)
126
+ console.log(`${ICONS.success} ${truncate(msg, 50)}${suffix}`)
103
127
  }
104
128
  return this
105
129
  },
@@ -107,7 +131,7 @@ const out: Output = {
107
131
  // Errors go to stderr even in quiet mode
108
132
  fail(msg: string) {
109
133
  this.stop()
110
- console.error(`${chalk.red('✗')} ${truncate(msg, 65)}`)
134
+ console.error(`${ICONS.fail} ${truncate(msg, 65)}`)
111
135
  return this
112
136
  },
113
137
 
@@ -116,7 +140,7 @@ const out: Output = {
116
140
  this.stop()
117
141
  const err = typeof error === 'string' ? getError(error) : error
118
142
  console.error()
119
- console.error(`${chalk.red('✗')} ${err.message}`)
143
+ console.error(`${ICONS.fail} ${err.message}`)
120
144
  if (err.file) {
121
145
  console.error(chalk.dim(` File: ${err.file}`))
122
146
  }
@@ -132,7 +156,91 @@ const out: Output = {
132
156
 
133
157
  warn(msg: string) {
134
158
  this.stop()
135
- if (!quietMode) console.log(`${chalk.yellow('⚠')} ${truncate(msg, 65)}`)
159
+ if (!quietMode) console.log(`${ICONS.warn} ${truncate(msg, 65)}`)
160
+ return this
161
+ },
162
+
163
+ // Informational message
164
+ info(msg: string) {
165
+ this.stop()
166
+ if (!quietMode) console.log(`${ICONS.info} ${msg}`)
167
+ return this
168
+ },
169
+
170
+ // Debug message (only if DEBUG=1 or DEBUG=true)
171
+ debug(msg: string) {
172
+ this.stop()
173
+ const debugEnabled = process.env.DEBUG === '1' || process.env.DEBUG === 'true'
174
+ if (!quietMode && debugEnabled) {
175
+ console.log(`${ICONS.debug} ${chalk.dim(msg)}`)
176
+ }
177
+ return this
178
+ },
179
+
180
+ // Alias for done - explicit success indicator
181
+ success(msg: string, metrics?: OutputMetrics) {
182
+ return this.done(msg, metrics)
183
+ },
184
+
185
+ // Bulleted list
186
+ list(items: string[], options: { bullet?: string; indent?: number } = {}) {
187
+ this.stop()
188
+ if (quietMode) return this
189
+ const bullet = options.bullet || ICONS.bullet
190
+ const indent = ' '.repeat(options.indent || 0)
191
+ for (const item of items) {
192
+ console.log(`${indent}${bullet} ${item}`)
193
+ }
194
+ return this
195
+ },
196
+
197
+ // Simple table output
198
+ table(rows: Array<Record<string, string | number>>, options: { header?: boolean } = {}) {
199
+ this.stop()
200
+ if (quietMode || rows.length === 0) return this
201
+
202
+ const keys = Object.keys(rows[0])
203
+ const colWidths: Record<string, number> = {}
204
+
205
+ // Calculate column widths
206
+ for (const key of keys) {
207
+ colWidths[key] = key.length
208
+ for (const row of rows) {
209
+ const val = String(row[key] ?? '')
210
+ if (val.length > colWidths[key]) colWidths[key] = val.length
211
+ }
212
+ }
213
+
214
+ // Print header if requested
215
+ if (options.header !== false) {
216
+ const headerLine = keys.map((k) => k.padEnd(colWidths[k])).join(' ')
217
+ console.log(chalk.dim(headerLine))
218
+ console.log(chalk.dim('─'.repeat(headerLine.length)))
219
+ }
220
+
221
+ // Print rows
222
+ for (const row of rows) {
223
+ const line = keys.map((k) => String(row[k] ?? '').padEnd(colWidths[k])).join(' ')
224
+ console.log(line)
225
+ }
226
+ return this
227
+ },
228
+
229
+ // Boxed content
230
+ box(title: string, content: string) {
231
+ this.stop()
232
+ if (quietMode) return this
233
+ const lines = content.split('\n')
234
+ const maxLen = Math.max(title.length, ...lines.map((l) => l.length))
235
+ const border = '─'.repeat(maxLen + 2)
236
+
237
+ console.log(chalk.dim(`┌${border}┐`))
238
+ console.log(chalk.dim('│') + ` ${chalk.bold(title.padEnd(maxLen))} ` + chalk.dim('│'))
239
+ console.log(chalk.dim(`├${border}┤`))
240
+ for (const line of lines) {
241
+ console.log(chalk.dim('│') + ` ${line.padEnd(maxLen)} ` + chalk.dim('│'))
242
+ }
243
+ console.log(chalk.dim(`└${border}┘`))
136
244
  return this
137
245
  },
138
246