tlc-claude-code 0.6.3 → 0.7.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,406 @@
1
+ #!/usr/bin/env node
2
+
3
+ const express = require('express');
4
+ const { createServer } = require('http');
5
+ const { WebSocketServer } = require('ws');
6
+ const { createProxyMiddleware } = require('http-proxy-middleware');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const { spawn } = require('child_process');
10
+ const chokidar = require('chokidar');
11
+
12
+ const { detectProject } = require('./lib/project-detector');
13
+ const { parsePlan, parseBugs } = require('./lib/plan-parser');
14
+
15
+ // Configuration
16
+ const TLC_PORT = parseInt(process.env.TLC_PORT || '3147');
17
+ const PROJECT_DIR = process.cwd();
18
+
19
+ // State
20
+ let appProcess = null;
21
+ let appPort = 3000;
22
+ let wsClients = new Set();
23
+ const logs = { app: [], test: [], git: [] };
24
+
25
+ // Create Express app
26
+ const app = express();
27
+ const server = createServer(app);
28
+ const wss = new WebSocketServer({ server });
29
+
30
+ // Middleware
31
+ app.use(express.json());
32
+ app.use(express.static(path.join(__dirname, 'dashboard')));
33
+
34
+ // Broadcast to all WebSocket clients
35
+ function broadcast(type, data) {
36
+ const message = JSON.stringify({ type, data });
37
+ wsClients.forEach(client => {
38
+ if (client.readyState === 1) { // OPEN
39
+ client.send(message);
40
+ }
41
+ });
42
+ }
43
+
44
+ // Add log entry
45
+ function addLog(type, text, level = '') {
46
+ const entry = { text, level, time: new Date().toISOString() };
47
+ logs[type].push(entry);
48
+ if (logs[type].length > 1000) logs[type].shift();
49
+ broadcast(`${type}-log`, { data: text, level });
50
+ }
51
+
52
+ // WebSocket connection handling
53
+ wss.on('connection', (ws) => {
54
+ wsClients.add(ws);
55
+ console.log(`[TLC] Client connected (${wsClients.size} total)`);
56
+
57
+ // Send recent logs to new client
58
+ ws.send(JSON.stringify({ type: 'init', data: { logs, appPort } }));
59
+
60
+ ws.on('close', () => {
61
+ wsClients.delete(ws);
62
+ console.log(`[TLC] Client disconnected (${wsClients.size} total)`);
63
+ });
64
+ });
65
+
66
+ // Start the user's app
67
+ async function startApp() {
68
+ const project = detectProject(PROJECT_DIR);
69
+
70
+ if (!project) {
71
+ addLog('app', 'Could not detect project type. Create a start command in .tlc.json', 'error');
72
+ return;
73
+ }
74
+
75
+ appPort = project.port;
76
+ addLog('app', `Detected: ${project.name}`, 'info');
77
+ addLog('app', `Command: ${project.cmd} ${project.args.join(' ')}`, 'info');
78
+ addLog('app', `Port: ${appPort}`, 'info');
79
+
80
+ // Kill existing process if any
81
+ if (appProcess) {
82
+ appProcess.kill();
83
+ await new Promise(resolve => setTimeout(resolve, 500));
84
+ }
85
+
86
+ appProcess = spawn(project.cmd, project.args, {
87
+ cwd: PROJECT_DIR,
88
+ env: { ...process.env, PORT: appPort.toString() },
89
+ shell: true
90
+ });
91
+
92
+ appProcess.stdout.on('data', (data) => {
93
+ const text = data.toString().trim();
94
+ if (text) addLog('app', text);
95
+ });
96
+
97
+ appProcess.stderr.on('data', (data) => {
98
+ const text = data.toString().trim();
99
+ if (text) addLog('app', text, 'error');
100
+ });
101
+
102
+ appProcess.on('exit', (code) => {
103
+ addLog('app', `App exited with code ${code}`, code === 0 ? 'info' : 'error');
104
+ appProcess = null;
105
+ });
106
+
107
+ broadcast('app-start', { port: appPort });
108
+ }
109
+
110
+ // Run tests
111
+ function runTests() {
112
+ addLog('test', '--- Running tests ---', 'info');
113
+
114
+ // Try to detect test command
115
+ let testCmd = 'npm';
116
+ let testArgs = ['test'];
117
+
118
+ const pkgPath = path.join(PROJECT_DIR, 'package.json');
119
+ if (fs.existsSync(pkgPath)) {
120
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
121
+ if (pkg.scripts?.test) {
122
+ testCmd = 'npm';
123
+ testArgs = ['test'];
124
+ }
125
+ }
126
+
127
+ const testProcess = spawn(testCmd, testArgs, {
128
+ cwd: PROJECT_DIR,
129
+ env: { ...process.env, CI: 'true' },
130
+ shell: true
131
+ });
132
+
133
+ testProcess.stdout.on('data', (data) => {
134
+ const text = data.toString().trim();
135
+ if (text) {
136
+ broadcast('test-output', { data: text, stream: 'stdout' });
137
+ addLog('test', text);
138
+ }
139
+ });
140
+
141
+ testProcess.stderr.on('data', (data) => {
142
+ const text = data.toString().trim();
143
+ if (text) {
144
+ broadcast('test-output', { data: text, stream: 'stderr' });
145
+ addLog('test', text, 'error');
146
+ }
147
+ });
148
+
149
+ testProcess.on('exit', (code) => {
150
+ broadcast('test-complete', { exitCode: code });
151
+ addLog('test', `Tests ${code === 0 ? 'passed' : 'failed'}`, code === 0 ? 'success' : 'error');
152
+ });
153
+ }
154
+
155
+ // API Routes
156
+ app.get('/api/status', (req, res) => {
157
+ const bugs = parseBugs(PROJECT_DIR);
158
+ const plan = parsePlan(PROJECT_DIR);
159
+
160
+ res.json({
161
+ appRunning: appProcess !== null,
162
+ appPort,
163
+ testsPass: plan.testsPass || 0,
164
+ testsFail: plan.testsFail || 0,
165
+ bugsOpen: bugs.filter(b => b.status === 'open').length,
166
+ phase: plan.currentPhase,
167
+ phaseName: plan.currentPhaseName
168
+ });
169
+ });
170
+
171
+ app.get('/api/logs/:type', (req, res) => {
172
+ const type = req.params.type;
173
+ if (logs[type]) {
174
+ res.json(logs[type]);
175
+ } else {
176
+ res.status(404).json({ error: 'Unknown log type' });
177
+ }
178
+ });
179
+
180
+ app.get('/api/tasks', (req, res) => {
181
+ const plan = parsePlan(PROJECT_DIR);
182
+ res.json({
183
+ phase: plan.currentPhase,
184
+ phaseName: plan.currentPhaseName,
185
+ items: plan.tasks
186
+ });
187
+ });
188
+
189
+ app.post('/api/bug', (req, res) => {
190
+ const { description, url, screenshot, severity } = req.body;
191
+
192
+ if (!description) {
193
+ return res.status(400).json({ error: 'Description required' });
194
+ }
195
+
196
+ const bugsFile = path.join(PROJECT_DIR, '.planning', 'BUGS.md');
197
+
198
+ // Generate bug ID
199
+ const bugs = parseBugs(PROJECT_DIR);
200
+ const nextId = bugs.length + 1;
201
+ const bugId = `BUG-${String(nextId).padStart(3, '0')}`;
202
+
203
+ // Create bug entry
204
+ const timestamp = new Date().toISOString().split('T')[0];
205
+ const bugEntry = `
206
+ ### ${bugId}: ${description.split('\n')[0].slice(0, 50)} [open]
207
+
208
+ - **Reported:** ${timestamp}
209
+ - **Severity:** ${severity || 'medium'}
210
+ - **URL:** ${url || 'N/A'}
211
+ ${screenshot ? `- **Screenshot:** screenshots/${bugId}.png` : ''}
212
+
213
+ ${description}
214
+
215
+ ---
216
+ `;
217
+
218
+ // Ensure .planning directory exists
219
+ const planningDir = path.join(PROJECT_DIR, '.planning');
220
+ if (!fs.existsSync(planningDir)) {
221
+ fs.mkdirSync(planningDir, { recursive: true });
222
+ }
223
+
224
+ // Save screenshot if provided
225
+ if (screenshot && screenshot.startsWith('data:image')) {
226
+ const screenshotDir = path.join(planningDir, 'screenshots');
227
+ if (!fs.existsSync(screenshotDir)) {
228
+ fs.mkdirSync(screenshotDir, { recursive: true });
229
+ }
230
+ const base64Data = screenshot.split(',')[1];
231
+ fs.writeFileSync(
232
+ path.join(screenshotDir, `${bugId}.png`),
233
+ Buffer.from(base64Data, 'base64')
234
+ );
235
+ }
236
+
237
+ // Append to BUGS.md
238
+ let content = '';
239
+ if (fs.existsSync(bugsFile)) {
240
+ content = fs.readFileSync(bugsFile, 'utf-8');
241
+ } else {
242
+ content = `# Bug Tracker
243
+
244
+ ## Open Bugs
245
+
246
+ `;
247
+ }
248
+
249
+ // Insert after "## Open Bugs" heading
250
+ const insertPoint = content.indexOf('## Open Bugs');
251
+ if (insertPoint !== -1) {
252
+ const afterHeading = content.indexOf('\n', insertPoint) + 1;
253
+ content = content.slice(0, afterHeading) + bugEntry + content.slice(afterHeading);
254
+ } else {
255
+ content += bugEntry;
256
+ }
257
+
258
+ fs.writeFileSync(bugsFile, content);
259
+
260
+ broadcast('bug-created', { bugId, description: description.slice(0, 50) });
261
+ addLog('app', `Bug ${bugId} created`, 'warn');
262
+
263
+ res.json({ success: true, bugId });
264
+ });
265
+
266
+ app.post('/api/test', (req, res) => {
267
+ runTests();
268
+ res.json({ success: true });
269
+ });
270
+
271
+ app.post('/api/restart', (req, res) => {
272
+ addLog('app', '--- Restarting app ---', 'warn');
273
+ broadcast('app-restart', {});
274
+ startApp();
275
+ res.json({ success: true });
276
+ });
277
+
278
+ // Proxy to running app
279
+ app.use('/app', createProxyMiddleware({
280
+ target: () => `http://localhost:${appPort}`,
281
+ changeOrigin: true,
282
+ pathRewrite: { '^/app': '' },
283
+ ws: true,
284
+ onError: (err, req, res) => {
285
+ res.status(502).send(`
286
+ <html>
287
+ <body style="font-family: system-ui; padding: 40px; background: #1a1a2e; color: #eee;">
288
+ <h2>App not running</h2>
289
+ <p>Waiting for app to start on port ${appPort}...</p>
290
+ <script>setTimeout(() => location.reload(), 2000)</script>
291
+ </body>
292
+ </html>
293
+ `);
294
+ }
295
+ }));
296
+
297
+ // File watching
298
+ function setupWatchers() {
299
+ // Watch source files
300
+ const sourceWatcher = chokidar.watch(
301
+ ['src', 'lib', 'app', 'pages', 'components'].map(d => path.join(PROJECT_DIR, d)),
302
+ { ignored: /node_modules/, ignoreInitial: true }
303
+ );
304
+
305
+ sourceWatcher.on('change', (filePath) => {
306
+ const relative = path.relative(PROJECT_DIR, filePath);
307
+ addLog('app', `File changed: ${relative}`, 'info');
308
+ broadcast('file-change', { path: relative });
309
+ });
310
+
311
+ // Watch git activity
312
+ const gitWatcher = chokidar.watch(path.join(PROJECT_DIR, '.git/logs/HEAD'), {
313
+ ignoreInitial: true
314
+ });
315
+
316
+ gitWatcher.on('change', () => {
317
+ // Parse last git log entry
318
+ try {
319
+ const logPath = path.join(PROJECT_DIR, '.git/logs/HEAD');
320
+ const content = fs.readFileSync(logPath, 'utf-8');
321
+ const lines = content.trim().split('\n');
322
+ const lastLine = lines[lines.length - 1];
323
+ const match = lastLine.match(/\t(.+)$/);
324
+ if (match) {
325
+ addLog('git', match[1], 'info');
326
+ broadcast('git-activity', { entry: match[1] });
327
+ }
328
+ } catch (e) {
329
+ // Ignore errors
330
+ }
331
+ });
332
+
333
+ // Watch planning files
334
+ const planWatcher = chokidar.watch(path.join(PROJECT_DIR, '.planning'), {
335
+ ignoreInitial: true
336
+ });
337
+
338
+ planWatcher.on('change', (filePath) => {
339
+ if (filePath.includes('PLAN.md')) {
340
+ broadcast('task-update', {});
341
+ }
342
+ if (filePath.includes('BUGS.md')) {
343
+ broadcast('bug-update', {});
344
+ }
345
+ });
346
+ }
347
+
348
+ // Graceful shutdown
349
+ function shutdown() {
350
+ console.log('\n[TLC] Shutting down...');
351
+
352
+ if (appProcess) {
353
+ appProcess.kill();
354
+ }
355
+
356
+ wsClients.forEach(client => client.close());
357
+ server.close(() => {
358
+ console.log('[TLC] Server stopped');
359
+ process.exit(0);
360
+ });
361
+ }
362
+
363
+ process.on('SIGINT', shutdown);
364
+ process.on('SIGTERM', shutdown);
365
+
366
+ // Start server
367
+ async function main() {
368
+ console.log(`
369
+ ████████╗██╗ ██████╗
370
+ ╚══██╔══╝██║ ██╔════╝
371
+ ██║ ██║ ██║
372
+ ██║ ██║ ██║
373
+ ██║ ███████╗╚██████╗
374
+ ╚═╝ ╚══════╝ ╚═════╝
375
+
376
+ TLC Dev Server
377
+ `);
378
+
379
+ server.listen(TLC_PORT, () => {
380
+ console.log(` Dashboard: http://localhost:${TLC_PORT}`);
381
+ console.log(` Share: http://${getLocalIP()}:${TLC_PORT}`);
382
+ console.log('');
383
+ });
384
+
385
+ setupWatchers();
386
+ await startApp();
387
+
388
+ console.log(' Press Ctrl+C to stop\n');
389
+ }
390
+
391
+ // Get local IP for sharing
392
+ function getLocalIP() {
393
+ const { networkInterfaces } = require('os');
394
+ const nets = networkInterfaces();
395
+
396
+ for (const name of Object.keys(nets)) {
397
+ for (const net of nets[name]) {
398
+ if (net.family === 'IPv4' && !net.internal) {
399
+ return net.address;
400
+ }
401
+ }
402
+ }
403
+ return 'localhost';
404
+ }
405
+
406
+ main().catch(console.error);
@@ -0,0 +1,146 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Parse tasks from PLAN.md files
6
+ */
7
+ function parsePlan(projectDir) {
8
+ const result = {
9
+ currentPhase: null,
10
+ currentPhaseName: '',
11
+ tasks: [],
12
+ testsPass: 0,
13
+ testsFail: 0
14
+ };
15
+
16
+ // Find current phase from ROADMAP.md
17
+ const roadmapPath = path.join(projectDir, '.planning', 'ROADMAP.md');
18
+ if (fs.existsSync(roadmapPath)) {
19
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
20
+
21
+ // Find first incomplete phase
22
+ const phaseMatches = content.matchAll(/##\s+Phase\s+(\d+)(?:\.(\d+))?[:\s]+(.+?)(?:\s*\[([x ])\])?$/gm);
23
+ for (const match of phaseMatches) {
24
+ const phaseNum = match[2] ? `${match[1]}.${match[2]}` : match[1];
25
+ const phaseName = match[3].trim();
26
+ const completed = match[4] === 'x';
27
+
28
+ if (!completed) {
29
+ result.currentPhase = phaseNum;
30
+ result.currentPhaseName = phaseName;
31
+ break;
32
+ }
33
+ }
34
+ }
35
+
36
+ // Load current phase PLAN.md
37
+ if (result.currentPhase) {
38
+ const planPath = path.join(
39
+ projectDir,
40
+ '.planning',
41
+ 'phases',
42
+ `${result.currentPhase}-PLAN.md`
43
+ );
44
+
45
+ if (fs.existsSync(planPath)) {
46
+ const content = fs.readFileSync(planPath, 'utf-8');
47
+ result.tasks = parseTasksFromPlan(content);
48
+ }
49
+ }
50
+
51
+ return result;
52
+ }
53
+
54
+ /**
55
+ * Parse task entries from PLAN.md content
56
+ * Supports formats:
57
+ * ### Task 1: Title [ ]
58
+ * ### Task 1: Title [>@user]
59
+ * ### Task 1: Title [x@user]
60
+ */
61
+ function parseTasksFromPlan(content) {
62
+ const tasks = [];
63
+ const taskRegex = /###\s+Task\s+(\d+)[:\s]+(.+?)\s*\[([^\]]*)\]/g;
64
+
65
+ let match;
66
+ while ((match = taskRegex.exec(content)) !== null) {
67
+ const [, num, title, statusMarker] = match;
68
+
69
+ let status = 'available';
70
+ let owner = null;
71
+
72
+ if (statusMarker.startsWith('x')) {
73
+ status = 'done';
74
+ const ownerMatch = statusMarker.match(/@(\w+)/);
75
+ if (ownerMatch) owner = ownerMatch[1];
76
+ } else if (statusMarker.startsWith('>')) {
77
+ status = 'working';
78
+ const ownerMatch = statusMarker.match(/@(\w+)/);
79
+ if (ownerMatch) owner = ownerMatch[1];
80
+ }
81
+
82
+ tasks.push({
83
+ num: parseInt(num),
84
+ title: title.trim(),
85
+ status,
86
+ owner
87
+ });
88
+ }
89
+
90
+ return tasks;
91
+ }
92
+
93
+ /**
94
+ * Parse bugs from BUGS.md
95
+ */
96
+ function parseBugs(projectDir) {
97
+ const bugs = [];
98
+ const bugsPath = path.join(projectDir, '.planning', 'BUGS.md');
99
+
100
+ if (!fs.existsSync(bugsPath)) {
101
+ return bugs;
102
+ }
103
+
104
+ const content = fs.readFileSync(bugsPath, 'utf-8');
105
+
106
+ // Match bug entries: ### BUG-001: Title [status]
107
+ const bugRegex = /###\s+(BUG-\d+)[:\s]+(.+?)\s*\[(\w+)\]/g;
108
+
109
+ let match;
110
+ while ((match = bugRegex.exec(content)) !== null) {
111
+ const [, id, title, status] = match;
112
+ bugs.push({
113
+ id,
114
+ title: title.trim(),
115
+ status: status.toLowerCase()
116
+ });
117
+ }
118
+
119
+ return bugs;
120
+ }
121
+
122
+ /**
123
+ * Get username for task claiming
124
+ */
125
+ function getUsername() {
126
+ // Check TLC_USER env var first
127
+ if (process.env.TLC_USER) {
128
+ return process.env.TLC_USER.toLowerCase().replace(/\s+/g, '-');
129
+ }
130
+
131
+ // Try git config
132
+ try {
133
+ const { execSync } = require('child_process');
134
+ const gitUser = execSync('git config user.name', { encoding: 'utf-8' }).trim();
135
+ if (gitUser) {
136
+ return gitUser.toLowerCase().split(' ')[0]; // First name only
137
+ }
138
+ } catch (e) {
139
+ // Ignore
140
+ }
141
+
142
+ // Fall back to system user
143
+ return require('os').userInfo().username || 'unknown';
144
+ }
145
+
146
+ module.exports = { parsePlan, parseBugs, getUsername };