@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import 'fs-extra';
|
|
5
|
+
import { list, inspect, scaffold, alias, group, rm, mv, pushGlobal, pullLocal, installSkill } from './ctl';
|
|
6
|
+
import { resolveCommand } from './resolve';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('agentctl')
|
|
14
|
+
.description('Agent Controller CLI - Unified control plane for humans and agents')
|
|
15
|
+
.version('1.0.0')
|
|
16
|
+
.allowUnknownOption()
|
|
17
|
+
.helpOption(false) // Disable default help to allow pass-through
|
|
18
|
+
.argument('[command...]', 'Command to run')
|
|
19
|
+
.action(async (args, _options, _command) => {
|
|
20
|
+
// If no args, check for help flag or just show help
|
|
21
|
+
if (!args || args.length === 0) {
|
|
22
|
+
// If they passed --help or -h, show help. If no args at all, show help.
|
|
23
|
+
// Since we ate options, we check raw args or just treat empty args as help.
|
|
24
|
+
// command.opts() won't have help if we disabled it?
|
|
25
|
+
// Actually, if we disable helpOption, --help becomes an unknown option or arg.
|
|
26
|
+
// Let's check process.argv for -h or --help if args is empty?
|
|
27
|
+
// "agentctl --help" -> args=[], options might contain help if we didn't disable it?
|
|
28
|
+
// With helpOption(false), --help is just a flag in argv.
|
|
29
|
+
|
|
30
|
+
// If args is empty and we see help flag, show help.
|
|
31
|
+
if (process.argv.includes('--help') || process.argv.includes('-h') || process.argv.length <= 2) {
|
|
32
|
+
program.help();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// If args are present, we try to resolve.
|
|
38
|
+
// BUT, "agentctl --help" will result in args being empty if it's parsed as option?
|
|
39
|
+
// Wait, if helpOption(false), then --help is an unknown option.
|
|
40
|
+
// If allowUnknownOption is true, it might not be in 'args' if it looks like a flag.
|
|
41
|
+
// Let's rely on resolveCommand. passed args are variadic.
|
|
42
|
+
|
|
43
|
+
// However, "agentctl dev --help" -> args=["dev", "--help"]?
|
|
44
|
+
// My repro says yes: [ 'dev-tools', 'gh', '--help' ].
|
|
45
|
+
|
|
46
|
+
// So for "agentctl --help", args might be ["--help"].
|
|
47
|
+
if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
|
|
48
|
+
program.help();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Bypass for ctl subcommand if it slipped through (shouldn't if registered)
|
|
53
|
+
if (args[0] === 'ctl') return;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// resolveCommand needs to handle flags in args if they are part of the path?
|
|
57
|
+
// No, flags usually come after. resolveCommand stops at first non-matching path part?
|
|
58
|
+
// resolveCommand logic: iterates args.
|
|
59
|
+
// "dev-tools gh --help" -> path "dev-tools gh", remaining "--help"
|
|
60
|
+
|
|
61
|
+
const result = await resolveCommand(args);
|
|
62
|
+
if (!result) {
|
|
63
|
+
// If not found, and they asked for help, show root help?
|
|
64
|
+
// Or if they just typed a wrong command.
|
|
65
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
66
|
+
// Try to show help for the partial command?
|
|
67
|
+
// For now, just show root list/help or error.
|
|
68
|
+
// If it's "agentctl dev --help" and "dev" is a group, resolveCommand SHOULD return the group.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.error(chalk.red(`Command '${args.join(' ')}' not found.`));
|
|
72
|
+
console.log(`Run ${chalk.cyan('agentctl list')} to see available commands.`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { manifest, args: remainingArgs, scope } = result;
|
|
77
|
+
|
|
78
|
+
if (manifest.run) {
|
|
79
|
+
// ... run logic ...
|
|
80
|
+
// remainingArgs should contain --help if it was passed.
|
|
81
|
+
const cmdDir = path.dirname(result.manifestPath);
|
|
82
|
+
let runCmd = manifest.run;
|
|
83
|
+
|
|
84
|
+
// Resolve relative path
|
|
85
|
+
if (runCmd.startsWith('./') || runCmd.startsWith('.\\')) {
|
|
86
|
+
runCmd = path.resolve(cmdDir, runCmd);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Interpolate {{DIR}}
|
|
90
|
+
runCmd = runCmd.replace(/{{DIR}}/g, cmdDir);
|
|
91
|
+
|
|
92
|
+
const fullCommand = `${runCmd} ${remainingArgs.join(' ')}`;
|
|
93
|
+
console.log(chalk.dim(`[${scope}] Running: ${fullCommand}`));
|
|
94
|
+
|
|
95
|
+
const child = spawn(fullCommand, {
|
|
96
|
+
cwd: process.cwd(), // Execute in CWD as discussed
|
|
97
|
+
shell: true,
|
|
98
|
+
stdio: 'inherit',
|
|
99
|
+
env: { ...process.env, AGENTCTL_SCOPE: scope }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
child.on('exit', (code) => {
|
|
103
|
+
process.exit(code || 0);
|
|
104
|
+
});
|
|
105
|
+
} else {
|
|
106
|
+
// Group
|
|
107
|
+
console.log(chalk.blue(chalk.bold(`${manifest.name}`)));
|
|
108
|
+
console.log(manifest.description || 'No description');
|
|
109
|
+
console.log('\nSubcommands:');
|
|
110
|
+
|
|
111
|
+
const all = await list();
|
|
112
|
+
const prefix = result.cmdPath + ' ';
|
|
113
|
+
// Filter logic roughly for direct children
|
|
114
|
+
const depth = result.cmdPath.split(' ').length;
|
|
115
|
+
|
|
116
|
+
const children = all.filter(c => c.path.startsWith(prefix) && c.path !== result.cmdPath);
|
|
117
|
+
const direct = children.filter(c => c.path.split(' ').length === depth + 1);
|
|
118
|
+
|
|
119
|
+
if (direct.length === 0 && children.length === 0) {
|
|
120
|
+
console.log(chalk.dim(' (No subcommands found)'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const child of direct) {
|
|
124
|
+
console.log(` ${child.path.split(' ').pop()}\t${chalk.dim(child.description)}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch (e: unknown) {
|
|
128
|
+
if (e instanceof Error) {
|
|
129
|
+
console.error(chalk.red(e.message));
|
|
130
|
+
} else {
|
|
131
|
+
console.error(chalk.red('An unknown error occurred'));
|
|
132
|
+
}
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const ctl = program.command('ctl')
|
|
138
|
+
.description('Agent Controller Management - Create, organizing, and managing commands');
|
|
139
|
+
|
|
140
|
+
// --- Lifecycle Commands ---
|
|
141
|
+
// We'll stick to flat list but with good descriptions.
|
|
142
|
+
|
|
143
|
+
// Helper for consistent error handling
|
|
144
|
+
// Helper for consistent error handling
|
|
145
|
+
const withErrorHandling = <T extends unknown[]>(fn: (...args: T) => Promise<void>) => {
|
|
146
|
+
return async (...args: T) => {
|
|
147
|
+
try {
|
|
148
|
+
await fn(...args);
|
|
149
|
+
} catch (e: unknown) {
|
|
150
|
+
if (e instanceof Error) {
|
|
151
|
+
console.error(chalk.red(e.message));
|
|
152
|
+
} else {
|
|
153
|
+
console.error(chalk.red(String(e)));
|
|
154
|
+
}
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
ctl.command('scaffold')
|
|
161
|
+
.description('Create a new capped command with a script file')
|
|
162
|
+
.argument('<path...>', 'Command path segments (e.g., "dev start")')
|
|
163
|
+
.addHelpText('after', `
|
|
164
|
+
Examples:
|
|
165
|
+
$ agentctl ctl scaffold dev start
|
|
166
|
+
$ agentctl ctl scaffold sys backup
|
|
167
|
+
`)
|
|
168
|
+
.action(withErrorHandling(async (pathParts) => {
|
|
169
|
+
await scaffold(pathParts);
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
ctl.command('alias')
|
|
173
|
+
.description('Create a new capped command that runs an inline shell command')
|
|
174
|
+
.argument('<args...>', 'Name parts followed by target (e.g., "tools" "gh" "gh")')
|
|
175
|
+
.action(withErrorHandling(async (args) => {
|
|
176
|
+
if (args.length < 2) {
|
|
177
|
+
console.error('Usage: ctl alias <name...> <target>');
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
const target = args.pop()!;
|
|
181
|
+
const name = args;
|
|
182
|
+
await alias(name, target);
|
|
183
|
+
}))
|
|
184
|
+
.addHelpText('after', `
|
|
185
|
+
Examples:
|
|
186
|
+
$ agentctl ctl alias tools gh "gh"
|
|
187
|
+
$ agentctl ctl alias dev build "npm run build"
|
|
188
|
+
`);
|
|
189
|
+
|
|
190
|
+
ctl.command('group')
|
|
191
|
+
.description('Create a new command group (namespace)')
|
|
192
|
+
.argument('<path...>', 'Group path (e.g., "dev")')
|
|
193
|
+
.addHelpText('after', `
|
|
194
|
+
Examples:
|
|
195
|
+
$ agentctl ctl group dev
|
|
196
|
+
$ agentctl ctl group tools
|
|
197
|
+
`)
|
|
198
|
+
.action(withErrorHandling(async (parts) => {
|
|
199
|
+
await group(parts);
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
ctl.command('rm')
|
|
203
|
+
.description('Remove a command or group permanently')
|
|
204
|
+
.argument('<path...>', 'Command path to remove')
|
|
205
|
+
.option('--global', 'Remove from global scope')
|
|
206
|
+
.addHelpText('after', `
|
|
207
|
+
Examples:
|
|
208
|
+
$ agentctl ctl rm dev start
|
|
209
|
+
$ agentctl ctl rm tools --global
|
|
210
|
+
`)
|
|
211
|
+
.action(withErrorHandling(async (parts, opts) => {
|
|
212
|
+
await rm(parts, { global: opts.global });
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
ctl.command('mv')
|
|
216
|
+
.description('Move a command or group to a new path')
|
|
217
|
+
.argument('<src>', 'Source path (quoted string or single token)')
|
|
218
|
+
.argument('<dest>', 'Destination path')
|
|
219
|
+
.option('--global', 'Move command in global scope')
|
|
220
|
+
.addHelpText('after', `
|
|
221
|
+
Examples:
|
|
222
|
+
$ agentctl ctl mv "dev start" "dev boot"
|
|
223
|
+
$ agentctl ctl mv tools/gh tools/github --global
|
|
224
|
+
`)
|
|
225
|
+
.action(withErrorHandling(async (src, dest, opts) => {
|
|
226
|
+
await mv(src.split(' '), dest.split(' '), { global: opts.global });
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
// --- Introspection ---
|
|
230
|
+
|
|
231
|
+
ctl.command('list')
|
|
232
|
+
.description('List all available commands across local and global scopes')
|
|
233
|
+
.action(withErrorHandling(async () => {
|
|
234
|
+
const items = await list();
|
|
235
|
+
console.log('TYPE SCOPE COMMAND DESCRIPTION');
|
|
236
|
+
for (const item of items) {
|
|
237
|
+
console.log(`${item.type.padEnd(9)} ${item.scope.padEnd(9)} ${item.path.padEnd(19)} ${item.description}`);
|
|
238
|
+
}
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
ctl.command('inspect')
|
|
242
|
+
.description('Inspect the internal manifest and details of a command')
|
|
243
|
+
.argument('<path...>', 'Command path to inspect')
|
|
244
|
+
.action(withErrorHandling(async (parts) => {
|
|
245
|
+
const info = await inspect(parts);
|
|
246
|
+
if (info) {
|
|
247
|
+
console.log(JSON.stringify(info, null, 2));
|
|
248
|
+
} else {
|
|
249
|
+
console.error('Command not found');
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
// --- Scoping ---
|
|
255
|
+
|
|
256
|
+
ctl.command('global')
|
|
257
|
+
.description('Push a local command to the global scope')
|
|
258
|
+
.argument('<path...>', 'Local command path')
|
|
259
|
+
.option('--move', 'Move instead of copy')
|
|
260
|
+
.option('--copy', 'Copy (default)')
|
|
261
|
+
.addHelpText('after', `
|
|
262
|
+
Examples:
|
|
263
|
+
$ agentctl ctl global sys --move
|
|
264
|
+
$ agentctl ctl global tools --copy
|
|
265
|
+
`)
|
|
266
|
+
.action(withErrorHandling(async (parts, opts) => {
|
|
267
|
+
await pushGlobal(parts, { move: opts.move, copy: opts.copy || !opts.move });
|
|
268
|
+
}));
|
|
269
|
+
|
|
270
|
+
ctl.command('local')
|
|
271
|
+
.description('Pull a global command to the local scope')
|
|
272
|
+
.argument('<path...>', 'Global command path')
|
|
273
|
+
.option('--move', 'Move instead of copy')
|
|
274
|
+
.option('--copy', 'Copy (default)')
|
|
275
|
+
.addHelpText('after', `
|
|
276
|
+
Examples:
|
|
277
|
+
$ agentctl ctl local tools --copy
|
|
278
|
+
`)
|
|
279
|
+
.action(withErrorHandling(async (parts, opts) => {
|
|
280
|
+
await pullLocal(parts, { move: opts.move, copy: opts.copy || !opts.move });
|
|
281
|
+
}));
|
|
282
|
+
|
|
283
|
+
// --- Agent Integration ---
|
|
284
|
+
// We attach this to the root `ctl` as options or a sub-command?
|
|
285
|
+
// Original code had it as options on `ctl`. We can make it a command for better help.
|
|
286
|
+
// But sticking to options maintains compatibility. We'll improve the option help.
|
|
287
|
+
|
|
288
|
+
ctl.option('--install-skill <agent>', 'Install skill for agent (cursor, antigravity, agentsmd, gemini)')
|
|
289
|
+
.option('--global', 'Install skill globally (for supported agents)')
|
|
290
|
+
.addHelpText('after', `
|
|
291
|
+
Examples:
|
|
292
|
+
$ agentctl ctl --install-skill cursor
|
|
293
|
+
$ agentctl ctl --install-skill antigravity --global
|
|
294
|
+
$ agentctl ctl --install-skill gemini
|
|
295
|
+
`)
|
|
296
|
+
.action(withErrorHandling(async (op, command) => {
|
|
297
|
+
const opts = ctl.opts();
|
|
298
|
+
if (opts.installSkill) {
|
|
299
|
+
await installSkill(opts.installSkill, { global: opts.global });
|
|
300
|
+
} else {
|
|
301
|
+
// If no subcmd and no option, show help
|
|
302
|
+
if (command.args.length === 0) {
|
|
303
|
+
ctl.help();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}));
|
|
307
|
+
|
|
308
|
+
// Inject dynamic commands into root help
|
|
309
|
+
// We need to do this before parsing
|
|
310
|
+
(async () => {
|
|
311
|
+
try {
|
|
312
|
+
const allCommands = await list();
|
|
313
|
+
const topLevel = allCommands.filter(c => !c.path.includes(' ')); // Only top level
|
|
314
|
+
|
|
315
|
+
if (topLevel.length > 0) {
|
|
316
|
+
const lines = [''];
|
|
317
|
+
lines.push('User Commands:');
|
|
318
|
+
for (const cmd of topLevel) {
|
|
319
|
+
// simple padding
|
|
320
|
+
lines.push(` ${cmd.path.padEnd(27)}${cmd.description}`);
|
|
321
|
+
}
|
|
322
|
+
lines.push('');
|
|
323
|
+
|
|
324
|
+
program.addHelpText('after', lines.join('\n'));
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
// Ignore errors during help generation (e.g. if not initialized)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
program.parse(process.argv);
|
|
331
|
+
})();
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
|
|
3
|
+
export interface Manifest {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
type?: 'scaffold' | 'alias' | 'group';
|
|
7
|
+
run?: string;
|
|
8
|
+
flags?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readManifest(p: string): Promise<Manifest | null> {
|
|
12
|
+
try {
|
|
13
|
+
return await fs.readJson(p);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isCappedManifest(m: Manifest): boolean {
|
|
20
|
+
return !!m.run || m.type === 'scaffold' || m.type === 'alias';
|
|
21
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { findLocalRoot, getGlobalRoot } from './fs-utils';
|
|
4
|
+
import { Manifest, readManifest, isCappedManifest } from './manifest';
|
|
5
|
+
|
|
6
|
+
export interface ResolvedCommand {
|
|
7
|
+
manifestPath: string;
|
|
8
|
+
manifest: Manifest;
|
|
9
|
+
args: string[]; // remaining args
|
|
10
|
+
scope: 'local' | 'global';
|
|
11
|
+
cmdPath: string; // The command path e.g. "dev start"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function resolveCommand(args: string[], options: { cwd?: string, globalDir?: string, global?: boolean } = {}): Promise<ResolvedCommand | null> {
|
|
15
|
+
const cwd = options.cwd || process.cwd();
|
|
16
|
+
const localRoot = !options.global ? findLocalRoot(cwd) : null;
|
|
17
|
+
const globalRoot = options.globalDir || getGlobalRoot();
|
|
18
|
+
|
|
19
|
+
const localAgentctl = localRoot ? path.join(localRoot, '.agentctl') : null;
|
|
20
|
+
|
|
21
|
+
let currentMatch: ResolvedCommand | null = null;
|
|
22
|
+
|
|
23
|
+
// Iterate through args to find longest match
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
// Path corresponding to args[0..i]
|
|
26
|
+
const currentArgs = args.slice(0, i + 1);
|
|
27
|
+
const relPath = currentArgs.join(path.sep);
|
|
28
|
+
const cmdPath = currentArgs.join(' ');
|
|
29
|
+
|
|
30
|
+
const localPath = localAgentctl ? path.join(localAgentctl, relPath) : null;
|
|
31
|
+
const globalPath = path.join(globalRoot, relPath);
|
|
32
|
+
|
|
33
|
+
let localManifest: Manifest | null = null;
|
|
34
|
+
let globalManifest: Manifest | null = null;
|
|
35
|
+
|
|
36
|
+
// check local
|
|
37
|
+
if (localPath && await fs.pathExists(localPath)) {
|
|
38
|
+
const mPath = path.join(localPath, 'manifest.json');
|
|
39
|
+
if (await fs.pathExists(mPath)) {
|
|
40
|
+
localManifest = await readManifest(mPath);
|
|
41
|
+
}
|
|
42
|
+
if (!localManifest && (await fs.stat(localPath)).isDirectory()) {
|
|
43
|
+
// Implicit group
|
|
44
|
+
localManifest = { name: args[i], type: 'group' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// check global
|
|
49
|
+
if (await fs.pathExists(globalPath)) {
|
|
50
|
+
const mPath = path.join(globalPath, 'manifest.json');
|
|
51
|
+
if (await fs.pathExists(mPath)) {
|
|
52
|
+
globalManifest = await readManifest(mPath);
|
|
53
|
+
}
|
|
54
|
+
if (!globalManifest && (await fs.stat(globalPath)).isDirectory()) {
|
|
55
|
+
globalManifest = { name: args[i], type: 'group' };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!localManifest && !globalManifest) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const remainingArgs = args.slice(i + 1);
|
|
64
|
+
|
|
65
|
+
// Priority logic
|
|
66
|
+
// 1. Local Capped -> Return Match immediately.
|
|
67
|
+
if (localManifest && isCappedManifest(localManifest)) {
|
|
68
|
+
return {
|
|
69
|
+
manifest: localManifest,
|
|
70
|
+
manifestPath: path.join(localPath!, 'manifest.json'),
|
|
71
|
+
args: remainingArgs,
|
|
72
|
+
scope: 'local',
|
|
73
|
+
cmdPath
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Global Capped
|
|
78
|
+
if (globalManifest && isCappedManifest(globalManifest)) {
|
|
79
|
+
// Check if shadowed by Local Group
|
|
80
|
+
if (localManifest) {
|
|
81
|
+
// Local exists (must be group since checked capped above).
|
|
82
|
+
// Shadowed. Treat as Local Group.
|
|
83
|
+
currentMatch = {
|
|
84
|
+
manifest: localManifest,
|
|
85
|
+
manifestPath: path.join(localPath!, 'manifest.json'),
|
|
86
|
+
args: remainingArgs,
|
|
87
|
+
scope: 'local',
|
|
88
|
+
cmdPath
|
|
89
|
+
};
|
|
90
|
+
} else {
|
|
91
|
+
// Not shadowed. Global Capped wins. Return immediately.
|
|
92
|
+
return {
|
|
93
|
+
manifest: globalManifest,
|
|
94
|
+
manifestPath: path.join(globalPath, 'manifest.json'),
|
|
95
|
+
args: remainingArgs,
|
|
96
|
+
scope: 'global',
|
|
97
|
+
cmdPath
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
// Neither is capped. Both are groups (or one is).
|
|
102
|
+
// Local wins if exists.
|
|
103
|
+
if (localManifest) {
|
|
104
|
+
currentMatch = {
|
|
105
|
+
manifest: localManifest,
|
|
106
|
+
manifestPath: path.join(localPath!, 'manifest.json'),
|
|
107
|
+
args: remainingArgs,
|
|
108
|
+
scope: 'local',
|
|
109
|
+
cmdPath
|
|
110
|
+
};
|
|
111
|
+
} else {
|
|
112
|
+
currentMatch = {
|
|
113
|
+
manifest: globalManifest!,
|
|
114
|
+
manifestPath: path.join(globalPath, 'manifest.json'),
|
|
115
|
+
args: remainingArgs,
|
|
116
|
+
scope: 'global',
|
|
117
|
+
cmdPath
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return currentMatch;
|
|
124
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
|
|
4
|
+
export const SUPPORTED_AGENTS = ['cursor', 'antigravity', 'agentsmd', 'gemini'];
|
|
5
|
+
|
|
6
|
+
export async function copySkill(targetDir: string, agent: string) {
|
|
7
|
+
// We assume the skill file is located at ../skills/agentctl/SKILL.md relative to specific dist/src/ location
|
|
8
|
+
// Or we find it in the project root if running from source.
|
|
9
|
+
// In production (dist), structure might be:
|
|
10
|
+
// dist/index.js
|
|
11
|
+
// skills/agentctl/SKILL.md (if we copy it to dist)
|
|
12
|
+
|
|
13
|
+
// Let's try to locate the source SKILL.md
|
|
14
|
+
// If we are in /src, it is in ../skills/agentctl/SKILL.md
|
|
15
|
+
// If we are in /dist/src (tsc default?), it depends on build.
|
|
16
|
+
|
|
17
|
+
// Robust finding:
|
|
18
|
+
let sourcePath = path.resolve(__dirname, '../../skills/agentctl/SKILL.md');
|
|
19
|
+
|
|
20
|
+
if (!fs.existsSync(sourcePath)) {
|
|
21
|
+
// Try looking in src check (dev mode)
|
|
22
|
+
sourcePath = path.resolve(__dirname, '../skills/agentctl/SKILL.md');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(sourcePath)) {
|
|
26
|
+
// Fallback for when running from dist/src
|
|
27
|
+
sourcePath = path.resolve(__dirname, '../../../skills/agentctl/SKILL.md');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!fs.existsSync(sourcePath)) {
|
|
31
|
+
throw new Error(`Could not locate source SKILL.md. Checked: ${path.resolve(__dirname, '../../skills/agentctl/SKILL.md')}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await fs.ensureDir(targetDir);
|
|
35
|
+
|
|
36
|
+
// Determine filename
|
|
37
|
+
const filename = agent === 'cursor' ? 'agentctl.md' : 'SKILL.md';
|
|
38
|
+
const targetFile = path.join(targetDir, filename);
|
|
39
|
+
|
|
40
|
+
await fs.copy(sourcePath, targetFile, { overwrite: true });
|
|
41
|
+
return targetFile;
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { createTestDir, cleanupTestDir } from './helpers';
|
|
5
|
+
import { alias } from '../src/ctl';
|
|
6
|
+
|
|
7
|
+
describe('ctl alias', () => {
|
|
8
|
+
let cwd: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
cwd = await createTestDir();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await cleanupTestDir(cwd);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('creates an alias manifest without script', async () => {
|
|
19
|
+
await alias(['tools', 'gh'], 'gh', { cwd });
|
|
20
|
+
|
|
21
|
+
const cmdDir = path.join(cwd, '.agentctl', 'tools', 'gh');
|
|
22
|
+
expect(await fs.pathExists(cmdDir)).toBe(true);
|
|
23
|
+
|
|
24
|
+
const manifestPath = path.join(cmdDir, 'manifest.json');
|
|
25
|
+
expect(await fs.pathExists(manifestPath)).toBe(true);
|
|
26
|
+
const manifest = await fs.readJson(manifestPath);
|
|
27
|
+
|
|
28
|
+
expect(manifest).toEqual(expect.objectContaining({
|
|
29
|
+
name: 'gh',
|
|
30
|
+
type: 'alias',
|
|
31
|
+
run: 'gh',
|
|
32
|
+
description: '',
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const scriptPath = path.join(cmdDir, 'command.sh');
|
|
36
|
+
const cmdPath = path.join(cmdDir, 'command.cmd');
|
|
37
|
+
expect(await fs.pathExists(scriptPath)).toBe(false);
|
|
38
|
+
expect(await fs.pathExists(cmdPath)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('fails if command already exists', async () => {
|
|
42
|
+
const cmdDir = path.join(cwd, '.agentctl', 'tools', 'gh');
|
|
43
|
+
await fs.ensureDir(cmdDir);
|
|
44
|
+
|
|
45
|
+
await expect(alias(['tools', 'gh'], 'gh', { cwd }))
|
|
46
|
+
.rejects.toThrow(/already exists/);
|
|
47
|
+
});
|
|
48
|
+
});
|