claude-tempo 0.1.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,579 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join, resolve } from 'path';
3
+ import { execFileSync, spawn as cpSpawn, ChildProcess } from 'child_process';
4
+ import { homedir } from 'os';
5
+ import { Connection, Client } from '@temporalio/client';
6
+ import { spawnInTerminal } from '../spawn';
7
+ import { runPreflight } from './preflight';
8
+ import * as out from './output';
9
+
10
+ /** Package root is two levels up from dist/cli/ */
11
+ const PACKAGE_ROOT = resolve(__dirname, '..', '..');
12
+
13
+ interface StartOpts {
14
+ ensemble: string;
15
+ conductor: boolean;
16
+ temporalAddress: string;
17
+ name?: string;
18
+ skipPreflight?: boolean;
19
+ }
20
+
21
+ export async function start(opts: StartOpts) {
22
+ const workDir = process.cwd();
23
+
24
+ if (!opts.skipPreflight) {
25
+ const result = await runPreflight({
26
+ temporalAddress: opts.temporalAddress,
27
+ projectDir: workDir,
28
+ });
29
+ for (const w of result.warnings) out.warn(w);
30
+ if (!result.ok) {
31
+ for (const e of result.errors) out.error(e);
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ const role = opts.conductor ? 'conductor' : 'player';
37
+ out.log(`Starting ${out.bold(role)} in ensemble ${out.cyan(opts.ensemble)}`);
38
+
39
+ const claudeArgs = [
40
+ '--dangerously-skip-permissions',
41
+ '--dangerously-load-development-channels', 'server:claude-tempo',
42
+ ];
43
+ if (opts.name) {
44
+ claudeArgs.push('-n', opts.name);
45
+ }
46
+
47
+ const envVars: Record<string, string> = {
48
+ CLAUDE_TEMPO_ENSEMBLE: opts.ensemble,
49
+ };
50
+ if (opts.conductor) {
51
+ envVars.CLAUDE_TEMPO_CONDUCTOR = 'true';
52
+ }
53
+
54
+ const { pid } = spawnInTerminal(claudeArgs, workDir, envVars);
55
+ out.success(`Launched ${role} session${opts.name ? ` "${opts.name}"` : ''} (pid ${pid ?? 'unknown'})`);
56
+ out.log(` Ensemble: ${opts.ensemble}`);
57
+ out.log(` Directory: ${workDir}`);
58
+ out.log(`\nCheck status: ${out.dim('claude-tempo status ' + opts.ensemble)}`);
59
+ }
60
+
61
+ interface StatusOpts {
62
+ ensemble?: string;
63
+ temporalAddress: string;
64
+ }
65
+
66
+ export async function status(opts: StatusOpts) {
67
+ let connection: Connection;
68
+ try {
69
+ connection = await Promise.race([
70
+ Connection.connect({ address: opts.temporalAddress }),
71
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
72
+ ]);
73
+ } catch {
74
+ out.error(`Cannot connect to Temporal at ${opts.temporalAddress}`);
75
+ out.log(` Run: ${out.dim('temporal server start-dev')}`);
76
+ process.exit(1);
77
+ return; // unreachable, helps TS
78
+ }
79
+
80
+ const client = new Client({ connection });
81
+
82
+ // Build query
83
+ let query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
84
+ if (opts.ensemble) {
85
+ query += ` AND ClaudeTempoEnsemble = "${opts.ensemble}"`;
86
+ }
87
+
88
+ const sessions: Array<{
89
+ id: string;
90
+ name: string;
91
+ part: string;
92
+ ensemble: string;
93
+ workDir: string;
94
+ branch: string;
95
+ host: string;
96
+ conductor: boolean;
97
+ }> = [];
98
+
99
+ for await (const wf of client.workflow.list({ query })) {
100
+ try {
101
+ const handle = client.workflow.getHandle(wf.workflowId);
102
+ const [metadata, part] = await Promise.all([
103
+ handle.query('getMetadata').catch(() => ({})),
104
+ handle.query('getPart').catch(() => ''),
105
+ ]);
106
+ const meta = metadata as Record<string, unknown>;
107
+ sessions.push({
108
+ id: wf.workflowId,
109
+ name: (meta.playerId as string) || wf.workflowId.split('-').pop() || '?',
110
+ part: (part as string) || '',
111
+ ensemble: (meta.ensemble as string) || '?',
112
+ workDir: (meta.workDir as string) || '?',
113
+ branch: (meta.gitBranch as string) || '',
114
+ host: (meta.hostname as string) || '',
115
+ conductor: (meta.isConductor as boolean) || false,
116
+ });
117
+ } catch {
118
+ // workflow may have closed between list and query
119
+ }
120
+ }
121
+
122
+ await connection.close();
123
+
124
+ if (sessions.length === 0) {
125
+ out.log(opts.ensemble
126
+ ? `No active sessions in ensemble "${opts.ensemble}".`
127
+ : 'No active sessions found.');
128
+ return;
129
+ }
130
+
131
+ // Group by ensemble
132
+ const byEnsemble = new Map<string, typeof sessions>();
133
+ for (const s of sessions) {
134
+ const list = byEnsemble.get(s.ensemble) || [];
135
+ list.push(s);
136
+ byEnsemble.set(s.ensemble, list);
137
+ }
138
+
139
+ for (const [ensemble, members] of byEnsemble) {
140
+ out.heading(`Ensemble: ${ensemble}`);
141
+ out.log(` ${out.dim(`${members.length} active session${members.length !== 1 ? 's' : ''}`)}`);
142
+ console.log();
143
+
144
+ // Sort: conductor first, then alphabetical
145
+ members.sort((a, b) => {
146
+ if (a.conductor !== b.conductor) return a.conductor ? -1 : 1;
147
+ return a.name.localeCompare(b.name);
148
+ });
149
+
150
+ for (const s of members) {
151
+ const role = s.conductor ? out.yellow(' (conductor)') : '';
152
+ const name = out.bold(s.name);
153
+ out.log(` ${name}${role}`);
154
+ if (s.part) out.log(` ${out.dim(s.part)}`);
155
+ const details = [s.workDir, s.branch, s.host].filter(Boolean).join(' ');
156
+ if (details) out.log(` ${out.dim(details)}`);
157
+ }
158
+ }
159
+ console.log();
160
+ }
161
+
162
+ interface InitOpts {
163
+ dir: string;
164
+ }
165
+
166
+ export async function init(opts: InitOpts) {
167
+ const mcpPath = join(opts.dir, '.mcp.json');
168
+
169
+ const entry = {
170
+ command: 'claude-tempo-server',
171
+ args: [] as string[],
172
+ };
173
+
174
+ if (existsSync(mcpPath)) {
175
+ try {
176
+ const existing = JSON.parse(readFileSync(mcpPath, 'utf8'));
177
+ if (existing?.mcpServers?.['claude-tempo']) {
178
+ out.success('.mcp.json already has a claude-tempo entry');
179
+ out.log(` ${out.dim(mcpPath)}`);
180
+ return;
181
+ }
182
+ // Merge into existing config
183
+ existing.mcpServers = existing.mcpServers || {};
184
+ existing.mcpServers['claude-tempo'] = entry;
185
+ writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n');
186
+ out.success('Added claude-tempo to existing .mcp.json');
187
+ } catch {
188
+ out.error(`Failed to parse ${mcpPath}. Fix the JSON or delete it and re-run.`);
189
+ process.exit(1);
190
+ }
191
+ } else {
192
+ const config = {
193
+ mcpServers: {
194
+ 'claude-tempo': entry,
195
+ },
196
+ };
197
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n');
198
+ out.success('Created .mcp.json with claude-tempo config');
199
+ }
200
+
201
+ out.log(` ${out.dim(mcpPath)}`);
202
+ out.log(`\nNext steps:`);
203
+ out.log(` 1. Start Temporal: ${out.dim('temporal server start-dev')}`);
204
+ out.log(` 2. Start conductor: ${out.dim('claude-tempo conduct')}`);
205
+ }
206
+
207
+ // --- Temporal server management ---
208
+
209
+ const CLAUDE_TEMPO_HOME = join(homedir(), '.claude-tempo');
210
+ const DEFAULT_DB_PATH = join(CLAUDE_TEMPO_HOME, 'temporal-data.db');
211
+
212
+ const SEARCH_ATTRIBUTES = [
213
+ { name: 'ClaudeTempoHostname', type: 'Keyword' },
214
+ { name: 'ClaudeTempoGitRoot', type: 'Keyword' },
215
+ { name: 'ClaudeTempoEnsemble', type: 'Keyword' },
216
+ { name: 'ClaudeTempoPlayerId', type: 'Keyword' },
217
+ ];
218
+
219
+ function isTemporalReachable(address: string): Promise<boolean> {
220
+ return Connection.connect({ address })
221
+ .then(conn => { conn.close(); return true; })
222
+ .catch(() => false);
223
+ }
224
+
225
+ function temporalCliExists(): boolean {
226
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
227
+ try {
228
+ execFileSync(cmd, ['temporal'], { stdio: 'ignore' });
229
+ return true;
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+
235
+ function registerSearchAttributes(temporalAddress: string) {
236
+ for (const attr of SEARCH_ATTRIBUTES) {
237
+ try {
238
+ execFileSync('temporal', [
239
+ 'operator', 'search-attribute', 'create',
240
+ '--address', temporalAddress,
241
+ '--name', attr.name,
242
+ '--type', attr.type,
243
+ ], { stdio: ['ignore', 'ignore', 'ignore'] });
244
+ out.success(`Registered search attribute: ${attr.name}`);
245
+ } catch {
246
+ // Already exists or other error — safe to ignore
247
+ out.dim(` ${attr.name} (already exists)`);
248
+ }
249
+ }
250
+ }
251
+
252
+ interface ServerOpts {
253
+ temporalAddress: string;
254
+ background: boolean;
255
+ }
256
+
257
+ export async function server(opts: ServerOpts) {
258
+ if (!temporalCliExists()) {
259
+ out.error('temporal CLI not found on PATH');
260
+ out.log(` Install: ${out.dim('https://docs.temporal.io/cli')}`);
261
+ process.exit(1);
262
+ }
263
+
264
+ // Check if already running
265
+ const alreadyRunning = await isTemporalReachable(opts.temporalAddress);
266
+ if (alreadyRunning) {
267
+ out.success(`Temporal already running at ${opts.temporalAddress}`);
268
+ out.log(' Registering search attributes...');
269
+ registerSearchAttributes(opts.temporalAddress);
270
+ return;
271
+ }
272
+
273
+ // Ensure data directory exists
274
+ mkdirSync(CLAUDE_TEMPO_HOME, { recursive: true });
275
+
276
+ const port = opts.temporalAddress.split(':')[1] || '7233';
277
+ const args = [
278
+ 'server', 'start-dev',
279
+ '--port', port,
280
+ '--db-filename', DEFAULT_DB_PATH,
281
+ ];
282
+
283
+ out.log(`Starting Temporal dev server on port ${port}...`);
284
+ out.log(` Data: ${out.dim(DEFAULT_DB_PATH)}`);
285
+
286
+ if (opts.background) {
287
+ const child = cpSpawn('temporal', args, {
288
+ detached: true,
289
+ stdio: 'ignore',
290
+ });
291
+ child.unref();
292
+ out.success(`Temporal started in background (pid ${child.pid})`);
293
+
294
+ // Wait for it to be ready
295
+ for (let i = 0; i < 20; i++) {
296
+ await new Promise(r => setTimeout(r, 500));
297
+ if (await isTemporalReachable(opts.temporalAddress)) break;
298
+ }
299
+ } else {
300
+ // Foreground — register attributes after startup, then hand over stdio
301
+ const child = cpSpawn('temporal', args, {
302
+ stdio: ['ignore', 'pipe', 'pipe'],
303
+ });
304
+
305
+ // Wait for ready, then register attributes
306
+ const waitForReady = async () => {
307
+ for (let i = 0; i < 20; i++) {
308
+ await new Promise(r => setTimeout(r, 500));
309
+ if (await isTemporalReachable(opts.temporalAddress)) {
310
+ out.success(`Temporal running at ${opts.temporalAddress}`);
311
+ out.log(' Registering search attributes...');
312
+ registerSearchAttributes(opts.temporalAddress);
313
+ out.log(`\n ${out.dim('Press Ctrl+C to stop')}\n`);
314
+ return;
315
+ }
316
+ }
317
+ out.warn('Temporal started but not responding — search attributes not registered');
318
+ };
319
+ waitForReady();
320
+
321
+ // Pipe output through
322
+ child.stdout?.pipe(process.stdout);
323
+ child.stderr?.pipe(process.stderr);
324
+
325
+ // Forward signals for clean shutdown
326
+ const forward = (sig: NodeJS.Signals) => { child.kill(sig); };
327
+ process.on('SIGINT', () => forward('SIGINT'));
328
+ process.on('SIGTERM', () => forward('SIGTERM'));
329
+
330
+ await new Promise<void>((resolve) => {
331
+ child.on('exit', (code) => {
332
+ if (code && code !== 0) out.error(`Temporal exited with code ${code}`);
333
+ resolve();
334
+ });
335
+ });
336
+ }
337
+
338
+ // Register search attributes (for background mode — foreground does it inline)
339
+ if (opts.background) {
340
+ out.log(' Registering search attributes...');
341
+ registerSearchAttributes(opts.temporalAddress);
342
+ out.success('Temporal ready');
343
+ }
344
+ }
345
+
346
+ // --- First-time setup: `up` command ---
347
+
348
+ interface UpOpts {
349
+ ensemble: string;
350
+ temporalAddress: string;
351
+ name?: string;
352
+ }
353
+
354
+ export async function up(opts: UpOpts) {
355
+ out.heading('claude-tempo setup');
356
+
357
+ // Step 1: Check temporal CLI
358
+ if (!temporalCliExists()) {
359
+ out.error('temporal CLI not found');
360
+ out.log(`\n Install the Temporal CLI first:`);
361
+ out.log(` ${out.dim('https://docs.temporal.io/cli')}\n`);
362
+ process.exit(1);
363
+ }
364
+ out.check('temporal CLI installed', true);
365
+
366
+ // Step 2: Start Temporal if needed
367
+ const temporalUp = await isTemporalReachable(opts.temporalAddress);
368
+ if (temporalUp) {
369
+ out.check('Temporal running', true, opts.temporalAddress);
370
+ } else {
371
+ out.log(` ${out.dim('...')} Starting Temporal dev server...`);
372
+ mkdirSync(CLAUDE_TEMPO_HOME, { recursive: true });
373
+ const port = opts.temporalAddress.split(':')[1] || '7233';
374
+ const child = cpSpawn('temporal', [
375
+ 'server', 'start-dev',
376
+ '--port', port,
377
+ '--db-filename', DEFAULT_DB_PATH,
378
+ ], { detached: true, stdio: 'ignore' });
379
+ child.unref();
380
+
381
+ // Wait for ready
382
+ let ready = false;
383
+ for (let i = 0; i < 20; i++) {
384
+ await new Promise(r => setTimeout(r, 500));
385
+ if (await isTemporalReachable(opts.temporalAddress)) { ready = true; break; }
386
+ }
387
+ if (!ready) {
388
+ out.error('Temporal did not start within 10 seconds');
389
+ process.exit(1);
390
+ }
391
+ out.check('Temporal started', true, `pid ${child.pid}, data in ~/.claude-tempo/`);
392
+ }
393
+
394
+ // Step 3: Register search attributes
395
+ registerSearchAttributes(opts.temporalAddress);
396
+
397
+ // Step 4: Init .mcp.json if needed
398
+ const mcpPath = join(process.cwd(), '.mcp.json');
399
+ let mcpExists = false;
400
+ if (existsSync(mcpPath)) {
401
+ try {
402
+ const mcp = JSON.parse(readFileSync(mcpPath, 'utf8'));
403
+ mcpExists = !!mcp?.mcpServers?.['claude-tempo'];
404
+ } catch { /* invalid */ }
405
+ }
406
+ if (mcpExists) {
407
+ out.check('.mcp.json configured', true);
408
+ } else {
409
+ await init({ dir: process.cwd() });
410
+ out.check('.mcp.json created', true);
411
+ }
412
+
413
+ // Step 5: Launch conductor
414
+ console.log();
415
+ out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}...`);
416
+ const claudeArgs = [
417
+ '--dangerously-skip-permissions',
418
+ '--dangerously-load-development-channels', 'server:claude-tempo',
419
+ ];
420
+ if (opts.name) claudeArgs.push('-n', opts.name);
421
+
422
+ const { pid } = spawnInTerminal(claudeArgs, process.cwd(), {
423
+ CLAUDE_TEMPO_ENSEMBLE: opts.ensemble,
424
+ CLAUDE_TEMPO_CONDUCTOR: 'true',
425
+ });
426
+
427
+ console.log();
428
+ out.success('You\'re all set!');
429
+ out.log(` Conductor launched (pid ${pid ?? 'unknown'})`);
430
+ out.log(` Ensemble: ${out.cyan(opts.ensemble)}`);
431
+ out.log(`\n ${out.bold('What next?')}`);
432
+ out.log(` ${out.dim('claude-tempo start ' + opts.ensemble)} Add a player session`);
433
+ out.log(` ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
434
+ out.log(` Or ask the conductor to ${out.dim('recruit')} players for you`);
435
+ console.log();
436
+ }
437
+
438
+ // --- Teardown: `down` command ---
439
+
440
+ interface DownOpts {
441
+ temporalAddress: string;
442
+ removeMcp: boolean;
443
+ dir: string;
444
+ }
445
+
446
+ export async function down(opts: DownOpts) {
447
+ out.heading('claude-tempo teardown');
448
+
449
+ // Step 1: Terminate all active workflows
450
+ const temporalUp = await isTemporalReachable(opts.temporalAddress);
451
+ if (temporalUp) {
452
+ try {
453
+ const connection = await Connection.connect({ address: opts.temporalAddress });
454
+ const client = new Client({ connection });
455
+ const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
456
+ let terminated = 0;
457
+ for await (const wf of client.workflow.list({ query })) {
458
+ try {
459
+ const handle = client.workflow.getHandle(wf.workflowId);
460
+ await handle.terminate('claude-tempo down');
461
+ terminated++;
462
+ } catch { /* already closed */ }
463
+ }
464
+ await connection.close();
465
+ if (terminated > 0) {
466
+ out.success(`Terminated ${terminated} active session${terminated !== 1 ? 's' : ''}`);
467
+ } else {
468
+ out.log(` ${out.dim('No active sessions to terminate')}`);
469
+ }
470
+ } catch {
471
+ out.warn('Could not terminate active sessions');
472
+ }
473
+ }
474
+
475
+ // Step 2: Stop Temporal server
476
+ if (temporalUp) {
477
+ // Find and kill the temporal dev server process
478
+ try {
479
+ if (process.platform === 'win32') {
480
+ execFileSync('taskkill', ['/F', '/IM', 'temporal.exe'], { stdio: 'ignore' });
481
+ } else {
482
+ // Kill temporal server processes started by start-dev
483
+ execFileSync('pkill', ['-f', 'temporal server start-dev'], { stdio: 'ignore' });
484
+ }
485
+ out.success('Temporal server stopped');
486
+ } catch {
487
+ out.warn('Could not stop Temporal server (may need to stop it manually)');
488
+ }
489
+ } else {
490
+ out.log(` ${out.dim('Temporal not running')}`);
491
+ }
492
+
493
+ // Step 3: Remove .mcp.json entry
494
+ if (opts.removeMcp) {
495
+ const mcpPath = join(opts.dir, '.mcp.json');
496
+ if (existsSync(mcpPath)) {
497
+ try {
498
+ const existing = JSON.parse(readFileSync(mcpPath, 'utf8'));
499
+ if (existing?.mcpServers?.['claude-tempo']) {
500
+ delete existing.mcpServers['claude-tempo'];
501
+ // If no other MCP servers remain, remove the file entirely
502
+ if (Object.keys(existing.mcpServers).length === 0) {
503
+ const { unlinkSync } = require('fs');
504
+ unlinkSync(mcpPath);
505
+ out.success('Removed .mcp.json (no other servers configured)');
506
+ } else {
507
+ writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n');
508
+ out.success('Removed claude-tempo from .mcp.json');
509
+ }
510
+ } else {
511
+ out.log(` ${out.dim('.mcp.json has no claude-tempo entry')}`);
512
+ }
513
+ } catch {
514
+ out.warn(`Could not update ${mcpPath}`);
515
+ }
516
+ } else {
517
+ out.log(` ${out.dim('No .mcp.json found')}`);
518
+ }
519
+ }
520
+
521
+ console.log();
522
+ out.success('claude-tempo is shut down');
523
+ out.log(` ${out.dim('Temporal data preserved in ~/.claude-tempo/ (delete manually to reset)')}`);
524
+ console.log();
525
+ }
526
+
527
+ export function help() {
528
+ console.log(`
529
+ ${out.bold('claude-tempo')} — Multi-session Claude Code coordination via Temporal
530
+
531
+ ${out.bold('Getting started:')}
532
+ ${out.cyan('claude-tempo up')} Set up everything and launch a conductor
533
+
534
+ ${out.bold('Usage:')}
535
+ claude-tempo <command> [options]
536
+
537
+ ${out.bold('Commands:')}
538
+ ${out.cyan('up')} [ensemble] First-time setup: start Temporal, configure MCP, launch conductor
539
+ ${out.cyan('down')} Stop Temporal, terminate sessions, remove MCP config
540
+ ${out.cyan('server')} Start the Temporal dev server and register search attributes
541
+ ${out.cyan('conduct')} [ensemble] Start a conductor session (one per ensemble)
542
+ ${out.cyan('start')} [ensemble] Start a player session
543
+ ${out.cyan('status')} [ensemble] Show active sessions and Temporal health
544
+ ${out.cyan('init')} Create .mcp.json config in the current directory
545
+ ${out.cyan('preflight')} Run preflight checks only
546
+ ${out.cyan('help')} Show this help message
547
+
548
+ ${out.bold('Options:')}
549
+ --temporal-address <addr> Temporal server address (default: localhost:7233)
550
+ -n, --name <name> Set the session window name (start/conduct/up only)
551
+ --skip-preflight Skip preflight checks (start/conduct only)
552
+ --background Run Temporal in background (server only)
553
+ --keep-mcp Don't remove .mcp.json entry (down only)
554
+ --dir <path> Target directory for init/down (default: cwd)
555
+
556
+ ${out.bold('First time? Run this:')}
557
+ ${out.dim('cd your-project')}
558
+ ${out.dim('claude-tempo up')}
559
+
560
+ ${out.bold('Typical workflow:')}
561
+ ${out.dim('claude-tempo server')} Start Temporal (once, keep running)
562
+ ${out.dim('claude-tempo conduct myband')} Start a conductor
563
+ ${out.dim('claude-tempo start myband')} Add player sessions
564
+ ${out.dim('claude-tempo status myband')} Check who's active
565
+
566
+ ${out.bold('Environment:')}
567
+ CLAUDE_TEMPO_ENSEMBLE Default ensemble name (fallback: "default")
568
+ TEMPORAL_ADDRESS Default Temporal address (fallback: localhost:7233)
569
+ `);
570
+ }
571
+
572
+ export function version() {
573
+ try {
574
+ const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'));
575
+ out.log(`claude-tempo v${pkg.version}`);
576
+ } catch {
577
+ out.log('claude-tempo (unknown version)');
578
+ }
579
+ }
@@ -0,0 +1,36 @@
1
+ const isColor = process.stdout.isTTY && !process.env.NO_COLOR;
2
+
3
+ const ansi = (code: string) => (s: string) => isColor ? `\x1b[${code}m${s}\x1b[0m` : s;
4
+
5
+ export const bold = ansi('1');
6
+ export const dim = ansi('2');
7
+ export const red = ansi('31');
8
+ export const green = ansi('32');
9
+ export const yellow = ansi('33');
10
+ export const cyan = ansi('36');
11
+
12
+ export function log(msg: string) {
13
+ console.log(msg);
14
+ }
15
+
16
+ export function error(msg: string) {
17
+ console.error(`${red('error')} ${msg}`);
18
+ }
19
+
20
+ export function success(msg: string) {
21
+ console.log(`${green('ok')} ${msg}`);
22
+ }
23
+
24
+ export function warn(msg: string) {
25
+ console.log(`${yellow('warn')} ${msg}`);
26
+ }
27
+
28
+ export function heading(msg: string) {
29
+ console.log(`\n${bold(msg)}`);
30
+ }
31
+
32
+ export function check(label: string, ok: boolean, detail?: string) {
33
+ const icon = ok ? green('pass') : red('FAIL');
34
+ const suffix = detail ? ` ${dim(`(${detail})`)}` : '';
35
+ console.log(` ${icon} ${label}${suffix}`);
36
+ }
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { execFileSync } from 'child_process';
3
+ import { join } from 'path';
4
+ import { Connection } from '@temporalio/client';
5
+ import { resolveClaudePath } from '../spawn';
6
+ import * as out from './output';
7
+
8
+ export interface PreflightResult {
9
+ ok: boolean;
10
+ errors: string[];
11
+ warnings: string[];
12
+ }
13
+
14
+ function whichSync(cmd: string): string | null {
15
+ const bin = process.platform === 'win32' ? 'where' : 'which';
16
+ try {
17
+ return execFileSync(bin, [cmd], { encoding: 'utf8' }).trim().split('\n')[0];
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export async function runPreflight(opts: {
24
+ temporalAddress: string;
25
+ projectDir: string;
26
+ }): Promise<PreflightResult> {
27
+ const errors: string[] = [];
28
+ const warnings: string[] = [];
29
+
30
+ out.heading('Preflight checks');
31
+
32
+ // 1. Node.js version
33
+ const major = parseInt(process.version.slice(1), 10);
34
+ const nodeOk = major >= 18;
35
+ out.check('Node.js >= 18', nodeOk, process.version);
36
+ if (!nodeOk) errors.push(`Node.js 18+ required, found ${process.version}`);
37
+
38
+ // 2. Temporal reachable
39
+ let temporalOk = false;
40
+ try {
41
+ const conn = await Promise.race([
42
+ Connection.connect({ address: opts.temporalAddress }),
43
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
44
+ ]);
45
+ await conn.close();
46
+ temporalOk = true;
47
+ } catch { /* unreachable */ }
48
+ out.check('Temporal reachable', temporalOk, opts.temporalAddress);
49
+ if (!temporalOk) errors.push(`Cannot connect to Temporal at ${opts.temporalAddress}. Run: temporal server start-dev`);
50
+
51
+ // 3. claude binary
52
+ const claudePath = resolveClaudePath();
53
+ const claudeOk = claudePath !== 'claude';
54
+ out.check('claude binary found', claudeOk, claudeOk ? claudePath : 'not on PATH');
55
+ if (!claudeOk) errors.push('claude binary not found on PATH. Install Claude Code first.');
56
+
57
+ // 4. claude-tempo-server binary (the MCP server)
58
+ const serverPath = whichSync('claude-tempo-server');
59
+ const serverOk = !!serverPath;
60
+ out.check('claude-tempo-server found', serverOk, serverOk ? serverPath! : 'not on PATH');
61
+ if (!serverOk) errors.push('claude-tempo-server not found on PATH. Run: npm install -g claude-tempo');
62
+
63
+ // 5. MCP config in project
64
+ const mcpPath = join(opts.projectDir, '.mcp.json');
65
+ let mcpOk = false;
66
+ if (existsSync(mcpPath)) {
67
+ try {
68
+ const mcp = JSON.parse(readFileSync(mcpPath, 'utf8'));
69
+ mcpOk = !!mcp?.mcpServers?.['claude-tempo'];
70
+ } catch { /* invalid json */ }
71
+ }
72
+ out.check('.mcp.json configured', mcpOk, mcpOk ? mcpPath : 'missing or no claude-tempo entry');
73
+ if (!mcpOk) warnings.push(`No claude-tempo MCP config in ${opts.projectDir}. Run: claude-tempo init`);
74
+
75
+ console.log();
76
+ return { ok: errors.length === 0, errors, warnings };
77
+ }