herdctl 1.3.9 → 1.4.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.
Files changed (39) hide show
  1. package/dist/commands/__tests__/agent.test.d.ts +2 -0
  2. package/dist/commands/__tests__/agent.test.d.ts.map +1 -0
  3. package/dist/commands/__tests__/agent.test.js +1461 -0
  4. package/dist/commands/__tests__/agent.test.js.map +1 -0
  5. package/dist/commands/__tests__/init-agent.test.d.ts +2 -0
  6. package/dist/commands/__tests__/init-agent.test.d.ts.map +1 -0
  7. package/dist/commands/__tests__/init-agent.test.js +363 -0
  8. package/dist/commands/__tests__/init-agent.test.js.map +1 -0
  9. package/dist/commands/__tests__/init-fleet.test.d.ts +2 -0
  10. package/dist/commands/__tests__/init-fleet.test.d.ts.map +1 -0
  11. package/dist/commands/__tests__/init-fleet.test.js +154 -0
  12. package/dist/commands/__tests__/init-fleet.test.js.map +1 -0
  13. package/dist/commands/__tests__/init.test.js +43 -213
  14. package/dist/commands/__tests__/init.test.js.map +1 -1
  15. package/dist/commands/agent.d.ts +143 -0
  16. package/dist/commands/agent.d.ts.map +1 -0
  17. package/dist/commands/agent.js +845 -0
  18. package/dist/commands/agent.js.map +1 -0
  19. package/dist/commands/init-agent.d.ts +22 -0
  20. package/dist/commands/init-agent.d.ts.map +1 -0
  21. package/dist/commands/init-agent.js +273 -0
  22. package/dist/commands/init-agent.js.map +1 -0
  23. package/dist/commands/init-fleet.d.ts +13 -0
  24. package/dist/commands/init-fleet.d.ts.map +1 -0
  25. package/dist/commands/init-fleet.js +91 -0
  26. package/dist/commands/init-fleet.js.map +1 -0
  27. package/dist/commands/init-utils.d.ts +8 -0
  28. package/dist/commands/init-utils.d.ts.map +1 -0
  29. package/dist/commands/init-utils.js +24 -0
  30. package/dist/commands/init-utils.js.map +1 -0
  31. package/dist/commands/init.d.ts +9 -9
  32. package/dist/commands/init.d.ts.map +1 -1
  33. package/dist/commands/init.js +30 -289
  34. package/dist/commands/init.js.map +1 -1
  35. package/dist/index.d.ts +3 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +131 -8
  38. package/dist/index.js.map +1 -1
  39. package/package.json +6 -5
