start-command 0.15.0 → 0.16.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,15 @@
1
1
  # start-command
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 35f3505: feat: Improve command output formatting with human-readable timestamps and duration
8
+ - Changed timestamp format from `[timestamp] Starting:` to `Starting at timestamp:`
9
+ - Changed finish message from `[timestamp] Finished` to `Finished at timestamp in X.XXX seconds`
10
+ - Added performance metric showing command execution duration
11
+ - Added `formatDuration` helper function for consistent duration formatting
12
+
3
13
  ## 0.15.0
4
14
 
5
15
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -33,6 +33,7 @@ const { handleFailure } = require('../lib/failure-handler');
33
33
  const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
34
34
  const { queryStatus } = require('../lib/status-formatter');
35
35
  const { printVersion } = require('../lib/version');
36
+ const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
36
37
 
37
38
  // Configuration from environment variables
38
39
  const config = {
@@ -283,6 +284,7 @@ async function runWithIsolation(
283
284
  const environment = options.isolated;
284
285
  const mode = getEffectiveMode(options);
285
286
  const startTime = getTimestamp();
287
+ const startTimeMs = Date.now();
286
288
 
287
289
  // Create log file path
288
290
  const logFilePath = createLogPath(environment || 'direct');
@@ -319,8 +321,14 @@ async function runWithIsolation(
319
321
  });
320
322
  }
321
323
 
322
- // Print session UUID at start
323
- console.log(sessionId);
324
+ // Print start block with session ID
325
+ console.log(
326
+ createStartBlock({
327
+ sessionId,
328
+ timestamp: startTime,
329
+ command: cmd,
330
+ })
331
+ );
324
332
  console.log('');
325
333
 
326
334
  // Handle --isolated-user option: create a new user with same permissions
@@ -373,10 +381,6 @@ async function runWithIsolation(
373
381
  console.log('');
374
382
  }
375
383
 
376
- // Print start message (unified format)
377
- console.log(`[${startTime}] Starting: ${cmd}`);
378
- console.log('');
379
-
380
384
  // Log isolation info
381
385
  if (environment) {
382
386
  console.log(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
@@ -479,13 +483,9 @@ async function runWithIsolation(
479
483
  }
480
484
  }
481
485
 
482
- // Print result and footer (unified format)
486
+ // Print result
483
487
  console.log('');
484
488
  console.log(result.message);
485
- console.log('');
486
- console.log(`[${endTime}] Finished`);
487
- console.log(`Exit code: ${exitCode}`);
488
- console.log(`Log saved: ${logFilePath}`);
489
489
 
490
490
  // Cleanup: delete the created user if we created one (unless --keep-user)
491
491
  if (createdUser && !options.keepUser) {
@@ -504,9 +504,18 @@ async function runWithIsolation(
504
504
  );
505
505
  }
506
506
 
507
- // Print session UUID at end
507
+ // Print finish block
508
+ const durationMs = Date.now() - startTimeMs;
508
509
  console.log('');
509
- console.log(sessionId);
510
+ console.log(
511
+ createFinishBlock({
512
+ sessionId,
513
+ timestamp: endTime,
514
+ exitCode,
515
+ logPath: logFilePath,
516
+ durationMs,
517
+ })
518
+ );
510
519
 
511
520
  process.exit(exitCode);
512
521
  }
