@mandujs/cli 0.10.0 → 0.12.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,354 @@
1
+ /**
2
+ * DNA-011: ANSI-aware Table Rendering
3
+ *
4
+ * 터미널 테이블 렌더링
5
+ * - ANSI 색상 코드 인식 (너비 계산에서 제외)
6
+ * - 유니코드 박스 문자 지원
7
+ * - 반응형 너비 조정 (flex 컬럼)
8
+ */
9
+
10
+ import { stripAnsi } from "./theme.js";
11
+
12
+ /**
13
+ * 테이블 컬럼 정의
14
+ */
15
+ export interface TableColumn {
16
+ /** 데이터 키 */
17
+ key: string;
18
+ /** 헤더 텍스트 */
19
+ header: string;
20
+ /** 정렬 (기본: left) */
21
+ align?: "left" | "right" | "center";
22
+ /** 최소 너비 */
23
+ minWidth?: number;
24
+ /** 최대 너비 */
25
+ maxWidth?: number;
26
+ /** 반응형 너비 조정 대상 */
27
+ flex?: boolean;
28
+ }
29
+
30
+ /**
31
+ * 테두리 스타일
32
+ */
33
+ export type BorderStyle = "unicode" | "ascii" | "none";
34
+
35
+ /**
36
+ * 테이블 렌더링 옵션
37
+ */
38
+ export interface RenderTableOptions {
39
+ /** 컬럼 정의 */
40
+ columns: TableColumn[];
41
+ /** 데이터 행 */
42
+ rows: Record<string, unknown>[];
43
+ /** 테두리 스타일 (기본: unicode) */
44
+ border?: BorderStyle;
45
+ /** 최대 테이블 너비 */
46
+ maxWidth?: number;
47
+ /** 헤더 표시 여부 (기본: true) */
48
+ showHeader?: boolean;
49
+ /** 컴팩트 모드 (패딩 최소화) */
50
+ compact?: boolean;
51
+ }
52
+
53
+ /**
54
+ * 박스 그리기 문자
55
+ */
56
+ interface BoxChars {
57
+ tl: string; // top-left
58
+ tr: string; // top-right
59
+ bl: string; // bottom-left
60
+ br: string; // bottom-right
61
+ h: string; // horizontal
62
+ v: string; // vertical
63
+ t: string; // top junction
64
+ b: string; // bottom junction
65
+ ml: string; // middle-left
66
+ mr: string; // middle-right
67
+ m: string; // middle junction
68
+ }
69
+
70
+ const UNICODE_BOX: BoxChars = {
71
+ tl: "┌",
72
+ tr: "┐",
73
+ bl: "└",
74
+ br: "┘",
75
+ h: "─",
76
+ v: "│",
77
+ t: "┬",
78
+ b: "┴",
79
+ ml: "├",
80
+ mr: "┤",
81
+ m: "┼",
82
+ };
83
+
84
+ const ASCII_BOX: BoxChars = {
85
+ tl: "+",
86
+ tr: "+",
87
+ bl: "+",
88
+ br: "+",
89
+ h: "-",
90
+ v: "|",
91
+ t: "+",
92
+ b: "+",
93
+ ml: "+",
94
+ mr: "+",
95
+ m: "+",
96
+ };
97
+
98
+ /**
99
+ * ANSI 코드를 제외한 실제 표시 너비 계산
100
+ */
101
+ function displayWidth(str: string): number {
102
+ return stripAnsi(str).length;
103
+ }
104
+
105
+ /**
106
+ * 문자열 패딩 (ANSI 인식)
107
+ */
108
+ function padString(
109
+ str: string,
110
+ width: number,
111
+ align: "left" | "right" | "center" = "left"
112
+ ): string {
113
+ const visibleLen = displayWidth(str);
114
+ const padding = width - visibleLen;
115
+
116
+ if (padding <= 0) return str;
117
+
118
+ switch (align) {
119
+ case "right":
120
+ return " ".repeat(padding) + str;
121
+ case "center": {
122
+ const left = Math.floor(padding / 2);
123
+ const right = padding - left;
124
+ return " ".repeat(left) + str + " ".repeat(right);
125
+ }
126
+ case "left":
127
+ default:
128
+ return str + " ".repeat(padding);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 문자열 트렁케이션 (ANSI 인식)
134
+ */
135
+ function truncateWithAnsi(str: string, maxWidth: number): string {
136
+ const plain = stripAnsi(str);
137
+ if (plain.length <= maxWidth) return str;
138
+
139
+ // ANSI 코드 위치 추적
140
+ const ansiRegex = /\x1b\[[0-9;]*m/g;
141
+ const ansiCodes: { index: number; code: string }[] = [];
142
+ let match;
143
+ while ((match = ansiRegex.exec(str)) !== null) {
144
+ ansiCodes.push({ index: match.index, code: match[0] });
145
+ }
146
+
147
+ // plain text 기준으로 트렁케이션
148
+ const truncatedPlain = plain.slice(0, maxWidth - 1) + "…";
149
+
150
+ // ANSI 코드가 없으면 그대로 반환
151
+ if (ansiCodes.length === 0) return truncatedPlain;
152
+
153
+ // ANSI 코드 재삽입 (복잡하므로 단순화)
154
+ // 첫 번째와 마지막 ANSI 코드만 보존
155
+ const firstCode = ansiCodes[0]?.code || "";
156
+ const resetCode = "\x1b[0m";
157
+
158
+ if (firstCode && ansiCodes.length > 0) {
159
+ return firstCode + truncatedPlain + resetCode;
160
+ }
161
+
162
+ return truncatedPlain;
163
+ }
164
+
165
+ /**
166
+ * 컬럼 너비 계산
167
+ */
168
+ function calculateWidths(
169
+ columns: TableColumn[],
170
+ rows: Record<string, unknown>[],
171
+ maxWidth?: number,
172
+ compact?: boolean,
173
+ border: BorderStyle = "unicode"
174
+ ): number[] {
175
+ const padding = compact ? 1 : 2;
176
+
177
+ // 각 컬럼의 초기 너비 계산
178
+ const widths = columns.map((col) => {
179
+ const headerWidth = displayWidth(col.header);
180
+ const maxCellWidth = Math.max(
181
+ 0,
182
+ ...rows.map((row) => displayWidth(String(row[col.key] ?? "")))
183
+ );
184
+ const contentWidth = Math.max(headerWidth, maxCellWidth);
185
+
186
+ let width = contentWidth + padding;
187
+
188
+ if (col.minWidth) width = Math.max(width, col.minWidth);
189
+ if (col.maxWidth) width = Math.min(width, col.maxWidth);
190
+
191
+ return width;
192
+ });
193
+
194
+ // 최대 너비 제약 처리
195
+ if (maxWidth) {
196
+ const borderWidth = border === "none" ? 0 : columns.length + 1; // 세로선 개수
197
+ const totalWidth = widths.reduce((a, b) => a + b, 0) + borderWidth;
198
+
199
+ if (totalWidth > maxWidth) {
200
+ const overflow = totalWidth - maxWidth;
201
+ const flexIndices = columns
202
+ .map((c, i) => (c.flex ? i : -1))
203
+ .filter((i) => i >= 0);
204
+
205
+ if (flexIndices.length > 0) {
206
+ // flex 컬럼에서 균등 축소
207
+ const shrinkPerColumn = Math.ceil(overflow / flexIndices.length);
208
+ for (const idx of flexIndices) {
209
+ const minW = columns[idx].minWidth ?? 5;
210
+ widths[idx] = Math.max(minW, widths[idx] - shrinkPerColumn);
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ return widths;
217
+ }
218
+
219
+ /**
220
+ * 테이블 렌더링
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * const output = renderTable({
225
+ * columns: [
226
+ * { key: "name", header: "Name", minWidth: 10 },
227
+ * { key: "status", header: "Status", align: "center" },
228
+ * { key: "size", header: "Size", align: "right" },
229
+ * ],
230
+ * rows: [
231
+ * { name: "file1.ts", status: "✓", size: "1.2KB" },
232
+ * { name: "file2.ts", status: "✗", size: "3.4KB" },
233
+ * ],
234
+ * border: "unicode",
235
+ * });
236
+ *
237
+ * // ┌────────────┬────────┬───────┐
238
+ * // │ Name │ Status │ Size │
239
+ * // ├────────────┼────────┼───────┤
240
+ * // │ file1.ts │ ✓ │ 1.2KB │
241
+ * // │ file2.ts │ ✗ │ 3.4KB │
242
+ * // └────────────┴────────┴───────┘
243
+ * ```
244
+ */
245
+ export function renderTable(options: RenderTableOptions): string {
246
+ const {
247
+ columns,
248
+ rows,
249
+ border = "unicode",
250
+ maxWidth,
251
+ showHeader = true,
252
+ compact = false,
253
+ } = options;
254
+
255
+ if (columns.length === 0) return "";
256
+
257
+ const widths = calculateWidths(columns, rows, maxWidth, compact, border);
258
+ const box = border === "unicode" ? UNICODE_BOX : ASCII_BOX;
259
+ const lines: string[] = [];
260
+
261
+ // 수평선 생성
262
+ const createLine = (
263
+ left: string,
264
+ mid: string,
265
+ right: string,
266
+ fill: string
267
+ ): string => {
268
+ const segments = widths.map((w) => fill.repeat(w));
269
+ return left + segments.join(mid) + right;
270
+ };
271
+
272
+ // 데이터 행 생성
273
+ const createRow = (data: Record<string, unknown>): string => {
274
+ const cells = columns.map((col, i) => {
275
+ let value = String(data[col.key] ?? "");
276
+ const width = widths[i];
277
+
278
+ // 트렁케이션
279
+ if (displayWidth(value) > width - (compact ? 1 : 2)) {
280
+ value = truncateWithAnsi(value, width - (compact ? 1 : 2));
281
+ }
282
+
283
+ // 패딩
284
+ const padded = padString(value, width - (compact ? 0 : 1), col.align);
285
+ return compact ? padded : " " + padded;
286
+ });
287
+
288
+ if (border === "none") {
289
+ return cells.join(" ");
290
+ }
291
+ return box.v + cells.join(box.v) + box.v;
292
+ };
293
+
294
+ // 상단 테두리
295
+ if (border !== "none") {
296
+ lines.push(createLine(box.tl, box.t, box.tr, box.h));
297
+ }
298
+
299
+ // 헤더
300
+ if (showHeader) {
301
+ const headerRow: Record<string, unknown> = {};
302
+ for (const col of columns) {
303
+ headerRow[col.key] = col.header;
304
+ }
305
+ lines.push(createRow(headerRow));
306
+
307
+ // 헤더 구분선
308
+ if (border !== "none") {
309
+ lines.push(createLine(box.ml, box.m, box.mr, box.h));
310
+ }
311
+ }
312
+
313
+ // 데이터 행
314
+ for (const row of rows) {
315
+ lines.push(createRow(row));
316
+ }
317
+
318
+ // 하단 테두리
319
+ if (border !== "none") {
320
+ lines.push(createLine(box.bl, box.b, box.br, box.h));
321
+ }
322
+
323
+ return lines.join("\n");
324
+ }
325
+
326
+ /**
327
+ * 간단한 리스트 테이블 (키-값 쌍)
328
+ *
329
+ * @example
330
+ * ```ts
331
+ * renderKeyValueTable([
332
+ * { key: "Name", value: "Mandu" },
333
+ * { key: "Version", value: "0.11.0" },
334
+ * ]);
335
+ * // ┌─────────┬─────────┐
336
+ * // │ Name │ Mandu │
337
+ * // │ Version │ 0.11.0 │
338
+ * // └─────────┴─────────┘
339
+ * ```
340
+ */
341
+ export function renderKeyValueTable(
342
+ items: { key: string; value: string }[],
343
+ options: { border?: BorderStyle; maxWidth?: number } = {}
344
+ ): string {
345
+ return renderTable({
346
+ columns: [
347
+ { key: "key", header: "Key" },
348
+ { key: "value", header: "Value", flex: true },
349
+ ],
350
+ rows: items,
351
+ showHeader: false,
352
+ ...options,
353
+ });
354
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * DNA-009: Mandu CLI Theme System
3
+ *
4
+ * Chalk-based dynamic color theme with NO_COLOR/FORCE_COLOR support
5
+ * Inspired by OpenClaw's terminal/theme.ts
6
+ */
7
+
8
+ import { MANDU_PALETTE } from "./palette.js";
9
+
10
+ // Bun's native console supports colors, but we need a simple wrapper
11
+ // for consistent theming across the CLI
12
+
13
+ /**
14
+ * Check if rich output (colors) is supported
15
+ */
16
+ function checkRichSupport(): boolean {
17
+ // NO_COLOR takes precedence (accessibility standard)
18
+ if (process.env.NO_COLOR) {
19
+ const forceColor = process.env.FORCE_COLOR?.trim();
20
+ if (forceColor !== "1" && forceColor !== "true") {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ // Check TTY
26
+ if (!process.stdout.isTTY) {
27
+ return false;
28
+ }
29
+
30
+ // Check TERM
31
+ const term = process.env.TERM;
32
+ if (term === "dumb") {
33
+ return false;
34
+ }
35
+
36
+ return true;
37
+ }
38
+
39
+ const richSupported = checkRichSupport();
40
+
41
+ /**
42
+ * ANSI escape code wrapper
43
+ */
44
+ function ansi(code: string) {
45
+ return richSupported ? `\x1b[${code}m` : "";
46
+ }
47
+
48
+ /**
49
+ * Convert hex to ANSI 256 color (approximation)
50
+ */
51
+ function hexToAnsi256(hex: string): number {
52
+ const r = parseInt(hex.slice(1, 3), 16);
53
+ const g = parseInt(hex.slice(3, 5), 16);
54
+ const b = parseInt(hex.slice(5, 7), 16);
55
+
56
+ // Convert to 6x6x6 color cube
57
+ const ri = Math.round((r / 255) * 5);
58
+ const gi = Math.round((g / 255) * 5);
59
+ const bi = Math.round((b / 255) * 5);
60
+
61
+ return 16 + 36 * ri + 6 * gi + bi;
62
+ }
63
+
64
+ /**
65
+ * Create a color function from hex
66
+ */
67
+ function hex(hexColor: string): (text: string) => string {
68
+ if (!richSupported) return (text) => text;
69
+
70
+ const colorCode = hexToAnsi256(hexColor);
71
+ return (text) => `\x1b[38;5;${colorCode}m${text}\x1b[0m`;
72
+ }
73
+
74
+ /**
75
+ * Create a bold color function
76
+ */
77
+ function boldHex(hexColor: string): (text: string) => string {
78
+ if (!richSupported) return (text) => text;
79
+
80
+ const colorCode = hexToAnsi256(hexColor);
81
+ return (text) => `\x1b[1;38;5;${colorCode}m${text}\x1b[0m`;
82
+ }
83
+
84
+ /**
85
+ * Mandu CLI Theme
86
+ */
87
+ export const theme = {
88
+ // Brand colors
89
+ accent: hex(MANDU_PALETTE.accent),
90
+ accentBright: hex(MANDU_PALETTE.accentBright),
91
+ accentDim: hex(MANDU_PALETTE.accentDim),
92
+
93
+ // Semantic colors
94
+ info: hex(MANDU_PALETTE.info),
95
+ success: hex(MANDU_PALETTE.success),
96
+ warn: hex(MANDU_PALETTE.warn),
97
+ error: hex(MANDU_PALETTE.error),
98
+
99
+ // Neutral
100
+ muted: hex(MANDU_PALETTE.muted),
101
+ dim: hex(MANDU_PALETTE.dim),
102
+
103
+ // Composite styles
104
+ heading: boldHex(MANDU_PALETTE.accent),
105
+ command: hex(MANDU_PALETTE.accentBright),
106
+ option: hex(MANDU_PALETTE.warn),
107
+ path: hex(MANDU_PALETTE.info),
108
+
109
+ // Basic styles
110
+ bold: richSupported ? (text: string) => `\x1b[1m${text}\x1b[0m` : (text: string) => text,
111
+ italic: richSupported ? (text: string) => `\x1b[3m${text}\x1b[0m` : (text: string) => text,
112
+ underline: richSupported ? (text: string) => `\x1b[4m${text}\x1b[0m` : (text: string) => text,
113
+
114
+ // Reset
115
+ reset: richSupported ? "\x1b[0m" : "",
116
+ } as const;
117
+
118
+ /**
119
+ * Check if rich output is available
120
+ */
121
+ export function isRich(): boolean {
122
+ return richSupported;
123
+ }
124
+
125
+ /**
126
+ * Conditionally apply color based on rich mode
127
+ */
128
+ export function colorize(
129
+ rich: boolean,
130
+ colorFn: (text: string) => string,
131
+ text: string
132
+ ): string {
133
+ return rich ? colorFn(text) : text;
134
+ }
135
+
136
+ /**
137
+ * Strip ANSI codes from string (for width calculation)
138
+ */
139
+ export function stripAnsi(text: string): string {
140
+ // eslint-disable-next-line no-control-regex
141
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
142
+ }
@@ -1,9 +1,12 @@
1
+ import { getOutputMode } from "../terminal/output";
2
+
1
3
  export type OutputFormat = "console" | "agent" | "json";
2
4
 
3
5
  function normalizeFormat(value?: string): OutputFormat | undefined {
4
6
  if (!value) return undefined;
5
- if (value === "console" || value === "agent" || value === "json") {
6
- return value;
7
+ const normalized = value.toLowerCase();
8
+ if (normalized === "console" || normalized === "agent" || normalized === "json") {
9
+ return normalized;
7
10
  }
8
11
  return undefined;
9
12
  }
@@ -14,28 +17,6 @@ export function resolveOutputFormat(explicit?: OutputFormat): OutputFormat {
14
17
  const direct = normalizeFormat(explicit) ?? normalizeFormat(env.MANDU_OUTPUT);
15
18
  if (direct) return direct;
16
19
 
17
- const agentSignals = [
18
- "MANDU_AGENT",
19
- "CODEX_AGENT",
20
- "CODEX",
21
- "CLAUDE_CODE",
22
- "ANTHROPIC_CLAUDE_CODE",
23
- ];
24
-
25
- for (const key of agentSignals) {
26
- const value = env[key];
27
- if (value === "1" || value === "true") {
28
- return "json";
29
- }
30
- }
31
-
32
- if (env.CI === "true") {
33
- return "json";
34
- }
35
-
36
- if (process.stdout && !process.stdout.isTTY) {
37
- return "json";
38
- }
39
-
40
- return "console";
20
+ const mode = getOutputMode();
21
+ return mode === "json" ? "json" : "console";
41
22
  }