cligr 1.0.6 → 1.0.7

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,93 @@
1
+ # Design: `cligr serve` Command
2
+
3
+ ## Goal
4
+ Add a `serve` command that starts an HTTP server with a web UI for toggling groups and individual items in `.cligr.yml`. Toggling an item changes a `disabledItems` list in config and restarts the running group. The UI receives real-time updates via Server-Sent Events (SSE).
5
+
6
+ ## 1. Architecture Overview
7
+
8
+ Running `cligr serve` starts an HTTP server on a default port (`7373`, configurable via `--port`). The UI shows all groups from `.cligr.yml`, each with:
9
+ - A master checkbox to start/stop the entire group.
10
+ - Per-item checkboxes to enable/disable individual items.
11
+
12
+ ### Core Components
13
+ - **`ConfigLoader`**: Gains `saveConfig()` and `toggleItem()` to persist `disabledItems` changes back to `.cligr.yml`.
14
+ - **`ProcessManager`**: Extends `EventEmitter` to emit events (`group-started`, `group-stopped`, `item-restarted`, `process-log`). Gains `restartGroup()`.
15
+ - **`serve.ts` command**: Sets up HTTP + SSE server; wires UI actions to `ConfigLoader` and `ProcessManager`.
16
+ - **UI**: Single-page HTML served inline from memory (no extra files needed).
17
+
18
+ ## 2. Config Changes: `disabledItems`
19
+
20
+ Each `GroupConfig` gains an optional `disabledItems` string array:
21
+
22
+ ```yaml
23
+ groups:
24
+ myapp:
25
+ tool: kubefwd
26
+ restart: yes
27
+ disabledItems:
28
+ - service2
29
+ items:
30
+ service1: "8080,80"
31
+ service2: "8081,80"
32
+ ```
33
+
34
+ ### ConfigLoader API Additions
35
+ - `saveConfig(config: CliGrConfig): void` — writes the full config back to the YAML file while preserving structure.
36
+ - `toggleItem(groupName: string, itemName: string, enabled: boolean): void` — adds/removes the item from `disabledItems` and calls `saveConfig()`.
37
+
38
+ ### Filtering
39
+ When loading a group for execution (`upCommand` or `serve`), items present in `disabledItems` are filtered out before building `ProcessItem`s.
40
+
41
+ ## 3. HTTP API & SSE
42
+
43
+ | Method | Endpoint | Body | Description |
44
+ |--------|----------|------|-------------|
45
+ | GET | `/` | — | Serves the HTML UI |
46
+ | GET | `/api/groups` | — | Returns all groups, items, running state, and `disabledItems` |
47
+ | POST | `/api/groups/:name/toggle` | `{ enabled: boolean }` | Starts or stops a group |
48
+ | POST | `/api/groups/:name/items/:item/toggle` | `{ enabled: boolean }` | Enables/disables an item |
49
+ | GET | `/api/events` | — | SSE stream for live updates |
50
+
51
+ ### SSE Event Types
52
+ - `status` — Sent whenever a group starts/stops or an item is toggled. Payload includes current state of all groups.
53
+ - `log` — Sent when a managed process writes to stdout/stderr. Payload: `{ group, item, line, isError }`.
54
+
55
+ ## 4. UI Behavior
56
+
57
+ - **Group header**: Shows group name, tool, and a checkbox. Checked = running; unchecked = stopped.
58
+ - **Item list**: Each item has a checkbox. Checked = enabled; unchecked = disabled.
59
+ - **Live log panel**: Scrollable panel showing the last ~500 lines, prefixed `[group/item]`.
60
+
61
+ ### Interaction Rules
62
+ - **Group checkbox toggle**: Checking starts the group with currently enabled items. Unchecking stops the whole group via `ProcessManager.killGroup()`.
63
+ - **Item checkbox toggle**: If the group is running, persist the `disabledItems` change, then call `ProcessManager.restartGroup()` (kill previous processes, respawn with the new enabled set). If the group is not running, only persist the config change.
64
+ - **Log auto-scroll**: New lines scroll to bottom unless the user has manually scrolled up.
65
+
66
+ ## 5. ProcessManager Changes
67
+
68
+ `ProcessManager` will extend Node.js `EventEmitter`.
69
+
70
+ ### New Methods
71
+ - `restartGroup(groupName, items, restartPolicy)` — kills existing processes, removes the group from the map, then re-spawns with the given items.
72
+
73
+ ### Emitted Events
74
+ - `group-started` `(groupName)`
75
+ - `group-stopped` `(groupName)`
76
+ - `item-restarted` `(groupName, itemName)`
77
+ - `process-log` `(groupName, itemName, line: string, isError: boolean)` — stdout/stderr data is split on newlines so only complete lines are emitted
78
+
79
+ The `serve` command subscribes to these events and forwards them over SSE.
80
+
81
+ ## 6. Error Handling
82
+
83
+ - **Config write failure**: Return HTTP 500. The UI re-fetches status on error and reverts the checkbox.
84
+ - **Group spawn failure**: Emit a `status` event with an `error` field; UI shows an error banner.
85
+ - **Port in use**: Print `Port 7373 is already in use` and exit with code 1.
86
+ - **Process crash loop**: Existing `ProcessManager` behavior (stop restarting after 3 crashes in 10s). Logged to UI via `process-log` events.
87
+
88
+ ## 7. Testing Approach
89
+
90
+ - `ConfigLoader`: test `saveConfig()` round-trip and `disabledItems` filtering.
91
+ - `serve` command: test that the HTTP server starts and `/api/groups` returns the expected JSON.
92
+ - SSE endpoint: test that `ProcessManager` events are forwarded as SSE messages.
93
+ - No browser-based tests; test HTTP and SSE semantics directly via HTTP clients.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cligr",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "cligr": "dist/index.js"
@@ -4,22 +4,27 @@ export async function lsCommand(groupName: string): Promise<number> {
4
4
  const loader = new ConfigLoader();
5
5
 
6
6
  try {
7
- const { config, items } = loader.getGroup(groupName);
7
+ const { config } = loader.getGroup(groupName);
8
8
 
9
9
  console.log(`\nGroup: ${groupName}`);
10
10
  console.log(`Tool: ${config.tool}`);
11
11
  console.log(`Restart: ${config.restart}`);
12
12
  console.log('\nItems:');
13
13
 
14
- for (const item of items) {
15
- console.log(` ${item.name}: ${item.value}`);
14
+ const disabled = new Set(config.disabledItems || []);
15
+ for (const [name, value] of Object.entries(config.items)) {
16
+ const marker = disabled.has(name) ? ' [disabled]' : '';
17
+ console.log(` ${name}: ${value}${marker}`);
16
18
  }
17
19
 
18
20
  console.log('');
19
21
 
20
22
  return 0;
21
- } catch (err) {
22
- console.error((err as Error).message);
23
- return 1;
23
+ } catch (error) {
24
+ if (error instanceof Error && error.name === 'ConfigError') {
25
+ console.error(error.message);
26
+ return 1;
27
+ }
28
+ throw error;
24
29
  }
25
30
  }
@@ -0,0 +1,360 @@
1
+ import http from 'http';
2
+ import { ConfigLoader } from '../config/loader.js';
3
+ import { ProcessManager } from '../process/manager.js';
4
+ import { TemplateExpander } from '../process/template.js';
5
+
6
+ export async function serveCommand(portArg?: string): Promise<number> {
7
+ const port = portArg ? parseInt(portArg, 10) : 7373;
8
+ const loader = new ConfigLoader();
9
+ const manager = new ProcessManager();
10
+
11
+ // Clean up any stale PID files on startup
12
+ await manager.cleanupStalePids();
13
+
14
+ const clients: http.ServerResponse[] = [];
15
+
16
+ const sendEvent = (event: string, data: unknown) => {
17
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
18
+ for (let i = clients.length - 1; i >= 0; i--) {
19
+ const client = clients[i];
20
+ try {
21
+ client.write(payload);
22
+ } catch {
23
+ clients.splice(i, 1);
24
+ try { client.end(); } catch { /* ignore */ }
25
+ }
26
+ }
27
+ };
28
+
29
+ manager.on('group-started', (groupName) => {
30
+ sendEvent('status', { type: 'group-started', groupName });
31
+ });
32
+
33
+ manager.on('group-stopped', (groupName) => {
34
+ sendEvent('status', { type: 'group-stopped', groupName });
35
+ });
36
+
37
+ manager.on('item-restarted', (groupName, itemName) => {
38
+ sendEvent('status', { type: 'item-restarted', groupName, itemName });
39
+ });
40
+
41
+ manager.on('process-log', (groupName, itemName, line, isError) => {
42
+ sendEvent('log', { group: groupName, item: itemName, line, isError });
43
+ });
44
+
45
+ const server = http.createServer((req, res) => {
46
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
47
+
48
+ // CORS headers
49
+ res.setHeader('Access-Control-Allow-Origin', '*');
50
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
51
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
52
+
53
+ if (req.method === 'OPTIONS') {
54
+ res.writeHead(204);
55
+ res.end();
56
+ return;
57
+ }
58
+
59
+ if (url.pathname === '/') {
60
+ res.setHeader('Content-Type', 'text/html');
61
+ res.writeHead(200);
62
+ res.end(serveHtml());
63
+ return;
64
+ }
65
+
66
+ if (url.pathname === '/api/groups') {
67
+ try {
68
+ const config = loader.load();
69
+ const groups = Object.entries(config.groups).map(([name, group]) => ({
70
+ name,
71
+ tool: group.tool,
72
+ restart: group.restart,
73
+ items: Object.entries(group.items).map(([itemName, value]) => ({
74
+ name: itemName,
75
+ value,
76
+ enabled: !(group.disabledItems || []).includes(itemName),
77
+ })),
78
+ running: manager.isGroupRunning(name),
79
+ }));
80
+ res.setHeader('Content-Type', 'application/json');
81
+ res.writeHead(200);
82
+ res.end(JSON.stringify({ groups }));
83
+ } catch (err) {
84
+ res.setHeader('Content-Type', 'application/json');
85
+ res.writeHead(500);
86
+ res.end(JSON.stringify({ error: (err as Error).message }));
87
+ }
88
+ return;
89
+ }
90
+
91
+ if (url.pathname === '/api/events') {
92
+ res.setHeader('Content-Type', 'text/event-stream');
93
+ res.setHeader('Cache-Control', 'no-cache');
94
+ res.setHeader('Connection', 'keep-alive');
95
+ res.writeHead(200);
96
+ res.write(':ok\n\n');
97
+ clients.push(res);
98
+ req.on('close', () => {
99
+ const index = clients.indexOf(res);
100
+ if (index !== -1) clients.splice(index, 1);
101
+ });
102
+ return;
103
+ }
104
+
105
+ const toggleMatch = url.pathname.match(/^\/api\/groups\/([^/]+)\/toggle$/);
106
+ if (toggleMatch && req.method === 'POST') {
107
+ const groupName = decodeURIComponent(toggleMatch[1]);
108
+ let body = '';
109
+ req.on('data', (chunk) => body += chunk);
110
+ req.on('end', async () => {
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(body);
114
+ } catch {
115
+ res.writeHead(400);
116
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
117
+ return;
118
+ }
119
+ const { enabled } = parsed;
120
+ try {
121
+ if (enabled) {
122
+ const { config, items, tool, toolTemplate, params } = loader.getGroup(groupName);
123
+ const processItems = items.map((item, index) =>
124
+ TemplateExpander.parseItem(tool, toolTemplate, item, index, params)
125
+ );
126
+ manager.spawnGroup(groupName, processItems, config.restart);
127
+ } else {
128
+ await manager.killGroup(groupName);
129
+ }
130
+ res.setHeader('Content-Type', 'application/json');
131
+ res.writeHead(200);
132
+ res.end(JSON.stringify({ success: true }));
133
+ } catch (err) {
134
+ res.setHeader('Content-Type', 'application/json');
135
+ res.writeHead(500);
136
+ res.end(JSON.stringify({ error: (err as Error).message }));
137
+ }
138
+ });
139
+ return;
140
+ }
141
+
142
+ const itemToggleMatch = url.pathname.match(/^\/api\/groups\/([^/]+)\/items\/([^/]+)\/toggle$/);
143
+ if (itemToggleMatch && req.method === 'POST') {
144
+ const groupName = decodeURIComponent(itemToggleMatch[1]);
145
+ const itemName = decodeURIComponent(itemToggleMatch[2]);
146
+ let body = '';
147
+ req.on('data', (chunk) => body += chunk);
148
+ req.on('end', async () => {
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(body);
152
+ } catch {
153
+ res.writeHead(400);
154
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
155
+ return;
156
+ }
157
+ const { enabled } = parsed;
158
+ try {
159
+ loader.toggleItem(groupName, itemName, enabled);
160
+
161
+ if (manager.isGroupRunning(groupName)) {
162
+ const { config, items, tool, toolTemplate, params } = loader.getGroup(groupName);
163
+ const processItems = items.map((item, index) =>
164
+ TemplateExpander.parseItem(tool, toolTemplate, item, index, params)
165
+ );
166
+ await manager.restartGroup(groupName, processItems, config.restart);
167
+ }
168
+
169
+ res.setHeader('Content-Type', 'application/json');
170
+ res.writeHead(200);
171
+ res.end(JSON.stringify({ success: true }));
172
+ } catch (err) {
173
+ res.setHeader('Content-Type', 'application/json');
174
+ res.writeHead(500);
175
+ res.end(JSON.stringify({ error: (err as Error).message }));
176
+ }
177
+ });
178
+ return;
179
+ }
180
+
181
+ res.writeHead(404);
182
+ res.end('Not found');
183
+ });
184
+
185
+ server.on('error', (err: NodeJS.ErrnoException) => {
186
+ if (err.code === 'EADDRINUSE') {
187
+ console.error(`Port ${port} is already in use`);
188
+ process.exit(1);
189
+ } else {
190
+ console.error('Server error:', err);
191
+ process.exit(2);
192
+ }
193
+ });
194
+
195
+ let resolveCommand: (value: number) => void;
196
+ const commandPromise = new Promise<number>((resolve) => {
197
+ resolveCommand = resolve;
198
+ });
199
+
200
+ const shutdown = async () => {
201
+ console.log('\nShutting down...');
202
+ server.close();
203
+ await manager.killAll();
204
+ resolveCommand(0);
205
+ };
206
+
207
+ process.on('SIGINT', shutdown);
208
+ process.on('SIGTERM', shutdown);
209
+
210
+ server.listen(port, () => {
211
+ console.log(`cligr serve running at http://localhost:${port}`);
212
+ });
213
+
214
+ return commandPromise;
215
+ }
216
+
217
+ function serveHtml(): string {
218
+ return `<!DOCTYPE html>
219
+ <html>
220
+ <head>
221
+ <meta charset="utf-8">
222
+ <title>cligr serve</title>
223
+ <style>
224
+ * { box-sizing: border-box; }
225
+ html, body { height: 100%; margin: 0; }
226
+ body { font-family: system-ui, sans-serif; display: flex; flex-direction: column; }
227
+ h1 { font-size: 1.25rem; margin: 0; padding: 0.75rem 1rem; border-bottom: 1px solid #ccc; background: #f8f8f8; }
228
+ .container { display: flex; flex: 1; overflow: hidden; }
229
+ .sidebar { width: 320px; min-width: 180px; max-width: 60vw; border-right: 1px solid #ccc; padding: 1rem; overflow-y: auto; background: #fafafa; }
230
+ .resizer { width: 6px; background: #e0e0e0; cursor: col-resize; flex-shrink: 0; }
231
+ .resizer:hover { background: #bbb; }
232
+ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
233
+ .main h2 { font-size: 1rem; margin: 0; padding: 0.5rem 1rem; border-bottom: 1px solid #ccc; background: #f0f0f0; }
234
+ .group { border: 1px solid #ccc; border-radius: 6px; padding: 0.75rem; margin: 0 0 0.75rem 0; background: #fff; }
235
+ .group-header { display: flex; align-items: center; gap: 0.5rem; font-weight: bold; font-size: 1rem; }
236
+ .items { margin: 0.5rem 0 0 1.25rem; }
237
+ .item { display: flex; align-items: center; gap: 0.4rem; margin: 0.2rem 0; font-size: 0.9rem; }
238
+ .logs { flex: 1; background: #111; color: #0f0; font-family: monospace; font-size: 0.85rem; overflow-y: auto; padding: 0.75rem; white-space: pre-wrap; }
239
+ .error { color: #f55; }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <h1>cligr serve</h1>
244
+ <div class="container">
245
+ <div class="sidebar" id="groups"></div>
246
+ <div class="resizer" id="resizer"></div>
247
+ <div class="main">
248
+ <h2>Console</h2>
249
+ <div class="logs" id="logs"></div>
250
+ </div>
251
+ </div>
252
+
253
+ <script>
254
+ const groupsEl = document.getElementById('groups');
255
+ const logsEl = document.getElementById('logs');
256
+ const resizer = document.getElementById('resizer');
257
+ let autoScroll = true;
258
+
259
+ resizer.addEventListener('mousedown', (e) => {
260
+ e.preventDefault();
261
+ document.body.style.cursor = 'col-resize';
262
+ const startX = e.clientX;
263
+ const startWidth = groupsEl.offsetWidth;
264
+
265
+ const onMove = (ev) => {
266
+ const newWidth = startWidth + (ev.clientX - startX);
267
+ groupsEl.style.width = Math.max(180, Math.min(newWidth, window.innerWidth * 0.6)) + 'px';
268
+ };
269
+
270
+ const onUp = () => {
271
+ document.removeEventListener('mousemove', onMove);
272
+ document.removeEventListener('mouseup', onUp);
273
+ document.body.style.cursor = '';
274
+ };
275
+
276
+ document.addEventListener('mousemove', onMove);
277
+ document.addEventListener('mouseup', onUp);
278
+ });
279
+
280
+ async function fetchGroups() {
281
+ const res = await fetch('/api/groups');
282
+ const data = await res.json();
283
+ renderGroups(data.groups);
284
+ }
285
+
286
+ function renderGroups(groups) {
287
+ groupsEl.innerHTML = '';
288
+ for (const g of groups) {
289
+ const div = document.createElement('div');
290
+ div.className = 'group';
291
+
292
+ const header = document.createElement('div');
293
+ header.className = 'group-header';
294
+ const checkbox = document.createElement('input');
295
+ checkbox.type = 'checkbox';
296
+ checkbox.checked = g.running;
297
+ checkbox.onchange = async () => {
298
+ await fetch(\`/api/groups/\${encodeURIComponent(g.name)}/toggle\`, {
299
+ method: 'POST',
300
+ headers: { 'Content-Type': 'application/json' },
301
+ body: JSON.stringify({ enabled: checkbox.checked })
302
+ });
303
+ };
304
+ header.appendChild(checkbox);
305
+ header.appendChild(document.createTextNode(g.name + ' (' + g.tool + ')' + (g.running ? ' - running' : '')));
306
+ div.appendChild(header);
307
+
308
+ const itemsDiv = document.createElement('div');
309
+ itemsDiv.className = 'items';
310
+ for (const item of g.items) {
311
+ const itemDiv = document.createElement('div');
312
+ itemDiv.className = 'item';
313
+ const itemCb = document.createElement('input');
314
+ itemCb.type = 'checkbox';
315
+ itemCb.checked = item.enabled;
316
+ itemCb.onchange = async () => {
317
+ await fetch(\`/api/groups/\${encodeURIComponent(g.name)}/items/\${encodeURIComponent(item.name)}/toggle\`, {
318
+ method: 'POST',
319
+ headers: { 'Content-Type': 'application/json' },
320
+ body: JSON.stringify({ enabled: itemCb.checked })
321
+ });
322
+ };
323
+ itemDiv.appendChild(itemCb);
324
+ itemDiv.appendChild(document.createTextNode(item.name + ': ' + item.value));
325
+ itemsDiv.appendChild(itemDiv);
326
+ }
327
+ div.appendChild(itemsDiv);
328
+ groupsEl.appendChild(div);
329
+ }
330
+ }
331
+
332
+ logsEl.addEventListener('scroll', () => {
333
+ autoScroll = logsEl.scrollTop + logsEl.clientHeight >= logsEl.scrollHeight - 10;
334
+ });
335
+
336
+ function appendLog(line, isError) {
337
+ const span = document.createElement('div');
338
+ span.textContent = line;
339
+ if (isError) span.className = 'error';
340
+ logsEl.appendChild(span);
341
+ if (autoScroll) logsEl.scrollTop = logsEl.scrollHeight;
342
+ }
343
+
344
+ const evtSource = new EventSource('/api/events');
345
+ evtSource.addEventListener('status', (e) => {
346
+ fetchGroups();
347
+ });
348
+ evtSource.addEventListener('log', (e) => {
349
+ const data = JSON.parse(e.data);
350
+ appendLog(\`[\${data.group}/\${data.item}] \${data.line}\`, data.isError);
351
+ });
352
+ evtSource.onerror = () => {
353
+ appendLog('[SSE connection error]', true);
354
+ };
355
+
356
+ fetchGroups();
357
+ </script>
358
+ </body>
359
+ </html>`;
360
+ }
@@ -73,6 +73,7 @@ export class ConfigLoader {
73
73
  if (group && typeof group === 'object') {
74
74
  const groupObj = group as Record<string, unknown>;
75
75
  this.validateItems(groupObj.items, groupName);
76
+ this.validateDisabledItems(groupObj.items, groupObj.disabledItems, groupName);
76
77
  }
77
78
  }
