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
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli4ai Remote Service
|
|
3
|
+
*
|
|
4
|
+
* HTTP server that exposes cli4ai functionality for remote execution.
|
|
5
|
+
* Run with `cli4ai serve` to start the service.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
|
9
|
+
import { hostname } from 'os';
|
|
10
|
+
import { log, output } from '../lib/cli.js';
|
|
11
|
+
import { executeTool, ExecuteToolError, type ScopeLevel } from '../core/execute.js';
|
|
12
|
+
import { findPackage, getGlobalPackages, getLocalPackages } from '../core/config.js';
|
|
13
|
+
import { loadManifest } from '../core/manifest.js';
|
|
14
|
+
import {
|
|
15
|
+
loadRoutineDefinition,
|
|
16
|
+
runRoutine,
|
|
17
|
+
type RoutineRunSummary
|
|
18
|
+
} from '../core/routine-engine.js';
|
|
19
|
+
import { resolveRoutine } from '../core/routines.js';
|
|
20
|
+
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
22
|
+
// TYPES
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
export interface ServiceConfig {
|
|
26
|
+
/** Port to listen on (default: 4100) */
|
|
27
|
+
port: number;
|
|
28
|
+
/** Host to bind to (default: 0.0.0.0) */
|
|
29
|
+
host: string;
|
|
30
|
+
/** API key for authentication (optional but recommended) */
|
|
31
|
+
apiKey?: string;
|
|
32
|
+
/** Working directory for command execution */
|
|
33
|
+
cwd: string;
|
|
34
|
+
/** Allowed scope levels (defaults to ['read', 'write', 'full']) */
|
|
35
|
+
allowedScopes?: ScopeLevel[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RunToolRequest {
|
|
39
|
+
/** Package name */
|
|
40
|
+
package: string;
|
|
41
|
+
/** Command within the package */
|
|
42
|
+
command?: string;
|
|
43
|
+
/** Arguments to pass */
|
|
44
|
+
args?: string[];
|
|
45
|
+
/** Environment variables */
|
|
46
|
+
env?: Record<string, string>;
|
|
47
|
+
/** Standard input to pass to the tool */
|
|
48
|
+
stdin?: string;
|
|
49
|
+
/** Timeout in milliseconds */
|
|
50
|
+
timeout?: number;
|
|
51
|
+
/** Scope level for execution */
|
|
52
|
+
scope?: ScopeLevel;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RunToolResponse {
|
|
56
|
+
success: boolean;
|
|
57
|
+
exitCode: number;
|
|
58
|
+
stdout?: string;
|
|
59
|
+
stderr?: string;
|
|
60
|
+
durationMs: number;
|
|
61
|
+
error?: {
|
|
62
|
+
code: string;
|
|
63
|
+
message: string;
|
|
64
|
+
details?: Record<string, unknown>;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RunRoutineRequest {
|
|
69
|
+
/** Routine name */
|
|
70
|
+
routine: string;
|
|
71
|
+
/** Variables to pass to the routine */
|
|
72
|
+
vars?: Record<string, string>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ListPackagesResponse {
|
|
76
|
+
packages: Array<{
|
|
77
|
+
name: string;
|
|
78
|
+
version: string;
|
|
79
|
+
path: string;
|
|
80
|
+
source: 'local' | 'registry';
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PackageInfoResponse {
|
|
85
|
+
name: string;
|
|
86
|
+
version: string;
|
|
87
|
+
description?: string;
|
|
88
|
+
commands?: Record<string, { description: string }>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface HealthResponse {
|
|
92
|
+
status: 'ok';
|
|
93
|
+
hostname: string;
|
|
94
|
+
version: string;
|
|
95
|
+
uptime: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
99
|
+
// HELPERS
|
|
100
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
101
|
+
|
|
102
|
+
function parseBody(req: IncomingMessage): Promise<string> {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const chunks: Buffer[] = [];
|
|
105
|
+
req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
106
|
+
req.on('error', reject);
|
|
107
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
|
112
|
+
const body = JSON.stringify(data);
|
|
113
|
+
res.writeHead(status, {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
'Content-Length': Buffer.byteLength(body)
|
|
116
|
+
});
|
|
117
|
+
res.end(body);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sendError(res: ServerResponse, status: number, code: string, message: string, details?: Record<string, unknown>): void {
|
|
121
|
+
sendJson(res, status, { error: { code, message, details } });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
125
|
+
// REQUEST HANDLERS
|
|
126
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
127
|
+
|
|
128
|
+
async function handleHealth(config: ServiceConfig, startTime: number): Promise<HealthResponse> {
|
|
129
|
+
return {
|
|
130
|
+
status: 'ok',
|
|
131
|
+
hostname: hostname(),
|
|
132
|
+
version: '1.0.0',
|
|
133
|
+
uptime: Math.floor((Date.now() - startTime) / 1000)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function handleListPackages(config: ServiceConfig): Promise<ListPackagesResponse> {
|
|
138
|
+
const localPkgs = getLocalPackages(config.cwd);
|
|
139
|
+
const globalPkgs = getGlobalPackages();
|
|
140
|
+
|
|
141
|
+
const packages = [
|
|
142
|
+
...localPkgs.map(p => ({ name: p.name, version: p.version, path: p.path, source: p.source })),
|
|
143
|
+
...globalPkgs.map(p => ({ name: p.name, version: p.version, path: p.path, source: p.source }))
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Deduplicate by name (local takes precedence)
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
const unique = packages.filter(p => {
|
|
149
|
+
if (seen.has(p.name)) return false;
|
|
150
|
+
seen.add(p.name);
|
|
151
|
+
return true;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return { packages: unique };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handlePackageInfo(config: ServiceConfig, packageName: string): Promise<PackageInfoResponse | null> {
|
|
158
|
+
const pkg = findPackage(packageName, config.cwd);
|
|
159
|
+
if (!pkg) return null;
|
|
160
|
+
|
|
161
|
+
const manifest = loadManifest(pkg.path);
|
|
162
|
+
|
|
163
|
+
const commands: Record<string, { description: string }> = {};
|
|
164
|
+
if (manifest.commands) {
|
|
165
|
+
for (const [name, cmd] of Object.entries(manifest.commands)) {
|
|
166
|
+
commands[name] = { description: cmd.description };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
name: manifest.name,
|
|
172
|
+
version: manifest.version,
|
|
173
|
+
description: manifest.description,
|
|
174
|
+
commands
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function handleRunTool(config: ServiceConfig, request: RunToolRequest): Promise<RunToolResponse> {
|
|
179
|
+
const startTime = Date.now();
|
|
180
|
+
|
|
181
|
+
// Validate scope
|
|
182
|
+
const scope = request.scope ?? 'full';
|
|
183
|
+
const allowedScopes = config.allowedScopes ?? ['read', 'write', 'full'];
|
|
184
|
+
if (!allowedScopes.includes(scope)) {
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
exitCode: 1,
|
|
188
|
+
durationMs: Date.now() - startTime,
|
|
189
|
+
error: {
|
|
190
|
+
code: 'FORBIDDEN',
|
|
191
|
+
message: `Scope "${scope}" is not allowed on this remote`,
|
|
192
|
+
details: { allowedScopes }
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const result = await executeTool({
|
|
199
|
+
packageName: request.package,
|
|
200
|
+
command: request.command,
|
|
201
|
+
args: request.args ?? [],
|
|
202
|
+
cwd: config.cwd,
|
|
203
|
+
env: request.env,
|
|
204
|
+
stdin: request.stdin,
|
|
205
|
+
capture: 'pipe',
|
|
206
|
+
timeoutMs: request.timeout,
|
|
207
|
+
scope,
|
|
208
|
+
teeStderr: false
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
success: result.exitCode === 0,
|
|
213
|
+
exitCode: result.exitCode,
|
|
214
|
+
stdout: result.stdout,
|
|
215
|
+
stderr: result.stderr,
|
|
216
|
+
durationMs: result.durationMs
|
|
217
|
+
};
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const durationMs = Date.now() - startTime;
|
|
220
|
+
|
|
221
|
+
if (err instanceof ExecuteToolError) {
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
exitCode: 1,
|
|
225
|
+
durationMs,
|
|
226
|
+
error: {
|
|
227
|
+
code: err.code,
|
|
228
|
+
message: err.message,
|
|
229
|
+
details: err.details
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
success: false,
|
|
236
|
+
exitCode: 1,
|
|
237
|
+
durationMs,
|
|
238
|
+
error: {
|
|
239
|
+
code: 'API_ERROR',
|
|
240
|
+
message: err instanceof Error ? err.message : String(err)
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function handleRunRoutine(config: ServiceConfig, request: RunRoutineRequest): Promise<RoutineRunSummary | null> {
|
|
247
|
+
const resolved = resolveRoutine(request.routine, config.cwd);
|
|
248
|
+
if (!resolved) return null;
|
|
249
|
+
|
|
250
|
+
const def = loadRoutineDefinition(resolved.path);
|
|
251
|
+
const result = await runRoutine(def, request.vars ?? {}, config.cwd);
|
|
252
|
+
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
257
|
+
// MAIN SERVER
|
|
258
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
259
|
+
|
|
260
|
+
export function createService(config: ServiceConfig): ReturnType<typeof createServer> {
|
|
261
|
+
const startTime = Date.now();
|
|
262
|
+
|
|
263
|
+
const server = createServer(async (req, res) => {
|
|
264
|
+
const method = req.method ?? 'GET';
|
|
265
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
266
|
+
const path = url.pathname;
|
|
267
|
+
|
|
268
|
+
// CORS headers for cross-origin requests
|
|
269
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
270
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
271
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
|
272
|
+
|
|
273
|
+
// Handle preflight
|
|
274
|
+
if (method === 'OPTIONS') {
|
|
275
|
+
res.writeHead(204);
|
|
276
|
+
res.end();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Authentication check
|
|
281
|
+
if (config.apiKey) {
|
|
282
|
+
const providedKey = req.headers['x-api-key'] ||
|
|
283
|
+
req.headers['authorization']?.replace(/^Bearer\s+/i, '');
|
|
284
|
+
|
|
285
|
+
if (providedKey !== config.apiKey) {
|
|
286
|
+
sendError(res, 401, 'UNAUTHORIZED', 'Invalid or missing API key');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
// Route: GET /health
|
|
293
|
+
if (method === 'GET' && path === '/health') {
|
|
294
|
+
const data = await handleHealth(config, startTime);
|
|
295
|
+
sendJson(res, 200, data);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Route: GET /packages
|
|
300
|
+
if (method === 'GET' && path === '/packages') {
|
|
301
|
+
const data = await handleListPackages(config);
|
|
302
|
+
sendJson(res, 200, data);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Route: GET /packages/:name
|
|
307
|
+
if (method === 'GET' && path.startsWith('/packages/')) {
|
|
308
|
+
const packageName = path.slice('/packages/'.length);
|
|
309
|
+
const data = await handlePackageInfo(config, packageName);
|
|
310
|
+
if (!data) {
|
|
311
|
+
sendError(res, 404, 'NOT_FOUND', `Package not found: ${packageName}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
sendJson(res, 200, data);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Route: POST /run
|
|
319
|
+
if (method === 'POST' && path === '/run') {
|
|
320
|
+
const body = await parseBody(req);
|
|
321
|
+
let request: RunToolRequest;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
request = JSON.parse(body);
|
|
325
|
+
} catch {
|
|
326
|
+
sendError(res, 400, 'PARSE_ERROR', 'Invalid JSON body');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!request.package || typeof request.package !== 'string') {
|
|
331
|
+
sendError(res, 400, 'INVALID_INPUT', 'Missing required field: package');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const data = await handleRunTool(config, request);
|
|
336
|
+
sendJson(res, data.success ? 200 : 500, data);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Route: POST /routines/:name/run
|
|
341
|
+
if (method === 'POST' && path.match(/^\/routines\/[^/]+\/run$/)) {
|
|
342
|
+
const routineName = path.split('/')[2];
|
|
343
|
+
const body = await parseBody(req);
|
|
344
|
+
let request: RunRoutineRequest;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
request = body ? JSON.parse(body) : {};
|
|
348
|
+
request.routine = routineName;
|
|
349
|
+
} catch {
|
|
350
|
+
sendError(res, 400, 'PARSE_ERROR', 'Invalid JSON body');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const data = await handleRunRoutine(config, request);
|
|
355
|
+
if (!data) {
|
|
356
|
+
sendError(res, 404, 'NOT_FOUND', `Routine not found: ${routineName}`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
sendJson(res, data.status === 'success' ? 200 : 500, data);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 404 for unknown routes
|
|
365
|
+
sendError(res, 404, 'NOT_FOUND', `Unknown route: ${method} ${path}`);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error('Request error:', err);
|
|
368
|
+
sendError(res, 500, 'API_ERROR', err instanceof Error ? err.message : String(err));
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return server;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface StartServiceOptions {
|
|
376
|
+
port?: number;
|
|
377
|
+
host?: string;
|
|
378
|
+
apiKey?: string;
|
|
379
|
+
cwd?: string;
|
|
380
|
+
allowedScopes?: ScopeLevel[];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function startService(options: StartServiceOptions = {}): Promise<void> {
|
|
384
|
+
const config: ServiceConfig = {
|
|
385
|
+
port: options.port ?? 4100,
|
|
386
|
+
host: options.host ?? '0.0.0.0',
|
|
387
|
+
apiKey: options.apiKey,
|
|
388
|
+
cwd: options.cwd ?? process.cwd(),
|
|
389
|
+
allowedScopes: options.allowedScopes
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const server = createService(config);
|
|
393
|
+
|
|
394
|
+
return new Promise((resolve, reject) => {
|
|
395
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
396
|
+
if (err.code === 'EADDRINUSE') {
|
|
397
|
+
reject(new Error(`Port ${config.port} is already in use`));
|
|
398
|
+
} else {
|
|
399
|
+
reject(err);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
server.listen(config.port, config.host, () => {
|
|
404
|
+
log(`cli4ai service running on http://${config.host}:${config.port}`);
|
|
405
|
+
log(`Hostname: ${hostname()}`);
|
|
406
|
+
log(`Working directory: ${config.cwd}`);
|
|
407
|
+
if (config.apiKey) {
|
|
408
|
+
log(`Authentication: API key required`);
|
|
409
|
+
} else {
|
|
410
|
+
log(`Authentication: None (not recommended for production)`);
|
|
411
|
+
}
|
|
412
|
+
log('');
|
|
413
|
+
log('Endpoints:');
|
|
414
|
+
log(' GET /health - Service health check');
|
|
415
|
+
log(' GET /packages - List available packages');
|
|
416
|
+
log(' GET /packages/:name - Get package info');
|
|
417
|
+
log(' POST /run - Execute a tool');
|
|
418
|
+
log(' POST /routines/:name/run - Run a routine');
|
|
419
|
+
log('');
|
|
420
|
+
log('Press Ctrl+C to stop');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Handle graceful shutdown
|
|
424
|
+
const shutdown = () => {
|
|
425
|
+
log('\nShutting down...');
|
|
426
|
+
server.close(() => {
|
|
427
|
+
resolve();
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
process.on('SIGINT', shutdown);
|
|
432
|
+
process.on('SIGTERM', shutdown);
|
|
433
|
+
});
|
|
434
|
+
}
|