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.
- package/CHANGELOG.md +38 -0
- package/core/commands/analysis.ts +199 -83
- package/core/services/diff-generator.ts +356 -0
- package/core/services/sync-service.ts +6 -0
- package/core/utils/output.ts +113 -5
- package/dist/bin/prjct.mjs +707 -323
- package/package.json +1 -1
|
@@ -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
|
// ============================================================================
|
package/core/utils/output.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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
|
|