cligr 1.0.7 → 1.0.8

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 (32) hide show
  1. package/.claude/worktrees/agent-ac25cfb2/.claude/settings.local.json +30 -0
  2. package/.claude/worktrees/agent-ac25cfb2/README.md +65 -0
  3. package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-13-named-params-support.md +391 -0
  4. package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-25-named-items-design.md +164 -0
  5. package/.claude/worktrees/agent-ac25cfb2/docs/plans/2026-02-25-named-items-implementation.md +460 -0
  6. package/.claude/worktrees/agent-ac25cfb2/package-lock.json +554 -0
  7. package/.claude/worktrees/agent-ac25cfb2/package.json +27 -0
  8. package/.claude/worktrees/agent-ac25cfb2/scripts/build.js +20 -0
  9. package/.claude/worktrees/agent-ac25cfb2/scripts/test.js +168 -0
  10. package/.claude/worktrees/agent-ac25cfb2/src/commands/config.ts +121 -0
  11. package/.claude/worktrees/agent-ac25cfb2/src/commands/groups.ts +68 -0
  12. package/.claude/worktrees/agent-ac25cfb2/src/commands/ls.ts +25 -0
  13. package/.claude/worktrees/agent-ac25cfb2/src/commands/up.ts +49 -0
  14. package/.claude/worktrees/agent-ac25cfb2/src/config/loader.ts +148 -0
  15. package/.claude/worktrees/agent-ac25cfb2/src/config/types.ts +26 -0
  16. package/.claude/worktrees/agent-ac25cfb2/src/index.ts +97 -0
  17. package/.claude/worktrees/agent-ac25cfb2/src/process/manager.ts +270 -0
  18. package/.claude/worktrees/agent-ac25cfb2/src/process/pid-store.ts +203 -0
  19. package/.claude/worktrees/agent-ac25cfb2/src/process/template.ts +87 -0
  20. package/.claude/worktrees/agent-ac25cfb2/tests/integration/blocking-processes-fixed.test.ts +255 -0
  21. package/.claude/worktrees/agent-ac25cfb2/tests/integration/blocking-processes.test.ts +497 -0
  22. package/.claude/worktrees/agent-ac25cfb2/tests/integration/commands.test.ts +648 -0
  23. package/.claude/worktrees/agent-ac25cfb2/tests/integration/config-loader.test.ts +426 -0
  24. package/.claude/worktrees/agent-ac25cfb2/tests/integration/process-manager.test.ts +394 -0
  25. package/.claude/worktrees/agent-ac25cfb2/tests/integration/template-expander.test.ts +454 -0
  26. package/.claude/worktrees/agent-ac25cfb2/tsconfig.json +15 -0
  27. package/.claude/worktrees/agent-ac25cfb2/usage.md +9 -0
  28. package/dist/index.js +82 -25
  29. package/docs/superpowers/plans/2026-04-13-improve-web-ui-console.md +256 -0
  30. package/docs/superpowers/specs/2026-04-13-improve-web-ui-console-design.md +38 -0
  31. package/package.json +1 -1
  32. package/src/commands/serve.ts +64 -7
