@michaelhartmayer/agentctl 1.0.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.
- package/.eslintignore +5 -0
- package/.eslintrc.json +22 -0
- package/.husky/pre-commit +2 -0
- package/README.md +124 -0
- package/agentctl.cmd +2 -0
- package/dist/ctl.js +351 -0
- package/dist/fs-utils.js +35 -0
- package/dist/index.js +313 -0
- package/dist/manifest.js +30 -0
- package/dist/resolve.js +123 -0
- package/dist/skills.js +50 -0
- package/package.json +49 -0
- package/scripts/register-path.js +32 -0
- package/scripts/unregister-path.js +30 -0
- package/skills/agentctl/SKILL.md +59 -0
- package/src/ctl.ts +356 -0
- package/src/fs-utils.ts +30 -0
- package/src/index.ts +331 -0
- package/src/manifest.ts +21 -0
- package/src/resolve.ts +124 -0
- package/src/skills.ts +42 -0
- package/tests/alias.test.ts +48 -0
- package/tests/edge_cases.test.ts +699 -0
- package/tests/group.test.ts +48 -0
- package/tests/helpers.ts +16 -0
- package/tests/introspection.test.ts +71 -0
- package/tests/lifecycle-guards.test.ts +44 -0
- package/tests/lifecycle.test.ts +59 -0
- package/tests/manifest.test.ts +29 -0
- package/tests/resolve-priority.test.ts +72 -0
- package/tests/resolve.test.ts +78 -0
- package/tests/scaffold.test.ts +61 -0
- package/tests/scoping-guards.test.ts +74 -0
- package/tests/scoping.test.ts +66 -0
- package/tests/skills.test.ts +62 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { rm, mv, inspect, pullLocal, list, pushGlobal, installSkill, scaffold, group } from '../src/ctl';
|
|
6
|
+
import { resolveCommand } from '../src/resolve';
|
|
7
|
+
import { readManifest } from '../src/manifest';
|
|
8
|
+
import { getGlobalRoot, getAntigravityGlobalRoot } from '../src/fs-utils';
|
|
9
|
+
import * as fsUtils from '../src/fs-utils';
|
|
10
|
+
import { SUPPORTED_AGENTS, copySkill } from '../src/skills';
|
|
11
|
+
|
|
12
|
+
describe('edge cases for 100% coverage', () => {
|
|
13
|
+
const tmpDir = path.join(os.tmpdir(), 'agentctl-coverage-tests');
|
|
14
|
+
const localDir = path.join(tmpDir, 'local');
|
|
15
|
+
const globalDir = path.join(tmpDir, 'global');
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
await fs.ensureDir(localDir);
|
|
19
|
+
await fs.ensureDir(globalDir);
|
|
20
|
+
await fs.ensureDir(path.join(localDir, '.agentctl'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await fs.remove(tmpDir);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('covers getAntigravityGlobalRoot', () => {
|
|
28
|
+
const root = getAntigravityGlobalRoot();
|
|
29
|
+
expect(root).toContain('.gemini');
|
|
30
|
+
expect(root).toContain('antigravity');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('covers non-win32 getGlobalRoot', () => {
|
|
34
|
+
const originalPlatform = process.platform;
|
|
35
|
+
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
36
|
+
const root = getGlobalRoot();
|
|
37
|
+
expect(root).toContain('.config');
|
|
38
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('covers win32 getGlobalRoot without APPDATA', () => {
|
|
42
|
+
if (process.platform !== 'win32') return;
|
|
43
|
+
const originalAppData = process.env.APPDATA;
|
|
44
|
+
delete process.env.APPDATA;
|
|
45
|
+
const root = getGlobalRoot();
|
|
46
|
+
expect(root).toContain('AppData');
|
|
47
|
+
process.env.APPDATA = originalAppData;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('covers readManifest failure (corrupt JSON)', async () => {
|
|
51
|
+
const p = path.join(tmpDir, 'corrupt.json');
|
|
52
|
+
await fs.writeFile(p, '{ invalid json }');
|
|
53
|
+
const m = await readManifest(p);
|
|
54
|
+
expect(m).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('covers rm failure (command not found)', async () => {
|
|
58
|
+
await expect(rm(['notfound'], { cwd: localDir, globalDir })).rejects.toThrow('Command notfound not found');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('covers mv failure (command not found)', async () => {
|
|
62
|
+
await expect(mv(['notfound'], ['dest'], { cwd: localDir, globalDir })).rejects.toThrow('Command notfound not found');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('covers inspect failure (command not found)', async () => {
|
|
66
|
+
const result = await inspect(['notfound'], { cwd: localDir, globalDir });
|
|
67
|
+
expect(result).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('covers pullLocal copy (not move)', async () => {
|
|
71
|
+
const cmdDir = path.join(globalDir, 'cmd');
|
|
72
|
+
await fs.ensureDir(cmdDir);
|
|
73
|
+
await fs.writeJson(path.join(cmdDir, 'manifest.json'), { name: 'cmd', type: 'scaffold' });
|
|
74
|
+
|
|
75
|
+
await pullLocal(['cmd'], { cwd: localDir, globalDir, copy: true });
|
|
76
|
+
|
|
77
|
+
expect(await fs.pathExists(path.join(localDir, '.agentctl', 'cmd'))).toBe(true);
|
|
78
|
+
expect(await fs.pathExists(path.join(globalDir, 'cmd'))).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('covers implicit global group and group-winning-over-capped shadowing', async () => {
|
|
82
|
+
// Global has a capped command "work"
|
|
83
|
+
const globalWork = path.join(globalDir, 'work');
|
|
84
|
+
await fs.ensureDir(globalWork);
|
|
85
|
+
await fs.writeJson(path.join(globalWork, 'manifest.json'), { name: 'work', run: 'echo global' });
|
|
86
|
+
|
|
87
|
+
// Local has a group "work" (implicit)
|
|
88
|
+
const localWork = path.join(localDir, '.agentctl', 'work');
|
|
89
|
+
await fs.ensureDir(localWork);
|
|
90
|
+
|
|
91
|
+
// Global has an implicit group "other"
|
|
92
|
+
const globalOther = path.join(globalDir, 'other');
|
|
93
|
+
await fs.ensureDir(globalOther);
|
|
94
|
+
|
|
95
|
+
const resWork = await resolveCommand(['work'], { cwd: localDir, globalDir });
|
|
96
|
+
expect(resWork?.scope).toBe('local');
|
|
97
|
+
expect(resWork?.manifest.type).toBe('group');
|
|
98
|
+
|
|
99
|
+
const resOther = await resolveCommand(['other'], { cwd: localDir, globalDir });
|
|
100
|
+
expect(resOther?.scope).toBe('global');
|
|
101
|
+
expect(resOther?.manifest.type).toBe('group');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('covers list with manifest type logic and corrupt manifest', async () => {
|
|
105
|
+
const localWork = path.join(localDir, '.agentctl', 'work');
|
|
106
|
+
await fs.ensureDir(localWork);
|
|
107
|
+
// Type provided in manifest, but not capped
|
|
108
|
+
await fs.writeJson(path.join(localWork, 'manifest.json'), { name: 'work', type: 'group' });
|
|
109
|
+
|
|
110
|
+
const corruptCmd = path.join(localDir, '.agentctl', 'corrupt');
|
|
111
|
+
await fs.ensureDir(corruptCmd);
|
|
112
|
+
await fs.writeFile(path.join(corruptCmd, 'manifest.json'), '{ bad json }');
|
|
113
|
+
|
|
114
|
+
const cmds = await list({ cwd: localDir, globalDir });
|
|
115
|
+
const work = cmds.find(c => c.path === 'work');
|
|
116
|
+
expect(work?.type).toBe('group');
|
|
117
|
+
|
|
118
|
+
const corrupt = cmds.find(c => c.path === 'corrupt');
|
|
119
|
+
expect(corrupt?.type).toBe('group'); // Falls back to group on corrupt json
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('covers isCapped failure in prepareCommand', async () => {
|
|
123
|
+
const cappedDir = path.join(localDir, '.agentctl', 'capped');
|
|
124
|
+
await fs.ensureDir(cappedDir);
|
|
125
|
+
await fs.writeFile(path.join(cappedDir, 'manifest.json'), '{ bad json }');
|
|
126
|
+
|
|
127
|
+
// scaffold will call prepareCommand, which calls isCapped on parent dirs
|
|
128
|
+
await scaffold(['capped', 'sub'], { cwd: localDir });
|
|
129
|
+
|
|
130
|
+
expect(await fs.pathExists(path.join(localDir, '.agentctl', 'capped', 'sub'))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('covers list stat failure', async () => {
|
|
134
|
+
const originalStat = (await import('fs-extra')).default.stat;
|
|
135
|
+
const spy = vi.spyOn(fs, 'stat').mockImplementation(async (p: fs.PathLike) => {
|
|
136
|
+
if (p.toString().includes('failme')) {
|
|
137
|
+
throw new Error('stat error');
|
|
138
|
+
}
|
|
139
|
+
return originalStat(p);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const failDir = path.join(globalDir, 'failme');
|
|
143
|
+
await fs.ensureDir(failDir);
|
|
144
|
+
|
|
145
|
+
const cmds = await list({ cwd: localDir, globalDir });
|
|
146
|
+
expect(cmds.find(c => c.path === 'failme')).toBeUndefined();
|
|
147
|
+
|
|
148
|
+
spy.mockRestore();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('covers default options in pushGlobal and pullLocal', async () => {
|
|
152
|
+
// Need to be in a local root for these to not throw
|
|
153
|
+
const originalCwd = process.cwd();
|
|
154
|
+
process.chdir(localDir);
|
|
155
|
+
try {
|
|
156
|
+
await expect(pushGlobal(['notfound'])).rejects.toThrow('Local command notfound not found');
|
|
157
|
+
await expect(pullLocal(['notfound'])).rejects.toThrow('Global command notfound not found');
|
|
158
|
+
} finally {
|
|
159
|
+
process.chdir(originalCwd);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('covers installSkill errors and agents', async () => {
|
|
164
|
+
// We already have a test for supported agents but let's hit the ctl branch
|
|
165
|
+
await expect(installSkill('unknown', { cwd: localDir })).rejects.toThrow('Agent \'unknown\' not supported');
|
|
166
|
+
|
|
167
|
+
await installSkill('antigravity', { cwd: localDir });
|
|
168
|
+
expect(await fs.pathExists(path.join(localDir, '.agent', 'skills', 'agentctl', 'SKILL.md'))).toBe(true);
|
|
169
|
+
|
|
170
|
+
const fakeGlobal = path.join(tmpDir, 'fakeGlobal');
|
|
171
|
+
await installSkill('antigravity', { global: true, antigravityGlobalDir: fakeGlobal });
|
|
172
|
+
expect(await fs.pathExists(path.join(fakeGlobal, 'skills', 'agentctl', 'SKILL.md'))).toBe(true);
|
|
173
|
+
|
|
174
|
+
await installSkill('cursor', { cwd: localDir });
|
|
175
|
+
expect(await fs.pathExists(path.join(localDir, '.cursor', 'skills', 'agentctl.md'))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('covers mv in global scope', async () => {
|
|
179
|
+
const globalSrc = path.join(globalDir, 'src');
|
|
180
|
+
await fs.ensureDir(globalSrc);
|
|
181
|
+
await fs.writeJson(path.join(globalSrc, 'manifest.json'), { name: 'src', type: 'scaffold' });
|
|
182
|
+
|
|
183
|
+
await mv(['src'], ['dest'], { globalDir });
|
|
184
|
+
expect(await fs.pathExists(path.join(globalDir, 'dest'))).toBe(true);
|
|
185
|
+
expect(await fs.pathExists(path.join(globalDir, 'src'))).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('covers prepareCommand empty args', async () => {
|
|
189
|
+
await expect(scaffold([], { cwd: localDir })).rejects.toThrow('No command path provided');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('covers list with existing local group shadowing global group', async () => {
|
|
193
|
+
const localGroup = path.join(localDir, '.agentctl', 'group');
|
|
194
|
+
const globalGroup = path.join(globalDir, 'group');
|
|
195
|
+
await fs.ensureDir(localGroup);
|
|
196
|
+
await fs.ensureDir(globalGroup);
|
|
197
|
+
|
|
198
|
+
// This will hit the branch where it already has the path and scope is local
|
|
199
|
+
const cmds = await list({ cwd: localDir, globalDir });
|
|
200
|
+
expect(cmds.find(c => c.path === 'group')?.scope).toBe('local');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('covers non-win32 scaffold', async () => {
|
|
204
|
+
const originalPlatform = process.platform;
|
|
205
|
+
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
206
|
+
await scaffold(['linuxcmd'], { cwd: localDir });
|
|
207
|
+
const scriptPath = path.join(localDir, '.agentctl', 'linuxcmd', 'command.sh');
|
|
208
|
+
expect(await fs.pathExists(scriptPath)).toBe(true);
|
|
209
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('covers installSkill unimplemented agent logic', async () => {
|
|
213
|
+
// We modify the array to trick the check for coverage of the "else" branch
|
|
214
|
+
SUPPORTED_AGENTS.push('test-agent');
|
|
215
|
+
try {
|
|
216
|
+
await expect(installSkill('test-agent', { cwd: localDir })).rejects.toThrow("Agent logic for 'test-agent' not implemented");
|
|
217
|
+
} finally {
|
|
218
|
+
SUPPORTED_AGENTS.pop();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('covers copySkill skill file not found paths', async () => {
|
|
223
|
+
const originalExists = fs.existsSync;
|
|
224
|
+
let callCount = 0;
|
|
225
|
+
const spy = vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
|
|
226
|
+
const pStr = p.toString();
|
|
227
|
+
// Count calls for SKILL.md checks
|
|
228
|
+
if (pStr.includes('SKILL.md')) {
|
|
229
|
+
callCount++;
|
|
230
|
+
}
|
|
231
|
+
return originalExists(p);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const targetDir = path.join(localDir, 'skills');
|
|
235
|
+
|
|
236
|
+
vi.spyOn(fs, 'copy').mockResolvedValue(undefined);
|
|
237
|
+
|
|
238
|
+
// Case 0: Succeed on 1st attempt (immediate)
|
|
239
|
+
callCount = 0;
|
|
240
|
+
spy.mockImplementation((p: fs.PathLike) => {
|
|
241
|
+
const pStr = p.toString();
|
|
242
|
+
if (pStr.endsWith('SKILL.md')) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return originalExists(p);
|
|
246
|
+
});
|
|
247
|
+
await copySkill(targetDir, 'antigravity');
|
|
248
|
+
|
|
249
|
+
// Case 1: Succeed on 2nd attempt
|
|
250
|
+
callCount = 0;
|
|
251
|
+
spy.mockImplementation((p: fs.PathLike) => {
|
|
252
|
+
const pStr = p.toString();
|
|
253
|
+
if (pStr.endsWith('SKILL.md')) {
|
|
254
|
+
callCount++;
|
|
255
|
+
if (callCount < 2) return false;
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
return originalExists(p);
|
|
259
|
+
});
|
|
260
|
+
await copySkill(targetDir, 'antigravity');
|
|
261
|
+
|
|
262
|
+
// Case 2: Succeed on 3rd attempt
|
|
263
|
+
callCount = 0;
|
|
264
|
+
spy.mockImplementation((p: fs.PathLike) => {
|
|
265
|
+
const pStr = p.toString();
|
|
266
|
+
if (pStr.endsWith('SKILL.md')) {
|
|
267
|
+
callCount++;
|
|
268
|
+
if (callCount < 3) return false;
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
return originalExists(p);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await copySkill(targetDir, 'antigravity');
|
|
275
|
+
|
|
276
|
+
// Case 3: Fail all attempts
|
|
277
|
+
callCount = 0;
|
|
278
|
+
spy.mockImplementation((p: fs.PathLike) => {
|
|
279
|
+
const pStr = p.toString();
|
|
280
|
+
if (pStr.endsWith('SKILL.md')) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
return originalExists(p);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await expect(copySkill(targetDir, 'antigravity')).rejects.toThrow('Could not locate source SKILL.md');
|
|
287
|
+
|
|
288
|
+
spy.mockRestore();
|
|
289
|
+
vi.restoreAllMocks();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('covers resolveCommand global check and file-as-directory cases', async () => {
|
|
293
|
+
// Test global: true in resolveCommand
|
|
294
|
+
const res = await resolveCommand(['nothing'], { global: true, globalDir });
|
|
295
|
+
expect(res).toBeNull();
|
|
296
|
+
|
|
297
|
+
// Test file at path preventing implicit group (local)
|
|
298
|
+
const localFile = path.join(localDir, '.agentctl', 'file');
|
|
299
|
+
await fs.ensureDir(path.dirname(localFile));
|
|
300
|
+
await fs.writeFile(localFile, 'content');
|
|
301
|
+
|
|
302
|
+
const resLocalFile = await resolveCommand(['file'], { cwd: localDir, globalDir });
|
|
303
|
+
expect(resLocalFile).toBeNull(); // Should not treat as group
|
|
304
|
+
|
|
305
|
+
// Test file at path preventing implicit group (global)
|
|
306
|
+
const globalFile = path.join(globalDir, 'file');
|
|
307
|
+
await fs.writeFile(globalFile, 'content');
|
|
308
|
+
|
|
309
|
+
const resGlobalFile = await resolveCommand(['file'], { cwd: localDir, globalDir });
|
|
310
|
+
expect(resGlobalFile).toBeNull();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('covers list with file in directory (not directory)', async () => {
|
|
314
|
+
// Create a file inside .agentctl that list() iterates over but ignores because it's not a dir
|
|
315
|
+
const localCtl = path.join(localDir, '.agentctl');
|
|
316
|
+
await fs.ensureDir(localCtl);
|
|
317
|
+
const localFile = path.join(localCtl, 'notadir');
|
|
318
|
+
await fs.writeFile(localFile, 'content');
|
|
319
|
+
|
|
320
|
+
const cmds = await list({ cwd: localDir, globalDir });
|
|
321
|
+
expect(cmds.find(c => c.path === 'notadir')).toBeUndefined();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('covers isCapped exception', async () => {
|
|
325
|
+
const cappedDir = path.join(localDir, '.agentctl', 'badjson');
|
|
326
|
+
await fs.ensureDir(cappedDir);
|
|
327
|
+
await fs.writeFile(path.join(cappedDir, 'manifest.json'), '{ bad }');
|
|
328
|
+
|
|
329
|
+
// We try to scaffold inside it.
|
|
330
|
+
// The parent check for isCapped should fail to parse JSON and return false (safe).
|
|
331
|
+
await scaffold(['badjson', 'sub'], { cwd: localDir });
|
|
332
|
+
expect(await fs.pathExists(path.join(cappedDir, 'sub'))).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('covers list shadowing branches', async () => {
|
|
336
|
+
// Case: Global (Group) checked when local doesn't exist? (Implicit logic) -> Covered by standard flows.
|
|
337
|
+
// We want to verify `existing.scope === 'local'` check (line 289 ctl.ts)
|
|
338
|
+
// If we have a Global command, then Local command that is NOT group? (conflict?)
|
|
339
|
+
// Or Global command, then finding another Global command with same name (impossible in fs unless case sensitivity issues, but iteration logic handles files once).
|
|
340
|
+
|
|
341
|
+
// Let's cover explicit code path where we find a Global item and it shadows nothing (normal).
|
|
342
|
+
// Let's Try: Local Group exists. Global Scan finds same name.
|
|
343
|
+
// Walk Global:
|
|
344
|
+
// finds 'common'. Adds to map? No, map has 'common' from local scan (scope='local').
|
|
345
|
+
// entering else block (line 288).
|
|
346
|
+
// `existing.scope === 'local'` is true.
|
|
347
|
+
// `existing.type === 'group'` is true.
|
|
348
|
+
// `type` (of global item) is 'group'.
|
|
349
|
+
// -> recursively walk global.
|
|
350
|
+
|
|
351
|
+
// We need:
|
|
352
|
+
// 1. Local Group 'common'
|
|
353
|
+
// 2. Global Group 'common'
|
|
354
|
+
// This should trigger the merge walk.
|
|
355
|
+
|
|
356
|
+
const localCommon = path.join(localDir, '.agentctl', 'common');
|
|
357
|
+
await fs.ensureDir(localCommon);
|
|
358
|
+
|
|
359
|
+
const globalCommon = path.join(globalDir, 'common');
|
|
360
|
+
await fs.ensureDir(globalCommon);
|
|
361
|
+
await fs.ensureDir(path.join(globalCommon, 'sub')); // Subcommand in global common
|
|
362
|
+
|
|
363
|
+
const cmds = await list({ cwd: localDir, globalDir });
|
|
364
|
+
expect(cmds.find(c => c.path === 'common sub')).toBeDefined();
|
|
365
|
+
|
|
366
|
+
// Local Group shadows Global Capped
|
|
367
|
+
const localGroup = path.join(localDir, '.agentctl', 'shadow1');
|
|
368
|
+
await fs.ensureDir(localGroup);
|
|
369
|
+
|
|
370
|
+
const globalCapped = path.join(globalDir, 'shadow1');
|
|
371
|
+
await fs.ensureDir(globalCapped);
|
|
372
|
+
await fs.writeJson(path.join(globalCapped, 'manifest.json'), { name: 'shadow1', run: 'echo' });
|
|
373
|
+
|
|
374
|
+
const cmds2 = await list({ cwd: localDir, globalDir });
|
|
375
|
+
const shadow1 = cmds2.find(c => c.path === 'shadow1');
|
|
376
|
+
expect(shadow1?.scope).toBe('local');
|
|
377
|
+
expect(shadow1?.type).toBe('group');
|
|
378
|
+
|
|
379
|
+
// Local Capped shadows Global Group
|
|
380
|
+
const localCapped = path.join(localDir, '.agentctl', 'shadow2');
|
|
381
|
+
await fs.ensureDir(localCapped);
|
|
382
|
+
await fs.writeJson(path.join(localCapped, 'manifest.json'), { name: 'shadow2', run: 'echo' });
|
|
383
|
+
|
|
384
|
+
const globalGroup2 = path.join(globalDir, 'shadow2');
|
|
385
|
+
await fs.ensureDir(globalGroup2);
|
|
386
|
+
|
|
387
|
+
const cmds3 = await list({ cwd: localDir, globalDir });
|
|
388
|
+
const shadow2 = cmds3.find(c => c.path === 'shadow2');
|
|
389
|
+
expect(shadow2?.scope).toBe('local');
|
|
390
|
+
expect(shadow2?.type).not.toBe('group'); // capped/scaffold
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('covers mv implicit group (no manifest)', async () => {
|
|
394
|
+
// Create implicit group
|
|
395
|
+
const groupDir = path.join(localDir, '.agentctl', 'implicit');
|
|
396
|
+
await fs.ensureDir(groupDir);
|
|
397
|
+
|
|
398
|
+
// Move it
|
|
399
|
+
await mv(['implicit'], ['moved_implicit'], { cwd: localDir, globalDir });
|
|
400
|
+
|
|
401
|
+
expect(await fs.pathExists(path.join(localDir, '.agentctl', 'moved_implicit'))).toBe(true);
|
|
402
|
+
expect(await fs.pathExists(path.join(localDir, '.agentctl', 'implicit'))).toBe(false);
|
|
403
|
+
// And manifest check should simply pass (if exists logic)
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('covers list outside local context', async () => {
|
|
407
|
+
const nonProjectDir = path.join(tmpDir, 'nonproject');
|
|
408
|
+
await fs.ensureDir(nonProjectDir);
|
|
409
|
+
|
|
410
|
+
const cmds = await list({ cwd: nonProjectDir, globalDir });
|
|
411
|
+
// Should only return global commands
|
|
412
|
+
// Ensure there is at least one global command for verification or empty is fine
|
|
413
|
+
// verification:
|
|
414
|
+
expect(Array.isArray(cmds)).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('covers deep nesting (isCapped checks)', async () => {
|
|
418
|
+
// Create a group
|
|
419
|
+
await group(['L1'], { cwd: localDir });
|
|
420
|
+
// Create L2
|
|
421
|
+
await group(['L1', 'L2'], { cwd: localDir });
|
|
422
|
+
// Create L3
|
|
423
|
+
await scaffold(['L1', 'L2', 'L3'], { cwd: localDir });
|
|
424
|
+
|
|
425
|
+
expect(await fs.pathExists(path.join(localDir, '.agentctl', 'L1', 'L2', 'L3'))).toBe(true);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('covers capped manifest without type (defaults to scaffold)', async () => {
|
|
429
|
+
const cappedDir = path.join(localDir, '.agentctl', 'notype');
|
|
430
|
+
await fs.ensureDir(cappedDir);
|
|
431
|
+
// Explicitly undefined type but has run
|
|
432
|
+
await fs.writeJson(path.join(cappedDir, 'manifest.json'), { name: 'notype', run: 'echo' });
|
|
433
|
+
|
|
434
|
+
const cmds = await list({ cwd: localDir, globalDir });
|
|
435
|
+
const cmd = cmds.find(c => c.path === 'notype');
|
|
436
|
+
expect(cmd?.type).toBe('scaffold');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('covers mv rootDir failure logic', async () => {
|
|
440
|
+
// We mock findLocalRoot to succeed for resolveCommand (first call)
|
|
441
|
+
// and return null for mv (second call).
|
|
442
|
+
|
|
443
|
+
// This requires 'resolveCommand' to find something local.
|
|
444
|
+
// We can create a local command.
|
|
445
|
+
const cmdDir = path.join(localDir, '.agentctl', 'failmv');
|
|
446
|
+
await fs.ensureDir(cmdDir);
|
|
447
|
+
await fs.writeJson(path.join(cmdDir, 'manifest.json'), { name: 'failmv', type: 'scaffold' });
|
|
448
|
+
|
|
449
|
+
// Use spyOn with the wildcard import
|
|
450
|
+
const spy = vi.spyOn(fsUtils, 'findLocalRoot');
|
|
451
|
+
let calls = 0;
|
|
452
|
+
spy.mockImplementation(() => {
|
|
453
|
+
calls++;
|
|
454
|
+
if (calls === 1) return localDir; // For resolveCommand
|
|
455
|
+
return null; // For mv
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
await expect(mv(['failmv'], ['dest'], { cwd: localDir })).rejects.toThrow('Cannot determine root for move');
|
|
459
|
+
|
|
460
|
+
spy.mockRestore();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('covers rm global failure message', async () => {
|
|
464
|
+
// Ensure it doesn't exist
|
|
465
|
+
await expect(rm(['failrm'], { global: true, globalDir })).rejects.toThrow('in global scope');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('covers group manifest without type (implicit default)', async () => {
|
|
469
|
+
const groupDir = path.join(localDir, '.agentctl', 'implicittype');
|
|
470
|
+
await fs.ensureDir(groupDir);
|
|
471
|
+
await fs.writeJson(path.join(groupDir, 'manifest.json'), { name: 'implicittype', description: 'desc' });
|
|
472
|
+
|
|
473
|
+
const cmds = await list({ cwd: localDir, globalDir });
|
|
474
|
+
const cmd = cmds.find(c => c.path === 'implicittype');
|
|
475
|
+
expect(cmd?.type).toBe('group');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('covers installSkill gemini default path', async () => {
|
|
479
|
+
const origHome = process.env.HOME;
|
|
480
|
+
const origUserProfile = process.env.USERPROFILE;
|
|
481
|
+
const mockHome = path.join(tmpDir, 'mock_home');
|
|
482
|
+
|
|
483
|
+
process.env.HOME = mockHome;
|
|
484
|
+
process.env.USERPROFILE = mockHome;
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
await installSkill('gemini', { global: true });
|
|
488
|
+
|
|
489
|
+
const skillPath = path.join(mockHome, '.gemini', 'skills', 'agentctl', 'SKILL.md');
|
|
490
|
+
expect(await fs.pathExists(skillPath)).toBe(true);
|
|
491
|
+
} finally {
|
|
492
|
+
process.env.HOME = origHome;
|
|
493
|
+
process.env.USERPROFILE = origUserProfile;
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('covers list/prepareCommand without cwd option', async () => {
|
|
498
|
+
const origCwd = process.cwd();
|
|
499
|
+
try {
|
|
500
|
+
process.chdir(localDir);
|
|
501
|
+
|
|
502
|
+
const cmds = await list();
|
|
503
|
+
expect(Array.isArray(cmds)).toBe(true);
|
|
504
|
+
|
|
505
|
+
await scaffold(['newcmd']);
|
|
506
|
+
expect(await fs.pathExists(path.join(localDir, '.agentctl', 'newcmd'))).toBe(true);
|
|
507
|
+
|
|
508
|
+
// Also test mv without cwd
|
|
509
|
+
await mv(['newcmd'], ['newcmd_moved']);
|
|
510
|
+
expect(await fs.pathExists(path.join(localDir, '.agentctl', 'newcmd_moved'))).toBe(true);
|
|
511
|
+
|
|
512
|
+
} finally {
|
|
513
|
+
process.chdir(origCwd);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('covers pushGlobal/pullLocal outside local context', async () => {
|
|
518
|
+
const outsideDir = path.join(tmpDir, 'outside');
|
|
519
|
+
await fs.ensureDir(outsideDir);
|
|
520
|
+
|
|
521
|
+
await expect(pushGlobal(['any'], { cwd: outsideDir, globalDir })).rejects.toThrow('Not in a local context');
|
|
522
|
+
await expect(pullLocal(['any'], { cwd: outsideDir, globalDir })).rejects.toThrow('Not in a local context');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('covers mv global default root (no globalDir)', async () => {
|
|
526
|
+
const mockHome = path.join(tmpDir, 'mock_home_mv');
|
|
527
|
+
const origHome = process.env.HOME;
|
|
528
|
+
const origAppData = process.env.APPDATA;
|
|
529
|
+
|
|
530
|
+
process.env.HOME = mockHome;
|
|
531
|
+
process.env.APPDATA = mockHome;
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
let globalRoot;
|
|
535
|
+
if (process.platform === 'win32') {
|
|
536
|
+
globalRoot = path.join(mockHome, 'agentctl');
|
|
537
|
+
} else {
|
|
538
|
+
globalRoot = path.join(mockHome, '.config', 'agentctl');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
await fs.ensureDir(globalRoot);
|
|
542
|
+
const cmdDir = path.join(globalRoot, 'mv_def');
|
|
543
|
+
await fs.ensureDir(cmdDir);
|
|
544
|
+
await fs.writeJson(path.join(cmdDir, 'manifest.json'), { name: 'mv_def', type: 'scaffold' });
|
|
545
|
+
|
|
546
|
+
await mv(['mv_def'], ['mv_def_moved'], { cwd: localDir });
|
|
547
|
+
expect(await fs.pathExists(path.join(globalRoot, 'mv_def_moved'))).toBe(true);
|
|
548
|
+
} finally {
|
|
549
|
+
process.env.HOME = origHome;
|
|
550
|
+
process.env.APPDATA = origAppData;
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('covers installSkill antigravity default path', async () => {
|
|
555
|
+
const mockHome = path.join(tmpDir, 'mock_home_ag');
|
|
556
|
+
await fs.ensureDir(mockHome);
|
|
557
|
+
const origAppData = process.env.APPDATA;
|
|
558
|
+
const origHome = process.env.HOME;
|
|
559
|
+
const origUserProfile = process.env.USERPROFILE;
|
|
560
|
+
|
|
561
|
+
process.env.APPDATA = mockHome;
|
|
562
|
+
process.env.HOME = mockHome;
|
|
563
|
+
process.env.USERPROFILE = mockHome;
|
|
564
|
+
|
|
565
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
566
|
+
try {
|
|
567
|
+
await installSkill('antigravity', { global: true });
|
|
568
|
+
|
|
569
|
+
// Check logs to see where it installed
|
|
570
|
+
const calls = logSpy.mock.calls.map(c => c[0]);
|
|
571
|
+
const installMsg = calls.find(msg => msg.includes('Installed skill for antigravity'));
|
|
572
|
+
if (!installMsg) {
|
|
573
|
+
throw new Error('Install message not found');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Expected path should be in mockHome
|
|
577
|
+
// On Windows: mockHome/.gemini/antigravity or mockHome/antigravity...
|
|
578
|
+
// getAntigravityGlobalRoot uses os.homedir(). we mocked USERPROFILE/HOME, so it should be mockHome.
|
|
579
|
+
// .gemini/antigravity is the standard path in fs-utils.
|
|
580
|
+
|
|
581
|
+
expect(installMsg).toContain(mockHome);
|
|
582
|
+
expect(installMsg).toContain('antigravity');
|
|
583
|
+
expect(installMsg).toContain('skills');
|
|
584
|
+
|
|
585
|
+
// Also check file existence based on path extracted?
|
|
586
|
+
// "Installed skill for antigravity at <path>"
|
|
587
|
+
const installedPath = installMsg.split(' at ')[1].trim();
|
|
588
|
+
expect(await fs.pathExists(installedPath)).toBe(true);
|
|
589
|
+
|
|
590
|
+
} finally {
|
|
591
|
+
logSpy.mockRestore();
|
|
592
|
+
process.env.APPDATA = origAppData;
|
|
593
|
+
process.env.HOME = origHome;
|
|
594
|
+
process.env.USERPROFILE = origUserProfile;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('covers installSkill gemini global options and env vars', async () => {
|
|
599
|
+
const mockHome = path.join(tmpDir, 'mock_gemini');
|
|
600
|
+
await fs.ensureDir(mockHome);
|
|
601
|
+
|
|
602
|
+
const origHome = process.env.HOME;
|
|
603
|
+
const origUserProfile = process.env.USERPROFILE;
|
|
604
|
+
|
|
605
|
+
process.env.HOME = '';
|
|
606
|
+
process.env.USERPROFILE = '';
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
// Case 1: explicit globalDir
|
|
610
|
+
const explicitDir = path.join(tmpDir, 'explicit_gemini');
|
|
611
|
+
await installSkill('gemini', { global: true, geminiGlobalDir: explicitDir });
|
|
612
|
+
expect(await fs.pathExists(path.join(explicitDir, 'skills', 'agentctl', 'SKILL.md'))).toBe(true);
|
|
613
|
+
|
|
614
|
+
// Case 2: default via HOME
|
|
615
|
+
process.env.HOME = mockHome;
|
|
616
|
+
await installSkill('gemini', { global: true });
|
|
617
|
+
expect(await fs.pathExists(path.join(mockHome, '.gemini', 'skills', 'agentctl', 'SKILL.md'))).toBe(true);
|
|
618
|
+
|
|
619
|
+
// Case 3: default via USERPROFILE (HOME empty)
|
|
620
|
+
process.env.HOME = '';
|
|
621
|
+
process.env.USERPROFILE = mockHome;
|
|
622
|
+
// Clean up previous run
|
|
623
|
+
await fs.remove(path.join(mockHome, '.gemini'));
|
|
624
|
+
await installSkill('gemini', { global: true });
|
|
625
|
+
expect(await fs.pathExists(path.join(mockHome, '.gemini', 'skills', 'agentctl', 'SKILL.md'))).toBe(true);
|
|
626
|
+
|
|
627
|
+
} finally {
|
|
628
|
+
process.env.HOME = origHome;
|
|
629
|
+
process.env.USERPROFILE = origUserProfile;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('covers mv with explicit options (cwd/globalDir)', async () => {
|
|
634
|
+
// Local: explicit cwd
|
|
635
|
+
const subDir = path.join(localDir, 'subdir');
|
|
636
|
+
await fs.ensureDir(subDir);
|
|
637
|
+
// We need .agentctl relative to that cwd? No, findLocalRoot walks up.
|
|
638
|
+
// If we pass cwd=subDir, findLocalRoot finds localDir. Correct.
|
|
639
|
+
|
|
640
|
+
// Create source command
|
|
641
|
+
const srcCmd = path.join(localDir, '.agentctl', 'mv_src');
|
|
642
|
+
await fs.ensureDir(srcCmd);
|
|
643
|
+
await fs.writeJson(path.join(srcCmd, 'manifest.json'), { name: 'mv_src', type: 'scaffold' });
|
|
644
|
+
|
|
645
|
+
await mv(['mv_src'], ['mv_dest_cwd'], { cwd: subDir });
|
|
646
|
+
expect(await fs.pathExists(path.join(localDir, '.agentctl', 'mv_dest_cwd'))).toBe(true);
|
|
647
|
+
|
|
648
|
+
// Global: explicit globalDir
|
|
649
|
+
const customGlobal = path.join(tmpDir, 'custom_global');
|
|
650
|
+
await fs.ensureDir(customGlobal);
|
|
651
|
+
const globalSrc = path.join(customGlobal, 'g_src');
|
|
652
|
+
await fs.ensureDir(globalSrc);
|
|
653
|
+
await fs.writeJson(path.join(globalSrc, 'manifest.json'), { name: 'g_src', type: 'scaffold' });
|
|
654
|
+
|
|
655
|
+
await mv(['g_src'], ['g_dest'], { global: true, globalDir: customGlobal });
|
|
656
|
+
expect(await fs.pathExists(path.join(customGlobal, 'g_dest'))).toBe(true);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('covers list with non-existent global dir', async () => {
|
|
660
|
+
const missingGlobal = path.join(tmpDir, 'missing_global');
|
|
661
|
+
// Ensure it doesn't exist
|
|
662
|
+
await fs.remove(missingGlobal);
|
|
663
|
+
|
|
664
|
+
const cmds = await list({ cwd: localDir, globalDir: missingGlobal });
|
|
665
|
+
// Should rely on walking local only.
|
|
666
|
+
// We verify no error thrown and correct behavior.
|
|
667
|
+
expect(Array.isArray(cmds)).toBe(true);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('covers list collision (Global vs Global)', async () => {
|
|
671
|
+
// Setup: global/a (dir) -> global/a/b (subcommand) => logical path "a b"
|
|
672
|
+
// Setup: global/a b (dir) => logical path "a b"
|
|
673
|
+
|
|
674
|
+
const gRoot = path.join(tmpDir, 'g_collision');
|
|
675
|
+
await fs.ensureDir(gRoot);
|
|
676
|
+
|
|
677
|
+
const dirA = path.join(gRoot, 'a');
|
|
678
|
+
await fs.ensureDir(dirA);
|
|
679
|
+
const dirAB = path.join(dirA, 'b'); // "a b"
|
|
680
|
+
await fs.ensureDir(dirAB);
|
|
681
|
+
await fs.writeJson(path.join(dirAB, 'manifest.json'), { name: 'b', description: 'nested' });
|
|
682
|
+
|
|
683
|
+
const dirSpace = path.join(gRoot, 'a b'); // "a b"
|
|
684
|
+
await fs.ensureDir(dirSpace);
|
|
685
|
+
await fs.writeJson(path.join(dirSpace, 'manifest.json'), { name: 'a b', description: 'top' });
|
|
686
|
+
|
|
687
|
+
// Depending on iteration order, one will register first.
|
|
688
|
+
// The second one will hit `commands.has() -> true`.
|
|
689
|
+
// Inspecting `ctl.ts`: `commands.set` only happens if `!commands.has`.
|
|
690
|
+
// So the first one wins.
|
|
691
|
+
// We just need to ensure `scope !== 'local'` branch is hit.
|
|
692
|
+
// Since both are global, existing.scope is 'global'.
|
|
693
|
+
|
|
694
|
+
const cmds = await list({ cwd: localDir, globalDir: gRoot });
|
|
695
|
+
const collision = cmds.filter(c => c.path === 'a b');
|
|
696
|
+
expect(collision.length).toBe(1); // Should only have one entry
|
|
697
|
+
// And we verified logic didn't crash
|
|
698
|
+
});
|
|
699
|
+
});
|