@@ -532,6 +541,7 @@ function runDirect(cmd, sessionId) {
532
541
 
533
542
  let logContent = '';
534
543
  const startTime = getTimestamp();
544
+ const startTimeMs = Date.now();
535
545
 
536
546
  // Get runtime information
537
547
  const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js';
@@ -574,17 +584,18 @@ function runDirect(cmd, sessionId) {
574
584
  logContent += `Working Directory: ${process.cwd()}\n`;
575
585
  logContent += `${'='.repeat(50)}\n\n`;
576
586
 
577
- // Print session UUID at start
578
- console.log(sessionId);
579
- console.log('');
580
-
581
- // Print start message to console
582
- if (substitutionResult && substitutionResult.matched) {
583
- console.log(`[${startTime}] Input: ${parsedCommand}`);
584
- console.log(`[${startTime}] Executing: ${cmd}`);
585
- } else {
586
- console.log(`[${startTime}] Starting: ${cmd}`);
587
- }
587
+ // Print start block with session ID
588
+ const displayCommand =
589
+ substitutionResult && substitutionResult.matched
590
+ ? `${parsedCommand} -> ${cmd}`
591
+ : cmd;
592
+ console.log(
593
+ createStartBlock({
594
+ sessionId,
595
+ timestamp: startTime,
596
+ command: displayCommand,
597
+ })
598
+ );
588
599
  console.log('');
589
600
 
590
601
  // Execute the command with captured output
@@ -652,14 +663,18 @@ function runDirect(cmd, sessionId) {
652
663
  }
653
664
  }
654
665
 
655
- // Print footer to console
656
- console.log('');
657
- console.log(`[${endTime}] Finished`);
658
- console.log(`Exit code: ${exitCode}`);
659
- console.log(`Log saved: ${logFilePath}`);
666
+ // Print finish block
667
+ const durationMs = Date.now() - startTimeMs;
660
668
  console.log('');
661
- // Print session UUID at end
662
- console.log(sessionId);
669
+ console.log(
670
+ createFinishBlock({
671
+ sessionId,
672
+ timestamp: endTime,
673
+ exitCode,
674
+ logPath: logFilePath,
675
+ durationMs,
676
+ })
677
+ );
663
678
 
664
679
  // If command failed, try to auto-report
665
680
  if (exitCode !== 0) {
@@ -672,6 +687,7 @@ function runDirect(cmd, sessionId) {
672
687
  // Handle spawn errors
673
688
  child.on('error', (err) => {
674
689
  const endTime = getTimestamp();
690
+ const durationMs = Date.now() - startTimeMs;
675
691
  const errorMessage = `Error executing command: ${err.message}`;
676
692
 
677
693
  logContent += `\n${errorMessage}\n`;
@@ -702,12 +718,15 @@ function runDirect(cmd, sessionId) {
702
718
 
703
719
  console.error(`\n${errorMessage}`);
704
720
  console.log('');
705
- console.log(`[${endTime}] Finished`);
706
- console.log(`Exit code: 1`);
707
- console.log(`Log saved: ${logFilePath}`);
708
- console.log('');
709
- // Print session UUID at end
710
- console.log(sessionId);
721
+ console.log(
722
+ createFinishBlock({
723
+ sessionId,
724
+ timestamp: endTime,
725
+ exitCode: 1,
726
+ logPath: logFilePath,
727
+ durationMs,
728
+ })
729
+ );
711
730
 
712
731
  handleFailure(config, commandName, cmd, 1, logFilePath);
713
732
 
@@ -746,6 +765,7 @@ async function runDirectWithCommandStream(
746
765
 
747
766
  let logContent = '';
748
767
  const startTime = getTimestamp();
768
+ const startTimeMs = Date.now();
749
769
 
750
770
  // Get runtime information
751
771
  const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js';
@@ -790,17 +810,16 @@ async function runDirectWithCommandStream(
790
810
  logContent += `Working Directory: ${process.cwd()}\n`;
791
811
  logContent += `${'='.repeat(50)}\n\n`;
792
812
 
793
- // Print session UUID at start
794
- console.log(sessionId);
795
- console.log('');
796
-
797
- // Print start message to console
798
- if (subResult && subResult.matched) {
799
- console.log(`[${startTime}] Input: ${parsedCmd}`);
800
- console.log(`[${startTime}] Executing: ${cmd}`);
801
- } else {
802
- console.log(`[${startTime}] Starting: ${cmd}`);
803
- }
813
+ // Print start block with session ID
814
+ const displayCmd =
815
+ subResult && subResult.matched ? `${parsedCmd} -> ${cmd}` : cmd;
816
+ console.log(
817
+ createStartBlock({
818
+ sessionId,
819
+ timestamp: startTime,
820
+ command: displayCmd,
821
+ })
822
+ );
804
823
  console.log('[command-stream] Using command-stream library');
805
824
  console.log('');
806
825
 
@@ -876,14 +895,18 @@ async function runDirectWithCommandStream(
876
895
  }
877
896
  }
878
897
 
879
- // Print footer to console
898
+ // Print finish block
899
+ const durationMs = Date.now() - startTimeMs;
880
900
  console.log('');
881
- console.log(`[${endTime}] Finished`);
882
- console.log(`Exit code: ${exitCode}`);
883
- console.log(`Log saved: ${logFilePath}`);
884
- console.log('');
885
- // Print session UUID at end
886
- console.log(sessionId);
901
+ console.log(
902
+ createFinishBlock({
903
+ sessionId,
904
+ timestamp: endTime,
905
+ exitCode,
906
+ logPath: logFilePath,
907
+ durationMs,
908
+ })
909
+ );
887
910
 
888
911
  // If command failed, try to auto-report
889
912
  if (exitCode !== 0) {
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Output formatting utilities for nicely rendered command blocks
3
+ *
4
+ * Provides various styles for start/finish blocks to distinguish
5
+ * command output from the $ wrapper output.
6
+ *
7
+ * Available styles:
8
+ * 1. 'rounded' (default): Rounded unicode box borders (╭─╮ ╰─╯)
9
+ * 2. 'heavy': Heavy unicode box borders (┏━┓ ┗━┛)
10
+ * 3. 'double': Double line box borders (╔═╗ ╚═╝)
11
+ * 4. 'simple': Simple dash lines (────────)
12
+ * 5. 'ascii': Pure ASCII compatible (-------- +------+)
13
+ */
14
+
15
+ // Box drawing characters for different styles
16
+ const BOX_STYLES = {
17
+ rounded: {
18
+ topLeft: '╭',
19
+ topRight: '╮',
20
+ bottomLeft: '╰',
21
+ bottomRight: '╯',
22
+ horizontal: '─',
23
+ vertical: '│',
24
+ },
25
+ heavy: {
26
+ topLeft: '┏',
27
+ topRight: '┓',
28
+ bottomLeft: '┗',
29
+ bottomRight: '┛',
30
+ horizontal: '━',
31
+ vertical: '┃',
32
+ },
33
+ double: {
34
+ topLeft: '╔',
35
+ topRight: '╗',
36
+ bottomLeft: '╚',
37
+ bottomRight: '╝',
38
+ horizontal: '═',
39
+ vertical: '║',
40
+ },
41
+ simple: {
42
+ topLeft: '',
43
+ topRight: '',
44
+ bottomLeft: '',
45
+ bottomRight: '',
46
+ horizontal: '─',
47
+ vertical: '',
48
+ },
49
+ ascii: {
50
+ topLeft: '+',
51
+ topRight: '+',
52
+ bottomLeft: '+',
53
+ bottomRight: '+',
54
+ horizontal: '-',
55
+ vertical: '|',
56
+ },
57
+ };
58
+
59
+ // Default style (can be overridden via environment variable)
60
+ const DEFAULT_STYLE = process.env.START_OUTPUT_STYLE || 'rounded';
61
+
62
+ // Default block width
63
+ const DEFAULT_WIDTH = 60;
64
+
65
+ /**
66
+ * Get the box style configuration
67
+ * @param {string} [styleName] - Style name (rounded, heavy, double, simple, ascii)
68
+ * @returns {object} Box style configuration
69
+ */
70
+ function getBoxStyle(styleName = DEFAULT_STYLE) {
71
+ return BOX_STYLES[styleName] || BOX_STYLES.rounded;
72
+ }
73
+
74
+ /**
75
+ * Create a horizontal line
76
+ * @param {number} width - Line width
77
+ * @param {object} style - Box style
78
+ * @returns {string} Horizontal line
79
+ */
80
+ function createHorizontalLine(width, style) {
81
+ return style.horizontal.repeat(width);
82
+ }
83
+
84
+ /**
85
+ * Pad or truncate text to fit a specific width
86
+ * @param {string} text - Text to pad
87
+ * @param {number} width - Target width
88
+ * @returns {string} Padded text
89
+ */
90
+ function padText(text, width) {
91
+ if (text.length >= width) {
92
+ return text.substring(0, width);
93
+ }
94
+ return text + ' '.repeat(width - text.length);
95
+ }
96
+
97
+ /**
98
+ * Create a bordered line with text
99
+ * @param {string} text - Text content
100
+ * @param {number} width - Total width (including borders)
101
+ * @param {object} style - Box style
102
+ * @returns {string} Bordered line
103
+ */
104
+ function createBorderedLine(text, width, style) {
105
+ if (style.vertical) {
106
+ const innerWidth = width - 4; // 2 for borders, 2 for padding
107
+ const paddedText = padText(text, innerWidth);
108
+ return `${style.vertical} ${paddedText} ${style.vertical}`;
109
+ }
110
+ return text;
111
+ }
112
+
113
+ /**
114
+ * Create the top border of a box
115
+ * @param {number} width - Box width
116
+ * @param {object} style - Box style
117
+ * @returns {string} Top border
118
+ */
119
+ function createTopBorder(width, style) {
120
+ if (style.topLeft) {
121
+ const lineWidth = width - 2; // Subtract corners
122
+ return `${style.topLeft}${createHorizontalLine(lineWidth, style)}${style.topRight}`;
123
+ }
124
+ return createHorizontalLine(width, style);
125
+ }
126
+
127
+ /**
128
+ * Create the bottom border of a box
129
+ * @param {number} width - Box width
130
+ * @param {object} style - Box style
131
+ * @returns {string} Bottom border
132
+ */
133
+ function createBottomBorder(width, style) {
134
+ if (style.bottomLeft) {
135
+ const lineWidth = width - 2; // Subtract corners
136
+ return `${style.bottomLeft}${createHorizontalLine(lineWidth, style)}${style.bottomRight}`;
137
+ }
138
+ return createHorizontalLine(width, style);
139
+ }
140
+
141
+ /**
142
+ * Create a start block for command execution
143
+ * @param {object} options - Options for the block
144
+ * @param {string} options.sessionId - Session UUID
145
+ * @param {string} options.timestamp - Timestamp string
146
+ * @param {string} options.command - Command being executed
147
+ * @param {string} [options.style] - Box style name
148
+ * @param {number} [options.width] - Box width
149
+ * @returns {string} Formatted start block
150
+ */
151
+ function createStartBlock(options) {
152
+ const {
153
+ sessionId,
154
+ timestamp,
155
+ command,
156
+ style: styleName = DEFAULT_STYLE,
157
+ width = DEFAULT_WIDTH,
158
+ } = options;
159
+
160
+ const style = getBoxStyle(styleName);
161
+ const lines = [];
162
+
163
+ lines.push(createTopBorder(width, style));
164
+ lines.push(createBorderedLine(`Session ID: ${sessionId}`, width, style));
165
+ lines.push(
166
+ createBorderedLine(`Starting at ${timestamp}: ${command}`, width, style)
167
+ );
168
+ lines.push(createBottomBorder(width, style));
169
+
170
+ return lines.join('\n');
171
+ }
172
+
173
+ /**
174
+ * Format duration in seconds with appropriate precision
175
+ * @param {number} durationMs - Duration in milliseconds
176
+ * @returns {string} Formatted duration string
177
+ */
178
+ function formatDuration(durationMs) {
179
+ const seconds = durationMs / 1000;
180
+ if (seconds < 0.001) {
181
+ return '0.001';
182
+ } else if (seconds < 10) {
183
+ // For durations under 10 seconds, show 3 decimal places
184
+ return seconds.toFixed(3);
185
+ } else if (seconds < 100) {
186
+ return seconds.toFixed(2);
187
+ } else {
188
+ return seconds.toFixed(1);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Create a finish block for command execution
194
+ * @param {object} options - Options for the block
195
+ * @param {string} options.sessionId - Session UUID
196
+ * @param {string} options.timestamp - Timestamp string
197
+ * @param {number} options.exitCode - Exit code
198
+ * @param {string} options.logPath - Path to log file
199
+ * @param {number} [options.durationMs] - Duration in milliseconds
200
+ * @param {string} [options.style] - Box style name
201
+ * @param {number} [options.width] - Box width
202
+ * @returns {string} Formatted finish block
203
+ */
204
+ function createFinishBlock(options) {
205
+ const {
206
+ sessionId,
207
+ timestamp,
208
+ exitCode,
209
+ logPath,
210
+ durationMs,
211
+ style: styleName = DEFAULT_STYLE,
212
+ width = DEFAULT_WIDTH,
213
+ } = options;
214
+
215
+ const style = getBoxStyle(styleName);
216
+ const lines = [];
217
+
218
+ // Format the finished message with optional duration
219
+ let finishedMsg = `Finished at ${timestamp}`;
220
+ if (durationMs !== undefined && durationMs !== null) {
221
+ finishedMsg += ` in ${formatDuration(durationMs)} seconds`;
222
+ }
223
+
224
+ lines.push(createTopBorder(width, style));
225
+ lines.push(createBorderedLine(finishedMsg, width, style));
226
+ lines.push(createBorderedLine(`Exit code: ${exitCode}`, width, style));
227
+ lines.push(createBorderedLine(`Log: ${logPath}`, width, style));
228
+ lines.push(createBorderedLine(`Session ID: ${sessionId}`, width, style));
229
+ lines.push(createBottomBorder(width, style));
230
+
231
+ return lines.join('\n');
232
+ }
233
+
234
+ /**
235
+ * Escape a value for Links notation
236
+ * Smart quoting: uses single or double quotes based on content
237
+ * @param {string} str - String to escape
238
+ * @returns {string} Escaped string
239
+ */
240
+ function escapeForLinksNotation(str) {
241
+ if (str === null || str === undefined) {
242
+ return 'null';
243
+ }
244
+
245
+ const value = String(str);
246
+
247
+ // Check for characters that need quoting
248
+ const hasColon = value.includes(':');
249
+ const hasDoubleQuotes = value.includes('"');
250
+ const hasSingleQuotes = value.includes("'");
251
+ const hasParens = value.includes('(') || value.includes(')');
252
+ const hasNewline = value.includes('\n');
253
+ const hasSpace = value.includes(' ');
254
+
255
+ const needsQuoting =
256
+ hasColon ||
257
+ hasDoubleQuotes ||
258
+ hasSingleQuotes ||
259
+ hasParens ||
260
+ hasNewline ||
261
+ hasSpace;
262
+
263
+ if (!needsQuoting) {
264
+ return value;
265
+ }
266
+
267
+ if (hasDoubleQuotes && !hasSingleQuotes) {
268
+ // Has " but not ' → use single quotes
269
+ return `'${value}'`;
270
+ } else if (hasSingleQuotes && !hasDoubleQuotes) {
271
+ // Has ' but not " → use double quotes
272
+ return `"${value}"`;
273
+ } else if (hasDoubleQuotes && hasSingleQuotes) {
274
+ // Has both " and ' → choose wrapper with fewer escapes
275
+ const doubleQuoteCount = (value.match(/"/g) || []).length;
276
+ const singleQuoteCount = (value.match(/'/g) || []).length;
277
+
278
+ if (singleQuoteCount <= doubleQuoteCount) {
279
+ // Escape single quotes by doubling them
280
+ const escaped = value.replace(/'/g, "''");
281
+ return `'${escaped}'`;
282
+ } else {
283
+ // Escape double quotes by doubling them
284
+ const escaped = value.replace(/"/g, '""');
285
+ return `"${escaped}"`;
286
+ }
287
+ } else {
288
+ // Has colon, parentheses, newlines, or spaces but no quotes
289
+ return `"${value}"`;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Format an object as nested Links notation
295
+ * @param {object} obj - Object to format
296
+ * @param {number} [indent=2] - Indentation level (spaces)
297
+ * @param {number} [depth=0] - Current depth
298
+ * @returns {string} Links notation formatted string
299
+ */
300
+ function formatAsNestedLinksNotation(obj, indent = 2, depth = 0) {
301
+ if (obj === null || obj === undefined) {
302
+ return 'null';
303
+ }
304
+
305
+ if (typeof obj !== 'object') {
306
+ return escapeForLinksNotation(obj);
307
+ }
308
+
309
+ if (Array.isArray(obj)) {
310
+ // Format arrays
311
+ if (obj.length === 0) {
312
+ return '()';
313
+ }
314
+ const indentStr = ' '.repeat(indent * (depth + 1));
315
+ const items = obj.map((item) => {
316
+ const formatted = formatAsNestedLinksNotation(item, indent, depth + 1);
317
+ return `${indentStr}${formatted}`;
318
+ });
319
+ return `(\n${items.join('\n')}\n${' '.repeat(indent * depth)})`;
320
+ }
321
+
322
+ // Format objects
323
+ const entries = Object.entries(obj);
324
+ if (entries.length === 0) {
325
+ return '()';
326
+ }
327
+
328
+ const indentStr = ' '.repeat(indent * (depth + 1));
329
+ const lines = entries
330
+ .filter(([, value]) => value !== null && value !== undefined)
331
+ .map(([key, value]) => {
332
+ if (typeof value === 'object') {
333
+ const nested = formatAsNestedLinksNotation(value, indent, depth + 1);
334
+ return `${indentStr}${key}\n${nested}`;
335
+ }
336
+ const formattedValue = escapeForLinksNotation(value);
337
+ return `${indentStr}${key} ${formattedValue}`;
338
+ });
339
+
340
+ return lines.join('\n');
341
+ }
342
+
343
+ module.exports = {
344
+ BOX_STYLES,
345
+ DEFAULT_STYLE,
346
+ DEFAULT_WIDTH,
347
+ getBoxStyle,
348
+ createHorizontalLine,
349
+ createBorderedLine,
350
+ createTopBorder,
351
+ createBottomBorder,
352
+ createStartBlock,
353
+ createFinishBlock,
354
+ formatDuration,
355
+ escapeForLinksNotation,
356
+ formatAsNestedLinksNotation,
357
+ };
@@ -7,14 +7,23 @@
7
7
  * - Text: Human-readable text format
8
8
  */
9
9
 
10
+ const {
11
+ escapeForLinksNotation,
12
+ formatAsNestedLinksNotation,
13
+ } = require('./output-blocks');
14
+
10
15
  /**
11
16
  * Format execution record as Links Notation (indented style)
17
+ * Uses nested Links notation for object values (like options) instead of JSON
18
+ *
12
19
  * @param {Object} record - The execution record with toObject() method
13
20
  * @returns {string} Links Notation formatted string in indented style
14
21
  *
15
22
  * Output format:
16
23
  * <uuid>
17
24
  * <key> "<value>"
25
+ * options
26
+ * <nested_key> <nested_value>
18
27
  * ...
19
28
  */
20
29
  function formatRecordAsLinksNotation(record) {
@@ -23,16 +32,27 @@ function formatRecordAsLinksNotation(record) {
23
32
 
24
33
  for (const [key, value] of Object.entries(obj)) {
25
34
  if (value !== null && value !== undefined) {
26
- // Format value based on type
27
- let formattedValue;
28
- if (typeof value === 'object') {
29
- formattedValue = JSON.stringify(value);
35
+ if (key === 'options' && typeof value === 'object') {
36
+ // Format options as nested Links notation
37
+ const optionEntries = Object.entries(value).filter(
38
+ ([, v]) => v !== null && v !== undefined
39
+ );
40
+ if (optionEntries.length > 0) {
41
+ lines.push(' options');
42
+ for (const [optKey, optValue] of optionEntries) {
43
+ const formattedOptValue = escapeForLinksNotation(optValue);
44
+ lines.push(` ${optKey} ${formattedOptValue}`);
45
+ }
46
+ }
47
+ } else if (typeof value === 'object') {
48
+ // For other objects, still format as nested Links notation
49
+ lines.push(` ${key}`);
50
+ const nested = formatAsNestedLinksNotation(value, 2, 2);
51
+ lines.push(nested);
30
52
  } else {
31
- formattedValue = String(value);
53
+ const formattedValue = escapeForLinksNotation(value);
54
+ lines.push(` ${key} ${formattedValue}`);
32
55
  }
33
- // Escape quotes in the value
34
- const escapedValue = formattedValue.replace(/"/g, '\\"');
35
- lines.push(` ${key} "${escapedValue}"`);
36
56
  }
37
57
  }
38
58
 
@@ -62,8 +82,15 @@ function formatRecordAsText(record) {
62
82
  `Log Path: ${obj.logPath}`,
63
83
  ];
64
84
 
65
- if (Object.keys(obj.options).length > 0) {
66
- lines.push(`Options: ${JSON.stringify(obj.options)}`);
85
+ // Format options as nested list instead of JSON
86
+ const optionEntries = Object.entries(obj.options || {}).filter(
87
+ ([, v]) => v !== null && v !== undefined
88
+ );
89
+ if (optionEntries.length > 0) {
90
+ lines.push(`Options:`);
91
+ for (const [key, value] of optionEntries) {
92
+ lines.push(` ${key}: ${value}`);
93
+ }
67
94
  }
68
95
 
69
96
  return lines.join('\n');
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Tests for output-blocks module
3
+ */
4
+
5
+ const { describe, it, expect } = require('bun:test');
6
+
7
+ const {
8
+ BOX_STYLES,
9
+ DEFAULT_STYLE,
10
+ DEFAULT_WIDTH,
11
+ getBoxStyle,
12
+ createStartBlock,
13
+ createFinishBlock,
14
+ formatDuration,
15
+ escapeForLinksNotation,
16
+ formatAsNestedLinksNotation,
17
+ } = require('../src/lib/output-blocks');
18
+
19
+ describe('output-blocks module', () => {
20
+ describe('BOX_STYLES', () => {
21
+ it('should have all expected styles', () => {
22
+ expect(BOX_STYLES).toHaveProperty('rounded');
23
+ expect(BOX_STYLES).toHaveProperty('heavy');
24
+ expect(BOX_STYLES).toHaveProperty('double');
25
+ expect(BOX_STYLES).toHaveProperty('simple');
26
+ expect(BOX_STYLES).toHaveProperty('ascii');
27
+ });
28
+
29
+ it('should have correct rounded style characters', () => {
30
+ expect(BOX_STYLES.rounded.topLeft).toBe('╭');
31
+ expect(BOX_STYLES.rounded.topRight).toBe('╮');
32
+ expect(BOX_STYLES.rounded.bottomLeft).toBe('╰');
33
+ expect(BOX_STYLES.rounded.bottomRight).toBe('╯');
34
+ });
35
+ });
36
+
37
+ describe('getBoxStyle', () => {
38
+ it('should return rounded style by default', () => {
39
+ const style = getBoxStyle();
40
+ expect(style).toEqual(BOX_STYLES.rounded);
41
+ });
42
+
43
+ it('should return requested style', () => {
44
+ expect(getBoxStyle('heavy')).toEqual(BOX_STYLES.heavy);
45
+ expect(getBoxStyle('double')).toEqual(BOX_STYLES.double);
46
+ expect(getBoxStyle('ascii')).toEqual(BOX_STYLES.ascii);
47
+ });
48
+
49
+ it('should return rounded for unknown style', () => {
50
+ const style = getBoxStyle('unknown');
51
+ expect(style).toEqual(BOX_STYLES.rounded);
52
+ });
53
+ });
54
+
55
+ describe('createStartBlock', () => {
56
+ it('should create a start block with session ID', () => {
57
+ const block = createStartBlock({
58
+ sessionId: 'test-uuid-1234',
59
+ timestamp: '2025-01-01 00:00:00',
60
+ command: 'echo hello',
61
+ });
62
+
63
+ expect(block).toContain('╭');
64
+ expect(block).toContain('╰');
65
+ expect(block).toContain('Session ID: test-uuid-1234');
66
+ expect(block).toContain('Starting at 2025-01-01 00:00:00: echo hello');
67
+ });
68
+
69
+ it('should use specified style', () => {
70
+ const block = createStartBlock({
71
+ sessionId: 'test-uuid',
72
+ timestamp: '2025-01-01 00:00:00',
73
+ command: 'echo hello',
74
+ style: 'ascii',
75
+ });
76
+
77
+ expect(block).toContain('+');
78
+ expect(block).toContain('-');
79
+ });
80
+ });
81
+
82
+ describe('createFinishBlock', () => {
83
+ it('should create a finish block with session ID and exit code', () => {
84
+ const block = createFinishBlock({
85
+ sessionId: 'test-uuid-1234',
86
+ timestamp: '2025-01-01 00:00:01',
87
+ exitCode: 0,
88
+ logPath: '/tmp/test.log',
89
+ durationMs: 17,
90
+ });
91
+
92
+ expect(block).toContain('╭');
93
+ expect(block).toContain('╰');
94
+ expect(block).toContain('Session ID: test-uuid-1234');
95
+ expect(block).toContain(
96
+ 'Finished at 2025-01-01 00:00:01 in 0.017 seconds'
97
+ );
98
+ expect(block).toContain('Exit code: 0');
99
+ expect(block).toContain('Log: /tmp/test.log');
100
+ });
101
+
102
+ it('should create a finish block without duration when not provided', () => {
103
+ const block = createFinishBlock({
104
+ sessionId: 'test-uuid-1234',
105
+ timestamp: '2025-01-01 00:00:01',
106
+ exitCode: 0,
107
+ logPath: '/tmp/test.log',
108
+ });
109
+
110
+ expect(block).toContain('Finished at 2025-01-01 00:00:01');
111
+ expect(block).not.toContain('seconds');
112
+ });
113
+ });
114
+
115
+ describe('formatDuration', () => {
116
+ it('should format very small durations', () => {
117
+ expect(formatDuration(0.5)).toBe('0.001');
118
+ });
119
+
120
+ it('should format millisecond durations', () => {
121
+ expect(formatDuration(17)).toBe('0.017');
122
+ expect(formatDuration(500)).toBe('0.500');
123
+ });
124
+
125
+ it('should format second durations', () => {
126
+ expect(formatDuration(1000)).toBe('1.000');
127
+ expect(formatDuration(5678)).toBe('5.678');
128
+ });
129
+
130
+ it('should format longer durations with less precision', () => {
131
+ expect(formatDuration(12345)).toBe('12.35');
132
+ expect(formatDuration(123456)).toBe('123.5');
133
+ });
134
+ });
135
+
136
+ describe('escapeForLinksNotation', () => {
137
+ it('should not quote simple values', () => {
138
+ expect(escapeForLinksNotation('simple')).toBe('simple');
139
+ expect(escapeForLinksNotation('123')).toBe('123');
140
+ expect(escapeForLinksNotation('true')).toBe('true');
141
+ });
142
+
143
+ it('should quote values with spaces', () => {
144
+ expect(escapeForLinksNotation('hello world')).toBe('"hello world"');
145
+ });
146
+
147
+ it('should quote values with colons', () => {
148
+ expect(escapeForLinksNotation('key:value')).toBe('"key:value"');
149
+ });
150
+
151
+ it('should use single quotes for values with double quotes', () => {
152
+ expect(escapeForLinksNotation('say "hello"')).toBe('\'say "hello"\'');
153
+ });
154
+
155
+ it('should use double quotes for values with single quotes', () => {
156
+ expect(escapeForLinksNotation("it's cool")).toBe('"it\'s cool"');
157
+ });
158
+
159
+ it('should escape quotes when both types are present', () => {
160
+ const result = escapeForLinksNotation('say "hello" it\'s');
161
+ // Should wrap in one quote type and escape the other
162
+ expect(result).toMatch(/^["'].*["']$/);
163
+ });
164
+
165
+ it('should handle null values', () => {
166
+ expect(escapeForLinksNotation(null)).toBe('null');
167
+ expect(escapeForLinksNotation(undefined)).toBe('null');
168
+ });
169
+ });
170
+
171
+ describe('formatAsNestedLinksNotation', () => {
172
+ it('should format simple objects', () => {
173
+ const obj = { key: 'value', number: 123 };
174
+ const result = formatAsNestedLinksNotation(obj);
175
+
176
+ expect(result).toContain('key value');
177
+ expect(result).toContain('number 123');
178
+ });
179
+
180
+ it('should quote values with spaces', () => {
181
+ const obj = { message: 'hello world' };
182
+ const result = formatAsNestedLinksNotation(obj);
183
+
184
+ expect(result).toContain('message "hello world"');
185
+ });
186
+
187
+ it('should handle empty objects', () => {
188
+ expect(formatAsNestedLinksNotation({})).toBe('()');
189
+ });
190
+
191
+ it('should handle null', () => {
192
+ expect(formatAsNestedLinksNotation(null)).toBe('null');
193
+ });
194
+ });
195
+ });
196
+
197
+ console.log('=== Output Blocks Unit Tests ===');
@@ -82,9 +82,10 @@ describe('--status query functionality', () => {
82
82
  expect(result.exitCode).toBe(0);
83
83
  // Should start with UUID on its own line
84
84
  expect(result.stdout).toMatch(new RegExp(`^${testRecord.uuid}\\n`));
85
- // Should have indented properties
86
- expect(result.stdout).toContain(` uuid "${testRecord.uuid}"`);
87
- expect(result.stdout).toContain(' status "executed"');
85
+ // Should have indented properties (values without special chars are not quoted)
86
+ expect(result.stdout).toContain(` uuid ${testRecord.uuid}`);
87
+ expect(result.stdout).toContain(' status executed');
88
+ // Command with space should be quoted
88
89
  expect(result.stdout).toContain(' command "echo hello world"');
89
90
  });
90
91
 
@@ -99,7 +100,8 @@ describe('--status query functionality', () => {
99
100
  expect(result.exitCode).toBe(0);
100
101
  // Should start with UUID on its own line
101
102
  expect(result.stdout).toMatch(new RegExp(`^${testRecord.uuid}\\n`));
102
- expect(result.stdout).toContain(` uuid "${testRecord.uuid}"`);
103
+ // UUID without special chars is not quoted
104
+ expect(result.stdout).toContain(` uuid ${testRecord.uuid}`);
103
105
  });
104
106
  });
105
107