create-claude-workspace 1.1.120 → 1.1.122

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.
@@ -2,11 +2,12 @@
2
2
  // ─── Autonomous development loop ───
3
3
  // Headless runner for Claude Code orchestrator using Agent SDK.
4
4
  import { resolve } from 'node:path';
5
- import { appendFileSync, existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
5
+ import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
6
6
  import { execSync } from 'node:child_process';
7
7
  import { DEFAULTS } from './lib/types.mjs';
8
8
  import { emptyCheckpoint, readCheckpoint, writeCheckpoint } from './lib/state.mjs';
9
9
  import { pollForNewWork } from './lib/idle-poll.mjs';
10
+ import { Formatter } from './lib/formatter.mjs';
10
11
  import { query } from '@anthropic-ai/claude-agent-sdk';
11
12
  import { config as dotenvConfig } from '@dotenvx/dotenvx';
12
13
  // ─── Args ───
@@ -164,16 +165,6 @@ Options:
164
165
  `);
165
166
  }
166
167
  // ─── Helpers ───
167
- let logPath = '';
168
- function log(msg) {
169
- const line = `[${new Date().toISOString()}] ${msg}`;
170
- console.log(line);
171
- if (logPath)
172
- try {
173
- appendFileSync(logPath, line + '\n');
174
- }
175
- catch { /* */ }
176
- }
177
168
  function sleep(ms, ref) {
178
169
  return new Promise(r => {
179
170
  const t = setTimeout(r, ms);
@@ -200,7 +191,6 @@ function notify(cmd, type, message, iteration) {
200
191
  catch { /* */ }
201
192
  }
202
193
  function checkAuth() {
203
- // Check if Claude Code has credentials (API key or OAuth session)
204
194
  if (process.env.ANTHROPIC_API_KEY)
205
195
  return true;
206
196
  try {
@@ -215,10 +205,10 @@ function checkAuth() {
215
205
  catch { /* */ }
216
206
  return false;
217
207
  }
218
- function acquireLock(dir) {
208
+ function acquireLock(dir, fmt) {
219
209
  const lockFile = resolve(dir, '.claude/autonomous.lock');
220
210
  if (existsSync(lockFile)) {
221
- log('Lock file exists. Another instance running?');
211
+ fmt.warn('Lock file exists. Another instance running?');
222
212
  return false;
223
213
  }
224
214
  try {
@@ -275,27 +265,33 @@ async function main() {
275
265
  process.exit(0);
276
266
  }
277
267
  dotenvConfig({ path: resolve(opts.projectDir, '.env'), override: false, quiet: true });
278
- logPath = resolve(opts.projectDir, opts.logFile);
279
- log('Autonomous development loop starting.');
280
- log(`Project: ${opts.projectDir}`);
281
- log(`Iterations: ${opts.maxIterations} | Turns: ${opts.maxTurns}`);
268
+ const logPath = resolve(opts.projectDir, opts.logFile);
269
+ const fmt = new Formatter(logPath);
270
+ fmt.banner();
271
+ fmt.info(`Project: ${opts.projectDir}`);
272
+ fmt.info(`Max iterations: ${opts.maxIterations} │ Max turns: ${opts.maxTurns}`);
282
273
  // Auth check
283
274
  if (!checkAuth()) {
284
- log('ERROR: Not authenticated. Either:');
285
- log(' 1. Set ANTHROPIC_API_KEY env var (API billing), or');
286
- log(' 2. Run `claude login` to authenticate with Claude Max plan');
287
- log(' For Docker: mount ~/.claude/.credentials.json into the container');
275
+ fmt.error('Not authenticated. Either:');
276
+ fmt.error(' 1. Set ANTHROPIC_API_KEY env var (API billing)');
277
+ fmt.error(' 2. Run `claude login` to authenticate with Claude Max plan');
278
+ fmt.error(' For Docker: mount ~/.claude/.credentials.json into the container');
288
279
  process.exit(1);
289
280
  }
281
+ fmt.success('Authentication verified');
290
282
  // Lock
291
- if (!opts.noLock && !acquireLock(opts.projectDir)) {
283
+ if (!opts.noLock && !acquireLock(opts.projectDir, fmt)) {
292
284
  process.exit(1);
293
285
  }
294
286
  // Git pull
295
- if (!opts.noPull)
296
- gitPull(opts.projectDir);
287
+ if (!opts.noPull) {
288
+ if (gitPull(opts.projectDir))
289
+ fmt.info('Git: pulled latest changes');
290
+ else
291
+ fmt.warn('Git: pull failed or not a git repo');
292
+ }
297
293
  if (opts.dryRun) {
298
- log('Dry run complete.');
294
+ fmt.success('Dry run complete. All checks passed.');
299
295
  if (!opts.noLock)
300
296
  releaseLock(opts.projectDir);
301
297
  process.exit(0);
@@ -305,7 +301,7 @@ async function main() {
305
301
  const existing = readCheckpoint(opts.projectDir);
306
302
  if (opts.resume && existing) {
307
303
  checkpoint = existing;
308
- log(`Resuming from iteration ${checkpoint.iteration}`);
304
+ fmt.info(`Resuming from iteration ${checkpoint.iteration}`);
309
305
  }
310
306
  else {
311
307
  checkpoint = emptyCheckpoint();
@@ -317,12 +313,11 @@ async function main() {
317
313
  if (!opts.noLock)
318
314
  releaseLock(opts.projectDir);
319
315
  writeCheckpoint(opts.projectDir, checkpoint);
320
- log('Stopped.');
316
+ fmt.warn('Interrupted. State saved.');
321
317
  process.exit(0);
322
318
  };
323
319
  process.on('SIGINT', cleanup);
324
320
  process.on('SIGTERM', cleanup);
325
- log('---');
326
321
  // ─── Loop ───
327
322
  for (let i = checkpoint.iteration + 1; i <= opts.maxIterations && !stopping; i++) {
328
323
  // Check completion → idle polling
@@ -331,23 +326,22 @@ async function main() {
331
326
  if (!checkpoint.completionVerified) {
332
327
  checkpoint.completionVerified = true;
333
328
  writeCheckpoint(opts.projectDir, checkpoint);
334
- // Fall through to run one more iteration (orchestrator verifies)
335
329
  }
336
330
  else {
337
- log('Project complete. Entering idle polling...');
331
+ fmt.info('Project complete. Entering idle polling...');
338
332
  let foundWork = false;
339
333
  const idleStart = Date.now();
340
334
  while (!stopping) {
341
- const poll = await pollForNewWork(opts.projectDir, { info: log, warn: log, error: log, debug: () => { } });
335
+ const poll = await pollForNewWork(opts.projectDir, { info: (m) => fmt.info(m), warn: (m) => fmt.warn(m), error: (m) => fmt.error(m), debug: () => { } });
342
336
  if (poll.hasWork) {
343
- log(`New work detected (${poll.source}). Resuming...`);
337
+ fmt.success(`New work detected (${poll.source}). Resuming...`);
344
338
  checkpoint.completionVerified = false;
345
339
  writeCheckpoint(opts.projectDir, checkpoint);
346
340
  foundWork = true;
347
341
  break;
348
342
  }
349
343
  if (opts.maxIdleTime > 0 && Date.now() - idleStart >= opts.maxIdleTime) {
350
- log('Max idle time reached. Exiting.');
344
+ fmt.info('Max idle time reached. Exiting.');
351
345
  break;
352
346
  }
353
347
  await sleep(opts.idlePollInterval, stoppingRef);
@@ -357,13 +351,14 @@ async function main() {
357
351
  continue;
358
352
  }
359
353
  }
360
- log(`Iteration ${i}/${opts.maxIterations}`);
354
+ fmt.iterationStart(i, opts.maxIterations);
355
+ fmt.resetAgentStack();
361
356
  // Run Claude via Agent SDK
362
357
  try {
363
358
  const resumeOpts = {};
364
359
  if (opts.resumeSession) {
365
360
  resumeOpts.resume = opts.resumeSession;
366
- opts.resumeSession = null; // Only resume once
361
+ opts.resumeSession = null;
367
362
  }
368
363
  else if (opts.resume && checkpoint.lastSessionId) {
369
364
  resumeOpts.continue = true;
@@ -380,17 +375,17 @@ async function main() {
380
375
  ...resumeOpts,
381
376
  },
382
377
  })) {
378
+ fmt.handleMessage(message);
383
379
  if (message.type === 'result') {
384
- const result = message;
385
- checkpoint.lastSessionId = result.session_id ?? null;
386
- log(`Result: ${JSON.stringify(result).slice(0, 300)}`);
380
+ checkpoint.lastSessionId = fmt.getSessionId(message);
387
381
  }
388
382
  }
389
383
  checkpoint.stats.iterations++;
384
+ fmt.iterationEnd();
390
385
  }
391
386
  catch (err) {
392
- log(`Error: ${err.message}`);
393
- log(`Waiting ${formatDuration(opts.cooldown)}...`);
387
+ fmt.error(`${err.message}`);
388
+ fmt.warn(`Cooling down ${formatDuration(opts.cooldown)}...`);
394
389
  await sleep(opts.cooldown, stoppingRef);
395
390
  if (stopping)
396
391
  break;
@@ -400,7 +395,7 @@ async function main() {
400
395
  checkpoint.iteration = i;
401
396
  writeCheckpoint(opts.projectDir, checkpoint);
402
397
  if (i < opts.maxIterations && !stopping) {
403
- log(`Next iteration in ${formatDuration(opts.delay)}...`);
398
+ fmt.info(`Next iteration in ${formatDuration(opts.delay)}...`);
404
399
  await sleep(opts.delay, stoppingRef);
405
400
  }
406
401
  }
@@ -408,8 +403,7 @@ async function main() {
408
403
  if (!opts.noLock)
409
404
  releaseLock(opts.projectDir);
410
405
  writeCheckpoint(opts.projectDir, checkpoint);
411
- log('---');
412
- log('Autonomous loop ended.');
406
+ fmt.success(`Loop ended after ${checkpoint.stats.iterations} iterations.`);
413
407
  notify(opts.notifyCommand, 'completed', 'Loop ended', checkpoint.iteration);
414
408
  }
415
409
  // ─── Export for CLI integration ───
@@ -329,7 +329,9 @@ async function main() {
329
329
  }
330
330
  // 2. Build
331
331
  step('2/4 Building container image...');
332
- const buildResult = compose(opts.rebuild ? ['build', '--no-cache'] : ['build', '-q']);
332
+ // Always use --pull to pick up base image updates; --no-cache only on explicit --rebuild
333
+ const buildArgs = opts.rebuild ? ['build', '--no-cache', '--pull'] : ['build', '--pull', '-q'];
334
+ const buildResult = compose(buildArgs);
333
335
  if (buildResult.status !== 0) {
334
336
  error('Docker image build failed. Fix the errors above and re-run.');
335
337
  process.exit(1);
@@ -0,0 +1,309 @@
1
+ // ─── Beautiful terminal output for autonomous loop ───
2
+ import { appendFileSync } from 'node:fs';
3
+ // ─── ANSI colors ───
4
+ const c = {
5
+ reset: '\x1b[0m',
6
+ bold: '\x1b[1m',
7
+ dim: '\x1b[2m',
8
+ italic: '\x1b[3m',
9
+ // Foreground
10
+ black: '\x1b[30m',
11
+ red: '\x1b[31m',
12
+ green: '\x1b[32m',
13
+ yellow: '\x1b[33m',
14
+ blue: '\x1b[34m',
15
+ magenta: '\x1b[35m',
16
+ cyan: '\x1b[36m',
17
+ white: '\x1b[37m',
18
+ // Bright foreground
19
+ gray: '\x1b[90m',
20
+ brightRed: '\x1b[91m',
21
+ brightGreen: '\x1b[92m',
22
+ brightYellow: '\x1b[93m',
23
+ brightBlue: '\x1b[94m',
24
+ brightMagenta: '\x1b[95m',
25
+ brightCyan: '\x1b[96m',
26
+ // Background
27
+ bgBlue: '\x1b[44m',
28
+ bgMagenta: '\x1b[45m',
29
+ bgCyan: '\x1b[46m',
30
+ bgGray: '\x1b[100m',
31
+ };
32
+ // ─── Agent colors (rotating) ───
33
+ const AGENT_COLORS = [c.brightCyan, c.brightMagenta, c.brightGreen, c.brightYellow, c.brightBlue, c.brightRed];
34
+ const agentColorMap = new Map();
35
+ let colorIndex = 0;
36
+ function agentColor(name) {
37
+ if (!agentColorMap.has(name)) {
38
+ agentColorMap.set(name, AGENT_COLORS[colorIndex % AGENT_COLORS.length]);
39
+ colorIndex++;
40
+ }
41
+ return agentColorMap.get(name);
42
+ }
43
+ // ─── Tool icons ───
44
+ const TOOL_ICONS = {
45
+ Bash: '⚡',
46
+ Read: '📖',
47
+ Write: '✏️',
48
+ Edit: '🔧',
49
+ Glob: '🔍',
50
+ Grep: '🔎',
51
+ Agent: '🤖',
52
+ TodoRead: '📋',
53
+ TodoWrite: '📝',
54
+ WebSearch: '🌐',
55
+ WebFetch: '🌐',
56
+ AskUserQuestion: '❓',
57
+ };
58
+ function toolIcon(name) {
59
+ return TOOL_ICONS[name] || '⚙️';
60
+ }
61
+ // ─── Time formatting ───
62
+ function ts() {
63
+ return new Date().toLocaleTimeString('en-GB', { hour12: false });
64
+ }
65
+ function duration(ms) {
66
+ if (ms < 1000)
67
+ return `${ms}ms`;
68
+ if (ms < 60_000)
69
+ return `${(ms / 1000).toFixed(1)}s`;
70
+ if (ms < 3600_000)
71
+ return `${(ms / 60_000).toFixed(1)}m`;
72
+ return `${(ms / 3600_000).toFixed(1)}h`;
73
+ }
74
+ // ─── Formatting helpers ───
75
+ function truncate(s, max) {
76
+ const clean = s.replace(/\n/g, ' ').trim();
77
+ return clean.length > max ? clean.slice(0, max) + '…' : clean;
78
+ }
79
+ function formatPath(path) {
80
+ // Shorten long paths: /project/libs/shared/ui/src/lib/component.ts → .../ui/src/lib/component.ts
81
+ const short = path.replace(/^\/project\//, '');
82
+ return `${c.cyan}${short}${c.reset}`;
83
+ }
84
+ function formatCommand(cmd) {
85
+ const short = truncate(cmd, 80);
86
+ return `${c.yellow}${short}${c.reset}`;
87
+ }
88
+ // ─── Main formatter class ───
89
+ export class Formatter {
90
+ logFile;
91
+ iterStartTime = 0;
92
+ totalTokens = { input: 0, output: 0 };
93
+ toolCalls = 0;
94
+ agentStack = [];
95
+ constructor(logFile) {
96
+ this.logFile = logFile;
97
+ }
98
+ // ── Raw log (file only, no formatting) ──
99
+ fileLog(msg) {
100
+ if (!this.logFile)
101
+ return;
102
+ try {
103
+ appendFileSync(this.logFile, `[${new Date().toISOString()}] ${msg}\n`);
104
+ }
105
+ catch { /* */ }
106
+ }
107
+ // ── Print to console + file ──
108
+ print(formatted, raw) {
109
+ console.log(formatted);
110
+ this.fileLog(raw || formatted.replace(/\x1b\[[0-9;]*m/g, ''));
111
+ }
112
+ // ─── Public API ───
113
+ banner() {
114
+ this.print('');
115
+ this.print(`${c.bold}${c.cyan} ╔══════════════════════════════════════════════╗${c.reset}`);
116
+ this.print(`${c.bold}${c.cyan} ║ ${c.white}Claude Starter Kit — Autonomous Loop${c.cyan} ║${c.reset}`);
117
+ this.print(`${c.bold}${c.cyan} ╚══════════════════════════════════════════════╝${c.reset}`);
118
+ this.print('');
119
+ }
120
+ info(msg) {
121
+ this.print(` ${c.gray}${ts()}${c.reset} ${c.blue}ℹ${c.reset} ${msg}`);
122
+ }
123
+ warn(msg) {
124
+ this.print(` ${c.gray}${ts()}${c.reset} ${c.yellow}⚠${c.reset} ${c.yellow}${msg}${c.reset}`);
125
+ }
126
+ error(msg) {
127
+ this.print(` ${c.gray}${ts()}${c.reset} ${c.red}✗${c.reset} ${c.red}${msg}${c.reset}`);
128
+ }
129
+ success(msg) {
130
+ this.print(` ${c.gray}${ts()}${c.reset} ${c.green}✓${c.reset} ${c.green}${msg}${c.reset}`);
131
+ }
132
+ iterationStart(i, max) {
133
+ this.iterStartTime = Date.now();
134
+ this.toolCalls = 0;
135
+ const pct = Math.round((i / max) * 100);
136
+ const bar = progressBar(pct, 20);
137
+ this.print('');
138
+ this.print(` ${c.bold}${c.white}─── Iteration ${i}/${max} ${c.gray}${bar} ${pct}%${c.reset}`);
139
+ this.print('');
140
+ }
141
+ iterationEnd() {
142
+ const elapsed = Date.now() - this.iterStartTime;
143
+ const tokens = this.totalTokens.input + this.totalTokens.output;
144
+ this.print('');
145
+ this.print(` ${c.gray}──── ${duration(elapsed)} │ ${this.toolCalls} tools │ ${formatTokens(tokens)} tokens ────${c.reset}`);
146
+ }
147
+ // ─── SDK Message handling ───
148
+ handleMessage(message) {
149
+ const msg = message;
150
+ switch (message.type) {
151
+ case 'assistant':
152
+ this.handleAssistant(msg);
153
+ break;
154
+ case 'user':
155
+ this.handleToolResult(msg);
156
+ break;
157
+ case 'system':
158
+ this.handleSystem(msg);
159
+ break;
160
+ case 'result':
161
+ this.handleResult(msg);
162
+ break;
163
+ default:
164
+ // Silently skip unknown types (stream_event, etc.)
165
+ break;
166
+ }
167
+ }
168
+ handleAssistant(msg) {
169
+ const content = msg.message?.content;
170
+ if (!Array.isArray(content))
171
+ return;
172
+ for (const block of content) {
173
+ if (block.type === 'text' && block.text?.trim()) {
174
+ const text = truncate(block.text, 300);
175
+ const indent = this.indent();
176
+ this.print(`${indent}${c.white}${text}${c.reset}`, `TEXT: ${text}`);
177
+ }
178
+ if (block.type === 'tool_use') {
179
+ this.handleToolUse(block);
180
+ }
181
+ }
182
+ // Track tokens from usage
183
+ if (msg.message?.usage) {
184
+ this.totalTokens.input += msg.message.usage.input_tokens || 0;
185
+ this.totalTokens.output += msg.message.usage.output_tokens || 0;
186
+ }
187
+ }
188
+ handleToolUse(block) {
189
+ this.toolCalls++;
190
+ const name = block.name || 'unknown';
191
+ const icon = toolIcon(name);
192
+ const input = block.input || {};
193
+ const indent = this.indent();
194
+ if (name === 'Agent') {
195
+ // Agent delegation — show agent name and description
196
+ const agentType = input.subagent_type || input.type || 'agent';
197
+ const desc = truncate(input.description || input.prompt || '', 60);
198
+ const col = agentColor(agentType);
199
+ this.agentStack.push(agentType);
200
+ this.print(`${indent}${icon} ${col}${c.bold}${agentType}${c.reset} ${c.gray}${desc}${c.reset}`, `AGENT: ${agentType} — ${desc}`);
201
+ return;
202
+ }
203
+ // Format based on tool type
204
+ let detail = '';
205
+ if (name === 'Bash') {
206
+ detail = formatCommand(input.command || '');
207
+ }
208
+ else if (name === 'Read') {
209
+ detail = formatPath(input.file_path || '');
210
+ }
211
+ else if (name === 'Write' || name === 'Edit') {
212
+ detail = formatPath(input.file_path || '');
213
+ }
214
+ else if (name === 'Glob') {
215
+ detail = `${c.cyan}${input.pattern || ''}${c.reset}`;
216
+ }
217
+ else if (name === 'Grep') {
218
+ detail = `${c.cyan}/${input.pattern || ''}/`;
219
+ if (input.path)
220
+ detail += ` ${c.gray}in ${input.path}`;
221
+ detail += c.reset;
222
+ }
223
+ else if (name === 'TodoWrite') {
224
+ detail = `${c.gray}updating task list${c.reset}`;
225
+ }
226
+ else if (name === 'TodoRead') {
227
+ detail = `${c.gray}reading task list${c.reset}`;
228
+ }
229
+ else {
230
+ const summary = truncate(JSON.stringify(input), 80);
231
+ detail = `${c.gray}${summary}${c.reset}`;
232
+ }
233
+ this.print(`${indent}${c.gray}${ts()}${c.reset} ${icon} ${c.bold}${name}${c.reset} ${detail}`, `TOOL: ${name} ${JSON.stringify(input).slice(0, 200)}`);
234
+ }
235
+ handleToolResult(msg) {
236
+ const content = msg.message?.content;
237
+ if (!Array.isArray(content))
238
+ return;
239
+ for (const block of content) {
240
+ if (block.type !== 'tool_result')
241
+ continue;
242
+ const output = String(block.content || '').trim();
243
+ const isError = block.is_error === true;
244
+ const indent = this.indent();
245
+ if (isError) {
246
+ const short = truncate(output, 120);
247
+ this.print(`${indent} ${c.red}✗ ${short}${c.reset}`, `ERROR: ${short}`);
248
+ }
249
+ else if (output && output.length > 0) {
250
+ // Show short results inline, skip very long ones
251
+ if (output.length < 200) {
252
+ const short = truncate(output, 120);
253
+ this.print(`${indent} ${c.green}✓${c.reset} ${c.gray}${short}${c.reset}`, `RESULT: ${short}`);
254
+ }
255
+ else {
256
+ const lines = output.split('\n').length;
257
+ this.print(`${indent} ${c.green}✓${c.reset} ${c.gray}${lines} lines${c.reset}`, `RESULT: (${lines} lines)`);
258
+ }
259
+ }
260
+ }
261
+ }
262
+ handleSystem(msg) {
263
+ if (msg.subtype === 'task_progress') {
264
+ const desc = msg.description || '';
265
+ if (desc) {
266
+ const indent = this.indent();
267
+ this.print(`${indent} ${c.dim}${truncate(desc, 80)}${c.reset}`, `PROGRESS: ${desc}`);
268
+ }
269
+ return;
270
+ }
271
+ // Other system messages
272
+ const text = msg.message || JSON.stringify(msg).slice(0, 150);
273
+ this.info(`${c.dim}${truncate(String(text), 120)}${c.reset}`);
274
+ }
275
+ handleResult(msg) {
276
+ const sessionId = msg.session_id;
277
+ if (sessionId) {
278
+ this.fileLog(`SESSION: ${sessionId}`);
279
+ }
280
+ this.success('Iteration complete');
281
+ }
282
+ // ─── Helpers ───
283
+ indent() {
284
+ const depth = this.agentStack.length;
285
+ if (depth === 0)
286
+ return ' ';
287
+ return ' ' + '│ '.repeat(depth);
288
+ }
289
+ // Reset agent stack between iterations
290
+ resetAgentStack() {
291
+ this.agentStack = [];
292
+ }
293
+ getSessionId(msg) {
294
+ return msg.session_id ?? null;
295
+ }
296
+ }
297
+ // ─── Utilities ───
298
+ function progressBar(pct, width) {
299
+ const filled = Math.round((pct / 100) * width);
300
+ const empty = width - filled;
301
+ return `${c.green}${'█'.repeat(filled)}${c.gray}${'░'.repeat(empty)}${c.reset}`;
302
+ }
303
+ function formatTokens(n) {
304
+ if (n < 1000)
305
+ return String(n);
306
+ if (n < 1_000_000)
307
+ return `${(n / 1000).toFixed(1)}K`;
308
+ return `${(n / 1_000_000).toFixed(2)}M`;
309
+ }
@@ -0,0 +1,262 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdirSync, rmSync, readFileSync } from 'node:fs';
3
+ import { resolve, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { Formatter } from './formatter.mjs';
6
+ const TEST_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '__fmt_test_tmp__');
7
+ const LOG_FILE = resolve(TEST_DIR, 'test.log');
8
+ beforeEach(() => {
9
+ mkdirSync(TEST_DIR, { recursive: true });
10
+ vi.spyOn(console, 'log').mockImplementation(() => { });
11
+ });
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ rmSync(TEST_DIR, { recursive: true, force: true });
15
+ });
16
+ function readLog() {
17
+ try {
18
+ return readFileSync(LOG_FILE, 'utf-8');
19
+ }
20
+ catch {
21
+ return '';
22
+ }
23
+ }
24
+ describe('Formatter', () => {
25
+ it('creates instance with log file', () => {
26
+ const fmt = new Formatter(LOG_FILE);
27
+ expect(fmt).toBeDefined();
28
+ });
29
+ it('banner outputs to console', () => {
30
+ const fmt = new Formatter(LOG_FILE);
31
+ fmt.banner();
32
+ expect(console.log).toHaveBeenCalled();
33
+ });
34
+ it('info writes to console and file', () => {
35
+ const fmt = new Formatter(LOG_FILE);
36
+ fmt.info('test message');
37
+ expect(console.log).toHaveBeenCalled();
38
+ const log = readLog();
39
+ expect(log).toContain('test message');
40
+ });
41
+ it('warn writes with warning indicator', () => {
42
+ const fmt = new Formatter(LOG_FILE);
43
+ fmt.warn('warning msg');
44
+ const log = readLog();
45
+ expect(log).toContain('warning msg');
46
+ });
47
+ it('error writes with error indicator', () => {
48
+ const fmt = new Formatter(LOG_FILE);
49
+ fmt.error('error msg');
50
+ const log = readLog();
51
+ expect(log).toContain('error msg');
52
+ });
53
+ it('success writes with success indicator', () => {
54
+ const fmt = new Formatter(LOG_FILE);
55
+ fmt.success('done');
56
+ const log = readLog();
57
+ expect(log).toContain('done');
58
+ });
59
+ it('iterationStart writes iteration header', () => {
60
+ const fmt = new Formatter(LOG_FILE);
61
+ fmt.iterationStart(3, 50);
62
+ const calls = console.log.mock.calls.map((c) => c[0]);
63
+ const joined = calls.join('\n');
64
+ expect(joined).toContain('3');
65
+ expect(joined).toContain('50');
66
+ });
67
+ it('iterationEnd writes summary', () => {
68
+ const fmt = new Formatter(LOG_FILE);
69
+ fmt.iterationStart(1, 10);
70
+ fmt.iterationEnd();
71
+ const calls = console.log.mock.calls.map((c) => c[0]);
72
+ const joined = calls.join('\n');
73
+ expect(joined).toContain('tools');
74
+ expect(joined).toContain('tokens');
75
+ });
76
+ });
77
+ describe('Formatter.handleMessage', () => {
78
+ it('handles assistant text messages', () => {
79
+ const fmt = new Formatter(LOG_FILE);
80
+ fmt.handleMessage({
81
+ type: 'assistant',
82
+ message: { content: [{ type: 'text', text: 'Hello from Claude' }] },
83
+ });
84
+ const log = readLog();
85
+ expect(log).toContain('Hello from Claude');
86
+ });
87
+ it('handles tool_use Bash', () => {
88
+ const fmt = new Formatter(LOG_FILE);
89
+ fmt.handleMessage({
90
+ type: 'assistant',
91
+ message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls -la' } }] },
92
+ });
93
+ const log = readLog();
94
+ expect(log).toContain('Bash');
95
+ expect(log).toContain('ls -la');
96
+ });
97
+ it('handles tool_use Read', () => {
98
+ const fmt = new Formatter(LOG_FILE);
99
+ fmt.handleMessage({
100
+ type: 'assistant',
101
+ message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/project/src/index.ts' } }] },
102
+ });
103
+ const log = readLog();
104
+ expect(log).toContain('Read');
105
+ expect(log).toContain('index.ts');
106
+ });
107
+ it('handles tool_use Write', () => {
108
+ const fmt = new Formatter(LOG_FILE);
109
+ fmt.handleMessage({
110
+ type: 'assistant',
111
+ message: { content: [{ type: 'tool_use', name: 'Write', input: { file_path: '/project/new-file.ts' } }] },
112
+ });
113
+ const log = readLog();
114
+ expect(log).toContain('Write');
115
+ expect(log).toContain('new-file.ts');
116
+ });
117
+ it('handles tool_use Edit', () => {
118
+ const fmt = new Formatter(LOG_FILE);
119
+ fmt.handleMessage({
120
+ type: 'assistant',
121
+ message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: '/project/file.ts' } }] },
122
+ });
123
+ const log = readLog();
124
+ expect(log).toContain('Edit');
125
+ });
126
+ it('handles tool_use Glob', () => {
127
+ const fmt = new Formatter(LOG_FILE);
128
+ fmt.handleMessage({
129
+ type: 'assistant',
130
+ message: { content: [{ type: 'tool_use', name: 'Glob', input: { pattern: '**/*.ts' } }] },
131
+ });
132
+ const log = readLog();
133
+ expect(log).toContain('Glob');
134
+ expect(log).toContain('**/*.ts');
135
+ });
136
+ it('handles tool_use Grep', () => {
137
+ const fmt = new Formatter(LOG_FILE);
138
+ fmt.handleMessage({
139
+ type: 'assistant',
140
+ message: { content: [{ type: 'tool_use', name: 'Grep', input: { pattern: 'TODO', path: 'src/' } }] },
141
+ });
142
+ const log = readLog();
143
+ expect(log).toContain('Grep');
144
+ expect(log).toContain('TODO');
145
+ });
146
+ it('handles Agent delegation', () => {
147
+ const fmt = new Formatter(LOG_FILE);
148
+ fmt.handleMessage({
149
+ type: 'assistant',
150
+ message: { content: [{ type: 'tool_use', name: 'Agent', input: { subagent_type: 'backend-ts-architect', description: 'Plan API design' } }] },
151
+ });
152
+ const log = readLog();
153
+ expect(log).toContain('backend-ts-architect');
154
+ expect(log).toContain('Plan API design');
155
+ });
156
+ it('handles tool results (short)', () => {
157
+ const fmt = new Formatter(LOG_FILE);
158
+ fmt.handleMessage({
159
+ type: 'user',
160
+ message: { content: [{ type: 'tool_result', content: 'file1.ts\nfile2.ts', is_error: false }] },
161
+ });
162
+ const log = readLog();
163
+ expect(log).toContain('file1.ts');
164
+ });
165
+ it('handles tool results (long → line count)', () => {
166
+ const fmt = new Formatter(LOG_FILE);
167
+ const longOutput = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n');
168
+ fmt.handleMessage({
169
+ type: 'user',
170
+ message: { content: [{ type: 'tool_result', content: longOutput, is_error: false }] },
171
+ });
172
+ const log = readLog();
173
+ expect(log).toContain('50 lines');
174
+ });
175
+ it('handles tool error results', () => {
176
+ const fmt = new Formatter(LOG_FILE);
177
+ fmt.handleMessage({
178
+ type: 'user',
179
+ message: { content: [{ type: 'tool_result', content: 'Permission denied', is_error: true }] },
180
+ });
181
+ const log = readLog();
182
+ expect(log).toContain('Permission denied');
183
+ });
184
+ it('handles system task_progress', () => {
185
+ const fmt = new Formatter(LOG_FILE);
186
+ fmt.handleMessage({
187
+ type: 'system',
188
+ subtype: 'task_progress',
189
+ description: 'Reading package.json',
190
+ });
191
+ const log = readLog();
192
+ expect(log).toContain('Reading package.json');
193
+ });
194
+ it('handles result message', () => {
195
+ const fmt = new Formatter(LOG_FILE);
196
+ fmt.handleMessage({
197
+ type: 'result',
198
+ session_id: 'abc-123',
199
+ });
200
+ const log = readLog();
201
+ expect(log).toContain('SESSION: abc-123');
202
+ });
203
+ it('getSessionId extracts session_id', () => {
204
+ const fmt = new Formatter(LOG_FILE);
205
+ expect(fmt.getSessionId({ session_id: 'test-id' })).toBe('test-id');
206
+ expect(fmt.getSessionId({})).toBeNull();
207
+ });
208
+ it('ignores unknown message types silently', () => {
209
+ const fmt = new Formatter(LOG_FILE);
210
+ fmt.handleMessage({ type: 'stream_event', data: 'something' });
211
+ const log = readLog();
212
+ expect(log).toBe('');
213
+ });
214
+ it('handles multiple content blocks in one message', () => {
215
+ const fmt = new Formatter(LOG_FILE);
216
+ fmt.handleMessage({
217
+ type: 'assistant',
218
+ message: {
219
+ content: [
220
+ { type: 'text', text: 'Planning the implementation' },
221
+ { type: 'tool_use', name: 'Read', input: { file_path: '/project/src/main.ts' } },
222
+ { type: 'tool_use', name: 'Bash', input: { command: 'npm test' } },
223
+ ],
224
+ },
225
+ });
226
+ const log = readLog();
227
+ expect(log).toContain('Planning the implementation');
228
+ expect(log).toContain('Read');
229
+ expect(log).toContain('Bash');
230
+ expect(log).toContain('npm test');
231
+ });
232
+ it('tracks tokens from usage', () => {
233
+ const fmt = new Formatter(LOG_FILE);
234
+ fmt.iterationStart(1, 10);
235
+ fmt.handleMessage({
236
+ type: 'assistant',
237
+ message: {
238
+ content: [{ type: 'text', text: 'hi' }],
239
+ usage: { input_tokens: 1000, output_tokens: 500 },
240
+ },
241
+ });
242
+ fmt.iterationEnd();
243
+ const calls = console.log.mock.calls.map((c) => c[0]);
244
+ const summary = calls.join('\n');
245
+ expect(summary).toContain('1.5K');
246
+ });
247
+ it('increments tool call counter', () => {
248
+ const fmt = new Formatter(LOG_FILE);
249
+ fmt.iterationStart(1, 10);
250
+ fmt.handleMessage({
251
+ type: 'assistant',
252
+ message: { content: [
253
+ { type: 'tool_use', name: 'Read', input: { file_path: 'a.ts' } },
254
+ { type: 'tool_use', name: 'Read', input: { file_path: 'b.ts' } },
255
+ ] },
256
+ });
257
+ fmt.iterationEnd();
258
+ const calls = console.log.mock.calls.map((c) => c[0]);
259
+ const summary = calls.join('\n');
260
+ expect(summary).toContain('2 tools');
261
+ });
262
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "1.1.120",
3
+ "version": "1.1.122",
4
4
  "author": "",
5
5
  "repository": {
6
6
  "type": "git",