opencode-swarm-plugin 0.50.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tests for log command
3
+ *
4
+ * TDD: Write tests first, then implement
5
+ */
6
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
7
+ import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+
11
+ const LOG_DIR = join(homedir(), ".config", "swarm-tools", "logs");
12
+ const TEST_LOG_DIR = join(process.cwd(), "test-logs");
13
+
14
+ describe("log command", () => {
15
+ beforeAll(() => {
16
+ // Create test log directory
17
+ if (!existsSync(TEST_LOG_DIR)) {
18
+ mkdirSync(TEST_LOG_DIR, { recursive: true });
19
+ }
20
+
21
+ // Create sample log files
22
+ const today = new Date().toISOString().split("T")[0];
23
+ const yesterday = new Date(Date.now() - 86400000).toISOString().split("T")[0];
24
+
25
+ writeFileSync(
26
+ join(TEST_LOG_DIR, `tools-${today}.log`),
27
+ JSON.stringify({ time: new Date().toISOString(), level: "info", msg: "test tool call", tool: "hive_create" }) + "\n" +
28
+ JSON.stringify({ time: new Date().toISOString(), level: "debug", msg: "another call", tool: "swarm_status" }) + "\n"
29
+ );
30
+
31
+ writeFileSync(
32
+ join(TEST_LOG_DIR, `swarmmail-${today}.log`),
33
+ JSON.stringify({ time: new Date().toISOString(), level: "info", msg: "message sent", to: ["agent"] }) + "\n"
34
+ );
35
+
36
+ writeFileSync(
37
+ join(TEST_LOG_DIR, `errors-${today}.log`),
38
+ JSON.stringify({ time: new Date().toISOString(), level: "error", msg: "something failed", error: "Test error" }) + "\n"
39
+ );
40
+
41
+ writeFileSync(
42
+ join(TEST_LOG_DIR, `tools-${yesterday}.log`),
43
+ JSON.stringify({ time: new Date(Date.now() - 86400000).toISOString(), level: "info", msg: "old log entry" }) + "\n"
44
+ );
45
+ });
46
+
47
+ afterAll(() => {
48
+ // Clean up test logs
49
+ if (existsSync(TEST_LOG_DIR)) {
50
+ rmSync(TEST_LOG_DIR, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test("log helper functions format log entries correctly", () => {
55
+ // This will be implemented in plugin-wrapper-template.ts
56
+ // For now, we test the expected format
57
+ const entry = {
58
+ time: new Date().toISOString(),
59
+ level: "info",
60
+ msg: "test message",
61
+ tool: "hive_create",
62
+ args: { title: "Test" }
63
+ };
64
+
65
+ const formatted = JSON.stringify(entry);
66
+ expect(formatted).toContain("time");
67
+ expect(formatted).toContain("level");
68
+ expect(formatted).toContain("msg");
69
+ });
70
+
71
+ test("date-stamped log files use YYYY-MM-DD format", () => {
72
+ const today = new Date().toISOString().split("T")[0];
73
+ expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
74
+ });
75
+
76
+ test("log rotation keeps only recent files", () => {
77
+ // Test that files older than 7 days would be deleted
78
+ const sevenDaysAgo = new Date(Date.now() - 7 * 86400000);
79
+ const eightDaysAgo = new Date(Date.now() - 8 * 86400000);
80
+
81
+ const sevenDaysDate = sevenDaysAgo.toISOString().split("T")[0];
82
+ const eightDaysDate = eightDaysAgo.toISOString().split("T")[0];
83
+
84
+ // Files with these dates should be deleted by rotation
85
+ expect(sevenDaysDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
86
+ expect(eightDaysDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
87
+ });
88
+ });
89
+
90
+ describe("swarm log CLI", () => {
91
+ test("shows all logs by default", () => {
92
+ // CLI implementation will be tested via spawn
93
+ // For now, we verify the expected behavior exists
94
+ expect(true).toBe(true);
95
+ });
96
+
97
+ test("filters by log type (tools, swarmmail, errors)", () => {
98
+ // CLI should support: swarm log tools, swarm log swarmmail, swarm log errors
99
+ const logTypes = ["tools", "swarmmail", "errors"];
100
+ expect(logTypes).toContain("tools");
101
+ expect(logTypes).toContain("swarmmail");
102
+ expect(logTypes).toContain("errors");
103
+ });
104
+
105
+ test("filters by time with --since flag", () => {
106
+ // CLI should support: swarm log --since 30s, --since 5m, --since 2h
107
+ const timeUnits = ["s", "m", "h"];
108
+ expect(timeUnits).toContain("s");
109
+ expect(timeUnits).toContain("m");
110
+ expect(timeUnits).toContain("h");
111
+ });
112
+
113
+ test("supports watch mode with --watch flag", () => {
114
+ // CLI should support: swarm log --watch
115
+ expect(true).toBe(true);
116
+ });
117
+ });
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Log Command - View and tail swarm logs
3
+ *
4
+ * Commands:
5
+ * swarm log [type] - Show recent logs (all types or specific: tools, swarmmail, errors, compaction)
6
+ * swarm log --since <time> - Show logs since time (e.g., 30s, 5m, 2h, 24h)
7
+ * swarm log --watch - Watch mode (live tail)
8
+ * swarm log --level <level> - Filter by level (info, debug, warn, error)
9
+ * swarm log --limit <n> - Limit output lines (default: 50)
10
+ * swarm log --json - JSON output
11
+ *
12
+ * Log files:
13
+ * ~/.config/swarm-tools/logs/tools-YYYY-MM-DD.log
14
+ * ~/.config/swarm-tools/logs/swarmmail-YYYY-MM-DD.log
15
+ * ~/.config/swarm-tools/logs/errors-YYYY-MM-DD.log
16
+ * ~/.config/swarm-tools/logs/compaction.log (legacy, single file)
17
+ */
18
+
19
+ import * as p from "@clack/prompts";
20
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+
24
+ // Color utilities (inline)
25
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
26
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
27
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
28
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
29
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
30
+ const gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
31
+
32
+ const LOG_DIR = join(homedir(), ".config", "swarm-tools", "logs");
33
+
34
+ interface LogEntry {
35
+ time: string;
36
+ level: string;
37
+ msg: string;
38
+ [key: string]: any;
39
+ }
40
+
41
+ interface LogOptions {
42
+ type?: string; // tools, swarmmail, errors, compaction
43
+ since?: number; // milliseconds
44
+ watch?: boolean;
45
+ level?: string; // info, debug, warn, error
46
+ limit?: number;
47
+ json?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Main log command handler
52
+ */
53
+ export async function log() {
54
+ const args = process.argv.slice(3);
55
+
56
+ if (args.includes("--help") || args.includes("help")) {
57
+ showHelp();
58
+ return;
59
+ }
60
+
61
+ const options = parseOptions(args);
62
+
63
+ if (options.watch) {
64
+ await watchLogs(options);
65
+ } else {
66
+ await showLogs(options);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Parse command-line arguments
72
+ */
73
+ function parseOptions(args: string[]): LogOptions {
74
+ const options: LogOptions = {
75
+ limit: 50,
76
+ };
77
+
78
+ for (let i = 0; i < args.length; i++) {
79
+ const arg = args[i];
80
+
81
+ if (arg === "--since" && i + 1 < args.length) {
82
+ options.since = parseSince(args[++i]);
83
+ } else if (arg === "--watch" || arg === "-w") {
84
+ options.watch = true;
85
+ } else if (arg === "--level" && i + 1 < args.length) {
86
+ options.level = args[++i];
87
+ } else if (arg === "--limit" && i + 1 < args.length) {
88
+ options.limit = parseInt(args[++i], 10);
89
+ } else if (arg === "--json") {
90
+ options.json = true;
91
+ } else if (!arg.startsWith("--")) {
92
+ // Positional argument - log type
93
+ options.type = arg;
94
+ }
95
+ }
96
+
97
+ return options;
98
+ }
99
+
100
+ /**
101
+ * Parse --since time string (e.g., "30s", "5m", "2h", "24h")
102
+ */
103
+ function parseSince(since: string): number {
104
+ const match = since.match(/^(\d+)([smhd])$/);
105
+ if (!match) {
106
+ p.log.error(`Invalid --since format: ${since}. Use: 30s, 5m, 2h, 24h`);
107
+ process.exit(1);
108
+ }
109
+
110
+ const [, value, unit] = match;
111
+ const num = parseInt(value, 10);
112
+
113
+ const units: Record<string, number> = {
114
+ s: 1000,
115
+ m: 60 * 1000,
116
+ h: 60 * 60 * 1000,
117
+ d: 24 * 60 * 60 * 1000,
118
+ };
119
+
120
+ return num * units[unit];
121
+ }
122
+
123
+ /**
124
+ * Get log files for a specific type
125
+ */
126
+ function getLogFiles(type?: string): string[] {
127
+ if (!existsSync(LOG_DIR)) {
128
+ return [];
129
+ }
130
+
131
+ const files = readdirSync(LOG_DIR);
132
+ const today = new Date().toISOString().split("T")[0];
133
+
134
+ if (type === "compaction") {
135
+ // Legacy single file
136
+ return files
137
+ .filter((f) => f === "compaction.log")
138
+ .map((f) => join(LOG_DIR, f));
139
+ }
140
+
141
+ // Date-stamped log files
142
+ const pattern = type
143
+ ? new RegExp(`^${type}-\\d{4}-\\d{2}-\\d{2}\\.log$`)
144
+ : /^(tools|swarmmail|errors)-\d{4}-\d{2}-\d{2}\.log$/;
145
+
146
+ return files
147
+ .filter((f) => pattern.test(f))
148
+ .map((f) => join(LOG_DIR, f))
149
+ .sort((a, b) => {
150
+ // Sort by modification time (newest first)
151
+ const statA = statSync(a);
152
+ const statB = statSync(b);
153
+ return statB.mtimeMs - statA.mtimeMs;
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Read and parse log entries from a file
159
+ */
160
+ function readLogEntries(filePath: string): LogEntry[] {
161
+ if (!existsSync(filePath)) {
162
+ return [];
163
+ }
164
+
165
+ const content = readFileSync(filePath, "utf-8");
166
+ const lines = content.split("\n").filter((line) => line.trim());
167
+
168
+ return lines
169
+ .map((line) => {
170
+ try {
171
+ return JSON.parse(line) as LogEntry;
172
+ } catch {
173
+ return null;
174
+ }
175
+ })
176
+ .filter((entry): entry is LogEntry => entry !== null);
177
+ }
178
+
179
+ /**
180
+ * Filter log entries by options
181
+ */
182
+ function filterEntries(entries: LogEntry[], options: LogOptions): LogEntry[] {
183
+ let filtered = entries;
184
+
185
+ // Filter by time
186
+ if (options.since) {
187
+ const cutoff = Date.now() - options.since;
188
+ filtered = filtered.filter((e) => new Date(e.time).getTime() >= cutoff);
189
+ }
190
+
191
+ // Filter by level
192
+ if (options.level) {
193
+ filtered = filtered.filter((e) => e.level === options.level);
194
+ }
195
+
196
+ return filtered;
197
+ }
198
+
199
+ /**
200
+ * Format log entry for display
201
+ */
202
+ function formatEntry(entry: LogEntry): string {
203
+ const time = new Date(entry.time).toLocaleTimeString();
204
+ const level = formatLevel(entry.level);
205
+ const msg = entry.msg;
206
+
207
+ // Extract additional fields (excluding time, level, msg)
208
+ const { time: _, level: __, msg: ___, ...rest } = entry;
209
+ const extra = Object.keys(rest).length > 0 ? dim(JSON.stringify(rest)) : "";
210
+
211
+ return `${gray(time)} ${level} ${msg} ${extra}`;
212
+ }
213
+
214
+ /**
215
+ * Format log level with color
216
+ */
217
+ function formatLevel(level: string): string {
218
+ switch (level) {
219
+ case "error":
220
+ return red("[ERROR]");
221
+ case "warn":
222
+ return yellow("[WARN] ");
223
+ case "info":
224
+ return green("[INFO] ");
225
+ case "debug":
226
+ return cyan("[DEBUG]");
227
+ default:
228
+ return `[${level}]`;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Show logs (non-watch mode)
234
+ */
235
+ async function showLogs(options: LogOptions) {
236
+ const files = getLogFiles(options.type);
237
+
238
+ if (files.length === 0) {
239
+ if (options.type) {
240
+ p.log.warn(`No logs found for type: ${options.type}`);
241
+ } else {
242
+ p.log.warn("No logs found. Run a swarm command to generate logs.");
243
+ }
244
+ return;
245
+ }
246
+
247
+ // Read all entries from all files
248
+ const allEntries = files.flatMap((f) => readLogEntries(f));
249
+
250
+ // Filter
251
+ const filtered = filterEntries(allEntries, options);
252
+
253
+ // Sort by time (newest last for tail-like output)
254
+ const sorted = filtered.sort(
255
+ (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
256
+ );
257
+
258
+ // Limit
259
+ const limited =
260
+ options.limit && options.limit > 0
261
+ ? sorted.slice(-options.limit)
262
+ : sorted;
263
+
264
+ if (options.json) {
265
+ console.log(JSON.stringify(limited, null, 2));
266
+ return;
267
+ }
268
+
269
+ // Pretty output
270
+ if (limited.length === 0) {
271
+ p.log.warn("No matching log entries");
272
+ return;
273
+ }
274
+
275
+ p.log.message(dim(`Showing ${limited.length} log entries`));
276
+ console.log("");
277
+
278
+ for (const entry of limited) {
279
+ console.log(formatEntry(entry));
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Watch logs (live tail)
285
+ */
286
+ async function watchLogs(options: LogOptions) {
287
+ p.log.info("Watching logs (Ctrl+C to stop)...");
288
+ console.log("");
289
+
290
+ // Track last read position for each file
291
+ const filePositions = new Map<string, number>();
292
+
293
+ // eslint-disable-next-line no-constant-condition
294
+ while (true) {
295
+ const files = getLogFiles(options.type);
296
+
297
+ for (const file of files) {
298
+ const lastPos = filePositions.get(file) ?? 0;
299
+ const content = readFileSync(file, "utf-8");
300
+
301
+ if (content.length > lastPos) {
302
+ const newContent = content.slice(lastPos);
303
+ const newLines = newContent.split("\n").filter((line) => line.trim());
304
+
305
+ for (const line of newLines) {
306
+ try {
307
+ const entry = JSON.parse(line) as LogEntry;
308
+ const filtered = filterEntries([entry], options);
309
+
310
+ if (filtered.length > 0) {
311
+ if (options.json) {
312
+ console.log(JSON.stringify(entry));
313
+ } else {
314
+ console.log(formatEntry(entry));
315
+ }
316
+ }
317
+ } catch {
318
+ // Skip invalid lines
319
+ }
320
+ }
321
+
322
+ filePositions.set(file, content.length);
323
+ }
324
+ }
325
+
326
+ // Poll interval (500ms)
327
+ await new Promise((resolve) => setTimeout(resolve, 500));
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Show help
333
+ */
334
+ function showHelp() {
335
+ console.log(`
336
+ ${cyan("swarm log")} - View and tail swarm logs
337
+
338
+ ${yellow("USAGE:")}
339
+ swarm log [type] [options]
340
+
341
+ ${yellow("TYPES:")}
342
+ tools Tool invocations (hive_*, swarm_*, etc.)
343
+ swarmmail Inter-agent messages
344
+ errors Error logs
345
+ compaction Context compaction events (legacy single file)
346
+
347
+ ${yellow("OPTIONS:")}
348
+ --since <time> Show logs since time (e.g., 30s, 5m, 2h, 24h)
349
+ --watch, -w Watch mode (live tail)
350
+ --level <level> Filter by level (info, debug, warn, error)
351
+ --limit <n> Limit output lines (default: 50)
352
+ --json JSON output
353
+
354
+ ${yellow("EXAMPLES:")}
355
+ swarm log # Show recent logs (all types)
356
+ swarm log tools # Show tool invocations
357
+ swarm log --since 5m # Show logs from last 5 minutes
358
+ swarm log errors --watch # Live tail error logs
359
+ swarm log --level error --limit 20 # Show last 20 error-level logs
360
+ swarm log compaction --json # Show compaction logs as JSON
361
+ `);
362
+ }