coder-config 0.40.1

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/cli.js +431 -0
  4. package/config-loader.js +294 -0
  5. package/hooks/activity-track.sh +56 -0
  6. package/hooks/codex-workstream.sh +44 -0
  7. package/hooks/gemini-workstream.sh +44 -0
  8. package/hooks/workstream-inject.sh +20 -0
  9. package/lib/activity.js +283 -0
  10. package/lib/apply.js +344 -0
  11. package/lib/cli.js +267 -0
  12. package/lib/config.js +171 -0
  13. package/lib/constants.js +55 -0
  14. package/lib/env.js +114 -0
  15. package/lib/index.js +47 -0
  16. package/lib/init.js +122 -0
  17. package/lib/mcps.js +139 -0
  18. package/lib/memory.js +201 -0
  19. package/lib/projects.js +138 -0
  20. package/lib/registry.js +83 -0
  21. package/lib/utils.js +129 -0
  22. package/lib/workstreams.js +652 -0
  23. package/package.json +80 -0
  24. package/scripts/capture-screenshots.js +142 -0
  25. package/scripts/postinstall.js +122 -0
  26. package/scripts/release.sh +71 -0
  27. package/scripts/sync-version.js +77 -0
  28. package/scripts/tauri-prepare.js +328 -0
  29. package/shared/mcp-registry.json +76 -0
  30. package/ui/dist/assets/index-DbZ3_HBD.js +3204 -0
  31. package/ui/dist/assets/index-DjLdm3Mr.css +32 -0
  32. package/ui/dist/icons/icon-192.svg +16 -0
  33. package/ui/dist/icons/icon-512.svg +16 -0
  34. package/ui/dist/index.html +39 -0
  35. package/ui/dist/manifest.json +25 -0
  36. package/ui/dist/sw.js +24 -0
  37. package/ui/dist/tutorial/claude-settings.png +0 -0
  38. package/ui/dist/tutorial/header.png +0 -0
  39. package/ui/dist/tutorial/mcp-registry.png +0 -0
  40. package/ui/dist/tutorial/memory-view.png +0 -0
  41. package/ui/dist/tutorial/permissions.png +0 -0
  42. package/ui/dist/tutorial/plugins-view.png +0 -0
  43. package/ui/dist/tutorial/project-explorer.png +0 -0
  44. package/ui/dist/tutorial/projects-view.png +0 -0
  45. package/ui/dist/tutorial/sidebar.png +0 -0
  46. package/ui/dist/tutorial/tutorial-view.png +0 -0
  47. package/ui/dist/tutorial/workstreams-view.png +0 -0
  48. package/ui/routes/activity.js +58 -0
  49. package/ui/routes/commands.js +74 -0
  50. package/ui/routes/configs.js +329 -0
  51. package/ui/routes/env.js +40 -0
  52. package/ui/routes/file-explorer.js +668 -0
  53. package/ui/routes/index.js +41 -0
  54. package/ui/routes/mcp-discovery.js +235 -0
  55. package/ui/routes/memory.js +385 -0
  56. package/ui/routes/package.json +3 -0
  57. package/ui/routes/plugins.js +466 -0
  58. package/ui/routes/projects.js +198 -0
  59. package/ui/routes/registry.js +30 -0
  60. package/ui/routes/rules.js +74 -0
  61. package/ui/routes/search.js +125 -0
  62. package/ui/routes/settings.js +381 -0
  63. package/ui/routes/subprojects.js +208 -0
  64. package/ui/routes/tool-sync.js +127 -0
  65. package/ui/routes/updates.js +339 -0
  66. package/ui/routes/workstreams.js +224 -0
  67. package/ui/server.cjs +773 -0
  68. package/ui/terminal-server.cjs +160 -0
