rafcode 2.1.1 → 2.3.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.
Files changed (135) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/CLAUDE.md +59 -11
  3. package/RAF/ahslfe-config-wizard/decisions.md +34 -0
  4. package/RAF/ahslfe-config-wizard/input.md +1 -0
  5. package/RAF/ahslfe-config-wizard/outcomes/01-define-config-schema.md +38 -0
  6. package/RAF/ahslfe-config-wizard/outcomes/02-refactor-codebase-to-use-config.md +67 -0
  7. package/RAF/ahslfe-config-wizard/outcomes/03-create-config-documentation.md +37 -0
  8. package/RAF/ahslfe-config-wizard/outcomes/04-implement-raf-config-command.md +47 -0
  9. package/RAF/ahslfe-config-wizard/outcomes/05-update-claude-md.md +26 -0
  10. package/RAF/ahslfe-config-wizard/plans/01-define-config-schema.md +73 -0
  11. package/RAF/ahslfe-config-wizard/plans/02-refactor-codebase-to-use-config.md +74 -0
  12. package/RAF/ahslfe-config-wizard/plans/03-create-config-documentation.md +57 -0
  13. package/RAF/ahslfe-config-wizard/plans/04-implement-raf-config-command.md +66 -0
  14. package/RAF/ahslfe-config-wizard/plans/05-update-claude-md.md +60 -0
  15. package/RAF/ahstvo-token-tracker/decisions.md +44 -0
  16. package/RAF/ahstvo-token-tracker/input.md +3 -0
  17. package/RAF/ahstvo-token-tracker/outcomes/01-full-model-id-support.md +43 -0
  18. package/RAF/ahstvo-token-tracker/outcomes/02-name-generation-no-session.md +33 -0
  19. package/RAF/ahstvo-token-tracker/outcomes/03-unify-stream-json-execution.md +48 -0
  20. package/RAF/ahstvo-token-tracker/outcomes/04-token-tracking-cost-calculation.md +53 -0
  21. package/RAF/ahstvo-token-tracker/outcomes/05-token-cost-console-reporting.md +57 -0
  22. package/RAF/ahstvo-token-tracker/outcomes/06-runtime-verbose-toggle.md +53 -0
  23. package/RAF/ahstvo-token-tracker/outcomes/07-readme-config-docs.md +36 -0
  24. package/RAF/ahstvo-token-tracker/plans/01-full-model-id-support.md +35 -0
  25. package/RAF/ahstvo-token-tracker/plans/02-name-generation-no-session.md +36 -0
  26. package/RAF/ahstvo-token-tracker/plans/03-unify-stream-json-execution.md +44 -0
  27. package/RAF/ahstvo-token-tracker/plans/04-token-tracking-cost-calculation.md +56 -0
  28. package/RAF/ahstvo-token-tracker/plans/05-token-cost-console-reporting.md +55 -0
  29. package/RAF/ahstvo-token-tracker/plans/06-runtime-verbose-toggle.md +48 -0
  30. package/RAF/ahstvo-token-tracker/plans/07-readme-config-docs.md +44 -0
  31. package/RAF/ahtahs-token-reaper/decisions.md +37 -0
  32. package/RAF/ahtahs-token-reaper/input.md +20 -0
  33. package/RAF/ahtahs-token-reaper/outcomes/01-extend-token-tracker-data-model.md +42 -0
  34. package/RAF/ahtahs-token-reaper/outcomes/02-accumulate-usage-in-retry-loop.md +31 -0
  35. package/RAF/ahtahs-token-reaper/outcomes/03-per-attempt-display-formatting.md +60 -0
  36. package/RAF/ahtahs-token-reaper/outcomes/04-add-model-name-to-claude-call-logs.md +57 -0
  37. package/RAF/ahtahs-token-reaper/outcomes/05-handle-invalid-config-in-raf-config.md +46 -0
  38. package/RAF/ahtahs-token-reaper/outcomes/06-fix-verbose-toggle-timer-display.md +38 -0
  39. package/RAF/ahtahs-token-reaper/plans/01-extend-token-tracker-data-model.md +36 -0
  40. package/RAF/ahtahs-token-reaper/plans/02-accumulate-usage-in-retry-loop.md +36 -0
  41. package/RAF/ahtahs-token-reaper/plans/03-per-attempt-display-formatting.md +43 -0
  42. package/RAF/ahtahs-token-reaper/plans/04-add-model-name-to-claude-call-logs.md +38 -0
  43. package/RAF/ahtahs-token-reaper/plans/05-handle-invalid-config-in-raf-config.md +36 -0
  44. package/RAF/ahtahs-token-reaper/plans/06-fix-verbose-toggle-timer-display.md +40 -0
  45. package/README.md +34 -0
  46. package/dist/commands/config.d.ts +3 -0
  47. package/dist/commands/config.d.ts.map +1 -0
  48. package/dist/commands/config.js +195 -0
  49. package/dist/commands/config.js.map +1 -0
  50. package/dist/commands/do.d.ts.map +1 -1
  51. package/dist/commands/do.js +55 -7
  52. package/dist/commands/do.js.map +1 -1
  53. package/dist/commands/plan.d.ts.map +1 -1
  54. package/dist/commands/plan.js +5 -3
  55. package/dist/commands/plan.js.map +1 -1
  56. package/dist/core/claude-runner.d.ts +19 -2
  57. package/dist/core/claude-runner.d.ts.map +1 -1
  58. package/dist/core/claude-runner.js +43 -96
  59. package/dist/core/claude-runner.js.map +1 -1
  60. package/dist/core/failure-analyzer.d.ts.map +1 -1
  61. package/dist/core/failure-analyzer.js +6 -3
  62. package/dist/core/failure-analyzer.js.map +1 -1
  63. package/dist/core/git.d.ts.map +1 -1
  64. package/dist/core/git.js +10 -3
  65. package/dist/core/git.js.map +1 -1
  66. package/dist/core/pull-request.d.ts +1 -1
  67. package/dist/core/pull-request.d.ts.map +1 -1
  68. package/dist/core/pull-request.js +9 -4
  69. package/dist/core/pull-request.js.map +1 -1
  70. package/dist/index.js +2 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/parsers/stream-renderer.d.ts +16 -1
  73. package/dist/parsers/stream-renderer.d.ts.map +1 -1
  74. package/dist/parsers/stream-renderer.js +34 -4
  75. package/dist/parsers/stream-renderer.js.map +1 -1
  76. package/dist/prompts/execution.d.ts.map +1 -1
  77. package/dist/prompts/execution.js +11 -1
  78. package/dist/prompts/execution.js.map +1 -1
  79. package/dist/types/config.d.ts +95 -4
  80. package/dist/types/config.d.ts.map +1 -1
  81. package/dist/types/config.js +63 -3
  82. package/dist/types/config.js.map +1 -1
  83. package/dist/utils/config.d.ts +65 -7
  84. package/dist/utils/config.d.ts.map +1 -1
  85. package/dist/utils/config.js +297 -21
  86. package/dist/utils/config.js.map +1 -1
  87. package/dist/utils/name-generator.d.ts +3 -7
  88. package/dist/utils/name-generator.d.ts.map +1 -1
  89. package/dist/utils/name-generator.js +75 -61
  90. package/dist/utils/name-generator.js.map +1 -1
  91. package/dist/utils/terminal-symbols.d.ts +25 -0
  92. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  93. package/dist/utils/terminal-symbols.js +87 -0
  94. package/dist/utils/terminal-symbols.js.map +1 -1
  95. package/dist/utils/token-tracker.d.ts +55 -0
  96. package/dist/utils/token-tracker.d.ts.map +1 -0
  97. package/dist/utils/token-tracker.js +142 -0
  98. package/dist/utils/token-tracker.js.map +1 -0
  99. package/dist/utils/validation.d.ts +5 -5
  100. package/dist/utils/validation.d.ts.map +1 -1
  101. package/dist/utils/validation.js +10 -6
  102. package/dist/utils/validation.js.map +1 -1
  103. package/dist/utils/verbose-toggle.d.ts +33 -0
  104. package/dist/utils/verbose-toggle.d.ts.map +1 -0
  105. package/dist/utils/verbose-toggle.js +94 -0
  106. package/dist/utils/verbose-toggle.js.map +1 -0
  107. package/package.json +1 -1
  108. package/src/commands/config.ts +230 -0
  109. package/src/commands/do.ts +64 -6
  110. package/src/commands/plan.ts +5 -3
  111. package/src/core/claude-runner.ts +59 -115
  112. package/src/core/failure-analyzer.ts +6 -3
  113. package/src/core/git.ts +10 -3
  114. package/src/core/pull-request.ts +9 -4
  115. package/src/index.ts +2 -0
  116. package/src/parsers/stream-renderer.ts +54 -4
  117. package/src/prompts/config-docs.md +331 -0
  118. package/src/prompts/execution.ts +13 -1
  119. package/src/types/config.ts +156 -7
  120. package/src/utils/config.ts +357 -21
  121. package/src/utils/name-generator.ts +84 -71
  122. package/src/utils/terminal-symbols.ts +103 -0
  123. package/src/utils/token-tracker.ts +177 -0
  124. package/src/utils/validation.ts +15 -10
  125. package/src/utils/verbose-toggle.ts +103 -0
  126. package/tests/unit/claude-runner.test.ts +171 -7
  127. package/tests/unit/config-command.test.ts +242 -0
  128. package/tests/unit/config.test.ts +632 -30
  129. package/tests/unit/name-generator.test.ts +99 -75
  130. package/tests/unit/pull-request.test.ts +2 -0
  131. package/tests/unit/stream-renderer.test.ts +83 -0
  132. package/tests/unit/terminal-symbols.test.ts +245 -0
  133. package/tests/unit/timer-verbose-integration.test.ts +170 -0
  134. package/tests/unit/token-tracker.test.ts +685 -0
  135. package/tests/unit/verbose-toggle.test.ts +204 -0