78
79
 
@@ -105,6 +106,36 @@ export class ConfigLoader {
105
106
  }
106
107
  }
107
108
 
109
+ private validateDisabledItems(items: unknown, disabledItems: unknown, groupName: string): void {
110
+ if (disabledItems === undefined || disabledItems === null) {
111
+ return;
112
+ }
113
+
114
+ if (!Array.isArray(disabledItems)) {
115
+ throw new ConfigError(`Group "${groupName}": disabledItems must be an array of strings`);
116
+ }
117
+
118
+ const seen = new Set<string>();
119
+ const itemKeys = items && typeof items === 'object' && !Array.isArray(items)
120
+ ? new Set(Object.keys(items as Record<string, unknown>))
121
+ : new Set<string>();
122
+
123
+ for (const entry of disabledItems) {
124
+ if (typeof entry !== 'string') {
125
+ throw new ConfigError(`Group "${groupName}": disabledItems must be an array of strings`);
126
+ }
127
+
128
+ if (seen.has(entry)) {
129
+ throw new ConfigError(`Group "${groupName}": disabledItems contains duplicate "${entry}"`);
130
+ }
131
+ seen.add(entry);
132
+
133
+ if (!itemKeys.has(entry)) {
134
+ throw new ConfigError(`Group "${groupName}": disabledItems entry "${entry}" does not match any item`);
135
+ }
136
+ }
137
+ }
138
+
108
139
  private normalizeItems(items: Record<string, string>): ItemEntry[] {
109
140
  return Object.entries(items).map(([name, value]) => ({
110
141
  name,
@@ -121,8 +152,14 @@ export class ConfigLoader {
121
152
  throw new ConfigError(`Unknown group: ${name}. Available: ${available}`);
122
153
  }
123
154
 
124
- // Normalize items to ItemEntry[]
125
- const items = this.normalizeItems(group.items);
155
+ const disabled = new Set(group.disabledItems || []);
156
+ const enabledItems: Record<string, string> = {};
157
+ for (const [name, value] of Object.entries(group.items)) {
158
+ if (!disabled.has(name)) {
159
+ enabledItems[name] = value;
160
+ }
161
+ }
162
+ const items = this.normalizeItems(enabledItems);
126
163
 
127
164
  // Resolve tool
128
165
  let toolTemplate: string | null = null;
@@ -141,6 +178,38 @@ export class ConfigLoader {
141
178
  return { config: group, items, tool, toolTemplate, params };
142
179
  }
143
180
 
181
+ saveConfig(config: CliGrConfig): void {
182
+ const yamlContent = yaml.dump(config, { indent: 2, lineWidth: -1 });
183
+ fs.writeFileSync(this.configPath, yamlContent, 'utf-8');
184
+ }
185
+
186
+ toggleItem(groupName: string, itemName: string, enabled: boolean): void {
187
+ const config = this.load();
188
+ const group = config.groups[groupName];
189
+ if (!group) {
190
+ throw new ConfigError(`Unknown group: ${groupName}`);
191
+ }
192
+
193
+ if (!Object.hasOwn(group.items || {}, itemName)) {
194
+ throw new ConfigError(`Item "${itemName}" not found in group "${groupName}"`);
195
+ }
196
+
197
+ const disabled = new Set(group.disabledItems || []);
198
+ if (enabled) {
199
+ disabled.delete(itemName);
200
+ } else {
201
+ disabled.add(itemName);
202
+ }
203
+
204
+ if (disabled.size === 0) {
205
+ delete (group as unknown as Record<string, unknown>).disabledItems;
206
+ } else {
207
+ group.disabledItems = Array.from(disabled);
208
+ }
209
+
210
+ this.saveConfig(config);
211
+ }
212
+
144
213
  listGroups(): string[] {
145
214
  const config = this.load();
146
215
  return Object.keys(config.groups);
@@ -11,6 +11,7 @@ export interface GroupConfig {
11
11
  tool: string;
12
12
  restart?: 'yes' | 'no' | 'unless-stopped';
13
13
  params?: Record<string, string>;
14
+ disabledItems?: string[];
14
15
  items: Record<string, string>;
15
16
  }
16
17
 
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { upCommand } from './commands/up.js';
4
4
  import { lsCommand } from './commands/ls.js';
5
5
  import { configCommand } from './commands/config.js';
6
6
  import { groupsCommand } from './commands/groups.js';
7
+ import { serveCommand } from './commands/serve.js';
7
8
 
8
9
  async function main(): Promise<void> {
9
10
  const args = process.argv.slice(2);
@@ -18,7 +19,7 @@ async function main(): Promise<void> {
18
19
  let verbose = false;
19
20
 
20
21
  // Check if this is a known command
21
- const knownCommands = ['config', 'up', 'ls', 'groups'];
22
+ const knownCommands = ['config', 'up', 'ls', 'groups', 'serve'];
22
23
 
23
24
  if (knownCommands.includes(firstArg)) {
24
25
  // It's a command
@@ -38,7 +39,7 @@ async function main(): Promise<void> {
38
39
  groupName = rest[0];
39
40
 
40
41
  // config and groups commands don't require group name
41
- if (command !== 'config' && command !== 'groups' && !groupName) {
42
+ if (command !== 'config' && command !== 'groups' && command !== 'serve' && !groupName) {
42
43
  console.error('Error: group name required');
43
44
  printUsage();
44
45
  process.exit(1);
@@ -60,6 +61,9 @@ async function main(): Promise<void> {
60
61
  case 'groups':
61
62
  exitCode = await groupsCommand(verbose);
62
63
  break;
64
+ case 'serve':
65
+ exitCode = await serveCommand(rest[0]);
66
+ break;
63
67
  }
64
68
 
65
69
  process.exit(exitCode);
@@ -77,7 +81,8 @@ Usage: cligr <group> | <command> [options]
77
81
  Commands:
78
82
  config Open config file in editor
79
83
  ls <group> List all items in the group
80
- groups [-v|--verbose] List all groups
84
+ groups [-v|--verbose] List all groups
85
+ serve [port] Start web UI server (default port 7373)
81
86
 
82
87
  Options:
83
88
  -v, --verbose Show detailed group information
@@ -88,6 +93,8 @@ Examples:
88
93
  cligr ls test1
89
94
  cligr groups
90
95
  cligr groups -v
96
+ cligr serve
97
+ cligr serve 8080
91
98
  `);
92
99
  }
93
100