cli4ai 1.0.4 → 1.1.1
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/package.json +3 -2
- package/src/cli.ts +94 -1
- package/src/commands/remotes.ts +253 -0
- package/src/commands/routines.ts +27 -8
- package/src/commands/run.ts +64 -1
- package/src/commands/serve.ts +66 -0
- package/src/core/config.ts +9 -2
- package/src/core/link.ts +17 -1
- package/src/core/remote-client.ts +419 -0
- package/src/core/remotes.ts +268 -0
- package/src/core/routine-engine.ts +91 -23
- package/src/core/routines.ts +9 -6
- package/src/core/scheduler.ts +2 -2
- package/src/lib/cli.ts +8 -1
- package/src/server/service.ts +434 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cli4ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "The package manager for AI CLI tools - cli4ai.com",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"commander": "^14.0.0",
|
|
18
18
|
"cron-parser": "^4.9.0",
|
|
19
|
-
"semver": "^7.6.0"
|
|
19
|
+
"semver": "^7.6.0",
|
|
20
|
+
"yaml": "^2.8.2"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@types/node": "^22.0.0",
|
package/src/cli.ts
CHANGED
|
@@ -34,6 +34,16 @@ import {
|
|
|
34
34
|
schedulerHistoryCommand,
|
|
35
35
|
schedulerRunCommand
|
|
36
36
|
} from './commands/scheduler.js';
|
|
37
|
+
import {
|
|
38
|
+
remotesListCommand,
|
|
39
|
+
remotesAddCommand,
|
|
40
|
+
remotesUpdateCommand,
|
|
41
|
+
remotesRemoveCommand,
|
|
42
|
+
remotesShowCommand,
|
|
43
|
+
remotesTestCommand,
|
|
44
|
+
remotesPackagesCommand
|
|
45
|
+
} from './commands/remotes.js';
|
|
46
|
+
import { serveCommand } from './commands/serve.js';
|
|
37
47
|
|
|
38
48
|
export function createProgram(): Command {
|
|
39
49
|
const program = new Command()
|
|
@@ -96,6 +106,8 @@ export function createProgram(): Command {
|
|
|
96
106
|
.option('-e, --env <vars...>', 'Environment variables (KEY=value)')
|
|
97
107
|
.option('--scope <level>', 'Permission scope: read, write, or full (default: full)')
|
|
98
108
|
.option('--sandbox', 'Run in sandboxed environment with restricted file system access')
|
|
109
|
+
.option('-r, --remote <name>', 'Execute on a remote cli4ai service')
|
|
110
|
+
.option('--timeout <ms>', 'Timeout in milliseconds')
|
|
99
111
|
// Allow passing tool flags through (e.g. `cli4ai run chrome screenshot --full-page`)
|
|
100
112
|
.allowUnknownOption(true)
|
|
101
113
|
.addHelpText('after', `
|
|
@@ -105,6 +117,7 @@ Examples:
|
|
|
105
117
|
cli4ai run chrome screenshot https://example.com --full-page
|
|
106
118
|
cli4ai run github list-issues --scope read
|
|
107
119
|
cli4ai run untrusted-pkg process --sandbox
|
|
120
|
+
cli4ai run browser screenshot --remote chrome-server
|
|
108
121
|
|
|
109
122
|
Pass-through:
|
|
110
123
|
Use "--" to pass flags that would otherwise be parsed by cli4ai:
|
|
@@ -115,6 +128,10 @@ Security:
|
|
|
115
128
|
--scope write Allow write operations
|
|
116
129
|
--scope full Full access (default)
|
|
117
130
|
--sandbox Restrict file system access to temp directories
|
|
131
|
+
|
|
132
|
+
Remote Execution:
|
|
133
|
+
--remote <name> Execute on a configured remote service
|
|
134
|
+
Use "cli4ai remotes add" to configure remotes
|
|
118
135
|
`)
|
|
119
136
|
.action(withErrorHandling(runCommand));
|
|
120
137
|
|
|
@@ -246,7 +263,7 @@ Security:
|
|
|
246
263
|
.command('create <name>')
|
|
247
264
|
.description('Create a new routine')
|
|
248
265
|
.option('-g, --global', 'Create routine globally')
|
|
249
|
-
.option('--type <type>', 'Routine type (
|
|
266
|
+
.option('--type <type>', 'Routine type (yaml, json, bash)', 'yaml')
|
|
250
267
|
.action(withErrorHandling(routinesCreateCommand));
|
|
251
268
|
|
|
252
269
|
routines
|
|
@@ -312,5 +329,81 @@ Security:
|
|
|
312
329
|
.description('Manually trigger a scheduled routine')
|
|
313
330
|
.action(withErrorHandling(schedulerRunCommand));
|
|
314
331
|
|
|
332
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
333
|
+
// REMOTE EXECUTION
|
|
334
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
335
|
+
|
|
336
|
+
program
|
|
337
|
+
.command('serve')
|
|
338
|
+
.description('Start cli4ai as a remote service')
|
|
339
|
+
.option('-p, --port <port>', 'Port to listen on', '4100')
|
|
340
|
+
.option('-H, --host <host>', 'Host to bind to', '0.0.0.0')
|
|
341
|
+
.option('-k, --api-key <key>', 'API key for authentication')
|
|
342
|
+
.option('--scope <levels>', 'Comma-separated allowed scopes (read,write,full)')
|
|
343
|
+
.option('--cwd <dir>', 'Working directory for execution')
|
|
344
|
+
.addHelpText('after', `
|
|
345
|
+
|
|
346
|
+
Examples:
|
|
347
|
+
cli4ai serve # Start on port 4100
|
|
348
|
+
cli4ai serve --port 8080 # Start on port 8080
|
|
349
|
+
cli4ai serve --api-key mysecretkey # Require API key
|
|
350
|
+
cli4ai serve --scope read,write # Restrict to read/write operations
|
|
351
|
+
|
|
352
|
+
The service exposes these endpoints:
|
|
353
|
+
GET /health - Service health check
|
|
354
|
+
GET /packages - List available packages
|
|
355
|
+
GET /packages/:name - Get package info
|
|
356
|
+
POST /run - Execute a tool
|
|
357
|
+
POST /routines/:name/run - Run a routine
|
|
358
|
+
`)
|
|
359
|
+
.action(withErrorHandling(serveCommand));
|
|
360
|
+
|
|
361
|
+
const remotes = program
|
|
362
|
+
.command('remotes')
|
|
363
|
+
.description('Manage remote cli4ai services');
|
|
364
|
+
|
|
365
|
+
remotes
|
|
366
|
+
.command('list')
|
|
367
|
+
.alias('ls')
|
|
368
|
+
.description('List configured remotes')
|
|
369
|
+
.action(withErrorHandling(remotesListCommand));
|
|
370
|
+
|
|
371
|
+
remotes
|
|
372
|
+
.command('add <name> <url>')
|
|
373
|
+
.description('Add a remote service')
|
|
374
|
+
.option('-k, --api-key <key>', 'API key for authentication')
|
|
375
|
+
.option('-d, --description <desc>', 'Description of this remote')
|
|
376
|
+
.option('--no-test', 'Skip connection test')
|
|
377
|
+
.action(withErrorHandling(remotesAddCommand));
|
|
378
|
+
|
|
379
|
+
remotes
|
|
380
|
+
.command('update <name>')
|
|
381
|
+
.description('Update a remote service')
|
|
382
|
+
.option('-u, --url <url>', 'New URL')
|
|
383
|
+
.option('-k, --api-key <key>', 'New API key')
|
|
384
|
+
.option('-d, --description <desc>', 'New description')
|
|
385
|
+
.action(withErrorHandling(remotesUpdateCommand));
|
|
386
|
+
|
|
387
|
+
remotes
|
|
388
|
+
.command('remove <name>')
|
|
389
|
+
.alias('rm')
|
|
390
|
+
.description('Remove a remote')
|
|
391
|
+
.action(withErrorHandling(remotesRemoveCommand));
|
|
392
|
+
|
|
393
|
+
remotes
|
|
394
|
+
.command('show <name>')
|
|
395
|
+
.description('Show remote details')
|
|
396
|
+
.action(withErrorHandling(remotesShowCommand));
|
|
397
|
+
|
|
398
|
+
remotes
|
|
399
|
+
.command('test <name>')
|
|
400
|
+
.description('Test connection to a remote')
|
|
401
|
+
.action(withErrorHandling(remotesTestCommand));
|
|
402
|
+
|
|
403
|
+
remotes
|
|
404
|
+
.command('packages <name>')
|
|
405
|
+
.description('List packages on a remote')
|
|
406
|
+
.action(withErrorHandling(remotesPackagesCommand));
|
|
407
|
+
|
|
315
408
|
return program;
|
|
316
409
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote hosts management command.
|
|
3
|
+
*
|
|
4
|
+
* Manage connections to remote cli4ai instances for distributed execution.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { output, outputError, log } from '../lib/cli.js';
|
|
8
|
+
import {
|
|
9
|
+
getRemotes,
|
|
10
|
+
getRemote,
|
|
11
|
+
addRemote,
|
|
12
|
+
updateRemote,
|
|
13
|
+
removeRemote,
|
|
14
|
+
RemoteNotFoundError,
|
|
15
|
+
RemoteAlreadyExistsError,
|
|
16
|
+
InvalidRemoteUrlError
|
|
17
|
+
} from '../core/remotes.js';
|
|
18
|
+
import { testRemoteConnection, remoteListPackages } from '../core/remote-client.js';
|
|
19
|
+
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
// LIST REMOTES
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
export async function remotesListCommand(): Promise<void> {
|
|
25
|
+
const remotes = getRemotes();
|
|
26
|
+
|
|
27
|
+
if (remotes.length === 0) {
|
|
28
|
+
output({ remotes: [], message: 'No remotes configured. Use "cli4ai remotes add <name> <url>" to add one.' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
output({
|
|
33
|
+
remotes: remotes.map(r => ({
|
|
34
|
+
name: r.name,
|
|
35
|
+
url: r.url,
|
|
36
|
+
description: r.description,
|
|
37
|
+
hasApiKey: !!r.apiKey,
|
|
38
|
+
addedAt: r.addedAt,
|
|
39
|
+
lastConnected: r.lastConnected
|
|
40
|
+
}))
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
45
|
+
// ADD REMOTE
|
|
46
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
export interface AddRemoteOptions {
|
|
49
|
+
apiKey?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
test?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function remotesAddCommand(
|
|
55
|
+
name: string,
|
|
56
|
+
url: string,
|
|
57
|
+
options: AddRemoteOptions
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
const remote = addRemote(name, url, {
|
|
61
|
+
apiKey: options.apiKey,
|
|
62
|
+
description: options.description
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
log(`Added remote: ${remote.name} -> ${remote.url}`);
|
|
66
|
+
|
|
67
|
+
// Test connection if requested
|
|
68
|
+
if (options.test !== false) {
|
|
69
|
+
log('Testing connection...');
|
|
70
|
+
const result = await testRemoteConnection(name);
|
|
71
|
+
|
|
72
|
+
if (result.success) {
|
|
73
|
+
log(`Connection successful: ${result.message}`);
|
|
74
|
+
output({
|
|
75
|
+
remote: {
|
|
76
|
+
name: remote.name,
|
|
77
|
+
url: remote.url,
|
|
78
|
+
description: remote.description,
|
|
79
|
+
hasApiKey: !!remote.apiKey
|
|
80
|
+
},
|
|
81
|
+
connectionTest: result
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
log(`Warning: Connection failed - ${result.message}`);
|
|
85
|
+
log('The remote was added but could not be reached. Check the URL and try again.');
|
|
86
|
+
output({
|
|
87
|
+
remote: {
|
|
88
|
+
name: remote.name,
|
|
89
|
+
url: remote.url,
|
|
90
|
+
description: remote.description,
|
|
91
|
+
hasApiKey: !!remote.apiKey
|
|
92
|
+
},
|
|
93
|
+
connectionTest: result
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
output({
|
|
98
|
+
remote: {
|
|
99
|
+
name: remote.name,
|
|
100
|
+
url: remote.url,
|
|
101
|
+
description: remote.description,
|
|
102
|
+
hasApiKey: !!remote.apiKey
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err instanceof RemoteAlreadyExistsError) {
|
|
108
|
+
outputError('ALREADY_EXISTS', `Remote "${err.remoteName}" already exists. Use "cli4ai remotes update" to modify it.`);
|
|
109
|
+
}
|
|
110
|
+
if (err instanceof InvalidRemoteUrlError) {
|
|
111
|
+
outputError('INVALID_INPUT', `Invalid URL "${err.url}": ${err.reason}`);
|
|
112
|
+
}
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
118
|
+
// UPDATE REMOTE
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
export interface UpdateRemoteOptions {
|
|
122
|
+
url?: string;
|
|
123
|
+
apiKey?: string;
|
|
124
|
+
description?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function remotesUpdateCommand(
|
|
128
|
+
name: string,
|
|
129
|
+
options: UpdateRemoteOptions
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
try {
|
|
132
|
+
// Check if any update provided
|
|
133
|
+
if (!options.url && !options.apiKey && !options.description) {
|
|
134
|
+
outputError('INVALID_INPUT', 'No updates provided. Use --url, --api-key, or --description.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const remote = updateRemote(name, options);
|
|
138
|
+
|
|
139
|
+
output({
|
|
140
|
+
updated: true,
|
|
141
|
+
remote: {
|
|
142
|
+
name: remote.name,
|
|
143
|
+
url: remote.url,
|
|
144
|
+
description: remote.description,
|
|
145
|
+
hasApiKey: !!remote.apiKey
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err instanceof RemoteNotFoundError) {
|
|
150
|
+
outputError('NOT_FOUND', `Remote "${err.remoteName}" not found`);
|
|
151
|
+
}
|
|
152
|
+
if (err instanceof InvalidRemoteUrlError) {
|
|
153
|
+
outputError('INVALID_INPUT', `Invalid URL "${err.url}": ${err.reason}`);
|
|
154
|
+
}
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
160
|
+
// REMOVE REMOTE
|
|
161
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
162
|
+
|
|
163
|
+
export async function remotesRemoveCommand(name: string): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
removeRemote(name);
|
|
166
|
+
output({ removed: true, name });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err instanceof RemoteNotFoundError) {
|
|
169
|
+
outputError('NOT_FOUND', `Remote "${err.remoteName}" not found`);
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
176
|
+
// SHOW REMOTE
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
178
|
+
|
|
179
|
+
export async function remotesShowCommand(name: string): Promise<void> {
|
|
180
|
+
const remote = getRemote(name);
|
|
181
|
+
|
|
182
|
+
if (!remote) {
|
|
183
|
+
outputError('NOT_FOUND', `Remote "${name}" not found`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
output({
|
|
187
|
+
name: remote.name,
|
|
188
|
+
url: remote.url,
|
|
189
|
+
description: remote.description,
|
|
190
|
+
hasApiKey: !!remote.apiKey,
|
|
191
|
+
addedAt: remote.addedAt,
|
|
192
|
+
lastConnected: remote.lastConnected
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
197
|
+
// TEST REMOTE
|
|
198
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
199
|
+
|
|
200
|
+
export async function remotesTestCommand(name: string): Promise<void> {
|
|
201
|
+
const remote = getRemote(name);
|
|
202
|
+
|
|
203
|
+
if (!remote) {
|
|
204
|
+
outputError('NOT_FOUND', `Remote "${name}" not found`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
log(`Testing connection to ${remote.name} (${remote.url})...`);
|
|
208
|
+
|
|
209
|
+
const result = await testRemoteConnection(name);
|
|
210
|
+
|
|
211
|
+
if (result.success) {
|
|
212
|
+
log(`Connection successful!`);
|
|
213
|
+
output({
|
|
214
|
+
name,
|
|
215
|
+
url: remote.url,
|
|
216
|
+
connected: true,
|
|
217
|
+
...result.details
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
log(`Connection failed: ${result.message}`);
|
|
221
|
+
output({
|
|
222
|
+
name,
|
|
223
|
+
url: remote.url,
|
|
224
|
+
connected: false,
|
|
225
|
+
error: result.message
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
231
|
+
// LIST PACKAGES ON REMOTE
|
|
232
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
233
|
+
|
|
234
|
+
export async function remotesPackagesCommand(name: string): Promise<void> {
|
|
235
|
+
const remote = getRemote(name);
|
|
236
|
+
|
|
237
|
+
if (!remote) {
|
|
238
|
+
outputError('NOT_FOUND', `Remote "${name}" not found`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const result = await remoteListPackages(name);
|
|
243
|
+
output({
|
|
244
|
+
remote: name,
|
|
245
|
+
packages: result.packages
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err instanceof Error) {
|
|
249
|
+
outputError('API_ERROR', `Failed to list packages on remote "${name}": ${err.message}`);
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
}
|
package/src/commands/routines.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { spawn, spawnSync } from 'child_process';
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, chmodSync, rmSync } from 'fs';
|
|
7
7
|
import { resolve } from 'path';
|
|
8
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
8
9
|
import { output, outputError, log } from '../lib/cli.js';
|
|
9
10
|
import { ensureCli4aiHome, ensureLocalDir, ROUTINES_DIR, LOCAL_ROUTINES_DIR } from '../core/config.js';
|
|
10
11
|
import { getGlobalRoutines, getLocalRoutines, resolveRoutine, validateRoutineName, type RoutineInfo } from '../core/routines.js';
|
|
@@ -23,7 +24,7 @@ interface RoutinesRunOptions {
|
|
|
23
24
|
|
|
24
25
|
interface RoutinesCreateOptions {
|
|
25
26
|
global?: boolean;
|
|
26
|
-
type?: 'json' | 'bash';
|
|
27
|
+
type?: 'yaml' | 'json' | 'bash';
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function ensureValidRoutineName(name: string): void {
|
|
@@ -139,22 +140,37 @@ function getTargetDir(global: boolean, projectDir: string): string {
|
|
|
139
140
|
export async function routinesCreateCommand(name: string, options: RoutinesCreateOptions): Promise<void> {
|
|
140
141
|
ensureValidRoutineName(name);
|
|
141
142
|
const projectDir = process.cwd();
|
|
142
|
-
const type = options.type ?? '
|
|
143
|
-
if (type !== 'bash' && type !== 'json') {
|
|
143
|
+
const type = options.type ?? 'yaml';
|
|
144
|
+
if (type !== 'yaml' && type !== 'bash' && type !== 'json') {
|
|
144
145
|
outputError('INVALID_INPUT', `Invalid routine type: ${type}`, {
|
|
145
|
-
allowed: ['
|
|
146
|
+
allowed: ['yaml', 'json', 'bash']
|
|
146
147
|
});
|
|
147
148
|
}
|
|
148
149
|
const dir = getTargetDir(!!options.global, projectDir);
|
|
149
150
|
|
|
150
|
-
const
|
|
151
|
+
const suffixMap: Record<string, string> = {
|
|
152
|
+
yaml: '.routine.yaml',
|
|
153
|
+
json: '.routine.json',
|
|
154
|
+
bash: '.routine.sh'
|
|
155
|
+
};
|
|
156
|
+
const suffix = suffixMap[type];
|
|
151
157
|
const filePath = resolve(dir, `${name}${suffix}`);
|
|
152
158
|
|
|
153
159
|
if (existsSync(filePath)) {
|
|
154
160
|
outputError('INVALID_INPUT', `Routine already exists: ${name}`, { path: filePath });
|
|
155
161
|
}
|
|
156
162
|
|
|
157
|
-
if (type === '
|
|
163
|
+
if (type === 'yaml') {
|
|
164
|
+
const template = {
|
|
165
|
+
version: 1,
|
|
166
|
+
name,
|
|
167
|
+
description: 'TODO: describe this routine',
|
|
168
|
+
vars: {},
|
|
169
|
+
steps: [],
|
|
170
|
+
result: '{{steps}}'
|
|
171
|
+
};
|
|
172
|
+
writeFileSync(filePath, stringifyYaml(template));
|
|
173
|
+
} else if (type === 'json') {
|
|
158
174
|
const template = {
|
|
159
175
|
version: 1,
|
|
160
176
|
name,
|
|
@@ -208,8 +224,11 @@ export async function routinesRunCommand(
|
|
|
208
224
|
|
|
209
225
|
const vars = parseVars(options.var);
|
|
210
226
|
|
|
227
|
+
// Structured routines (YAML or JSON) use the routine engine
|
|
228
|
+
const isStructured = routine.kind === 'yaml' || routine.kind === 'json';
|
|
229
|
+
|
|
211
230
|
if (options.dryRun) {
|
|
212
|
-
if (
|
|
231
|
+
if (isStructured) {
|
|
213
232
|
try {
|
|
214
233
|
const def = loadRoutineDefinition(routine.path);
|
|
215
234
|
const plan = await dryRunRoutine(def, vars, process.cwd());
|
|
@@ -252,7 +271,7 @@ export async function routinesRunCommand(
|
|
|
252
271
|
return;
|
|
253
272
|
}
|
|
254
273
|
|
|
255
|
-
if (
|
|
274
|
+
if (isStructured) {
|
|
256
275
|
try {
|
|
257
276
|
const def = loadRoutineDefinition(routine.path);
|
|
258
277
|
const summary = await runRoutine(def, vars, process.cwd());
|
package/src/commands/run.ts
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
* cli4ai run - Execute a tool command
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { outputError } from '../lib/cli.js';
|
|
5
|
+
import { output, outputError, log } from '../lib/cli.js';
|
|
6
6
|
import { executeTool, ExecuteToolError, type ScopeLevel } from '../core/execute.js';
|
|
7
|
+
import { remoteRunTool, RemoteConnectionError, RemoteApiError } from '../core/remote-client.js';
|
|
8
|
+
import { getRemote } from '../core/remotes.js';
|
|
7
9
|
|
|
8
10
|
interface RunOptions {
|
|
9
11
|
env?: string[];
|
|
10
12
|
scope?: string;
|
|
11
13
|
sandbox?: boolean;
|
|
14
|
+
remote?: string;
|
|
15
|
+
timeout?: string;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export async function runCommand(
|
|
@@ -41,6 +45,64 @@ export async function runCommand(
|
|
|
41
45
|
scope = options.scope as ScopeLevel;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
// Parse timeout
|
|
49
|
+
let timeout: number | undefined;
|
|
50
|
+
if (options.timeout) {
|
|
51
|
+
timeout = parseInt(options.timeout, 10);
|
|
52
|
+
if (isNaN(timeout) || timeout < 0) {
|
|
53
|
+
outputError('INVALID_INPUT', 'Timeout must be a positive number (milliseconds)');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Remote execution
|
|
58
|
+
if (options.remote) {
|
|
59
|
+
const remote = getRemote(options.remote);
|
|
60
|
+
if (!remote) {
|
|
61
|
+
outputError('NOT_FOUND', `Remote "${options.remote}" not found`, {
|
|
62
|
+
hint: 'Use "cli4ai remotes add <name> <url>" to configure a remote'
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
log(`Executing on remote: ${remote.name} (${remote.url})`);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const result = await remoteRunTool(options.remote, {
|
|
70
|
+
package: packageName,
|
|
71
|
+
command,
|
|
72
|
+
args,
|
|
73
|
+
env: Object.keys(extraEnv).length > 0 ? extraEnv : undefined,
|
|
74
|
+
timeout,
|
|
75
|
+
scope
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Output stdout/stderr
|
|
79
|
+
if (result.stdout) {
|
|
80
|
+
process.stdout.write(result.stdout);
|
|
81
|
+
}
|
|
82
|
+
if (result.stderr) {
|
|
83
|
+
process.stderr.write(result.stderr);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
process.exitCode = result.exitCode;
|
|
87
|
+
|
|
88
|
+
if (!result.success && result.error) {
|
|
89
|
+
log(`Remote error: ${result.error.message}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err instanceof RemoteConnectionError) {
|
|
95
|
+
outputError('NETWORK_ERROR', err.message, { remote: options.remote, url: remote.url });
|
|
96
|
+
}
|
|
97
|
+
if (err instanceof RemoteApiError) {
|
|
98
|
+
outputError(err.code, err.message, err.details);
|
|
99
|
+
}
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
outputError('API_ERROR', message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Local execution
|
|
44
106
|
try {
|
|
45
107
|
const result = await executeTool({
|
|
46
108
|
packageName,
|
|
@@ -49,6 +111,7 @@ export async function runCommand(
|
|
|
49
111
|
cwd: process.cwd(),
|
|
50
112
|
env: extraEnv,
|
|
51
113
|
capture: 'inherit',
|
|
114
|
+
timeoutMs: timeout,
|
|
52
115
|
scope,
|
|
53
116
|
sandbox: options.sandbox ?? false
|
|
54
117
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve command - Start cli4ai as a remote service.
|
|
3
|
+
*
|
|
4
|
+
* Allows other cli4ai instances to execute tools on this machine.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { output, outputError, log } from '../lib/cli.js';
|
|
8
|
+
import { startService, type StartServiceOptions } from '../server/service.js';
|
|
9
|
+
import type { ScopeLevel } from '../core/execute.js';
|
|
10
|
+
|
|
11
|
+
export interface ServeCommandOptions {
|
|
12
|
+
port?: string;
|
|
13
|
+
host?: string;
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
scope?: string;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function serveCommand(options: ServeCommandOptions): Promise<void> {
|
|
20
|
+
const serviceOptions: StartServiceOptions = {
|
|
21
|
+
cwd: options.cwd ?? process.cwd()
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Parse port
|
|
25
|
+
if (options.port) {
|
|
26
|
+
const port = parseInt(options.port, 10);
|
|
27
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
28
|
+
outputError('INVALID_INPUT', 'Port must be a number between 1 and 65535');
|
|
29
|
+
}
|
|
30
|
+
serviceOptions.port = port;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Parse host
|
|
34
|
+
if (options.host) {
|
|
35
|
+
serviceOptions.host = options.host;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// API key
|
|
39
|
+
if (options.apiKey) {
|
|
40
|
+
serviceOptions.apiKey = options.apiKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parse allowed scopes
|
|
44
|
+
if (options.scope) {
|
|
45
|
+
const scopes = options.scope.split(',').map(s => s.trim()) as ScopeLevel[];
|
|
46
|
+
const validScopes: ScopeLevel[] = ['read', 'write', 'full'];
|
|
47
|
+
|
|
48
|
+
for (const scope of scopes) {
|
|
49
|
+
if (!validScopes.includes(scope)) {
|
|
50
|
+
outputError('INVALID_INPUT', `Invalid scope "${scope}". Must be one of: read, write, full`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
serviceOptions.allowedScopes = scopes;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await startService(serviceOptions);
|
|
59
|
+
output({ status: 'stopped' });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err instanceof Error) {
|
|
62
|
+
outputError('API_ERROR', err.message);
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -349,7 +349,14 @@ export function addLocalRegistry(path: string): void {
|
|
|
349
349
|
*/
|
|
350
350
|
export function removeLocalRegistry(path: string): void {
|
|
351
351
|
const absolutePath = resolve(path);
|
|
352
|
-
|
|
352
|
+
|
|
353
|
+
// SECURITY: Validate symlink target consistently with addLocalRegistry
|
|
354
|
+
const safePath = validateSymlinkTarget(absolutePath);
|
|
355
|
+
if (!safePath) {
|
|
356
|
+
outputError('INVALID_INPUT', `Registry path points to an unsafe location: ${absolutePath}`, {
|
|
357
|
+
hint: 'Symlinks must point to directories within safe locations'
|
|
358
|
+
});
|
|
359
|
+
}
|
|
353
360
|
|
|
354
361
|
let removed = false;
|
|
355
362
|
updateConfig((config) => {
|
|
@@ -551,7 +558,7 @@ export function getGlobalPackages(): InstalledPackage[] {
|
|
|
551
558
|
name: manifest.name,
|
|
552
559
|
version: manifest.version,
|
|
553
560
|
path: safePath,
|
|
554
|
-
source: '
|
|
561
|
+
source: 'registry',
|
|
555
562
|
installedAt: new Date().toISOString()
|
|
556
563
|
});
|
|
557
564
|
} catch {
|