@@ -1,13 +1,35 @@
1
1
  import { jest } from '@jest/globals';
2
-
3
- // Mock execSync before importing the module
4
- const mockExecSync = jest.fn();
2
+ import { EventEmitter } from 'node:events';
3
+
4
+ // Helper to create a mock spawn that returns a fake ChildProcess
5
+ function createMockSpawn(stdoutData: string | null, exitCode: number = 0) {
6
+ const stdout = new EventEmitter();
7
+ const stderr = new EventEmitter();
8
+ const proc = new EventEmitter() as any;
9
+ proc.stdout = stdout;
10
+ proc.stderr = stderr;
11
+ proc.kill = jest.fn();
12
+
13
+ // Schedule data emission and close after spawn is called
14
+ setTimeout(() => {
15
+ if (stdoutData !== null) {
16
+ stdout.emit('data', Buffer.from(stdoutData));
17
+ }
18
+ proc.emit('close', exitCode);
19
+ }, 0);
20
+
21
+ return proc;
22
+ }
23
+
24
+ // Mock spawn before importing the module
25
+ const mockSpawn = jest.fn();
5
26
  jest.unstable_mockModule('node:child_process', () => ({
6
- execSync: mockExecSync,
27
+ spawn: mockSpawn,
28
+ execSync: jest.fn(), // keep available for transitive imports
7
29
  }));