@@ -0,0 +1,1461 @@
1
+ import * as fs from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ // Mock @herdctl/core entirely
6
+ vi.mock("@herdctl/core", () => ({
7
+ // Logger - mock that routes to console so tests can capture it
8
+ createLogger: () => ({
9
+ debug: (...args) => console.debug(...args),
10
+ info: (...args) => console.log(...args),
11
+ warn: (...args) => console.warn(...args),
12
+ error: (...args) => console.error(...args),
13
+ }),
14
+ // Source parsing
15
+ parseSourceSpecifier: vi.fn(),
16
+ stringifySourceSpecifier: vi.fn(),
17
+ SourceParseError: class SourceParseError extends Error {
18
+ source;
19
+ constructor(message, source) {
20
+ super(message);
21
+ this.name = "SourceParseError";
22
+ this.source = source;
23
+ }
24
+ },
25
+ isGitHubSource: vi.fn(),
26
+ isLocalSource: vi.fn(),
27
+ // Repository fetching
28
+ fetchRepository: vi.fn(),
29
+ RepositoryFetchError: class RepositoryFetchError extends Error {
30
+ source;
31
+ cause;
32
+ constructor(message, source, cause) {
33
+ super(message);
34
+ this.name = "RepositoryFetchError";
35
+ this.source = source;
36
+ this.cause = cause;
37
+ }
38
+ },
39
+ GitHubCloneAuthError: class GitHubCloneAuthError extends Error {
40
+ source;
41
+ constructor(source, cause) {
42
+ super("Auth failed");
43
+ this.name = "GitHubCloneAuthError";
44
+ this.source = source;
45
+ }
46
+ },
47
+ GitHubRepoNotFoundError: class GitHubRepoNotFoundError extends Error {
48
+ source;
49
+ constructor(source, cause) {
50
+ super("Repo not found");
51
+ this.name = "GitHubRepoNotFoundError";
52
+ this.source = source;
53
+ }
54
+ },
55
+ NetworkError: class NetworkError extends Error {
56
+ source;
57
+ constructor(source, cause) {
58
+ super("Network error");
59
+ this.name = "NetworkError";
60
+ this.source = source;
61
+ }
62
+ },
63
+ LocalPathError: class LocalPathError extends Error {
64
+ source;
65
+ constructor(source, reason, cause) {
66
+ super(reason);
67
+ this.name = "LocalPathError";
68
+ this.source = source;
69
+ }
70
+ },
71
+ // Repository validation
72
+ validateRepository: vi.fn(),
73
+ // File installation
74
+ installAgentFiles: vi.fn(),
75
+ AgentInstallError: class AgentInstallError extends Error {
76
+ code;
77
+ constructor(message, code) {
78
+ super(message);
79
+ this.name = "AgentInstallError";
80
+ this.code = code;
81
+ }
82
+ },
83
+ AGENT_ALREADY_EXISTS: "AGENT_ALREADY_EXISTS",
84
+ // Fleet config update
85
+ addAgentToFleetConfig: vi.fn(),
86
+ FleetConfigError: class FleetConfigError extends Error {
87
+ code;
88
+ constructor(message, code) {
89
+ super(message);
90
+ this.name = "FleetConfigError";
91
+ this.code = code;
92
+ }
93
+ },
94
+ // Environment variable scanning
95
+ scanEnvVariables: vi.fn(),
96
+ // Agent discovery
97
+ discoverAgents: vi.fn(),
98
+ AgentDiscoveryError: class AgentDiscoveryError extends Error {
99
+ code;
100
+ constructor(message, code) {
101
+ super(message);
102
+ this.name = "AgentDiscoveryError";
103
+ this.code = code;
104
+ }
105
+ },
106
+ // Agent info
107
+ getAgentInfo: vi.fn(),
108
+ // Agent removal
109
+ removeAgent: vi.fn(),
110
+ AgentRemoveError: class AgentRemoveError extends Error {
111
+ code;
112
+ constructor(message, code) {
113
+ super(message);
114
+ this.name = "AgentRemoveError";
115
+ this.code = code;
116
+ }
117
+ },
118
+ AGENT_NOT_FOUND: "AGENT_NOT_FOUND",
119
+ // Config loader (for fleet-of-fleets support)
120
+ loadConfig: vi.fn(),
121
+ ConfigError: class ConfigError extends Error {
122
+ constructor(message) {
123
+ super(message);
124
+ this.name = "ConfigError";
125
+ }
126
+ },
127
+ ConfigNotFoundError: class ConfigNotFoundError extends Error {
128
+ searchedPaths;
129
+ startDirectory;
130
+ constructor(startDirectory, searchedPaths = []) {
131
+ super(`No herdctl configuration file found from '${startDirectory}'`);
132
+ this.name = "ConfigNotFoundError";
133
+ this.startDirectory = startDirectory;
134
+ this.searchedPaths = searchedPaths;
135
+ }
136
+ },
137
+ }));
138
+ import { AGENT_ALREADY_EXISTS, AGENT_NOT_FOUND, AgentDiscoveryError, AgentInstallError, AgentRemoveError, addAgentToFleetConfig, ConfigError, ConfigNotFoundError, discoverAgents, FleetConfigError, fetchRepository, GitHubCloneAuthError, GitHubRepoNotFoundError, getAgentInfo, installAgentFiles, isGitHubSource, isLocalSource, loadConfig, parseSourceSpecifier, RepositoryFetchError, removeAgent, SourceParseError, scanEnvVariables, stringifySourceSpecifier, validateRepository, } from "@herdctl/core";
139
+ import { agentAddCommand, agentInfoCommand, agentListCommand, agentRemoveCommand, buildFleetTree, renderFleetTree, } from "../agent.js";
140
+ const mockedParseSourceSpecifier = vi.mocked(parseSourceSpecifier);
141
+ const mockedStringifySourceSpecifier = vi.mocked(stringifySourceSpecifier);
142
+ const mockedIsGitHubSource = vi.mocked(isGitHubSource);
143
+ const mockedIsLocalSource = vi.mocked(isLocalSource);
144
+ const mockedFetchRepository = vi.mocked(fetchRepository);
145
+ const mockedValidateRepository = vi.mocked(validateRepository);
146
+ const mockedInstallAgentFiles = vi.mocked(installAgentFiles);
147
+ const mockedAddAgentToFleetConfig = vi.mocked(addAgentToFleetConfig);
148
+ const mockedScanEnvVariables = vi.mocked(scanEnvVariables);
149
+ const mockedRemoveAgent = vi.mocked(removeAgent);
150
+ const mockedDiscoverAgents = vi.mocked(discoverAgents);
151
+ const mockedGetAgentInfo = vi.mocked(getAgentInfo);
152
+ const mockedLoadConfig = vi.mocked(loadConfig);
153
+ function createTempDir() {
154
+ const baseDir = path.join(tmpdir(), `herdctl-agent-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
155
+ fs.mkdirSync(baseDir, { recursive: true });
156
+ return fs.realpathSync(baseDir);
157
+ }
158
+ function cleanupTempDir(dir) {
159
+ fs.rmSync(dir, { recursive: true, force: true });
160
+ }
161
+ /** Create a minimal agent repository structure in a temp directory */
162
+ function createMockAgentRepo(dir, agentName = "test-agent") {
163
+ fs.writeFileSync(path.join(dir, "agent.yaml"), `name: ${agentName}
164
+ permission_mode: default
165
+ runtime: sdk
166
+ `, "utf-8");
167
+ fs.writeFileSync(path.join(dir, "CLAUDE.md"), "# Agent Instructions\n", "utf-8");
168
+ }
169
+ /** Create a minimal herdctl.yaml for tests */
170
+ function createFleetConfig(dir) {
171
+ fs.writeFileSync(path.join(dir, "herdctl.yaml"), `version: 1
172
+
173
+ fleet:
174
+ name: test-fleet
175
+
176
+ agents: []
177
+ `, "utf-8");
178
+ }
179
+ describe("agentAddCommand", () => {
180
+ let tempDir;
181
+ let fetchedRepoDir;
182
+ let originalCwd;
183
+ let consoleLogs;
184
+ let consoleErrors;
185
+ let originalConsoleLog;
186
+ let originalConsoleError;
187
+ let originalProcessExit;
188
+ let exitCode;
189
+ let cleanupCalled;
190
+ beforeEach(() => {
191
+ tempDir = createTempDir();
192
+ fetchedRepoDir = createTempDir();
193
+ originalCwd = process.cwd();
194
+ process.chdir(tempDir);
195
+ consoleLogs = [];
196
+ consoleErrors = [];
197
+ originalConsoleLog = console.log;
198
+ originalConsoleError = console.error;
199
+ console.log = (...args) => consoleLogs.push(args.join(" "));
200
+ console.error = (...args) => consoleErrors.push(args.join(" "));
201
+ exitCode = undefined;
202
+ process.exitCode = undefined;
203
+ originalProcessExit = process.exit;
204
+ process.exit = ((code) => {
205
+ exitCode = code ?? 0;
206
+ throw new Error(`process.exit(${code})`);
207
+ });
208
+ cleanupCalled = false;
209
+ vi.clearAllMocks();
210
+ });
211
+ afterEach(() => {
212
+ process.chdir(originalCwd);
213
+ cleanupTempDir(tempDir);
214
+ cleanupTempDir(fetchedRepoDir);
215
+ console.log = originalConsoleLog;
216
+ console.error = originalConsoleError;
217
+ process.exit = originalProcessExit;
218
+ });
219
+ /** Set up default mocks for a successful GitHub installation */
220
+ function setupSuccessfulGitHubMocks(agentName = "test-agent") {
221
+ createFleetConfig(tempDir);
222
+ createMockAgentRepo(fetchedRepoDir, agentName);
223
+ mockedParseSourceSpecifier.mockReturnValue({
224
+ type: "github",
225
+ owner: "user",
226
+ repo: "repo",
227
+ ref: "v1.0.0",
228
+ });
229
+ mockedStringifySourceSpecifier.mockReturnValue("github:user/repo@v1.0.0");
230
+ mockedIsGitHubSource.mockReturnValue(true);
231
+ mockedIsLocalSource.mockReturnValue(false);
232
+ mockedFetchRepository.mockResolvedValue({
233
+ path: fetchedRepoDir,
234
+ cleanup: async () => {
235
+ cleanupCalled = true;
236
+ },
237
+ });
238
+ mockedValidateRepository.mockResolvedValue({
239
+ valid: true,
240
+ agentName,
241
+ agentConfig: { name: agentName, permission_mode: "default", runtime: "sdk" },
242
+ repoMetadata: null,
243
+ errors: [],
244
+ warnings: [],
245
+ });
246
+ const installPath = path.join(tempDir, "agents", agentName);
247
+ mockedInstallAgentFiles.mockResolvedValue({
248
+ agentName,
249
+ installPath,
250
+ copiedFiles: ["agent.yaml", "CLAUDE.md"],
251
+ });
252
+ // Create the installed files so scanEnvVariables can read them
253
+ fs.mkdirSync(path.join(tempDir, "agents", agentName), { recursive: true });
254
+ fs.copyFileSync(path.join(fetchedRepoDir, "agent.yaml"), path.join(tempDir, "agents", agentName, "agent.yaml"));
255
+ mockedAddAgentToFleetConfig.mockResolvedValue({
256
+ modified: true,
257
+ agentPath: `./agents/${agentName}/agent.yaml`,
258
+ alreadyExists: false,
259
+ });
260
+ mockedScanEnvVariables.mockReturnValue({
261
+ variables: [],
262
+ required: [],
263
+ optional: [],
264
+ });
265
+ }
266
+ /** Set up default mocks for a successful local installation */
267
+ function setupSuccessfulLocalMocks(agentName = "local-agent") {
268
+ createFleetConfig(tempDir);
269
+ createMockAgentRepo(fetchedRepoDir, agentName);
270
+ mockedParseSourceSpecifier.mockReturnValue({
271
+ type: "local",
272
+ path: fetchedRepoDir,
273
+ });
274
+ mockedStringifySourceSpecifier.mockReturnValue(fetchedRepoDir);
275
+ mockedIsGitHubSource.mockReturnValue(false);
276
+ mockedIsLocalSource.mockReturnValue(true);
277
+ mockedFetchRepository.mockResolvedValue({
278
+ path: fetchedRepoDir,
279
+ cleanup: async () => {
280
+ cleanupCalled = true;
281
+ },
282
+ });
283
+ mockedValidateRepository.mockResolvedValue({
284
+ valid: true,
285
+ agentName,
286
+ agentConfig: { name: agentName, permission_mode: "default", runtime: "sdk" },
287
+ repoMetadata: null,
288
+ errors: [],
289
+ warnings: [],
290
+ });
291
+ const installPath = path.join(tempDir, "agents", agentName);
292
+ mockedInstallAgentFiles.mockResolvedValue({
293
+ agentName,
294
+ installPath,
295
+ copiedFiles: ["agent.yaml", "CLAUDE.md"],
296
+ });
297
+ // Create the installed files so scanEnvVariables can read them
298
+ fs.mkdirSync(path.join(tempDir, "agents", agentName), { recursive: true });
299
+ fs.copyFileSync(path.join(fetchedRepoDir, "agent.yaml"), path.join(tempDir, "agents", agentName, "agent.yaml"));
300
+ mockedAddAgentToFleetConfig.mockResolvedValue({
301
+ modified: true,
302
+ agentPath: `./agents/${agentName}/agent.yaml`,
303
+ alreadyExists: false,
304
+ });
305
+ mockedScanEnvVariables.mockReturnValue({
306
+ variables: [],
307
+ required: [],
308
+ optional: [],
309
+ });
310
+ }
311
+ describe("successful installation from GitHub", () => {
312
+ it("installs an agent from GitHub source", async () => {
313
+ setupSuccessfulGitHubMocks("my-agent");
314
+ await agentAddCommand("github:user/repo@v1.0.0", {});
315
+ expect(mockedParseSourceSpecifier).toHaveBeenCalledWith("github:user/repo@v1.0.0");
316
+ expect(mockedFetchRepository).toHaveBeenCalled();
317
+ expect(mockedValidateRepository).toHaveBeenCalledWith(fetchedRepoDir);
318
+ expect(mockedInstallAgentFiles).toHaveBeenCalled();
319
+ expect(mockedAddAgentToFleetConfig).toHaveBeenCalled();
320
+ expect(consoleLogs.some((log) => log.includes("installed successfully"))).toBe(true);
321
+ });
322
+ it("calls cleanup even after successful installation", async () => {
323
+ setupSuccessfulGitHubMocks();
324
+ await agentAddCommand("github:user/repo", {});
325
+ expect(cleanupCalled).toBe(true);
326
+ });
327
+ });
328
+ describe("successful installation from local path", () => {
329
+ it("installs an agent from local path", async () => {
330
+ setupSuccessfulLocalMocks("local-agent");
331
+ await agentAddCommand("./local/path", {});
332
+ expect(mockedParseSourceSpecifier).toHaveBeenCalledWith("./local/path");
333
+ expect(mockedFetchRepository).toHaveBeenCalled();
334
+ expect(mockedValidateRepository).toHaveBeenCalled();
335
+ expect(mockedInstallAgentFiles).toHaveBeenCalled();
336
+ expect(mockedAddAgentToFleetConfig).toHaveBeenCalled();
337
+ expect(consoleLogs.some((log) => log.includes("installed successfully"))).toBe(true);
338
+ });
339
+ });
340
+ describe("dry run mode", () => {
341
+ it("does not modify files in dry run mode", async () => {
342
+ setupSuccessfulGitHubMocks("dry-run-agent");
343
+ await agentAddCommand("github:user/repo", { dryRun: true });
344
+ // Should parse and fetch
345
+ expect(mockedParseSourceSpecifier).toHaveBeenCalled();
346
+ expect(mockedFetchRepository).toHaveBeenCalled();
347
+ expect(mockedValidateRepository).toHaveBeenCalled();
348
+ // Should NOT install or update config
349
+ expect(mockedInstallAgentFiles).not.toHaveBeenCalled();
350
+ expect(mockedAddAgentToFleetConfig).not.toHaveBeenCalled();
351
+ // Should print dry run message
352
+ expect(consoleLogs.some((log) => log.includes("Dry run mode"))).toBe(true);
353
+ expect(consoleLogs.some((log) => log.includes("Would install"))).toBe(true);
354
+ });
355
+ it("still calls cleanup in dry run mode", async () => {
356
+ setupSuccessfulGitHubMocks();
357
+ await agentAddCommand("github:user/repo", { dryRun: true });
358
+ expect(cleanupCalled).toBe(true);
359
+ });
360
+ });
361
+ describe("error handling", () => {
362
+ it("handles SourceParseError gracefully", async () => {
363
+ mockedParseSourceSpecifier.mockImplementation(() => {
364
+ throw new SourceParseError("Invalid source format", "bad-source");
365
+ });
366
+ await expect(agentAddCommand("bad-source", {})).rejects.toThrow("process.exit");
367
+ expect(exitCode).toBe(1);
368
+ expect(consoleErrors.some((e) => e.includes("Invalid source"))).toBe(true);
369
+ });
370
+ it("handles RepositoryFetchError gracefully", async () => {
371
+ createFleetConfig(tempDir);
372
+ mockedParseSourceSpecifier.mockReturnValue({
373
+ type: "github",
374
+ owner: "user",
375
+ repo: "repo",
376
+ });
377
+ mockedStringifySourceSpecifier.mockReturnValue("github:user/repo");
378
+ mockedIsGitHubSource.mockReturnValue(true);
379
+ mockedIsLocalSource.mockReturnValue(false);
380
+ mockedFetchRepository.mockRejectedValue(new RepositoryFetchError("Clone failed", { type: "github", owner: "user", repo: "repo" }));
381
+ await expect(agentAddCommand("github:user/repo", {})).rejects.toThrow("process.exit");
382
+ expect(exitCode).toBe(1);
383
+ expect(consoleErrors.some((e) => e.includes("Failed to fetch"))).toBe(true);
384
+ });
385
+ it("handles GitHubCloneAuthError gracefully", async () => {
386
+ createFleetConfig(tempDir);
387
+ mockedParseSourceSpecifier.mockReturnValue({
388
+ type: "github",
389
+ owner: "user",
390
+ repo: "private-repo",
391
+ });
392
+ mockedStringifySourceSpecifier.mockReturnValue("github:user/private-repo");
393
+ mockedIsGitHubSource.mockReturnValue(true);
394
+ mockedIsLocalSource.mockReturnValue(false);
395
+ mockedFetchRepository.mockRejectedValue(new GitHubCloneAuthError({ type: "github", owner: "user", repo: "private-repo" }));
396
+ await expect(agentAddCommand("github:user/private-repo", {})).rejects.toThrow("process.exit");
397
+ expect(exitCode).toBe(1);
398
+ expect(consoleErrors.some((e) => e.includes("Authentication failed"))).toBe(true);
399
+ });
400
+ it("handles GitHubRepoNotFoundError gracefully", async () => {
401
+ createFleetConfig(tempDir);
402
+ mockedParseSourceSpecifier.mockReturnValue({
403
+ type: "github",
404
+ owner: "user",
405
+ repo: "nonexistent",
406
+ });
407
+ mockedStringifySourceSpecifier.mockReturnValue("github:user/nonexistent");
408
+ mockedIsGitHubSource.mockReturnValue(true);
409
+ mockedIsLocalSource.mockReturnValue(false);
410
+ mockedFetchRepository.mockRejectedValue(new GitHubRepoNotFoundError({ type: "github", owner: "user", repo: "nonexistent" }));
411
+ await expect(agentAddCommand("github:user/nonexistent", {})).rejects.toThrow("process.exit");
412
+ expect(exitCode).toBe(1);
413
+ expect(consoleErrors.some((e) => e.includes("Repository not found"))).toBe(true);
414
+ });
415
+ it("handles AgentInstallError (already exists)", async () => {
416
+ createFleetConfig(tempDir);
417
+ createMockAgentRepo(fetchedRepoDir, "existing-agent");
418
+ mockedParseSourceSpecifier.mockReturnValue({
419
+ type: "github",
420
+ owner: "user",
421
+ repo: "repo",
422
+ });
423
+ mockedStringifySourceSpecifier.mockReturnValue("github:user/repo");
424
+ mockedIsGitHubSource.mockReturnValue(true);
425
+ mockedIsLocalSource.mockReturnValue(false);
426
+ mockedFetchRepository.mockResolvedValue({
427
+ path: fetchedRepoDir,
428
+ cleanup: async () => {
429
+ cleanupCalled = true;
430
+ },
431
+ });
432
+ mockedValidateRepository.mockResolvedValue({
433
+ valid: true,
434
+ agentName: "existing-agent",
435
+ agentConfig: { name: "existing-agent", permission_mode: "default", runtime: "sdk" },
436
+ repoMetadata: null,
437
+ errors: [],
438
+ warnings: [],
439
+ });
440
+ mockedInstallAgentFiles.mockRejectedValue(new AgentInstallError("Agent already exists", AGENT_ALREADY_EXISTS));
441
+ await agentAddCommand("github:user/repo", {});
442
+ expect(process.exitCode).toBe(1);
443
+ expect(consoleErrors.some((e) => e.includes("Installation failed"))).toBe(true);
444
+ expect(consoleErrors.some((e) => e.includes("--force"))).toBe(true);
445
+ expect(cleanupCalled).toBe(true);
446
+ });
447
+ it("handles validation errors (stops installation)", async () => {
448
+ createFleetConfig(tempDir);
449
+ createMockAgentRepo(fetchedRepoDir, "invalid-agent");
450
+ mockedParseSourceSpecifier.mockReturnValue({
451
+ type: "github",
452
+ owner: "user",
453
+ repo: "repo",
454
+ });
455
+ mockedStringifySourceSpecifier.mockReturnValue("github:user/repo");
456
+ mockedIsGitHubSource.mockReturnValue(true);
457
+ mockedIsLocalSource.mockReturnValue(false);
458
+ mockedFetchRepository.mockResolvedValue({
459
+ path: fetchedRepoDir,
460
+ cleanup: async () => {
461
+ cleanupCalled = true;
462
+ },
463
+ });
464
+ mockedValidateRepository.mockResolvedValue({
465
+ valid: false,
466
+ agentName: null,
467
+ agentConfig: null,
468
+ repoMetadata: null,
469
+ errors: [
470
+ { code: "MISSING_AGENT_YAML", message: "agent.yaml not found", path: "agent.yaml" },
471
+ ],
472
+ warnings: [],
473
+ });
474
+ await agentAddCommand("github:user/repo", {});
475
+ expect(process.exitCode).toBe(1);
476
+ expect(consoleErrors.some((e) => e.includes("Validation failed"))).toBe(true);
477
+ expect(mockedInstallAgentFiles).not.toHaveBeenCalled();
478
+ expect(cleanupCalled).toBe(true);
479
+ });
480
+ it("handles validation warnings (continues installation)", async () => {
481
+ setupSuccessfulGitHubMocks("warning-agent");
482
+ mockedValidateRepository.mockResolvedValue({
483
+ valid: true,
484
+ agentName: "warning-agent",
485
+ agentConfig: { name: "warning-agent", permission_mode: "default", runtime: "sdk" },
486
+ repoMetadata: null,
487
+ errors: [],
488
+ warnings: [
489
+ { code: "MISSING_README", message: "No README.md found", path: "README.md" },
490
+ { code: "MISSING_CLAUDE_MD", message: "No CLAUDE.md found", path: "CLAUDE.md" },
491
+ ],
492
+ });
493
+ await agentAddCommand("github:user/repo", {});
494
+ // Should print warnings
495
+ expect(consoleLogs.some((log) => log.includes("Warnings"))).toBe(true);
496
+ // But should still install
497
+ expect(mockedInstallAgentFiles).toHaveBeenCalled();
498
+ expect(consoleLogs.some((log) => log.includes("installed successfully"))).toBe(true);
499
+ });
500
+ it("calls cleanup even on errors", async () => {
501
+ createFleetConfig(tempDir);
502
+ createMockAgentRepo(fetchedRepoDir, "error-agent");
503
+ mockedParseSourceSpecifier.mockReturnValue({
504
+ type: "github",
505
+ owner: "user",
506
+ repo: "repo",
507
+ });
508
+ mockedStringifySourceSpecifier.mockReturnValue("github:user/repo");
509
+ mockedIsGitHubSource.mockReturnValue(true);
510
+ mockedIsLocalSource.mockReturnValue(false);
511
+ mockedFetchRepository.mockResolvedValue({
512
+ path: fetchedRepoDir,
513
+ cleanup: async () => {
514
+ cleanupCalled = true;
515
+ },
516
+ });
517
+ mockedValidateRepository.mockRejectedValue(new Error("Unexpected error"));
518
+ await expect(agentAddCommand("github:user/repo", {})).rejects.toThrow("Unexpected error");
519
+ expect(cleanupCalled).toBe(true);
520
+ });
521
+ it("handles FleetConfigError gracefully", async () => {
522
+ setupSuccessfulGitHubMocks("config-error-agent");
523
+ mockedAddAgentToFleetConfig.mockRejectedValue(new FleetConfigError("Config not found", "CONFIG_NOT_FOUND"));
524
+ await agentAddCommand("github:user/repo", {});
525
+ expect(process.exitCode).toBe(1);
526
+ expect(consoleErrors.some((e) => e.includes("Config update failed"))).toBe(true);
527
+ expect(cleanupCalled).toBe(true);
528
+ });
529
+ });
530
+ describe("environment variables display", () => {
531
+ it("displays required env vars correctly", async () => {
532
+ setupSuccessfulGitHubMocks("env-agent");
533
+ mockedScanEnvVariables.mockReturnValue({
534
+ variables: [{ name: "DISCORD_WEBHOOK_URL" }, { name: "WEBSITES" }],
535
+ required: [{ name: "DISCORD_WEBHOOK_URL" }, { name: "WEBSITES" }],
536
+ optional: [],
537
+ });
538
+ await agentAddCommand("github:user/repo", {});
539
+ expect(consoleLogs.some((log) => log.includes("Environment variables to configure"))).toBe(true);
540
+ expect(consoleLogs.some((log) => log.includes("Required (no defaults)"))).toBe(true);
541
+ expect(consoleLogs.some((log) => log.includes("DISCORD_WEBHOOK_URL"))).toBe(true);
542
+ expect(consoleLogs.some((log) => log.includes("WEBSITES"))).toBe(true);
543
+ });
544
+ it("displays optional env vars with defaults", async () => {
545
+ setupSuccessfulGitHubMocks("env-agent");
546
+ mockedScanEnvVariables.mockReturnValue({
547
+ variables: [{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" }],
548
+ required: [],
549
+ optional: [{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" }],
550
+ });
551
+ await agentAddCommand("github:user/repo", {});
552
+ expect(consoleLogs.some((log) => log.includes("Optional (have defaults)"))).toBe(true);
553
+ expect(consoleLogs.some((log) => log.includes("CRON_SCHEDULE"))).toBe(true);
554
+ expect(consoleLogs.some((log) => log.includes("*/5 * * * *"))).toBe(true);
555
+ });
556
+ it("does not display env section when no variables found", async () => {
557
+ setupSuccessfulGitHubMocks("no-env-agent");
558
+ mockedScanEnvVariables.mockReturnValue({
559
+ variables: [],
560
+ required: [],
561
+ optional: [],
562
+ });
563
+ await agentAddCommand("github:user/repo", {});
564
+ expect(consoleLogs.some((log) => log.includes("Environment variables to configure"))).toBe(false);
565
+ });
566
+ });
567
+ describe("force mode", () => {
568
+ it("passes force option to installAgentFiles", async () => {
569
+ setupSuccessfulGitHubMocks("force-agent");
570
+ await agentAddCommand("github:user/repo", { force: true });
571
+ expect(mockedInstallAgentFiles).toHaveBeenCalledWith(expect.objectContaining({
572
+ force: true,
573
+ }));
574
+ });
575
+ it("does not pass force when not specified", async () => {
576
+ setupSuccessfulGitHubMocks("no-force-agent");
577
+ await agentAddCommand("github:user/repo", {});
578
+ expect(mockedInstallAgentFiles).toHaveBeenCalledWith(expect.objectContaining({
579
+ force: undefined,
580
+ }));
581
+ });
582
+ });
583
+ describe("custom path option", () => {
584
+ it("passes custom path to installAgentFiles", async () => {
585
+ setupSuccessfulGitHubMocks("custom-path-agent");
586
+ const customPath = path.join(tempDir, "custom", "location");
587
+ await agentAddCommand("github:user/repo", { path: customPath });
588
+ expect(mockedInstallAgentFiles).toHaveBeenCalledWith(expect.objectContaining({
589
+ targetPath: customPath,
590
+ }));
591
+ });
592
+ });
593
+ });
594
+ describe("agentListCommand", () => {
595
+ let tempDir;
596
+ let originalCwd;
597
+ let consoleLogs;
598
+ let consoleErrors;
599
+ let originalConsoleLog;
600
+ let originalConsoleError;
601
+ let originalProcessExit;
602
+ let exitCode;
603
+ beforeEach(() => {
604
+ tempDir = createTempDir();
605
+ originalCwd = process.cwd();
606
+ process.chdir(tempDir);
607
+ consoleLogs = [];
608
+ consoleErrors = [];
609
+ originalConsoleLog = console.log;
610
+ originalConsoleError = console.error;
611
+ console.log = (...args) => consoleLogs.push(args.join(" "));
612
+ console.error = (...args) => consoleErrors.push(args.join(" "));
613
+ exitCode = undefined;
614
+ originalProcessExit = process.exit;
615
+ process.exit = ((code) => {
616
+ exitCode = code ?? 0;
617
+ throw new Error(`process.exit(${code})`);
618
+ });
619
+ vi.clearAllMocks();
620
+ });
621
+ afterEach(() => {
622
+ process.chdir(originalCwd);
623
+ cleanupTempDir(tempDir);
624
+ console.log = originalConsoleLog;
625
+ console.error = originalConsoleError;
626
+ process.exit = originalProcessExit;
627
+ });
628
+ describe("successful listing", () => {
629
+ it("lists agents in a table format", async () => {
630
+ mockedDiscoverAgents.mockResolvedValue({
631
+ agents: [
632
+ {
633
+ name: "agent-alpha",
634
+ installed: true,
635
+ path: "/path/to/agents/agent-alpha",
636
+ configPath: "./agents/agent-alpha/agent.yaml",
637
+ version: "1.0.0",
638
+ metadata: {
639
+ source: {
640
+ type: "github",
641
+ url: "https://github.com/user/agent-alpha",
642
+ ref: "v1.0.0",
643
+ version: "1.0.0",
644
+ },
645
+ installed_at: "2024-01-15T10:30:00Z",
646
+ installed_by: "herdctl@0.5.0",
647
+ },
648
+ },
649
+ {
650
+ name: "agent-beta",
651
+ installed: false,
652
+ path: "/path/to/agents/agent-beta",
653
+ configPath: "./agents/agent-beta/agent.yaml",
654
+ },
655
+ ],
656
+ });
657
+ await agentListCommand({});
658
+ expect(mockedDiscoverAgents).toHaveBeenCalled();
659
+ expect(consoleLogs.some((log) => log.includes("Agents in fleet"))).toBe(true);
660
+ expect(consoleLogs.some((log) => log.includes("agent-alpha"))).toBe(true);
661
+ expect(consoleLogs.some((log) => log.includes("agent-beta"))).toBe(true);
662
+ expect(consoleLogs.some((log) => log.includes("installed"))).toBe(true);
663
+ expect(consoleLogs.some((log) => log.includes("manual"))).toBe(true);
664
+ expect(consoleLogs.some((log) => log.includes("Total: 2 agents"))).toBe(true);
665
+ });
666
+ it("outputs JSON when --json flag is provided", async () => {
667
+ mockedDiscoverAgents.mockResolvedValue({
668
+ agents: [
669
+ {
670
+ name: "json-agent",
671
+ installed: true,
672
+ path: "/path/to/agents/json-agent",
673
+ configPath: "./agents/json-agent/agent.yaml",
674
+ version: "2.0.0",
675
+ metadata: {
676
+ source: { type: "github", url: "https://github.com/user/json-agent" },
677
+ installed_at: "2024-02-20T15:00:00Z",
678
+ installed_by: "herdctl@1.0.0",
679
+ },
680
+ },
681
+ ],
682
+ });
683
+ await agentListCommand({ json: true });
684
+ // Parse the output as JSON
685
+ const output = consoleLogs.join("\n");
686
+ const parsed = JSON.parse(output);
687
+ expect(Array.isArray(parsed)).toBe(true);
688
+ expect(parsed).toHaveLength(1);
689
+ expect(parsed[0].name).toBe("json-agent");
690
+ });
691
+ it("shows helpful message when no agents found", async () => {
692
+ mockedDiscoverAgents.mockResolvedValue({ agents: [] });
693
+ await agentListCommand({});
694
+ expect(consoleLogs.some((log) => log.includes("No agents found"))).toBe(true);
695
+ expect(consoleLogs.some((log) => log.includes("herdctl agent add"))).toBe(true);
696
+ expect(consoleLogs.some((log) => log.includes("herdctl init agent"))).toBe(true);
697
+ });
698
+ it("shows version from metadata", async () => {
699
+ mockedDiscoverAgents.mockResolvedValue({
700
+ agents: [
701
+ {
702
+ name: "versioned-agent",
703
+ installed: true,
704
+ path: "/path/to/agents/versioned-agent",
705
+ configPath: "./agents/versioned-agent/agent.yaml",
706
+ version: "3.2.1",
707
+ metadata: {
708
+ source: { type: "github", version: "3.2.1" },
709
+ installed_at: "2024-03-01T12:00:00Z",
710
+ },
711
+ },
712
+ ],
713
+ });
714
+ await agentListCommand({});
715
+ expect(consoleLogs.some((log) => log.includes("3.2.1"))).toBe(true);
716
+ });
717
+ it("shows dash for missing version", async () => {
718
+ mockedDiscoverAgents.mockResolvedValue({
719
+ agents: [
720
+ {
721
+ name: "no-version-agent",
722
+ installed: false,
723
+ path: "/path/to/agents/no-version-agent",
724
+ configPath: "./agents/no-version-agent/agent.yaml",
725
+ },
726
+ ],
727
+ });
728
+ await agentListCommand({});
729
+ // The table should show "-" for missing version
730
+ expect(consoleLogs.some((log) => log.includes("no-version-agent"))).toBe(true);
731
+ });
732
+ it("formats GitHub source correctly", async () => {
733
+ mockedDiscoverAgents.mockResolvedValue({
734
+ agents: [
735
+ {
736
+ name: "github-agent",
737
+ installed: true,
738
+ path: "/path/to/agents/github-agent",
739
+ configPath: "./agents/github-agent/agent.yaml",
740
+ metadata: {
741
+ source: {
742
+ type: "github",
743
+ url: "https://github.com/myorg/myrepo",
744
+ ref: "v2.0.0",
745
+ },
746
+ installed_at: "2024-01-01T00:00:00Z",
747
+ },
748
+ },
749
+ ],
750
+ });
751
+ await agentListCommand({});
752
+ expect(consoleLogs.some((log) => log.includes("myorg/myrepo@v2.0.0"))).toBe(true);
753
+ });
754
+ it("shows local source type", async () => {
755
+ mockedDiscoverAgents.mockResolvedValue({
756
+ agents: [
757
+ {
758
+ name: "local-agent",
759
+ installed: true,
760
+ path: "/path/to/agents/local-agent",
761
+ configPath: "./agents/local-agent/agent.yaml",
762
+ metadata: {
763
+ source: {
764
+ type: "local",
765
+ url: "/path/to/source",
766
+ },
767
+ installed_at: "2024-01-01T00:00:00Z",
768
+ },
769
+ },
770
+ ],
771
+ });
772
+ await agentListCommand({});
773
+ expect(consoleLogs.some((log) => log.includes("/path/to/source"))).toBe(true);
774
+ });
775
+ it("shows singular 'agent' for one agent", async () => {
776
+ mockedDiscoverAgents.mockResolvedValue({
777
+ agents: [
778
+ {
779
+ name: "single-agent",
780
+ installed: false,
781
+ path: "/path/to/agents/single-agent",
782
+ configPath: "./agents/single-agent/agent.yaml",
783
+ },
784
+ ],
785
+ });
786
+ await agentListCommand({});
787
+ expect(consoleLogs.some((log) => log.includes("Total: 1 agent"))).toBe(true);
788
+ expect(consoleLogs.some((log) => log.includes("Total: 1 agents"))).toBe(false);
789
+ });
790
+ });
791
+ describe("error handling", () => {
792
+ it("handles AgentDiscoveryError gracefully", async () => {
793
+ mockedDiscoverAgents.mockRejectedValue(new AgentDiscoveryError("Fleet config not found", "DISCOVERY_CONFIG_NOT_FOUND"));
794
+ await expect(agentListCommand({})).rejects.toThrow("process.exit");
795
+ expect(exitCode).toBe(1);
796
+ expect(consoleErrors.some((e) => e.includes("Discovery failed"))).toBe(true);
797
+ expect(consoleErrors.some((e) => e.includes("Fleet config not found"))).toBe(true);
798
+ });
799
+ it("re-throws unknown errors", async () => {
800
+ mockedDiscoverAgents.mockRejectedValue(new Error("Unknown error"));
801
+ await expect(agentListCommand({})).rejects.toThrow("Unknown error");
802
+ });
803
+ });
804
+ });
805
+ describe("agentInfoCommand", () => {
806
+ let tempDir;
807
+ let originalCwd;
808
+ let consoleLogs;
809
+ let consoleErrors;
810
+ let originalConsoleLog;
811
+ let originalConsoleError;
812
+ let originalProcessExit;
813
+ let exitCode;
814
+ beforeEach(() => {
815
+ tempDir = createTempDir();
816
+ originalCwd = process.cwd();
817
+ process.chdir(tempDir);
818
+ consoleLogs = [];
819
+ consoleErrors = [];
820
+ originalConsoleLog = console.log;
821
+ originalConsoleError = console.error;
822
+ console.log = (...args) => consoleLogs.push(args.join(" "));
823
+ console.error = (...args) => consoleErrors.push(args.join(" "));
824
+ exitCode = undefined;
825
+ originalProcessExit = process.exit;
826
+ process.exit = ((code) => {
827
+ exitCode = code ?? 0;
828
+ throw new Error(`process.exit(${code})`);
829
+ });
830
+ vi.clearAllMocks();
831
+ });
832
+ afterEach(() => {
833
+ process.chdir(originalCwd);
834
+ cleanupTempDir(tempDir);
835
+ console.log = originalConsoleLog;
836
+ console.error = originalConsoleError;
837
+ process.exit = originalProcessExit;
838
+ });
839
+ describe("agent found", () => {
840
+ it("prints formatted info for a complete agent", async () => {
841
+ mockedGetAgentInfo.mockResolvedValue({
842
+ name: "my-agent",
843
+ description: "A helpful agent",
844
+ installed: true,
845
+ metadata: {
846
+ source: {
847
+ type: "github",
848
+ url: "https://github.com/user/repo",
849
+ ref: "v1.0.0",
850
+ version: "1.0.0",
851
+ },
852
+ installed_at: "2024-01-15T10:30:00Z",
853
+ installed_by: "herdctl@0.5.0",
854
+ },
855
+ path: "/path/to/agents/my-agent",
856
+ configPath: "./agents/my-agent/agent.yaml",
857
+ version: "1.0.0",
858
+ repoMetadata: {
859
+ name: "my-agent",
860
+ version: "1.0.0",
861
+ description: "A helpful agent",
862
+ author: "test-author",
863
+ },
864
+ envVariables: {
865
+ variables: [
866
+ { name: "DISCORD_WEBHOOK_URL" },
867
+ { name: "WEBSITES" },
868
+ { name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" },
869
+ ],
870
+ required: [{ name: "DISCORD_WEBHOOK_URL" }, { name: "WEBSITES" }],
871
+ optional: [{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" }],
872
+ },
873
+ schedules: {
874
+ "check-websites": { type: "cron", cron: "*/5 * * * *" },
875
+ },
876
+ hasWorkspace: true,
877
+ files: ["agent.yaml", "CLAUDE.md", "knowledge/guide.md"],
878
+ });
879
+ await agentInfoCommand("my-agent", {});
880
+ expect(mockedGetAgentInfo).toHaveBeenCalledWith({
881
+ name: "my-agent",
882
+ configPath: expect.stringContaining("herdctl.yaml"),
883
+ });
884
+ expect(consoleLogs.some((log) => log.includes("Agent: my-agent"))).toBe(true);
885
+ expect(consoleLogs.some((log) => log.includes("Description: A helpful agent"))).toBe(true);
886
+ expect(consoleLogs.some((log) => log.includes("Installed (via GitHub)"))).toBe(true);
887
+ expect(consoleLogs.some((log) => log.includes("Source: https://github.com/user/repo"))).toBe(true);
888
+ expect(consoleLogs.some((log) => log.includes("Version: 1.0.0"))).toBe(true);
889
+ expect(consoleLogs.some((log) => log.includes("Installed: 2024-01-15T10:30:00Z"))).toBe(true);
890
+ expect(consoleLogs.some((log) => log.includes("Schedules:"))).toBe(true);
891
+ expect(consoleLogs.some((log) => log.includes("check-websites:"))).toBe(true);
892
+ expect(consoleLogs.some((log) => log.includes("Environment Variables:"))).toBe(true);
893
+ expect(consoleLogs.some((log) => log.includes("DISCORD_WEBHOOK_URL"))).toBe(true);
894
+ expect(consoleLogs.some((log) => log.includes("WEBSITES"))).toBe(true);
895
+ expect(consoleLogs.some((log) => log.includes("CRON_SCHEDULE"))).toBe(true);
896
+ expect(consoleLogs.some((log) => log.includes("Files:"))).toBe(true);
897
+ expect(consoleLogs.some((log) => log.includes("agent.yaml"))).toBe(true);
898
+ expect(consoleLogs.some((log) => log.includes("CLAUDE.md"))).toBe(true);
899
+ expect(consoleLogs.some((log) => log.includes("Workspace:"))).toBe(true);
900
+ });
901
+ it("prints info for manual agent without metadata", async () => {
902
+ mockedGetAgentInfo.mockResolvedValue({
903
+ name: "manual-agent",
904
+ description: "A manual agent",
905
+ installed: false,
906
+ path: "/path/to/agents/manual-agent",
907
+ configPath: "./agents/manual-agent/agent.yaml",
908
+ hasWorkspace: false,
909
+ files: ["agent.yaml"],
910
+ });
911
+ await agentInfoCommand("manual-agent", {});
912
+ expect(consoleLogs.some((log) => log.includes("Agent: manual-agent"))).toBe(true);
913
+ expect(consoleLogs.some((log) => log.includes("Manual (not installed via herdctl)"))).toBe(true);
914
+ expect(consoleLogs.some((log) => log.includes("Workspace: (not created)"))).toBe(true);
915
+ });
916
+ it("outputs JSON when --json flag is provided", async () => {
917
+ const agentInfo = {
918
+ name: "json-agent",
919
+ description: "A JSON agent",
920
+ installed: true,
921
+ metadata: {
922
+ source: { type: "github", url: "https://github.com/user/json-agent" },
923
+ installed_at: "2024-02-20T15:00:00Z",
924
+ installed_by: "herdctl@1.0.0",
925
+ },
926
+ path: "/path/to/agents/json-agent",
927
+ configPath: "./agents/json-agent/agent.yaml",
928
+ version: "2.0.0",
929
+ hasWorkspace: true,
930
+ files: ["agent.yaml"],
931
+ };
932
+ mockedGetAgentInfo.mockResolvedValue(agentInfo);
933
+ await agentInfoCommand("json-agent", { json: true });
934
+ // Parse the output as JSON
935
+ const output = consoleLogs.join("\n");
936
+ const parsed = JSON.parse(output);
937
+ expect(parsed.name).toBe("json-agent");
938
+ expect(parsed.installed).toBe(true);
939
+ expect(parsed.version).toBe("2.0.0");
940
+ expect(parsed.hasWorkspace).toBe(true);
941
+ });
942
+ it("prints info for agent without optional fields", async () => {
943
+ mockedGetAgentInfo.mockResolvedValue({
944
+ name: "minimal-agent",
945
+ installed: false,
946
+ path: "/path/to/agents/minimal-agent",
947
+ configPath: "./agents/minimal-agent/agent.yaml",
948
+ hasWorkspace: false,
949
+ files: ["agent.yaml"],
950
+ });
951
+ await agentInfoCommand("minimal-agent", {});
952
+ expect(consoleLogs.some((log) => log.includes("Agent: minimal-agent"))).toBe(true);
953
+ // Should not include these sections
954
+ expect(consoleLogs.some((log) => log.includes("Schedules:"))).toBe(false);
955
+ expect(consoleLogs.some((log) => log.includes("Environment Variables:"))).toBe(false);
956
+ expect(consoleLogs.some((log) => log.includes("Description:"))).toBe(false);
957
+ });
958
+ it("handles local source type correctly", async () => {
959
+ mockedGetAgentInfo.mockResolvedValue({
960
+ name: "local-agent",
961
+ installed: true,
962
+ metadata: {
963
+ source: {
964
+ type: "local",
965
+ url: "/path/to/source",
966
+ },
967
+ installed_at: "2024-01-01T00:00:00Z",
968
+ },
969
+ path: "/path/to/agents/local-agent",
970
+ configPath: "./agents/local-agent/agent.yaml",
971
+ hasWorkspace: false,
972
+ files: ["agent.yaml"],
973
+ });
974
+ await agentInfoCommand("local-agent", {});
975
+ expect(consoleLogs.some((log) => log.includes("Installed (via local path)"))).toBe(true);
976
+ expect(consoleLogs.some((log) => log.includes("Source: /path/to/source"))).toBe(true);
977
+ });
978
+ });
979
+ describe("agent not found", () => {
980
+ it("prints error and exits when agent is not found", async () => {
981
+ mockedGetAgentInfo.mockResolvedValue(null);
982
+ await expect(agentInfoCommand("nonexistent", {})).rejects.toThrow("process.exit");
983
+ expect(exitCode).toBe(1);
984
+ expect(consoleErrors.some((e) => e.includes("Agent 'nonexistent' not found"))).toBe(true);
985
+ expect(consoleErrors.some((e) => e.includes("herdctl agent list"))).toBe(true);
986
+ });
987
+ });
988
+ describe("error handling", () => {
989
+ it("handles AgentDiscoveryError gracefully", async () => {
990
+ mockedGetAgentInfo.mockRejectedValue(new AgentDiscoveryError("Fleet config not found", "DISCOVERY_CONFIG_NOT_FOUND"));
991
+ await expect(agentInfoCommand("any-agent", {})).rejects.toThrow("process.exit");
992
+ expect(exitCode).toBe(1);
993
+ expect(consoleErrors.some((e) => e.includes("Discovery failed"))).toBe(true);
994
+ });
995
+ it("re-throws unknown errors", async () => {
996
+ mockedGetAgentInfo.mockRejectedValue(new Error("Unknown error"));
997
+ await expect(agentInfoCommand("any-agent", {})).rejects.toThrow("Unknown error");
998
+ });
999
+ });
1000
+ });
1001
+ describe("agentRemoveCommand", () => {
1002
+ let tempDir;
1003
+ let originalCwd;
1004
+ let consoleLogs;
1005
+ let consoleErrors;
1006
+ let originalConsoleLog;
1007
+ let originalConsoleError;
1008
+ let originalProcessExit;
1009
+ let exitCode;
1010
+ beforeEach(() => {
1011
+ tempDir = createTempDir();
1012
+ originalCwd = process.cwd();
1013
+ process.chdir(tempDir);
1014
+ consoleLogs = [];
1015
+ consoleErrors = [];
1016
+ originalConsoleLog = console.log;
1017
+ originalConsoleError = console.error;
1018
+ console.log = (...args) => consoleLogs.push(args.join(" "));
1019
+ console.error = (...args) => consoleErrors.push(args.join(" "));
1020
+ exitCode = undefined;
1021
+ originalProcessExit = process.exit;
1022
+ process.exit = ((code) => {
1023
+ exitCode = code ?? 0;
1024
+ throw new Error(`process.exit(${code})`);
1025
+ });
1026
+ vi.clearAllMocks();
1027
+ });
1028
+ afterEach(() => {
1029
+ process.chdir(originalCwd);
1030
+ cleanupTempDir(tempDir);
1031
+ console.log = originalConsoleLog;
1032
+ console.error = originalConsoleError;
1033
+ process.exit = originalProcessExit;
1034
+ });
1035
+ describe("successful removal", () => {
1036
+ it("removes agent and prints summary", async () => {
1037
+ mockedRemoveAgent.mockResolvedValue({
1038
+ agentName: "my-agent",
1039
+ removedPath: path.join(tempDir, "agents", "my-agent"),
1040
+ filesRemoved: true,
1041
+ configUpdated: true,
1042
+ workspacePreserved: false,
1043
+ });
1044
+ await agentRemoveCommand("my-agent", {});
1045
+ expect(mockedRemoveAgent).toHaveBeenCalledWith({
1046
+ name: "my-agent",
1047
+ configPath: expect.stringContaining("herdctl.yaml"),
1048
+ keepWorkspace: false,
1049
+ });
1050
+ expect(consoleLogs.some((log) => log.includes("Removing agent 'my-agent'"))).toBe(true);
1051
+ expect(consoleLogs.some((log) => log.includes("Deleted"))).toBe(true);
1052
+ expect(consoleLogs.some((log) => log.includes("Updated herdctl.yaml"))).toBe(true);
1053
+ });
1054
+ it("prints env variable summary when agent has env vars", async () => {
1055
+ mockedRemoveAgent.mockResolvedValue({
1056
+ agentName: "env-agent",
1057
+ removedPath: path.join(tempDir, "agents", "env-agent"),
1058
+ filesRemoved: true,
1059
+ configUpdated: true,
1060
+ workspacePreserved: false,
1061
+ envVariables: {
1062
+ variables: [
1063
+ { name: "DISCORD_WEBHOOK_URL" },
1064
+ { name: "WEBSITES" },
1065
+ { name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" },
1066
+ ],
1067
+ required: [{ name: "DISCORD_WEBHOOK_URL" }, { name: "WEBSITES" }],
1068
+ optional: [{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" }],
1069
+ },
1070
+ });
1071
+ await agentRemoveCommand("env-agent", {});
1072
+ expect(consoleLogs.some((log) => log.includes("environment variables"))).toBe(true);
1073
+ expect(consoleLogs.some((log) => log.includes("Required:"))).toBe(true);
1074
+ expect(consoleLogs.some((log) => log.includes("DISCORD_WEBHOOK_URL"))).toBe(true);
1075
+ expect(consoleLogs.some((log) => log.includes("WEBSITES"))).toBe(true);
1076
+ expect(consoleLogs.some((log) => log.includes("Optional:"))).toBe(true);
1077
+ expect(consoleLogs.some((log) => log.includes("CRON_SCHEDULE"))).toBe(true);
1078
+ expect(consoleLogs.some((log) => log.includes("*/5 * * * *"))).toBe(true);
1079
+ expect(consoleLogs.some((log) => log.includes("remove these from your .env file"))).toBe(true);
1080
+ });
1081
+ it("does not print env section when no env variables", async () => {
1082
+ mockedRemoveAgent.mockResolvedValue({
1083
+ agentName: "no-env-agent",
1084
+ removedPath: path.join(tempDir, "agents", "no-env-agent"),
1085
+ filesRemoved: true,
1086
+ configUpdated: true,
1087
+ workspacePreserved: false,
1088
+ envVariables: {
1089
+ variables: [],
1090
+ required: [],
1091
+ optional: [],
1092
+ },
1093
+ });
1094
+ await agentRemoveCommand("no-env-agent", {});
1095
+ expect(consoleLogs.some((log) => log.includes("environment variables"))).toBe(false);
1096
+ });
1097
+ it("shows workspace preserved message when keepWorkspace is true", async () => {
1098
+ mockedRemoveAgent.mockResolvedValue({
1099
+ agentName: "workspace-agent",
1100
+ removedPath: path.join(tempDir, "agents", "workspace-agent"),
1101
+ filesRemoved: true,
1102
+ configUpdated: true,
1103
+ workspacePreserved: true,
1104
+ });
1105
+ await agentRemoveCommand("workspace-agent", { keepWorkspace: true });
1106
+ expect(mockedRemoveAgent).toHaveBeenCalledWith(expect.objectContaining({
1107
+ keepWorkspace: true,
1108
+ }));
1109
+ expect(consoleLogs.some((log) => log.includes("workspace preserved"))).toBe(true);
1110
+ });
1111
+ });
1112
+ describe("error handling", () => {
1113
+ it("handles AgentRemoveError (agent not found) gracefully", async () => {
1114
+ mockedRemoveAgent.mockRejectedValue(new AgentRemoveError("Agent 'nonexistent' not found", AGENT_NOT_FOUND));
1115
+ await expect(agentRemoveCommand("nonexistent", {})).rejects.toThrow("process.exit");
1116
+ expect(exitCode).toBe(1);
1117
+ expect(consoleErrors.some((e) => e.includes("Agent 'nonexistent' not found"))).toBe(true);
1118
+ expect(consoleErrors.some((e) => e.includes("herdctl agent list"))).toBe(true);
1119
+ });
1120
+ it("handles AgentRemoveError (other errors) gracefully", async () => {
1121
+ mockedRemoveAgent.mockRejectedValue(new AgentRemoveError("Some other error", "AGENT_REMOVE_ERROR"));
1122
+ await expect(agentRemoveCommand("some-agent", {})).rejects.toThrow("process.exit");
1123
+ expect(exitCode).toBe(1);
1124
+ expect(consoleErrors.some((e) => e.includes("Removal failed"))).toBe(true);
1125
+ });
1126
+ it("handles AgentDiscoveryError gracefully", async () => {
1127
+ mockedRemoveAgent.mockRejectedValue(new AgentDiscoveryError("Fleet config not found", "DISCOVERY_CONFIG_NOT_FOUND"));
1128
+ await expect(agentRemoveCommand("any-agent", {})).rejects.toThrow("process.exit");
1129
+ expect(exitCode).toBe(1);
1130
+ expect(consoleErrors.some((e) => e.includes("Discovery failed"))).toBe(true);
1131
+ });
1132
+ it("re-throws unknown errors", async () => {
1133
+ mockedRemoveAgent.mockRejectedValue(new Error("Unknown error"));
1134
+ await expect(agentRemoveCommand("any-agent", {})).rejects.toThrow("Unknown error");
1135
+ });
1136
+ });
1137
+ describe("options handling", () => {
1138
+ it("passes keepWorkspace option to removeAgent", async () => {
1139
+ mockedRemoveAgent.mockResolvedValue({
1140
+ agentName: "test-agent",
1141
+ removedPath: path.join(tempDir, "agents", "test-agent"),
1142
+ filesRemoved: true,
1143
+ configUpdated: true,
1144
+ workspacePreserved: true,
1145
+ });
1146
+ await agentRemoveCommand("test-agent", { keepWorkspace: true });
1147
+ expect(mockedRemoveAgent).toHaveBeenCalledWith(expect.objectContaining({
1148
+ keepWorkspace: true,
1149
+ }));
1150
+ });
1151
+ it("defaults keepWorkspace to false", async () => {
1152
+ mockedRemoveAgent.mockResolvedValue({
1153
+ agentName: "test-agent",
1154
+ removedPath: path.join(tempDir, "agents", "test-agent"),
1155
+ filesRemoved: true,
1156
+ configUpdated: true,
1157
+ workspacePreserved: false,
1158
+ });
1159
+ await agentRemoveCommand("test-agent", {});
1160
+ expect(mockedRemoveAgent).toHaveBeenCalledWith(expect.objectContaining({
1161
+ keepWorkspace: false,
1162
+ }));
1163
+ });
1164
+ it("accepts force option (no-op for now)", async () => {
1165
+ mockedRemoveAgent.mockResolvedValue({
1166
+ agentName: "force-agent",
1167
+ removedPath: path.join(tempDir, "agents", "force-agent"),
1168
+ filesRemoved: true,
1169
+ configUpdated: true,
1170
+ workspacePreserved: false,
1171
+ });
1172
+ // force option is accepted but doesn't change behavior
1173
+ await agentRemoveCommand("force-agent", { force: true });
1174
+ expect(mockedRemoveAgent).toHaveBeenCalled();
1175
+ });
1176
+ });
1177
+ });
1178
+ // =============================================================================
1179
+ // Fleet Tree View Tests
1180
+ // =============================================================================
1181
+ describe("buildFleetTree", () => {
1182
+ it("groups root-level agents under root node", () => {
1183
+ const agents = [
1184
+ { name: "agent-a", fleetPath: [], qualifiedName: "agent-a" },
1185
+ { name: "agent-b", fleetPath: [], qualifiedName: "agent-b" },
1186
+ ];
1187
+ const tree = buildFleetTree(agents, "my-fleet", "My Fleet");
1188
+ expect(tree.name).toBe("my-fleet");
1189
+ expect(tree.description).toBe("My Fleet");
1190
+ expect(tree.agents).toEqual(["agent-a", "agent-b"]);
1191
+ expect(tree.children).toHaveLength(0);
1192
+ });
1193
+ it("creates child nodes for sub-fleet agents", () => {
1194
+ const agents = [
1195
+ { name: "engineer", fleetPath: ["herdctl"], qualifiedName: "herdctl.engineer" },
1196
+ { name: "tester", fleetPath: ["herdctl"], qualifiedName: "herdctl.tester" },
1197
+ { name: "agent-one", fleetPath: ["personal"], qualifiedName: "personal.agent-one" },
1198
+ ];
1199
+ const tree = buildFleetTree(agents, "global");
1200
+ expect(tree.agents).toHaveLength(0);
1201
+ expect(tree.children).toHaveLength(2);
1202
+ const herdctl = tree.children.find((c) => c.name === "herdctl");
1203
+ expect(herdctl).toBeDefined();
1204
+ expect(herdctl.agents).toEqual(["engineer", "tester"]);
1205
+ const personal = tree.children.find((c) => c.name === "personal");
1206
+ expect(personal).toBeDefined();
1207
+ expect(personal.agents).toEqual(["agent-one"]);
1208
+ });
1209
+ it("handles nested fleet paths (multi-level)", () => {
1210
+ const agents = [
1211
+ {
1212
+ name: "deep-agent",
1213
+ fleetPath: ["level1", "level2"],
1214
+ qualifiedName: "level1.level2.deep-agent",
1215
+ },
1216
+ ];
1217
+ const tree = buildFleetTree(agents, "root");
1218
+ expect(tree.children).toHaveLength(1);
1219
+ expect(tree.children[0].name).toBe("level1");
1220
+ expect(tree.children[0].children).toHaveLength(1);
1221
+ expect(tree.children[0].children[0].name).toBe("level2");
1222
+ expect(tree.children[0].children[0].agents).toEqual(["deep-agent"]);
1223
+ });
1224
+ it("handles mix of root and sub-fleet agents", () => {
1225
+ const agents = [
1226
+ { name: "root-agent", fleetPath: [], qualifiedName: "root-agent" },
1227
+ { name: "sub-agent", fleetPath: ["sub"], qualifiedName: "sub.sub-agent" },
1228
+ ];
1229
+ const tree = buildFleetTree(agents, "root");
1230
+ expect(tree.agents).toEqual(["root-agent"]);
1231
+ expect(tree.children).toHaveLength(1);
1232
+ expect(tree.children[0].name).toBe("sub");
1233
+ expect(tree.children[0].agents).toEqual(["sub-agent"]);
1234
+ });
1235
+ });
1236
+ describe("renderFleetTree", () => {
1237
+ it("renders a simple tree with box-drawing characters", () => {
1238
+ const tree = buildFleetTree([
1239
+ { name: "agent-one", fleetPath: ["personal"], qualifiedName: "personal.agent-one" },
1240
+ { name: "agent-two", fleetPath: ["personal"], qualifiedName: "personal.agent-two" },
1241
+ { name: "engineer", fleetPath: ["herdctl"], qualifiedName: "herdctl.engineer" },
1242
+ ], "global", "Fleet of Fleets");
1243
+ const lines = renderFleetTree(tree);
1244
+ // Root line
1245
+ expect(lines[0]).toBe("global (Fleet of Fleets)");
1246
+ // Should contain sub-fleet names
1247
+ expect(lines.some((l) => l.includes("personal"))).toBe(true);
1248
+ expect(lines.some((l) => l.includes("herdctl"))).toBe(true);
1249
+ // Should contain agent names
1250
+ expect(lines.some((l) => l.includes("agent-one"))).toBe(true);
1251
+ expect(lines.some((l) => l.includes("agent-two"))).toBe(true);
1252
+ expect(lines.some((l) => l.includes("engineer"))).toBe(true);
1253
+ // Should use box-drawing characters
1254
+ expect(lines.some((l) => l.includes("\u251C\u2500\u2500") || l.includes("\u2514\u2500\u2500"))).toBe(true);
1255
+ });
1256
+ it("uses end connector for last items", () => {
1257
+ const tree = buildFleetTree([
1258
+ { name: "only-agent", fleetPath: ["sub"], qualifiedName: "sub.only-agent" },
1259
+ ], "root");
1260
+ const lines = renderFleetTree(tree);
1261
+ // The sub-fleet should use end connector since it's the last child
1262
+ expect(lines[1]).toContain("\u2514\u2500\u2500 sub");
1263
+ // The only agent should also use end connector
1264
+ expect(lines[2]).toContain("\u2514\u2500\u2500 only-agent");
1265
+ });
1266
+ it("shows agent counts in summary mode", () => {
1267
+ const tree = buildFleetTree([
1268
+ { name: "a1", fleetPath: ["sub"], qualifiedName: "sub.a1" },
1269
+ { name: "a2", fleetPath: ["sub"], qualifiedName: "sub.a2" },
1270
+ { name: "a3", fleetPath: ["sub"], qualifiedName: "sub.a3" },
1271
+ ], "root");
1272
+ const lines = renderFleetTree(tree, "", true, true, true);
1273
+ // Should show count instead of individual names
1274
+ expect(lines.some((l) => l.includes("(3 agents)"))).toBe(true);
1275
+ // Should NOT show individual agent names
1276
+ expect(lines.some((l) => l.includes("a1"))).toBe(false);
1277
+ expect(lines.some((l) => l.includes("a2"))).toBe(false);
1278
+ });
1279
+ it("renders root-level agents", () => {
1280
+ const tree = buildFleetTree([
1281
+ { name: "root-agent", fleetPath: [], qualifiedName: "root-agent" },
1282
+ ], "fleet");
1283
+ const lines = renderFleetTree(tree);
1284
+ expect(lines[0]).toBe("fleet");
1285
+ expect(lines[1]).toContain("root-agent");
1286
+ });
1287
+ });
1288
+ describe("agentListCommand with sub-fleets (tree view)", () => {
1289
+ let tempDir;
1290
+ let originalCwd;
1291
+ let consoleLogs;
1292
+ let consoleErrors;
1293
+ let originalConsoleLog;
1294
+ let originalConsoleError;
1295
+ let originalProcessExit;
1296
+ let exitCode;
1297
+ beforeEach(() => {
1298
+ tempDir = createTempDir();
1299
+ originalCwd = process.cwd();
1300
+ process.chdir(tempDir);
1301
+ consoleLogs = [];
1302
+ consoleErrors = [];
1303
+ originalConsoleLog = console.log;
1304
+ originalConsoleError = console.error;
1305
+ console.log = (...args) => consoleLogs.push(args.join(" "));
1306
+ console.error = (...args) => consoleErrors.push(args.join(" "));
1307
+ exitCode = undefined;
1308
+ originalProcessExit = process.exit;
1309
+ process.exit = ((code) => {
1310
+ exitCode = code ?? 0;
1311
+ throw new Error(`process.exit(${code})`);
1312
+ });
1313
+ vi.clearAllMocks();
1314
+ });
1315
+ afterEach(() => {
1316
+ process.chdir(originalCwd);
1317
+ cleanupTempDir(tempDir);
1318
+ console.log = originalConsoleLog;
1319
+ console.error = originalConsoleError;
1320
+ process.exit = originalProcessExit;
1321
+ });
1322
+ /** Create a fleet config with sub-fleets */
1323
+ function createFleetConfigWithSubFleets(dir) {
1324
+ fs.writeFileSync(path.join(dir, "herdctl.yaml"), `version: 1
1325
+
1326
+ fleet:
1327
+ name: global
1328
+ description: Fleet of Fleets
1329
+
1330
+ fleets:
1331
+ - path: ./sub/herdctl.yaml
1332
+ `, "utf-8");
1333
+ }
1334
+ it("renders tree view when sub-fleets exist", async () => {
1335
+ createFleetConfigWithSubFleets(tempDir);
1336
+ mockedLoadConfig.mockResolvedValue({
1337
+ fleet: {
1338
+ version: 1,
1339
+ fleet: { name: "global", description: "Fleet of Fleets" },
1340
+ fleets: [{ path: "./sub/herdctl.yaml" }],
1341
+ agents: [],
1342
+ },
1343
+ agents: [
1344
+ {
1345
+ name: "engineer",
1346
+ fleetPath: ["herdctl"],
1347
+ qualifiedName: "herdctl.engineer",
1348
+ configPath: "/path/to/herdctl/agents/engineer/agent.yaml",
1349
+ },
1350
+ {
1351
+ name: "tester",
1352
+ fleetPath: ["herdctl"],
1353
+ qualifiedName: "herdctl.tester",
1354
+ configPath: "/path/to/herdctl/agents/tester/agent.yaml",
1355
+ },
1356
+ ],
1357
+ configPath: path.join(tempDir, "herdctl.yaml"),
1358
+ configDir: tempDir,
1359
+ });
1360
+ await agentListCommand({});
1361
+ expect(mockedLoadConfig).toHaveBeenCalled();
1362
+ // Should show tree with fleet name
1363
+ expect(consoleLogs.some((log) => log.includes("global"))).toBe(true);
1364
+ expect(consoleLogs.some((log) => log.includes("Fleet of Fleets"))).toBe(true);
1365
+ // Should show sub-fleet and agents
1366
+ expect(consoleLogs.some((log) => log.includes("herdctl"))).toBe(true);
1367
+ expect(consoleLogs.some((log) => log.includes("engineer"))).toBe(true);
1368
+ expect(consoleLogs.some((log) => log.includes("tester"))).toBe(true);
1369
+ // Should show total
1370
+ expect(consoleLogs.some((log) => log.includes("Total: 2 agents across fleet hierarchy"))).toBe(true);
1371
+ });
1372
+ it("outputs JSON with fleet hierarchy info when --json is used", async () => {
1373
+ createFleetConfigWithSubFleets(tempDir);
1374
+ mockedLoadConfig.mockResolvedValue({
1375
+ fleet: {
1376
+ version: 1,
1377
+ fleet: { name: "global" },
1378
+ fleets: [{ path: "./sub/herdctl.yaml" }],
1379
+ agents: [],
1380
+ },
1381
+ agents: [
1382
+ {
1383
+ name: "engineer",
1384
+ fleetPath: ["herdctl"],
1385
+ qualifiedName: "herdctl.engineer",
1386
+ configPath: "/path/to/engineer/agent.yaml",
1387
+ description: "Engineering agent",
1388
+ },
1389
+ ],
1390
+ configPath: path.join(tempDir, "herdctl.yaml"),
1391
+ configDir: tempDir,
1392
+ });
1393
+ await agentListCommand({ json: true });
1394
+ const output = consoleLogs.join("\n");
1395
+ const parsed = JSON.parse(output);
1396
+ expect(Array.isArray(parsed)).toBe(true);
1397
+ expect(parsed).toHaveLength(1);
1398
+ expect(parsed[0].name).toBe("engineer");
1399
+ expect(parsed[0].qualifiedName).toBe("herdctl.engineer");
1400
+ expect(parsed[0].fleetPath).toEqual(["herdctl"]);
1401
+ });
1402
+ it("shows helpful message when no agents across hierarchy", async () => {
1403
+ createFleetConfigWithSubFleets(tempDir);
1404
+ mockedLoadConfig.mockResolvedValue({
1405
+ fleet: {
1406
+ version: 1,
1407
+ fleet: { name: "global" },
1408
+ fleets: [{ path: "./sub/herdctl.yaml" }],
1409
+ agents: [],
1410
+ },
1411
+ agents: [],
1412
+ configPath: path.join(tempDir, "herdctl.yaml"),
1413
+ configDir: tempDir,
1414
+ });
1415
+ await agentListCommand({});
1416
+ expect(consoleLogs.some((log) => log.includes("No agents found across fleet hierarchy"))).toBe(true);
1417
+ expect(consoleLogs.some((log) => log.includes("herdctl agent add"))).toBe(true);
1418
+ });
1419
+ it("handles ConfigError gracefully", async () => {
1420
+ createFleetConfigWithSubFleets(tempDir);
1421
+ mockedLoadConfig.mockRejectedValue(new ConfigError("Bad config"));
1422
+ await expect(agentListCommand({})).rejects.toThrow("process.exit");
1423
+ expect(exitCode).toBe(1);
1424
+ expect(consoleErrors.some((e) => e.includes("Config error"))).toBe(true);
1425
+ });
1426
+ it("handles ConfigNotFoundError gracefully", async () => {
1427
+ createFleetConfigWithSubFleets(tempDir);
1428
+ mockedLoadConfig.mockRejectedValue(new ConfigNotFoundError("/some/path", []));
1429
+ await expect(agentListCommand({})).rejects.toThrow("process.exit");
1430
+ expect(exitCode).toBe(1);
1431
+ expect(consoleErrors.some((e) => e.includes("Config error"))).toBe(true);
1432
+ });
1433
+ it("falls back to flat table when no sub-fleets in config", async () => {
1434
+ // Create a config WITHOUT fleets
1435
+ fs.writeFileSync(path.join(tempDir, "herdctl.yaml"), `version: 1
1436
+
1437
+ fleet:
1438
+ name: simple-fleet
1439
+
1440
+ agents: []
1441
+ `, "utf-8");
1442
+ mockedDiscoverAgents.mockResolvedValue({
1443
+ agents: [
1444
+ {
1445
+ name: "simple-agent",
1446
+ installed: false,
1447
+ path: "/path/to/agents/simple-agent",
1448
+ configPath: "./agents/simple-agent/agent.yaml",
1449
+ },
1450
+ ],
1451
+ });
1452
+ await agentListCommand({});
1453
+ // Should use discoverAgents, NOT loadConfig
1454
+ expect(mockedDiscoverAgents).toHaveBeenCalled();
1455
+ expect(mockedLoadConfig).not.toHaveBeenCalled();
1456
+ // Should show flat table
1457
+ expect(consoleLogs.some((log) => log.includes("Agents in fleet:"))).toBe(true);
1458
+ expect(consoleLogs.some((log) => log.includes("simple-agent"))).toBe(true);
1459
+ });
1460
+ });
1461
+ //# sourceMappingURL=agent.test.js.map