@@ -0,0 +1,270 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import type { GroupConfig, ProcessItem } from '../config/types.js';
3
+ import { PidStore, type PidEntry } from './pid-store.js';
4
+
5
+ export type ProcessStatus = 'running' | 'stopped' | 'failed';
6
+
7
+ export class ManagedProcess {
8
+ constructor(
9
+ public item: ProcessItem,
10
+ public process: ChildProcess,
11
+ public status: ProcessStatus = 'running'
12
+ ) {}
13
+ }
14
+
15
+ export class ProcessManager {
16
+ private groups = new Map<string, ManagedProcess[]>();
17
+ private restartTimestamps = new Map<string, number[]>();
18
+ private readonly maxRestarts = 3;
19
+ private readonly restartWindow = 10000; // 10 seconds
20
+ private readonly pidStore = new PidStore();
21
+
22
+ spawnGroup(groupName: string, items: ProcessItem[], restartPolicy: GroupConfig['restart']): void {
23
+ if (this.groups.has(groupName)) {
24
+ throw new Error(`Group ${groupName} is already running`);
25
+ }
26
+
27
+ const processes: ManagedProcess[] = [];
28
+
29
+ for (const item of items) {
30
+ const proc = this.spawnProcess(item, groupName, restartPolicy);
31
+ processes.push(new ManagedProcess(item, proc));
32
+ }
33
+
34
+ this.groups.set(groupName, processes);
35
+ }
36
+
37
+ private spawnProcess(item: ProcessItem, groupName: string, restartPolicy: GroupConfig['restart']): ChildProcess {
38
+ // Parse command into executable and args, handling quoted strings
39
+ const { cmd, args } = this.parseCommand(item.fullCmd);
40
+
41
+ const proc = spawn(cmd, args, {
42
+ stdio: ['inherit', 'pipe', 'pipe'],
43
+ // On Windows, don't use shell to avoid PID mismatch
44
+ // The shell's PID would be stored instead of the actual process
45
+ // For commands that need shell, user should use cmd /c prefix
46
+ shell: false,
47
+ windowsHide: true // Hide console window on Windows
48
+ });
49
+
50
+ // Write PID file when process is spawned
51
+ // First, clean up any stale PID file for this process
52
+ // This prevents issues with leftover PIDs from previous crashes
53
+ this.pidStore.deletePid(groupName, item.name).catch(() => {});
54
+
55
+ if (proc.pid) {
56
+ const pidEntry: PidEntry = {
57
+ pid: proc.pid,
58
+ groupName,
59
+ itemName: item.name,
60
+ startTime: Date.now(),
61
+ restartPolicy,
62
+ fullCmd: item.fullCmd
63
+ };
64
+ this.pidStore.writePid(pidEntry).catch(err => {
65
+ console.error(`[${item.name}] Failed to write PID file:`, err);
66
+ });
67
+ }
68
+
69
+ // Prefix output with item name
70
+ if (proc.stdout) {
71
+ proc.stdout.on('data', (data) => {
72
+ process.stdout.write(`[${item.name}] ${data}`);
73
+ });
74
+ }
75
+
76
+ if (proc.stderr) {
77
+ proc.stderr.on('data', (data) => {
78
+ process.stderr.write(`[${item.name}] ${data}`);
79
+ });
80
+ }
81
+
82
+ // Handle exit and restart
83
+ proc.on('exit', (code, signal) => {
84
+ this.handleExit(groupName, item, restartPolicy, code, signal);
85
+ });
86
+
87
+ return proc;
88
+ }
89
+
90
+ private parseCommand(fullCmd: string): { cmd: string; args: string[] } {
91
+ // Handle quoted strings for Windows paths with spaces
92
+ const args: string[] = [];
93
+ let current = '';
94
+ let inQuote = false;
95
+ let quoteChar = '';
96
+
97
+ for (let i = 0; i < fullCmd.length; i++) {
98
+ const char = fullCmd[i];
99
+ const nextChar = fullCmd[i + 1];
100
+
101
+ if ((char === '"' || char === "'") && !inQuote) {
102
+ inQuote = true;
103
+ quoteChar = char;
104
+ } else if (char === quoteChar && inQuote) {
105
+ inQuote = false;
106
+ quoteChar = '';
107
+ } else if (char === ' ' && !inQuote) {
108
+ if (current) {
109
+ args.push(current);
110
+ current = '';
111
+ }
112
+ } else {
113
+ current += char;
114
+ }
115
+ }
116
+
117
+ if (current) {
118
+ args.push(current);
119
+ }
120
+
121
+ return { cmd: args[0] || '', args: args.slice(1) };
122
+ }
123
+
124
+ private handleExit(groupName: string, item: ProcessItem, restartPolicy: GroupConfig['restart'], code: number | null, signal: NodeJS.Signals | null): void {
125
+ // Check if killed by cligr (don't restart if unless-stopped)
126
+ // SIGTERM works on both Unix and Windows in Node.js
127
+ if (restartPolicy === 'unless-stopped' && signal === 'SIGTERM') {
128
+ // Clean up PID file when killed by cligr
129
+ this.pidStore.deletePid(groupName, item.name).catch(() => {});
130
+ return;
131
+ }
132
+
133
+ // Check restart policy
134
+ if (restartPolicy === 'no') {
135
+ // Clean up PID file when not restarting
136
+ this.pidStore.deletePid(groupName, item.name).catch(() => {});
137
+ return;
138
+ }
139
+
140
+ // Check for crash loop (within the restart window)
141
+ const key = `${groupName}-${item.name}`;
142
+ const now = Date.now();
143
+ const timestamps = this.restartTimestamps.get(key) || [];
144
+
145
+ // Filter out timestamps outside the restart window
146
+ const recentTimestamps = timestamps.filter(ts => now - ts < this.restartWindow);
147
+ recentTimestamps.push(now);
148
+ this.restartTimestamps.set(key, recentTimestamps);
149
+
150
+ if (recentTimestamps.length > this.maxRestarts) {
151
+ console.error(`[${item.name}] Crash loop detected. Stopping restarts.`);
152
+ // Clean up PID file when stopping due to crash loop
153
+ this.pidStore.deletePid(groupName, item.name).catch(() => {});
154
+ return;
155
+ }
156
+
157
+ // Restart after delay
158
+ setTimeout(() => {
159
+ console.log(`[${item.name}] Restarting... (exit code: ${code})`);
160
+ const newProc = this.spawnProcess(item, groupName, restartPolicy);
161
+
162
+ // Update the ManagedProcess in the groups Map with the new process handle
163
+ const processes = this.groups.get(groupName);
164
+ if (processes) {
165
+ const managedProc = processes.find(mp => mp.item.name === item.name);
166
+ if (managedProc) {
167
+ managedProc.process = newProc;
168
+ }
169
+ }
170
+ }, 1000);
171
+ }
172
+
173
+ killGroup(groupName: string): Promise<void> {
174
+ const processes = this.groups.get(groupName);
175
+ if (!processes) return Promise.resolve();
176
+
177
+ const killPromises = processes.map(mp => this.killProcess(mp.process));
178
+
179
+ this.groups.delete(groupName);
180
+
181
+ // Clean up PID files after killing
182
+ return Promise.all(killPromises).then(async () => {
183
+ await this.pidStore.deleteGroupPids(groupName);
184
+ });
185
+ }
186
+
187
+ private killPid(pid: number): Promise<void> {
188
+ return new Promise((resolve, reject) => {
189
+ try {
190
+ // First try SIGTERM for graceful shutdown
191
+ process.kill(pid, 'SIGTERM');
192
+
193
+ // Force kill with SIGKILL after 5 seconds if still running
194
+ const timeout = setTimeout(() => {
195
+ try {
196
+ process.kill(pid, 'SIGKILL');
197
+ } catch {
198
+ // Process might have already exited
199
+ }
200
+ }, 5000);
201
+
202
+ // Poll for process exit
203
+ const checkInterval = setInterval(() => {
204
+ if (!this.pidStore.isPidRunning(pid)) {
205
+ clearTimeout(timeout);
206
+ clearInterval(checkInterval);
207
+ resolve();
208
+ }
209
+ }, 100);
210
+
211
+ // If already dead, resolve quickly
212
+ if (!this.pidStore.isPidRunning(pid)) {
213
+ clearTimeout(timeout);
214
+ clearInterval(checkInterval);
215
+ resolve();
216
+ }
217
+ } catch (err) {
218
+ reject(err);
219
+ }
220
+ });
221
+ }
222
+
223
+ private killProcess(proc: ChildProcess): Promise<void> {
224
+ return new Promise((resolve) => {
225
+ // First try SIGTERM for graceful shutdown
226
+ proc.kill('SIGTERM');
227
+
228
+ // Force kill with SIGKILL after 5 seconds if still running
229
+ const timeout = setTimeout(() => {
230
+ if (!proc.killed) {
231
+ proc.kill('SIGKILL');
232
+ }
233
+ }, 5000);
234
+
235
+ proc.on('exit', () => {
236
+ clearTimeout(timeout);
237
+ resolve();
238
+ });
239
+
240
+ // If already dead, resolve immediately
241
+ if (proc.killed || proc.exitCode !== null) {
242
+ clearTimeout(timeout);
243
+ resolve();
244
+ }
245
+ });
246
+ }
247
+
248
+ killAll(): Promise<void> {
249
+ const killPromises: Promise<void>[] = [];
250
+ for (const groupName of this.groups.keys()) {
251
+ killPromises.push(this.killGroup(groupName));
252
+ }
253
+ return Promise.all(killPromises).then(() => {});
254
+ }
255
+
256
+ getGroupStatus(groupName: string): ProcessStatus[] {
257
+ const processes = this.groups.get(groupName);
258
+ if (!processes) return [];
259
+
260
+ return processes.map(mp => mp.status);
261
+ }
262
+
263
+ isGroupRunning(groupName: string): boolean {
264
+ return this.groups.has(groupName);
265
+ }
266
+
267
+ getRunningGroups(): string[] {
268
+ return Array.from(this.groups.keys());
269
+ }
270
+ }
@@ -0,0 +1,203 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ export interface PidEntry {
6
+ pid: number;
7
+ groupName: string;
8
+ itemName: string;
9
+ startTime: number;
10
+ restartPolicy: 'yes' | 'no' | 'unless-stopped';
11
+ fullCmd: string;
12
+ }
13
+
14
+ export class PidStore {
15
+ private readonly pidsDir: string;
16
+
17
+ constructor() {
18
+ this.pidsDir = path.join(os.homedir(), '.cligr', 'pids');
19
+ }
20
+
21
+ /**
22
+ * Initialize the PID directory
23
+ */
24
+ async ensureDir(): Promise<void> {
25
+ try {
26
+ await fs.mkdir(this.pidsDir, { recursive: true });
27
+ } catch (err) {
28
+ // Ignore if already exists
29
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
30
+ throw err;
31
+ }
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Sanitize item name for use in file path
37
+ * Replaces characters invalid on Windows (like colons in paths)
38
+ */
39
+ private sanitizeItemName(itemName: string): string {
40
+ // Replace characters that are invalid in Windows filenames
41
+ // < > : " / \ | ? *
42
+ return itemName.replace(/[<>:"/\\|?*]/g, '_');
43
+ }
44
+
45
+ /**
46
+ * Get the PID file path for a specific group and item
47
+ */
48
+ private getPidFilePath(groupName: string, itemName: string): string {
49
+ const sanitizedName = this.sanitizeItemName(itemName);
50
+ return path.join(this.pidsDir, `${groupName}_${sanitizedName}.pid`);
51
+ }
52
+
53
+ /**
54
+ * Write a PID file with metadata
55
+ */
56
+ async writePid(entry: PidEntry): Promise<void> {
57
+ await this.ensureDir();
58
+ const filePath = this.getPidFilePath(entry.groupName, entry.itemName);
59
+ await fs.writeFile(filePath, JSON.stringify(entry, null, 2), 'utf-8');
60
+ }
61
+
62
+ /**
63
+ * Read all PID entries for a specific group
64
+ */
65
+ async readPidsByGroup(groupName: string): Promise<PidEntry[]> {
66
+ await this.ensureDir();
67
+ const entries: PidEntry[] = [];
68
+
69
+ try {
70
+ const files = await fs.readdir(this.pidsDir);
71
+ const prefix = `${groupName}_`;
72
+
73
+ for (const file of files) {
74
+ if (file.startsWith(prefix) && file.endsWith('.pid')) {
75
+ try {
76
+ const content = await fs.readFile(path.join(this.pidsDir, file), 'utf-8');
77
+ entries.push(JSON.parse(content) as PidEntry);
78
+ } catch {
79
+ // Skip invalid files
80
+ continue;
81
+ }
82
+ }
83
+ }
84
+ } catch {
85
+ // Directory doesn't exist or can't be read
86
+ return [];
87
+ }
88
+
89
+ return entries;
90
+ }
91
+
92
+ /**
93
+ * Read all PID entries
94
+ */
95
+ async readAllPids(): Promise<PidEntry[]> {
96
+ await this.ensureDir();
97
+ const entries: PidEntry[] = [];
98
+
99
+ try {
100
+ const files = await fs.readdir(this.pidsDir);
101
+
102
+ for (const file of files) {
103
+ if (file.endsWith('.pid')) {
104
+ try {
105
+ const content = await fs.readFile(path.join(this.pidsDir, file), 'utf-8');
106
+ entries.push(JSON.parse(content) as PidEntry);
107
+ } catch {
108
+ // Skip invalid files
109
+ continue;
110
+ }
111
+ }
112
+ }
113
+ } catch {
114
+ // Directory doesn't exist or can't be read
115
+ return [];
116
+ }
117
+
118
+ return entries;
119
+ }
120
+
121
+ /**
122
+ * Delete a specific PID file
123
+ */
124
+ async deletePid(groupName: string, itemName: string): Promise<void> {
125
+ const filePath = this.getPidFilePath(groupName, itemName);
126
+ try {
127
+ await fs.unlink(filePath);
128
+ } catch (err) {
129
+ // Ignore if file doesn't exist
130
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
131
+ throw err;
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Delete all PID files for a group
138
+ */
139
+ async deleteGroupPids(groupName: string): Promise<void> {
140
+ const entries = await this.readPidsByGroup(groupName);
141
+ for (const entry of entries) {
142
+ await this.deletePid(entry.groupName, entry.itemName);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Check if a PID is currently running (cross-platform)
148
+ */
149
+ isPidRunning(pid: number): boolean {
150
+ try {
151
+ // Sending signal 0 checks if process exists without killing it
152
+ process.kill(pid, 0);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Check if a PID entry is still valid and running.
161
+ * This helps prevent killing wrong processes if PID is reused by the OS.
162
+ * A PID is considered valid if it's running AND started recently (within last 5 minutes).
163
+ */
164
+ isPidEntryValid(entry: PidEntry): boolean {
165
+ if (!this.isPidRunning(entry.pid)) {
166
+ return false;
167
+ }
168
+
169
+ // Check if the process start time is recent (within 5 minutes)
170
+ // This prevents killing a wrong process if PID was reused
171
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
172
+ return entry.startTime > fiveMinutesAgo;
173
+ }
174
+
175
+ /**
176
+ * Remove PID files for processes that are no longer running
177
+ * Returns the list of stale entries that were removed
178
+ */
179
+ async cleanupStalePids(): Promise<PidEntry[]> {
180
+ const allEntries = await this.readAllPids();
181
+ const staleEntries: PidEntry[] = [];
182
+
183
+ for (const entry of allEntries) {
184
+ // Check if PID is no longer running OR if entry is too old (> 5 minutes)
185
+ // This helps prevent PID reuse issues
186
+ if (!this.isPidEntryValid(entry)) {
187
+ staleEntries.push(entry);
188
+ await this.deletePid(entry.groupName, entry.itemName);
189
+ }
190
+ }
191
+
192
+ return staleEntries;
193
+ }
194
+
195
+ /**
196
+ * Get all unique group names that have PID files
197
+ */
198
+ async getRunningGroups(): Promise<string[]> {
199
+ const allEntries = await this.readAllPids();
200
+ const groups = new Set(allEntries.map(e => e.groupName));
201
+ return Array.from(groups);
202
+ }
203
+ }
@@ -0,0 +1,87 @@
1
+ import type { ProcessItem, ItemEntry } from '../config/types.js';
2
+
3
+ export class TemplateExpander {
4
+ /**
5
+ * Replaces named params in template ($name, $env, etc.)
6
+ * @param template - Command template with $paramName placeholders
7
+ * @param params - Key-value pairs for substitution
8
+ * @returns Template with named params replaced
9
+ */
10
+ private static expandNamedParams(template: string, params: Record<string, string>): string {
11
+ let result = template;
12
+ for (const [key, value] of Object.entries(params)) {
13
+ const placeholder = `$${key}`;
14
+ result = result.replaceAll(placeholder, value);
15
+ }
16
+ return result;
17
+ }
18
+
19
+ /**
20
+ * Expands a command template with item arguments
21
+ * @param template - Command template with $1, $2, $3 etc.
22
+ * @param item - ItemEntry with name and value
23
+ * @param index - Item index in group
24
+ * @param params - Optional named params for substitution ($name, $env, etc.)
25
+ * @returns ProcessItem with expanded command
26
+ */
27
+ static expand(template: string, item: ItemEntry, index: number, params: Record<string, string> = {}): ProcessItem {
28
+ const args = item.value.split(',').map(s => s.trim());
29
+
30
+ // Use explicit name from ItemEntry
31
+ const name = item.name;
32
+
33
+ // Replace $1, $2, $3 etc. with args (positional params)
34
+ let fullCmd = template;
35
+ for (let i = args.length - 1; i >= 0; i--) {
36
+ const placeholder = `$${i + 1}`;
37
+ fullCmd = fullCmd.replaceAll(placeholder, args[i]);
38
+ }
39
+
40
+ // Replace named params ($name, $env, etc.) AFTER positional params
41
+ fullCmd = this.expandNamedParams(fullCmd, params);
42
+
43
+ return { name, args, fullCmd };
44
+ }
45
+
46
+ /**
47
+ * Parses item string into command
48
+ * @param tool - Tool name or executable
49
+ * @param toolTemplate - Template from tools config (if registered tool)
50
+ * @param item - ItemEntry with name and value
51
+ * @param index - Item index in group
52
+ * @param params - Optional named params for substitution
53
+ */
54
+ static parseItem(
55
+ tool: string | null,
56
+ toolTemplate: string | null,
57
+ item: ItemEntry,
58
+ index: number,
59
+ params: Record<string, string> = {}
60
+ ): ProcessItem {
61
+ if (toolTemplate) {
62
+ // Use registered tool template
63
+ const result = this.expand(toolTemplate, item, index, params);
64
+
65
+ // If there are more args than placeholders in the template, append them
66
+ const placeholdersInTemplate = (toolTemplate.match(/\$\d+/g) || []);
67
+ let maxPlaceholder = 0;
68
+ for (const p of placeholdersInTemplate) {
69
+ const num = parseInt(p.substring(1), 10);
70
+ if (num > maxPlaceholder) maxPlaceholder = num;
71
+ }
72
+
73
+ if (maxPlaceholder > 0 && result.args.length > maxPlaceholder) {
74
+ const remainingArgs = result.args.slice(maxPlaceholder);
75
+ result.fullCmd = `${result.fullCmd} ${remainingArgs.join(' ')}`;
76
+ }
77
+
78
+ return result;
79
+ } else {
80
+ // Direct executable - use tool as command prefix
81
+ const args = item.value.split(',').map(s => s.trim());
82
+ const name = item.name;
83
+ const fullCmd = tool ? `${tool} ${item.value}` : item.value;
84
+ return { name, args, fullCmd };
85
+ }
86
+ }
87
+ }