@@ -0,0 +1,652 @@
1
+ /**
2
+ * Workstreams feature
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ /**
9
+ * Get workstreams file path
10
+ */
11
+ function getWorkstreamsPath(installDir) {
12
+ return path.join(installDir, 'workstreams.json');
13
+ }
14
+
15
+ /**
16
+ * Load workstreams
17
+ */
18
+ function loadWorkstreams(installDir) {
19
+ const wsPath = getWorkstreamsPath(installDir);
20
+ if (fs.existsSync(wsPath)) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(wsPath, 'utf8'));
23
+ } catch (e) {
24
+ return { workstreams: [], activeId: null, lastUsedByProject: {} };
25
+ }
26
+ }
27
+ return { workstreams: [], activeId: null, lastUsedByProject: {} };
28
+ }
29
+
30
+ /**
31
+ * Save workstreams
32
+ */
33
+ function saveWorkstreams(installDir, data) {
34
+ const wsPath = getWorkstreamsPath(installDir);
35
+ const dir = path.dirname(wsPath);
36
+ if (!fs.existsSync(dir)) {
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ }
39
+ fs.writeFileSync(wsPath, JSON.stringify(data, null, 2) + '\n');
40
+ }
41
+
42
+ /**
43
+ * List all workstreams
44
+ */
45
+ function workstreamList(installDir) {
46
+ const data = loadWorkstreams(installDir);
47
+
48
+ if (data.workstreams.length === 0) {
49
+ console.log('\nNo workstreams defined.');
50
+ console.log('Create one with: claude-config workstream create "Name"\n');
51
+ return data.workstreams;
52
+ }
53
+
54
+ console.log('\n📋 Workstreams:\n');
55
+ for (const ws of data.workstreams) {
56
+ const active = ws.id === data.activeId ? '● ' : '○ ';
57
+ console.log(`${active}${ws.name}`);
58
+ if (ws.projects && ws.projects.length > 0) {
59
+ console.log(` Projects: ${ws.projects.map(p => path.basename(p)).join(', ')}`);
60
+ }
61
+ if (ws.rules) {
62
+ const preview = ws.rules.substring(0, 60).replace(/\n/g, ' ');
63
+ console.log(` Rules: ${preview}${ws.rules.length > 60 ? '...' : ''}`);
64
+ }
65
+ }
66
+ console.log('');
67
+ return data.workstreams;
68
+ }
69
+
70
+ /**
71
+ * Create a new workstream
72
+ */
73
+ function workstreamCreate(installDir, name, projects = [], rules = '') {
74
+ if (!name) {
75
+ console.error('Usage: claude-config workstream create "Name"');
76
+ return null;
77
+ }
78
+
79
+ const data = loadWorkstreams(installDir);
80
+
81
+ if (data.workstreams.some(ws => ws.name.toLowerCase() === name.toLowerCase())) {
82
+ console.error(`Workstream "${name}" already exists`);
83
+ return null;
84
+ }
85
+
86
+ const workstream = {
87
+ id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
88
+ name,
89
+ projects: projects.map(p => path.resolve(p.replace(/^~/, process.env.HOME || ''))),
90
+ rules: rules || '',
91
+ createdAt: new Date().toISOString(),
92
+ updatedAt: new Date().toISOString()
93
+ };
94
+
95
+ data.workstreams.push(workstream);
96
+
97
+ if (!data.activeId) {
98
+ data.activeId = workstream.id;
99
+ }
100
+
101
+ saveWorkstreams(installDir, data);
102
+ console.log(`✓ Created workstream: ${name}`);
103
+ return workstream;
104
+ }
105
+
106
+ /**
107
+ * Update a workstream
108
+ */
109
+ function workstreamUpdate(installDir, idOrName, updates) {
110
+ const data = loadWorkstreams(installDir);
111
+ const ws = data.workstreams.find(
112
+ w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
113
+ );
114
+
115
+ if (!ws) {
116
+ console.error(`Workstream not found: ${idOrName}`);
117
+ return null;
118
+ }
119
+
120
+ if (updates.name !== undefined) ws.name = updates.name;
121
+ if (updates.projects !== undefined) {
122
+ ws.projects = updates.projects.map(p =>
123
+ path.resolve(p.replace(/^~/, process.env.HOME || ''))
124
+ );
125
+ }
126
+ if (updates.rules !== undefined) ws.rules = updates.rules;
127
+ ws.updatedAt = new Date().toISOString();
128
+
129
+ saveWorkstreams(installDir, data);
130
+ console.log(`✓ Updated workstream: ${ws.name}`);
131
+ return ws;
132
+ }
133
+
134
+ /**
135
+ * Delete a workstream
136
+ */
137
+ function workstreamDelete(installDir, idOrName) {
138
+ const data = loadWorkstreams(installDir);
139
+ const idx = data.workstreams.findIndex(
140
+ w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
141
+ );
142
+
143
+ if (idx === -1) {
144
+ console.error(`Workstream not found: ${idOrName}`);
145
+ return false;
146
+ }
147
+
148
+ const removed = data.workstreams.splice(idx, 1)[0];
149
+
150
+ if (data.activeId === removed.id) {
151
+ data.activeId = data.workstreams[0]?.id || null;
152
+ }
153
+
154
+ saveWorkstreams(installDir, data);
155
+ console.log(`✓ Deleted workstream: ${removed.name}`);
156
+ return true;
157
+ }
158
+
159
+ /**
160
+ * Set active workstream
161
+ */
162
+ function workstreamUse(installDir, idOrName) {
163
+ const data = loadWorkstreams(installDir);
164
+
165
+ if (!idOrName) {
166
+ const active = data.workstreams.find(w => w.id === data.activeId);
167
+ if (active) {
168
+ console.log(`Active workstream: ${active.name}`);
169
+ } else {
170
+ console.log('No active workstream');
171
+ }
172
+ return active || null;
173
+ }
174
+
175
+ const ws = data.workstreams.find(
176
+ w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
177
+ );
178
+
179
+ if (!ws) {
180
+ console.error(`Workstream not found: ${idOrName}`);
181
+ return null;
182
+ }
183
+
184
+ data.activeId = ws.id;
185
+ saveWorkstreams(installDir, data);
186
+ console.log(`✓ Switched to workstream: ${ws.name}`);
187
+ return ws;
188
+ }
189
+
190
+ /**
191
+ * Get active workstream (uses env var or file-based activeId)
192
+ */
193
+ function workstreamActive(installDir) {
194
+ return getActiveWorkstream(installDir);
195
+ }
196
+
197
+ /**
198
+ * Add project to workstream
199
+ */
200
+ function workstreamAddProject(installDir, idOrName, projectPath) {
201
+ const data = loadWorkstreams(installDir);
202
+ const ws = data.workstreams.find(
203
+ w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
204
+ );
205
+
206
+ if (!ws) {
207
+ console.error(`Workstream not found: ${idOrName}`);
208
+ return null;
209
+ }
210
+
211
+ const absPath = path.resolve(projectPath.replace(/^~/, process.env.HOME || ''));
212
+
213
+ if (!ws.projects.includes(absPath)) {
214
+ ws.projects.push(absPath);
215
+ ws.updatedAt = new Date().toISOString();
216
+ saveWorkstreams(installDir, data);
217
+ console.log(`✓ Added ${path.basename(absPath)} to ${ws.name}`);
218
+ } else {
219
+ console.log(`Project already in workstream: ${path.basename(absPath)}`);
220
+ }
221
+
222
+ return ws;
223
+ }
224
+
225
+ /**
226
+ * Remove project from workstream
227
+ */
228
+ function workstreamRemoveProject(installDir, idOrName, projectPath) {
229
+ const data = loadWorkstreams(installDir);
230
+ const ws = data.workstreams.find(
231
+ w => w.id === idOrName || w.name.toLowerCase() === idOrName.toLowerCase()
232
+ );
233
+
234
+ if (!ws) {
235
+ console.error(`Workstream not found: ${idOrName}`);
236
+ return null;
237
+ }
238
+
239
+ const absPath = path.resolve(projectPath.replace(/^~/, process.env.HOME || ''));
240
+ const idx = ws.projects.indexOf(absPath);
241
+
242
+ if (idx !== -1) {
243
+ ws.projects.splice(idx, 1);
244
+ ws.updatedAt = new Date().toISOString();
245
+ saveWorkstreams(installDir, data);
246
+ console.log(`✓ Removed ${path.basename(absPath)} from ${ws.name}`);
247
+ } else {
248
+ console.log(`Project not in workstream: ${path.basename(absPath)}`);
249
+ }
250
+
251
+ return ws;
252
+ }
253
+
254
+ /**
255
+ * Get active workstream - checks env var first, then falls back to file
256
+ */
257
+ function getActiveWorkstream(installDir) {
258
+ const data = loadWorkstreams(installDir);
259
+
260
+ // Check env var first (per-session activation)
261
+ const envWorkstream = process.env.CLAUDE_WORKSTREAM;
262
+ if (envWorkstream) {
263
+ const ws = data.workstreams.find(
264
+ w => w.id === envWorkstream || w.name.toLowerCase() === envWorkstream.toLowerCase()
265
+ );
266
+ if (ws) return ws;
267
+ }
268
+
269
+ // Fall back to file-based activeId
270
+ return data.workstreams.find(w => w.id === data.activeId) || null;
271
+ }
272
+
273
+ /**
274
+ * Inject active workstream context into Claude - includes restriction and context
275
+ */
276
+ function workstreamInject(installDir, silent = false) {
277
+ const active = getActiveWorkstream(installDir);
278
+
279
+ if (!active) {
280
+ if (!silent) console.log('No active workstream');
281
+ return null;
282
+ }
283
+
284
+ // Build the injection output
285
+ const lines = [];
286
+
287
+ // Header
288
+ lines.push(`## Active Workstream: ${active.name}`);
289
+ lines.push('');
290
+
291
+ // Restriction section (always include if there are projects)
292
+ if (active.projects && active.projects.length > 0) {
293
+ lines.push('### Restriction');
294
+ lines.push('');
295
+ lines.push('You are working within a scoped workstream. You may ONLY access files within these directories:');
296
+ lines.push('');
297
+ for (const p of active.projects) {
298
+ const displayPath = p.replace(process.env.HOME || '', '~');
299
+ lines.push(`- ${displayPath}`);
300
+ }
301
+ lines.push('');
302
+ lines.push('**Do NOT read, write, search, or reference files outside these directories.**');
303
+ lines.push('');
304
+ }
305
+
306
+ // Context section (user-defined context/rules)
307
+ const context = active.context || active.rules || '';
308
+ if (context.trim()) {
309
+ lines.push('### Context');
310
+ lines.push('');
311
+ lines.push(context.trim());
312
+ lines.push('');
313
+ }
314
+
315
+ // Repositories table
316
+ if (active.projects && active.projects.length > 0) {
317
+ lines.push('### Repositories in this Workstream');
318
+ lines.push('');
319
+ lines.push('| Repository | Path |');
320
+ lines.push('|------------|------|');
321
+ for (const p of active.projects) {
322
+ const name = path.basename(p);
323
+ const displayPath = p.replace(process.env.HOME || '', '~');
324
+ lines.push(`| ${name} | ${displayPath} |`);
325
+ }
326
+ lines.push('');
327
+ }
328
+
329
+ const output = lines.join('\n');
330
+
331
+ // Always output the context (for hooks), silent only suppresses "no active" message
332
+ console.log(output);
333
+
334
+ return output;
335
+ }
336
+
337
+ /**
338
+ * Detect workstream from current directory
339
+ */
340
+ function workstreamDetect(installDir, dir = process.cwd()) {
341
+ const data = loadWorkstreams(installDir);
342
+ const absDir = path.resolve(dir.replace(/^~/, process.env.HOME || ''));
343
+
344
+ const matches = data.workstreams.filter(ws =>
345
+ ws.projects.some(p => absDir.startsWith(p) || p.startsWith(absDir))
346
+ );
347
+
348
+ if (matches.length === 0) {
349
+ return null;
350
+ }
351
+
352
+ if (matches.length === 1) {
353
+ return matches[0];
354
+ }
355
+
356
+ if (data.lastUsedByProject && data.lastUsedByProject[absDir]) {
357
+ const lastUsed = matches.find(ws => ws.id === data.lastUsedByProject[absDir]);
358
+ if (lastUsed) return lastUsed;
359
+ }
360
+
361
+ return matches.sort((a, b) =>
362
+ new Date(b.updatedAt) - new Date(a.updatedAt)
363
+ )[0];
364
+ }
365
+
366
+ /**
367
+ * Get workstream by ID
368
+ */
369
+ function workstreamGet(installDir, id) {
370
+ const data = loadWorkstreams(installDir);
371
+ return data.workstreams.find(w => w.id === id) || null;
372
+ }
373
+
374
+ /**
375
+ * Count how many workstreams include a given project path
376
+ */
377
+ function countWorkstreamsForProject(installDir, projectPath) {
378
+ const data = loadWorkstreams(installDir);
379
+ const absPath = path.resolve(projectPath.replace(/^~/, process.env.HOME || ''));
380
+ return data.workstreams.filter(ws =>
381
+ ws.projects && ws.projects.includes(absPath)
382
+ ).length;
383
+ }
384
+
385
+ /**
386
+ * Install the pre-prompt hook for workstream injection
387
+ */
388
+ function workstreamInstallHook() {
389
+ const hookDir = path.join(process.env.HOME || '', '.claude', 'hooks');
390
+ const hookPath = path.join(hookDir, 'pre-prompt.sh');
391
+
392
+ // Ensure hooks directory exists
393
+ if (!fs.existsSync(hookDir)) {
394
+ fs.mkdirSync(hookDir, { recursive: true });
395
+ }
396
+
397
+ const hookContent = `#!/bin/bash
398
+ # Claude Code pre-prompt hook for workstream injection
399
+ # Installed by claude-config
400
+
401
+ # Check for active workstream via env var or file
402
+ if [ -n "$CLAUDE_WORKSTREAM" ] || claude-config workstream active >/dev/null 2>&1; then
403
+ claude-config workstream inject --silent
404
+ fi
405
+ `;
406
+
407
+ // Check if hook already exists
408
+ if (fs.existsSync(hookPath)) {
409
+ const existing = fs.readFileSync(hookPath, 'utf8');
410
+ if (existing.includes('claude-config workstream inject')) {
411
+ console.log('✓ Workstream hook already installed');
412
+ return true;
413
+ }
414
+ // Append to existing hook
415
+ fs.appendFileSync(hookPath, '\n' + hookContent);
416
+ console.log('✓ Appended workstream injection to existing pre-prompt hook');
417
+ } else {
418
+ fs.writeFileSync(hookPath, hookContent);
419
+ fs.chmodSync(hookPath, '755');
420
+ console.log('✓ Installed pre-prompt hook at ~/.claude/hooks/pre-prompt.sh');
421
+ }
422
+
423
+ console.log('\nWorkstream injection is now active. When a workstream is active,');
424
+ console.log('Claude will see the restriction and context at the start of each prompt.');
425
+ console.log('\nTo activate a workstream for this session:');
426
+ console.log(' export CLAUDE_WORKSTREAM=<name-or-id>');
427
+ console.log('\nOr use the global active workstream:');
428
+ console.log(' claude-config workstream use <name>');
429
+
430
+ return true;
431
+ }
432
+
433
+ /**
434
+ * Install the SessionStart hook for Gemini CLI workstream injection
435
+ */
436
+ function workstreamInstallHookGemini() {
437
+ const geminiDir = path.join(process.env.HOME || '', '.gemini');
438
+ const settingsPath = path.join(geminiDir, 'settings.json');
439
+
440
+ // Ensure .gemini directory exists
441
+ if (!fs.existsSync(geminiDir)) {
442
+ fs.mkdirSync(geminiDir, { recursive: true });
443
+ }
444
+
445
+ // Load existing settings or create new
446
+ let settings = {};
447
+ if (fs.existsSync(settingsPath)) {
448
+ try {
449
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
450
+ } catch (e) {
451
+ settings = {};
452
+ }
453
+ }
454
+
455
+ // Find the hook script path (relative to claude-config installation)
456
+ const hookScriptPath = path.join(__dirname, '..', 'hooks', 'gemini-workstream.sh');
457
+
458
+ // Check if hook already installed
459
+ const existingHooks = settings.hooks?.SessionStart || [];
460
+ const alreadyInstalled = existingHooks.some(h =>
461
+ h.name === 'claude-config-workstream' || (h.command && h.command.includes('gemini-workstream'))
462
+ );
463
+
464
+ if (alreadyInstalled) {
465
+ console.log('✓ Workstream hook already installed for Gemini CLI');
466
+ return true;
467
+ }
468
+
469
+ // Enable hooks system if not enabled
470
+ if (!settings.tools) settings.tools = {};
471
+ if (!settings.hooks) settings.hooks = {};
472
+ settings.tools.enableHooks = true;
473
+ settings.hooks.enabled = true;
474
+
475
+ // Add the SessionStart hook
476
+ if (!settings.hooks.SessionStart) {
477
+ settings.hooks.SessionStart = [];
478
+ }
479
+
480
+ settings.hooks.SessionStart.push({
481
+ name: 'claude-config-workstream',
482
+ type: 'command',
483
+ command: hookScriptPath,
484
+ description: 'Inject workstream context and restrictions',
485
+ timeout: 5000
486
+ });
487
+
488
+ // Save settings
489
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
490
+ console.log('✓ Installed SessionStart hook for Gemini CLI');
491
+ console.log(` Hook script: ${hookScriptPath}`);
492
+ console.log(` Settings: ${settingsPath}`);
493
+
494
+ console.log('\nWorkstream injection is now active for Gemini CLI.');
495
+ console.log('When a workstream is active, Gemini will see the restriction');
496
+ console.log('and context at the start of each session.');
497
+ console.log('\nTo activate a workstream for this session:');
498
+ console.log(' export CLAUDE_WORKSTREAM=<name-or-id>');
499
+
500
+ return true;
501
+ }
502
+
503
+ /**
504
+ * Install hook for Codex CLI workstream injection
505
+ * Codex uses TOML config at ~/.codex/config.toml
506
+ */
507
+ function workstreamInstallHookCodex() {
508
+ const codexDir = path.join(process.env.HOME || '', '.codex');
509
+ const configPath = path.join(codexDir, 'config.toml');
510
+
511
+ // Ensure .codex directory exists
512
+ if (!fs.existsSync(codexDir)) {
513
+ fs.mkdirSync(codexDir, { recursive: true });
514
+ }
515
+
516
+ // Find the hook script path
517
+ const hookScriptPath = path.join(__dirname, '..', 'hooks', 'codex-workstream.sh');
518
+
519
+ // Make sure hook script is executable
520
+ try {
521
+ fs.chmodSync(hookScriptPath, '755');
522
+ } catch (e) {
523
+ // Ignore permission errors
524
+ }
525
+
526
+ // For Codex, we'll create a pre-session hook in the hooks directory
527
+ const codexHooksDir = path.join(codexDir, 'hooks');
528
+ if (!fs.existsSync(codexHooksDir)) {
529
+ fs.mkdirSync(codexHooksDir, { recursive: true });
530
+ }
531
+
532
+ const targetHookPath = path.join(codexHooksDir, 'pre-session.sh');
533
+
534
+ // Check if hook already exists with our content
535
+ if (fs.existsSync(targetHookPath)) {
536
+ const existing = fs.readFileSync(targetHookPath, 'utf8');
537
+ if (existing.includes('claude-config workstream inject')) {
538
+ console.log('✓ Workstream hook already installed for Codex CLI');
539
+ return true;
540
+ }
541
+ // Append to existing hook
542
+ const appendContent = `
543
+ # claude-config workstream injection
544
+ if [ -n "$CLAUDE_WORKSTREAM" ] && command -v claude-config &> /dev/null; then
545
+ claude-config workstream inject --silent
546
+ fi
547
+ `;
548
+ fs.appendFileSync(targetHookPath, appendContent);
549
+ console.log('✓ Appended workstream injection to existing Codex pre-session hook');
550
+ } else {
551
+ // Create new hook
552
+ const hookContent = `#!/bin/bash
553
+ # Codex CLI pre-session hook for workstream injection
554
+ # Installed by claude-config
555
+
556
+ # Check for active workstream via env var
557
+ if [ -n "$CLAUDE_WORKSTREAM" ] && command -v claude-config &> /dev/null; then
558
+ claude-config workstream inject --silent
559
+ fi
560
+ `;
561
+ fs.writeFileSync(targetHookPath, hookContent);
562
+ fs.chmodSync(targetHookPath, '755');
563
+ console.log('✓ Installed pre-session hook for Codex CLI');
564
+ console.log(` Hook location: ${targetHookPath}`);
565
+ }
566
+
567
+ console.log('\nWorkstream injection is now active for Codex CLI.');
568
+ console.log('When a workstream is active, Codex will see the restriction');
569
+ console.log('and context at the start of each session.');
570
+ console.log('\nTo activate a workstream for this session:');
571
+ console.log(' export CLAUDE_WORKSTREAM=<name-or-id>');
572
+
573
+ return true;
574
+ }
575
+
576
+ /**
577
+ * Deactivate workstream (output shell command to unset env var)
578
+ */
579
+ function workstreamDeactivate() {
580
+ console.log('To deactivate the workstream for this session, run:');
581
+ console.log(' unset CLAUDE_WORKSTREAM');
582
+ console.log('\nOr to clear the global active workstream:');
583
+ console.log(' claude-config workstream use --clear');
584
+ return true;
585
+ }
586
+
587
+ /**
588
+ * Check if a path is within the active workstream's directories
589
+ * Used by pre-tool-call hooks for enforcement
590
+ * Returns true if path is valid, false otherwise
591
+ */
592
+ function workstreamCheckPath(installDir, targetPath, silent = false) {
593
+ const active = getActiveWorkstream(installDir);
594
+
595
+ // No active workstream = all paths allowed
596
+ if (!active) {
597
+ return true;
598
+ }
599
+
600
+ // No projects in workstream = all paths allowed
601
+ if (!active.projects || active.projects.length === 0) {
602
+ return true;
603
+ }
604
+
605
+ // Resolve the target path
606
+ const absPath = path.resolve(targetPath.replace(/^~/, process.env.HOME || ''));
607
+
608
+ // Check if path is within any of the workstream's directories
609
+ const isWithin = active.projects.some(projectPath => {
610
+ // Path is within if it starts with the project path
611
+ // Handle both exact match and subdirectories
612
+ return absPath === projectPath || absPath.startsWith(projectPath + path.sep);
613
+ });
614
+
615
+ if (!silent) {
616
+ if (isWithin) {
617
+ console.log(`✓ Path is within workstream "${active.name}"`);
618
+ } else {
619
+ console.error(`✗ Path is outside workstream "${active.name}"`);
620
+ console.error(` Allowed directories:`);
621
+ for (const p of active.projects) {
622
+ console.error(` - ${p.replace(process.env.HOME || '', '~')}`);
623
+ }
624
+ }
625
+ }
626
+
627
+ return isWithin;
628
+ }
629
+
630
+ module.exports = {
631
+ getWorkstreamsPath,
632
+ loadWorkstreams,
633
+ saveWorkstreams,
634
+ workstreamList,
635
+ workstreamCreate,
636
+ workstreamUpdate,
637
+ workstreamDelete,
638
+ workstreamUse,
639
+ workstreamActive,
640
+ workstreamAddProject,
641
+ workstreamRemoveProject,
642
+ workstreamInject,
643
+ workstreamDetect,
644
+ workstreamGet,
645
+ getActiveWorkstream,
646
+ countWorkstreamsForProject,
647
+ workstreamInstallHook,
648
+ workstreamInstallHookGemini,
649
+ workstreamInstallHookCodex,
650
+ workstreamDeactivate,
651
+ workstreamCheckPath,
652
+ };