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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/cli.js +431 -0
- package/config-loader.js +294 -0
- package/hooks/activity-track.sh +56 -0
- package/hooks/codex-workstream.sh +44 -0
- package/hooks/gemini-workstream.sh +44 -0
- package/hooks/workstream-inject.sh +20 -0
- package/lib/activity.js +283 -0
- package/lib/apply.js +344 -0
- package/lib/cli.js +267 -0
- package/lib/config.js +171 -0
- package/lib/constants.js +55 -0
- package/lib/env.js +114 -0
- package/lib/index.js +47 -0
- package/lib/init.js +122 -0
- package/lib/mcps.js +139 -0
- package/lib/memory.js +201 -0
- package/lib/projects.js +138 -0
- package/lib/registry.js +83 -0
- package/lib/utils.js +129 -0
- package/lib/workstreams.js +652 -0
- package/package.json +80 -0
- package/scripts/capture-screenshots.js +142 -0
- package/scripts/postinstall.js +122 -0
- package/scripts/release.sh +71 -0
- package/scripts/sync-version.js +77 -0
- package/scripts/tauri-prepare.js +328 -0
- package/shared/mcp-registry.json +76 -0
- package/ui/dist/assets/index-DbZ3_HBD.js +3204 -0
- package/ui/dist/assets/index-DjLdm3Mr.css +32 -0
- package/ui/dist/icons/icon-192.svg +16 -0
- package/ui/dist/icons/icon-512.svg +16 -0
- package/ui/dist/index.html +39 -0
- package/ui/dist/manifest.json +25 -0
- package/ui/dist/sw.js +24 -0
- package/ui/dist/tutorial/claude-settings.png +0 -0
- package/ui/dist/tutorial/header.png +0 -0
- package/ui/dist/tutorial/mcp-registry.png +0 -0
- package/ui/dist/tutorial/memory-view.png +0 -0
- package/ui/dist/tutorial/permissions.png +0 -0
- package/ui/dist/tutorial/plugins-view.png +0 -0
- package/ui/dist/tutorial/project-explorer.png +0 -0
- package/ui/dist/tutorial/projects-view.png +0 -0
- package/ui/dist/tutorial/sidebar.png +0 -0
- package/ui/dist/tutorial/tutorial-view.png +0 -0
- package/ui/dist/tutorial/workstreams-view.png +0 -0
- package/ui/routes/activity.js +58 -0
- package/ui/routes/commands.js +74 -0
- package/ui/routes/configs.js +329 -0
- package/ui/routes/env.js +40 -0
- package/ui/routes/file-explorer.js +668 -0
- package/ui/routes/index.js +41 -0
- package/ui/routes/mcp-discovery.js +235 -0
- package/ui/routes/memory.js +385 -0
- package/ui/routes/package.json +3 -0
- package/ui/routes/plugins.js +466 -0
- package/ui/routes/projects.js +198 -0
- package/ui/routes/registry.js +30 -0
- package/ui/routes/rules.js +74 -0
- package/ui/routes/search.js +125 -0
- package/ui/routes/settings.js +381 -0
- package/ui/routes/subprojects.js +208 -0
- package/ui/routes/tool-sync.js +127 -0
- package/ui/routes/updates.js +339 -0
- package/ui/routes/workstreams.js +224 -0
- package/ui/server.cjs +773 -0
- 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
|
+
};
|