8
30
 
9
31
  // Import after mocking
10
- const { generateProjectName, generateProjectNames, sanitizeGeneratedName, escapeShellArg } =
32
+ const { generateProjectName, generateProjectNames, sanitizeGeneratedName } =
11
33
  await import('../../src/utils/name-generator.js');
12
34
 
13
35
  describe('Name Generator', () => {
@@ -16,55 +38,63 @@ describe('Name Generator', () => {
16
38
  });
17
39
 
18
40
  describe('generateProjectName', () => {
19
- it('should return sanitized name from Sonnet response', async () => {
20
- mockExecSync.mockReturnValue('user-auth-system\n');
41
+ it('should return sanitized name from Claude response', async () => {
42
+ mockSpawn.mockReturnValue(createMockSpawn('user-auth-system\n'));
21
43
 
22
44
  const result = await generateProjectName('Build a user authentication system');
23
45
 
24
46
  expect(result).toBe('user-auth-system');
25
- expect(mockExecSync).toHaveBeenCalledTimes(1);
26
- expect(mockExecSync).toHaveBeenCalledWith(
27
- expect.stringContaining('claude --model sonnet --print'),
47
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
48
+ expect(mockSpawn).toHaveBeenCalledWith(
49
+ 'claude',
50
+ expect.arrayContaining(['--model', 'haiku', '--no-session-persistence', '-p']),
28
51
  expect.any(Object)
29
52
  );
30
53
  });
31
54
 
32
- it('should sanitize Sonnet response with quotes', async () => {
33
- mockExecSync.mockReturnValue('"api-rate-limiter"');
55
+ it('should pass --no-session-persistence flag', async () => {
56
+ mockSpawn.mockReturnValue(createMockSpawn('test-name\n'));
57
+
58
+ await generateProjectName('Test project');
59
+
60
+ const args = mockSpawn.mock.calls[0][1] as string[];
61
+ expect(args).toContain('--no-session-persistence');
62
+ });
63
+
64
+ it('should sanitize response with quotes', async () => {
65
+ mockSpawn.mockReturnValue(createMockSpawn('"api-rate-limiter"'));
34
66
 
35
67
  const result = await generateProjectName('Create an API rate limiting service');
36
68
 
37
69
  expect(result).toBe('api-rate-limiter');
38
70
  });
39
71
 
40
- it('should sanitize Sonnet response with special characters', async () => {
41
- mockExecSync.mockReturnValue('Some Project! Name');
72
+ it('should sanitize response with special characters', async () => {
73
+ mockSpawn.mockReturnValue(createMockSpawn('Some Project! Name'));
42
74
 
43
75
  const result = await generateProjectName('Some project description');
44
76
 
45
77
  expect(result).toBe('some-project-name');
46
78
  });
47
79
 
48
- it('should fall back to word extraction when Sonnet fails', async () => {
49
- mockExecSync.mockImplementation(() => {
50
- throw new Error('Command failed');
51
- });
80
+ it('should fall back to word extraction when Claude fails', async () => {
81
+ mockSpawn.mockReturnValue(createMockSpawn(null, 1));
52
82
 
53
83
  const result = await generateProjectName('Build a user authentication system with OAuth');
54
84
 
55
85
  expect(result).toBe('build-user-authentication');
56
86
  });
57
87
 
58
- it('should fall back to word extraction when Sonnet returns empty', async () => {
59
- mockExecSync.mockReturnValue('');
88
+ it('should fall back to word extraction when Claude returns empty', async () => {
89
+ mockSpawn.mockReturnValue(createMockSpawn(''));
60
90
 
61
91
  const result = await generateProjectName('Implement caching layer for database');
62
92
 
63
93
  expect(result).toBe('implement-caching-layer');
64
94
  });
65
95
 
66
- it('should fall back to word extraction when Sonnet returns single char', async () => {
67
- mockExecSync.mockReturnValue('a');
96
+ it('should fall back to word extraction when Claude returns single char', async () => {
97
+ mockSpawn.mockReturnValue(createMockSpawn('a'));
68
98
 
69
99
  const result = await generateProjectName('Add new logging functionality');
70
100
 
@@ -72,47 +102,60 @@ describe('Name Generator', () => {
72
102
  });
73
103
 
74
104
  it('should return "project" when description has no meaningful words', async () => {
75
- mockExecSync.mockImplementation(() => {
76
- throw new Error('Command failed');
77
- });
105
+ mockSpawn.mockReturnValue(createMockSpawn(null, 1));
78
106
 
79
107
  const result = await generateProjectName('a b c');
80
108
 
81
109
  expect(result).toBe('project');
82
110
  });
83
111
 
84
- it('should truncate long names from Sonnet', async () => {
112
+ it('should truncate long names from Claude', async () => {
85
113
  const longName =
86
114
  'this-is-a-very-long-project-name-that-exceeds-the-maximum-allowed-length-for-folder-names';
87
- mockExecSync.mockReturnValue(longName);
115
+ mockSpawn.mockReturnValue(createMockSpawn(longName));
88
116
 
89
117
  const result = await generateProjectName('Some project');
90
118
 
91
119
  expect(result.length).toBeLessThanOrEqual(50);
92
120
  });
93
121
 
94
- it('should handle multiline Sonnet response', async () => {
95
- mockExecSync.mockReturnValue('project-name\nSome extra explanation\n');
122
+ it('should handle multiline response', async () => {
123
+ mockSpawn.mockReturnValue(createMockSpawn('project-name\nSome extra explanation\n'));
96
124
 
97
125
  const result = await generateProjectName('Some project');
98
126
 
99
- // Should take first line after trim
127
+ // Should take full trimmed output
100
128
  expect(result).toBe('project-name-some-extra-explanation');
101
129
  });
102
130
 
103
131
  it('should convert uppercase to lowercase', async () => {
104
- mockExecSync.mockReturnValue('API-Gateway-Service');
132
+ mockSpawn.mockReturnValue(createMockSpawn('API-Gateway-Service'));
105
133
 
106
134
  const result = await generateProjectName('Build an API gateway');
107
135
 
108
136
  expect(result).toBe('api-gateway-service');
109
137
  });
138
+
139
+ it('should handle spawn error gracefully', async () => {
140
+ const proc = new EventEmitter() as any;
141
+ proc.stdout = new EventEmitter();
142
+ proc.stderr = new EventEmitter();
143
+ proc.kill = jest.fn();
144
+ setTimeout(() => {
145
+ proc.emit('error', new Error('ENOENT'));
146
+ }, 0);
147
+ mockSpawn.mockReturnValue(proc);
148
+
149
+ const result = await generateProjectName('Build something');
150
+
151
+ expect(result).toBe('build-something');
152
+ });
110
153
  });
111
154
 
112
155
  describe('generateProjectNames', () => {
113
- it('should return multiple sanitized names from Sonnet response', async () => {
114
- mockExecSync.mockReturnValue(
115
- 'phoenix-rise\nturbo-boost\nbug-squasher\ncatalyst\nmerlin\n'
156
+ it('should return multiple sanitized names from Claude response', async () => {
157
+ mockSpawn.mockReturnValue(
158
+ createMockSpawn('phoenix-rise\nturbo-boost\nbug-squasher\ncatalyst\nmerlin\n')
116
159
  );
117
160
 
118
161
  const result = await generateProjectNames('Build a user authentication system');
@@ -124,16 +167,15 @@ describe('Name Generator', () => {
124
167
  'catalyst',
125
168
  'merlin',
126
169
  ]);
127
- expect(mockExecSync).toHaveBeenCalledTimes(1);
128
- expect(mockExecSync).toHaveBeenCalledWith(
129
- expect.stringContaining('Generate 5 creative project names'),
130
- expect.any(Object)
131
- );
170
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
171
+ // Verify the prompt contains the multi-name generation prompt
172
+ const promptArg = (mockSpawn.mock.calls[0][1] as string[]).at(-1);
173
+ expect(promptArg).toContain('Generate 5 creative project names');
132
174
  });
133
175
 
134
176
  it('should handle names with numbering prefixes', async () => {
135
- mockExecSync.mockReturnValue(
136
- '1. phoenix-rise\n2. turbo-boost\n3. bug-squasher\n4. catalyst\n5. merlin\n'
177
+ mockSpawn.mockReturnValue(
178
+ createMockSpawn('1. phoenix-rise\n2. turbo-boost\n3. bug-squasher\n4. catalyst\n5. merlin\n')
137
179
  );
138
180
 
139
181
  const result = await generateProjectNames('Some project');
@@ -148,8 +190,8 @@ describe('Name Generator', () => {
148
190
  });
149
191
 
150
192
  it('should handle names with colon prefixes', async () => {
151
- mockExecSync.mockReturnValue(
152
- '1: phoenix-rise\n2: turbo-boost\n3: bug-squasher\n'
193
+ mockSpawn.mockReturnValue(
194
+ createMockSpawn('1: phoenix-rise\n2: turbo-boost\n3: bug-squasher\n')
153
195
  );
154
196
 
155
197
  const result = await generateProjectNames('Some project');
@@ -158,8 +200,8 @@ describe('Name Generator', () => {
158
200
  });
159
201
 
160
202
  it('should remove duplicate names', async () => {
161
- mockExecSync.mockReturnValue(
162
- 'phoenix\nturbo-boost\nphoenix\ncatalyst\nturbo-boost\n'
203
+ mockSpawn.mockReturnValue(
204
+ createMockSpawn('phoenix\nturbo-boost\nphoenix\ncatalyst\nturbo-boost\n')
163
205
  );
164
206
 
165
207
  const result = await generateProjectNames('Some project');
@@ -168,8 +210,8 @@ describe('Name Generator', () => {
168
210
  });
169
211
 
170
212
  it('should limit to 5 names maximum', async () => {
171
- mockExecSync.mockReturnValue(
172
- 'name-one\nname-two\nname-three\nname-four\nname-five\nname-six\nname-seven\n'
213
+ mockSpawn.mockReturnValue(
214
+ createMockSpawn('name-one\nname-two\nname-three\nname-four\nname-five\nname-six\nname-seven\n')
173
215
  );
174
216
 
175
217
  const result = await generateProjectNames('Some project');
@@ -178,17 +220,15 @@ describe('Name Generator', () => {
178
220
  });
179
221
 
180
222
  it('should return 3+ names when available', async () => {
181
- mockExecSync.mockReturnValue('phoenix\nturbo-boost\ncatalyst\n');
223
+ mockSpawn.mockReturnValue(createMockSpawn('phoenix\nturbo-boost\ncatalyst\n'));
182
224
 
183
225
  const result = await generateProjectNames('Some project');
184
226
 
185
227
  expect(result.length).toBe(3);
186
228
  });
187
229
 
188
- it('should fall back to single name when Sonnet fails', async () => {
189
- mockExecSync.mockImplementation(() => {
190
- throw new Error('Command failed');
191
- });
230
+ it('should fall back to single name when Claude fails', async () => {
231
+ mockSpawn.mockReturnValue(createMockSpawn(null, 1));
192
232
 
193
233
  const result = await generateProjectNames('Build a user authentication system with OAuth');
194
234
 
@@ -196,7 +236,7 @@ describe('Name Generator', () => {
196
236
  });
197
237
 
198
238
  it('should fall back to single name when too few names returned', async () => {
199
- mockExecSync.mockReturnValue('phoenix\nturbo\n');
239
+ mockSpawn.mockReturnValue(createMockSpawn('phoenix\nturbo\n'));
200
240
 
201
241
  const result = await generateProjectNames('Build something awesome');
202
242
 
@@ -205,7 +245,9 @@ describe('Name Generator', () => {
205
245
  });
206
246
 
207
247
  it('should filter out invalid/short names', async () => {
208
- mockExecSync.mockReturnValue('phoenix\na\nturbo-boost\nb\ncatalyst\n');
248
+ mockSpawn.mockReturnValue(
249
+ createMockSpawn('phoenix\na\nturbo-boost\nb\ncatalyst\n')
250
+ );
209
251
 
210
252
  const result = await generateProjectNames('Some project');
211
253
 
@@ -213,8 +255,8 @@ describe('Name Generator', () => {
213
255
  });
214
256
 
215
257
  it('should sanitize names with special characters', async () => {
216
- mockExecSync.mockReturnValue(
217
- 'Phoenix Rise!\nTurbo-Boost!!!\nBug Squasher\n'
258
+ mockSpawn.mockReturnValue(
259
+ createMockSpawn('Phoenix Rise!\nTurbo-Boost!!!\nBug Squasher\n')
218
260
  );
219
261
 
220
262
  const result = await generateProjectNames('Some project');
@@ -223,7 +265,7 @@ describe('Name Generator', () => {
223
265
  });
224
266
 
225
267
  it('should handle empty response', async () => {
226
- mockExecSync.mockReturnValue('');
268
+ mockSpawn.mockReturnValue(createMockSpawn(''));
227
269
 
228
270
  const result = await generateProjectNames('Some project');
229
271
 
@@ -262,22 +304,4 @@ describe('Name Generator', () => {
262
304
  expect(result?.length).toBeLessThanOrEqual(50);
263
305
  });
264
306
  });
265
-
266
- describe('escapeShellArg', () => {
267
- it('should escape double quotes', () => {
268
- expect(escapeShellArg('hello "world"')).toBe('hello \\"world\\"');
269
- });
270
-
271
- it('should escape backslashes', () => {
272
- expect(escapeShellArg('hello\\world')).toBe('hello\\\\world');
273
- });
274
-
275
- it('should escape dollar signs', () => {
276
- expect(escapeShellArg('$HOME')).toBe('\\$HOME');
277
- });
278
-
279
- it('should escape backticks', () => {
280
- expect(escapeShellArg('`whoami`')).toBe('\\`whoami\\`');
281
- });
282
- });
283
307
  });
@@ -25,8 +25,10 @@ jest.unstable_mockModule('node:fs', () => ({
25
25
 
26
26
  // Mock os
27
27
  const mockTmpdir = jest.fn().mockReturnValue('/tmp');
28
+ const mockHomedir = jest.fn().mockReturnValue('/home/testuser');
28
29
  jest.unstable_mockModule('node:os', () => ({
29
30
  tmpdir: mockTmpdir,
31
+ homedir: mockHomedir,
30
32
  }));
31
33
 
32
34
  // Mock logger to prevent console output
@@ -235,6 +235,89 @@ describe('renderStreamEvent', () => {
235
235
  expect(result.display).toBe('');
236
236
  expect(result.textContent).toBe('');
237
237
  });
238
+
239
+ it('should extract usage data from result event', () => {
240
+ const line = JSON.stringify({
241
+ type: 'result',
242
+ subtype: 'success',
243
+ result: 'Done',
244
+ usage: {
245
+ input_tokens: 1000,
246
+ output_tokens: 500,
247
+ cache_read_input_tokens: 200,
248
+ cache_creation_input_tokens: 100,
249
+ },
250
+ modelUsage: {
251
+ 'claude-opus-4-6': {
252
+ inputTokens: 1000,
253
+ outputTokens: 500,
254
+ cacheReadInputTokens: 200,
255
+ cacheCreationInputTokens: 100,
256
+ },
257
+ },
258
+ });
259
+ const result = renderStreamEvent(line);
260
+ expect(result.usageData).toBeDefined();
261
+ expect(result.usageData!.inputTokens).toBe(1000);
262
+ expect(result.usageData!.outputTokens).toBe(500);
263
+ expect(result.usageData!.cacheReadInputTokens).toBe(200);
264
+ expect(result.usageData!.cacheCreationInputTokens).toBe(100);
265
+ expect(result.usageData!.modelUsage['claude-opus-4-6']).toEqual({
266
+ inputTokens: 1000,
267
+ outputTokens: 500,
268
+ cacheReadInputTokens: 200,
269
+ cacheCreationInputTokens: 100,
270
+ });
271
+ });
272
+
273
+ it('should return undefined usageData when no usage in result', () => {
274
+ const line = JSON.stringify({
275
+ type: 'result',
276
+ subtype: 'success',
277
+ result: 'Done',
278
+ });
279
+ const result = renderStreamEvent(line);
280
+ expect(result.usageData).toBeUndefined();
281
+ });
282
+
283
+ it('should handle partial usage data gracefully', () => {
284
+ const line = JSON.stringify({
285
+ type: 'result',
286
+ usage: { input_tokens: 500 },
287
+ });
288
+ const result = renderStreamEvent(line);
289
+ expect(result.usageData).toBeDefined();
290
+ expect(result.usageData!.inputTokens).toBe(500);
291
+ expect(result.usageData!.outputTokens).toBe(0);
292
+ expect(result.usageData!.cacheReadInputTokens).toBe(0);
293
+ expect(result.usageData!.cacheCreationInputTokens).toBe(0);
294
+ expect(result.usageData!.modelUsage).toEqual({});
295
+ });
296
+
297
+ it('should handle multi-model usage data', () => {
298
+ const line = JSON.stringify({
299
+ type: 'result',
300
+ usage: {
301
+ input_tokens: 2000,
302
+ output_tokens: 800,
303
+ },
304
+ modelUsage: {
305
+ 'claude-opus-4-6': {
306
+ inputTokens: 1500,
307
+ outputTokens: 600,
308
+ },
309
+ 'claude-haiku-4-5-20251001': {
310
+ inputTokens: 500,
311
+ outputTokens: 200,
312
+ },
313
+ },
314
+ });
315
+ const result = renderStreamEvent(line);
316
+ expect(result.usageData).toBeDefined();
317
+ expect(Object.keys(result.usageData!.modelUsage)).toHaveLength(2);
318
+ expect(result.usageData!.modelUsage['claude-opus-4-6'].inputTokens).toBe(1500);
319
+ expect(result.usageData!.modelUsage['claude-haiku-4-5-20251001'].inputTokens).toBe(500);
320
+ });
238
321
  });
239
322
 
240
323
  describe('edge cases', () => {