clyops-api 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +167 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +64 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/server.d.ts +77 -0
- package/dist/server.js +518 -0
- package/dist/zod.d.ts +28 -0
- package/dist/zod.js +75 -0
- package/package.json +56 -3
- package/src/cli.ts +66 -0
- package/src/index.ts +4 -0
- package/src/server.ts +592 -0
- package/src/zod.ts +79 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
// An HTTP API over a directory of clyops tools: one POST endpoint per tool,
|
|
2
|
+
// validated and documented from its --help-json-schema.
|
|
3
|
+
import { allowed, InputError, isTextType, loadTools, runTool, startTool, tail, toJsonSchema, watchTools, } from 'clyops-tools';
|
|
4
|
+
import { JobQueue } from 'clyops-jobs';
|
|
5
|
+
import { mcpHttpHandler } from 'clyops-mcp';
|
|
6
|
+
import busboy from 'busboy';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { createReadStream, createWriteStream, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { basename, join } from 'node:path';
|
|
11
|
+
import { Readable, Transform } from 'node:stream';
|
|
12
|
+
import { finished, pipeline } from 'node:stream/promises';
|
|
13
|
+
import { plus, z } from 'plus-express';
|
|
14
|
+
import { toZod } from './zod.js';
|
|
15
|
+
const RunResponseSchema = z.object({
|
|
16
|
+
ok: z.boolean(),
|
|
17
|
+
exitCode: z.number().int().nullable(),
|
|
18
|
+
signal: z.string().nullable(),
|
|
19
|
+
timedOut: z.boolean(),
|
|
20
|
+
stdout: z.string().openapi({ description: 'Base64 when `stdoutEncoding` is `base64` (binary output)' }),
|
|
21
|
+
stdoutEncoding: z.literal('base64').optional(),
|
|
22
|
+
stdoutUrl: z.string().optional().openapi({ description: 'Where an async job\'s binary output is served' }),
|
|
23
|
+
stderr: z.string(),
|
|
24
|
+
truncated: z.boolean().optional(),
|
|
25
|
+
durationMs: z.number(),
|
|
26
|
+
command: z.array(z.string()),
|
|
27
|
+
json: z.unknown().optional(),
|
|
28
|
+
});
|
|
29
|
+
const JobSchema = z.object({
|
|
30
|
+
job_id: z.string(),
|
|
31
|
+
function: z.string(),
|
|
32
|
+
status: z.enum(['pending', 'processing', 'done', 'error']),
|
|
33
|
+
stage: z.string().nullable(),
|
|
34
|
+
started_at: z.string(),
|
|
35
|
+
updated_at: z.string(),
|
|
36
|
+
completed_at: z.string().nullable(),
|
|
37
|
+
error: z.string().nullable(),
|
|
38
|
+
result: RunResponseSchema.optional(),
|
|
39
|
+
}).passthrough();
|
|
40
|
+
const ErrorSchema = z.object({ error: z.string(), issues: z.unknown().optional() });
|
|
41
|
+
const json = (schema, description) => ({ description, content: { 'application/json': { schema } } });
|
|
42
|
+
/** A tool's declared binary stdout type, if any (spec section 1.3). */
|
|
43
|
+
function binaryStdout(tool) {
|
|
44
|
+
const type = tool.schema.stdout?.contentType;
|
|
45
|
+
return type && !isTextType(type) ? type.split(';')[0].trim() : undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether to answer with the raw stdout stream rather than the JSON envelope:
|
|
49
|
+
* declared binary output streams unless the client asks for JSON, and other
|
|
50
|
+
* output streams when it asks for application/octet-stream.
|
|
51
|
+
*/
|
|
52
|
+
function streams(tool, req) {
|
|
53
|
+
const accept = req.get('accept') ?? '';
|
|
54
|
+
return binaryStdout(tool) ? !accept.includes('application/json') : accept.includes('application/octet-stream');
|
|
55
|
+
}
|
|
56
|
+
/** The envelope as JSON: binary stdout base64-encoded. */
|
|
57
|
+
function envelope(result) {
|
|
58
|
+
const { stdoutBuffer, ...rest } = result;
|
|
59
|
+
return stdoutBuffer ? { ...rest, stdout: stdoutBuffer.toString('base64'), stdoutEncoding: 'base64' } : rest;
|
|
60
|
+
}
|
|
61
|
+
/** A header-safe one-line summary of stderr, for the trailer. */
|
|
62
|
+
function stderrTrailer(stderr) {
|
|
63
|
+
return tail(stderr, 3).join(' | ').replace(/[^\x20-\x7e]/g, '?').slice(-500);
|
|
64
|
+
}
|
|
65
|
+
/** Query values (strings, arrays of strings) typed by the input's JSON Schema, for a non-JSON body. */
|
|
66
|
+
function queryInput(query, schema) {
|
|
67
|
+
const typed = (value, type) => {
|
|
68
|
+
if ((type === 'integer' || type === 'number') && /^-?[0-9]*\.?[0-9]+$/.test(value))
|
|
69
|
+
return Number(value);
|
|
70
|
+
if (type === 'boolean' && /^(true|1|yes|on)$/i.test(value))
|
|
71
|
+
return true;
|
|
72
|
+
if (type === 'boolean' && /^(false|0|no|off)$/i.test(value))
|
|
73
|
+
return false;
|
|
74
|
+
return value;
|
|
75
|
+
};
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [key, raw] of Object.entries(query)) {
|
|
78
|
+
if (key === 'async')
|
|
79
|
+
continue;
|
|
80
|
+
const values = (Array.isArray(raw) ? raw : [raw]).map(String);
|
|
81
|
+
const prop = schema.properties?.[key];
|
|
82
|
+
out[key] = prop?.type === 'array' ? values.map((v) => typed(v, prop.items?.type)) : typed(values[values.length - 1], prop?.type);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** Fails a stream with a 413 once more than `limit` bytes have passed. */
|
|
87
|
+
function limited(limit) {
|
|
88
|
+
let size = 0;
|
|
89
|
+
return new Transform({
|
|
90
|
+
transform(chunk, _enc, done) {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > limit)
|
|
93
|
+
return done(Object.assign(new Error(`request body is larger than ${limit} bytes`), { status: 413 }));
|
|
94
|
+
done(null, chunk);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A multipart body: the `args` part is the JSON input, the `stdin` part is
|
|
100
|
+
* saved as the tool's stdin, and a part named after a path-valued input is
|
|
101
|
+
* saved and its path given as that input (repeated for array inputs). Files
|
|
102
|
+
* go to `dir`; at most `limit` bytes in all.
|
|
103
|
+
*/
|
|
104
|
+
function readMultipart(req, tool, dir, limit) {
|
|
105
|
+
const schema = toJsonSchema(tool.schema);
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const input = {};
|
|
108
|
+
let stdin;
|
|
109
|
+
let size = 0;
|
|
110
|
+
let n = 0;
|
|
111
|
+
const writes = [];
|
|
112
|
+
const bb = busboy({ headers: req.headers });
|
|
113
|
+
// Stop reading at the first problem; the rest of the body is discarded.
|
|
114
|
+
const fail = (err) => {
|
|
115
|
+
req.unpipe(bb);
|
|
116
|
+
req.resume();
|
|
117
|
+
reject(err);
|
|
118
|
+
};
|
|
119
|
+
const count = (bytes) => {
|
|
120
|
+
size += bytes;
|
|
121
|
+
if (size > limit)
|
|
122
|
+
fail(Object.assign(new Error(`request body is larger than ${limit} bytes`), { status: 413 }));
|
|
123
|
+
return size <= limit;
|
|
124
|
+
};
|
|
125
|
+
const save = (from, file) => {
|
|
126
|
+
const write = pipeline(from, createWriteStream(file));
|
|
127
|
+
write.catch(() => { }); // reported through fail or the close handler
|
|
128
|
+
writes.push(write);
|
|
129
|
+
};
|
|
130
|
+
bb.on('field', (name, value) => {
|
|
131
|
+
if (!count(Buffer.byteLength(value)))
|
|
132
|
+
return;
|
|
133
|
+
if (name === 'args') {
|
|
134
|
+
try {
|
|
135
|
+
Object.assign(input, JSON.parse(value));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
fail(new InputError('the args part is not JSON'));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (name === 'stdin') {
|
|
142
|
+
stdin = join(dir, 'stdin');
|
|
143
|
+
save(Readable.from([value]), stdin);
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
input[name] = value;
|
|
147
|
+
});
|
|
148
|
+
bb.on('file', (name, stream, info) => {
|
|
149
|
+
const file = name === 'stdin' ? join(dir, 'stdin') : join(dir, `${n++}-${basename(info.filename || name)}`);
|
|
150
|
+
if (name === 'stdin')
|
|
151
|
+
stdin = file;
|
|
152
|
+
else if (schema.properties[name]?.type === 'array')
|
|
153
|
+
input[name] = [...(input[name] ?? []), file];
|
|
154
|
+
else
|
|
155
|
+
input[name] = file;
|
|
156
|
+
stream.on('data', (chunk) => void count(chunk.length));
|
|
157
|
+
save(stream, file);
|
|
158
|
+
});
|
|
159
|
+
bb.on('error', (err) => fail(err));
|
|
160
|
+
bb.on('close', () => Promise.all(writes).then(() => resolve({ input, stdin }), reject));
|
|
161
|
+
req.pipe(bb);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Build the Express app. With `watch`, the tool endpoints, the OpenAPI
|
|
166
|
+
* document and /mcp follow the tools directory as it changes; otherwise they
|
|
167
|
+
* are fixed when the app is built.
|
|
168
|
+
*/
|
|
169
|
+
export async function createApi(opts) {
|
|
170
|
+
const maxBody = opts.maxBody ?? 10 << 20;
|
|
171
|
+
// A job's files (spooled stdin, uploads, binary stdout) live until it is dropped.
|
|
172
|
+
const jobDirs = new Map();
|
|
173
|
+
const queue = new JobQueue({
|
|
174
|
+
concurrency: opts.concurrency,
|
|
175
|
+
onDrop: (record) => {
|
|
176
|
+
const dir = jobDirs.get(record.job_id);
|
|
177
|
+
if (dir)
|
|
178
|
+
rmSync(dir, { recursive: true, force: true });
|
|
179
|
+
jobDirs.delete(record.job_id);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const onError = (cmd, err) => process.emitWarning(cmd ? `skipping ${cmd.words.join(' ')}: ${err.message}` : err.message);
|
|
183
|
+
const withPaths = (tools) => tools.map((t) => ({ ...t, path: `/tools/${t.words.join('/')}` }));
|
|
184
|
+
// Everything documented in OpenAPI lives on a router rebuilt for each set of tools.
|
|
185
|
+
let api;
|
|
186
|
+
const swap = (loaded) => (api = buildRoutes(opts, ctx, loaded.tree, withPaths(loaded.tools)));
|
|
187
|
+
const ctx = { queue, jobDirs, maxBody };
|
|
188
|
+
const watcher = opts.watch
|
|
189
|
+
? await watchTools(opts.root, { name: opts.name, filter: opts.filter, onError, onChange: (loaded) => opts.onReload?.(swap(loaded)) })
|
|
190
|
+
: undefined;
|
|
191
|
+
swap(watcher ? watcher.current() : await loadTools(opts.root, { name: opts.name, filter: opts.filter, onError }));
|
|
192
|
+
const app = express();
|
|
193
|
+
app.use(express.json({ limit: maxBody }));
|
|
194
|
+
const keys = Object.entries(opts.keys ?? {});
|
|
195
|
+
if (opts.apiKey || keys.length) {
|
|
196
|
+
app.use((req, res, next) => {
|
|
197
|
+
const given = req.get('x-api-key') ?? req.get('authorization')?.replace(/^Bearer\s+/i, '');
|
|
198
|
+
if (given && given === opts.apiKey)
|
|
199
|
+
res.locals.caller = { name: keys.length ? 'default' : undefined };
|
|
200
|
+
else {
|
|
201
|
+
const found = keys.find(([, k]) => given && k.key === given);
|
|
202
|
+
if (!found)
|
|
203
|
+
return void res.status(401).json({ error: 'missing or wrong API key' });
|
|
204
|
+
res.locals.caller = { name: found[0], filter: { allow: found[1].allow, deny: found[1].deny } };
|
|
205
|
+
}
|
|
206
|
+
next();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
app.get('/openapi.json', (_req, res) => {
|
|
210
|
+
res.json(documentStreams(api.registry.generateOpenAPIDocument(opts.apiKey || keys.length ? { security: [{ apiKey: [] }] } : {}), api.tools));
|
|
211
|
+
});
|
|
212
|
+
// A key only sees and runs the tools it may.
|
|
213
|
+
app.use((req, res, next) => {
|
|
214
|
+
const caller = res.locals.caller;
|
|
215
|
+
const tool = api.tools.find((t) => t.path === req.path);
|
|
216
|
+
if (tool && caller?.filter && !allowed(tool, caller.filter))
|
|
217
|
+
return void res.status(403).json({ error: `this key may not run ${tool.words.join(' ')}` });
|
|
218
|
+
next();
|
|
219
|
+
});
|
|
220
|
+
// A non-JSON body is the tool's stdin (the arguments come from the query
|
|
221
|
+
// string), or multipart with the arguments, stdin and files.
|
|
222
|
+
app.use((req, res, next) => {
|
|
223
|
+
const tool = req.method === 'POST' && req.headers['content-type'] && !req.is('application/json') && api.tools.find((t) => t.path === req.path);
|
|
224
|
+
if (!tool)
|
|
225
|
+
return next();
|
|
226
|
+
void runBody(opts, ctx, tool, req, res).catch(next);
|
|
227
|
+
});
|
|
228
|
+
// RouterPlus's type drops Router's call signature; it is still a Router.
|
|
229
|
+
app.use((req, res, next) => api.router(req, res, next));
|
|
230
|
+
if (opts.mcp !== false) {
|
|
231
|
+
app.post('/mcp', mcpHttpHandler((req) => {
|
|
232
|
+
const caller = req.res?.locals.caller;
|
|
233
|
+
return {
|
|
234
|
+
name: api.tree.name,
|
|
235
|
+
version: opts.version,
|
|
236
|
+
instructions: api.tree.description || undefined,
|
|
237
|
+
tools: caller?.filter ? api.tools.filter((t) => allowed(t, caller.filter)) : api.tools,
|
|
238
|
+
cwd: opts.cwd,
|
|
239
|
+
timeoutMs: opts.timeoutMs, positionalsOrder: opts.positionalsOrder,
|
|
240
|
+
within: opts.within,
|
|
241
|
+
maxOutput: opts.maxOutput,
|
|
242
|
+
audit: opts.audit && ((entry) => opts.audit?.({ ...entry, key: caller?.name })),
|
|
243
|
+
};
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
app.use(errorHandler);
|
|
247
|
+
return {
|
|
248
|
+
app,
|
|
249
|
+
queue,
|
|
250
|
+
/** The tree and tools being served now. */
|
|
251
|
+
current: () => ({ tree: api.tree, tools: api.tools }),
|
|
252
|
+
/** Stop watching (the app keeps serving the last set). */
|
|
253
|
+
close: () => watcher?.close(),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** A POST with a non-JSON body: validate the query (or the args part), then run with the body as stdin. */
|
|
257
|
+
async function runBody(opts, ctx, tool, req, res) {
|
|
258
|
+
const inputSchema = toJsonSchema(tool.schema);
|
|
259
|
+
const dir = mkdtempSync(join(tmpdir(), 'clyops-api-'));
|
|
260
|
+
let keep = false;
|
|
261
|
+
try {
|
|
262
|
+
let input;
|
|
263
|
+
let stdin;
|
|
264
|
+
if (req.is('multipart/form-data')) {
|
|
265
|
+
const parts = await readMultipart(req, tool, dir, ctx.maxBody);
|
|
266
|
+
input = parts.input;
|
|
267
|
+
stdin = parts.stdin;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
input = queryInput(req.query, inputSchema);
|
|
271
|
+
stdin = req;
|
|
272
|
+
}
|
|
273
|
+
const checked = toZod(inputSchema).safeParse(input);
|
|
274
|
+
if (!checked.success)
|
|
275
|
+
return void res.status(400).json({ error: 'Validation failed', issues: checked.error.issues });
|
|
276
|
+
if (req.query.async === 'true' && stdin === req) {
|
|
277
|
+
// A job runs later: keep the body until then.
|
|
278
|
+
stdin = join(dir, 'stdin');
|
|
279
|
+
await pipeline(req, limited(ctx.maxBody), createWriteStream(stdin));
|
|
280
|
+
}
|
|
281
|
+
keep = req.query.async === 'true';
|
|
282
|
+
await respond(opts, ctx, tool, input, req, res, stdin, keep ? dir : undefined);
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
if (!keep)
|
|
286
|
+
rmSync(dir, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Run a tool for a request and answer: a job (`?async=true`), the raw
|
|
291
|
+
* stdout stream, or the JSON envelope. `stdin` is a file path (opened when
|
|
292
|
+
* the tool starts) or a stream. A job owns `dir` and removes it when dropped.
|
|
293
|
+
*/
|
|
294
|
+
async function respond(opts, ctx, tool, input, req, res, stdin, dir) {
|
|
295
|
+
const caller = res.locals.caller;
|
|
296
|
+
const base = { cwd: opts.cwd, timeoutMs: opts.timeoutMs, positionalsOrder: opts.positionalsOrder, within: opts.within, maxOutput: opts.maxOutput };
|
|
297
|
+
const open = () => (typeof stdin === 'string' ? createReadStream(stdin) : stdin);
|
|
298
|
+
const audit = (result) => opts.audit?.({
|
|
299
|
+
key: caller?.name, tool: tool.words.join(' '), command: result.command, exitCode: result.exitCode, signal: result.signal,
|
|
300
|
+
timedOut: result.timedOut, durationMs: result.durationMs, via: 'api',
|
|
301
|
+
});
|
|
302
|
+
if (req.query.async === 'true') {
|
|
303
|
+
const jobDir = dir ?? mkdtempSync(join(tmpdir(), 'clyops-api-'));
|
|
304
|
+
const toFile = streams(tool, req);
|
|
305
|
+
const job = ctx.queue.add(tool.words.join(' '), async ({ signal }) => {
|
|
306
|
+
let result;
|
|
307
|
+
if (toFile) {
|
|
308
|
+
const started = startTool(tool, input, { ...base, signal, stdin: open(), stdout: 'stream' });
|
|
309
|
+
const [r] = await Promise.all([started.result, pipeline(started.stdout, createWriteStream(join(jobDir, 'stdout')))]);
|
|
310
|
+
result = { ...r, ok: r.exitCode === 0 && !r.timedOut && !r.signal, stdoutUrl: `/jobs/${job.record.job_id}/stdout` };
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
result = envelope(await runTool(tool, input, { ...base, signal, stdin: open(), stdout: binaryStdout(tool) ? 'buffer' : 'text' }));
|
|
314
|
+
}
|
|
315
|
+
audit(result);
|
|
316
|
+
return result;
|
|
317
|
+
}, caller?.name ? { key: caller.name } : {});
|
|
318
|
+
ctx.jobDirs.set(job.record.job_id, jobDir);
|
|
319
|
+
res.status(202).location(`/jobs/${job.record.job_id}`).json(job.record);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// A client that goes away stops the tool.
|
|
323
|
+
const abort = new AbortController();
|
|
324
|
+
res.on('close', () => !res.writableEnded && abort.abort());
|
|
325
|
+
if (!streams(tool, req)) {
|
|
326
|
+
const result = await runTool(tool, input, { ...base, signal: abort.signal, stdin: open(), stdout: binaryStdout(tool) ? 'buffer' : 'text' });
|
|
327
|
+
audit(result);
|
|
328
|
+
res.json(envelope(result));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Stream stdout as it comes. The exit status isn't known when the headers
|
|
332
|
+
// go out, so it follows as a trailer; a failure before any output is still
|
|
333
|
+
// a JSON error.
|
|
334
|
+
const started = startTool(tool, input, { ...base, signal: abort.signal, stdin: open(), stdout: 'stream' });
|
|
335
|
+
const out = started.stdout;
|
|
336
|
+
const first = await new Promise((resolve) => {
|
|
337
|
+
out.once('data', (chunk) => {
|
|
338
|
+
out.pause();
|
|
339
|
+
resolve(chunk);
|
|
340
|
+
});
|
|
341
|
+
out.once('end', () => resolve(null));
|
|
342
|
+
});
|
|
343
|
+
if (!first) {
|
|
344
|
+
const result = await started.result;
|
|
345
|
+
audit(result);
|
|
346
|
+
if (result.exitCode !== 0)
|
|
347
|
+
return void res.status(500).json(envelope({ ...result, ok: false }));
|
|
348
|
+
res.status(200).type(binaryStdout(tool) ?? 'application/octet-stream').end();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
res.status(200);
|
|
352
|
+
res.setHeader('Content-Type', binaryStdout(tool) ?? 'application/octet-stream');
|
|
353
|
+
res.setHeader('Trailer', 'X-Clyops-Exit-Code, X-Clyops-Stderr');
|
|
354
|
+
res.write(first);
|
|
355
|
+
out.pipe(res, { end: false });
|
|
356
|
+
const [result] = await Promise.all([started.result, finished(out)]);
|
|
357
|
+
audit(result);
|
|
358
|
+
res.addTrailers({ 'X-Clyops-Exit-Code': String(result.exitCode ?? ''), 'X-Clyops-Stderr': stderrTrailer(result.stderr) });
|
|
359
|
+
res.end();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The OpenAPI document with what plus-express can't express: the other
|
|
363
|
+
* request bodies (a tool's stdin, multipart), the query parameters they take,
|
|
364
|
+
* declared binary stdout and the tools' effects.
|
|
365
|
+
*/
|
|
366
|
+
function documentStreams(doc, tools) {
|
|
367
|
+
const binary = { type: 'string', format: 'binary' };
|
|
368
|
+
for (const tool of tools) {
|
|
369
|
+
const op = doc.paths?.[tool.path]?.post;
|
|
370
|
+
if (!op)
|
|
371
|
+
continue;
|
|
372
|
+
if (tool.schema.effects?.length)
|
|
373
|
+
op['x-clyops-effects'] = tool.schema.effects;
|
|
374
|
+
const content = (op.requestBody ??= { content: {} }).content;
|
|
375
|
+
for (const type of tool.schema.stdin?.contentType ? tool.schema.stdin.contentType.split(',').map((t) => t.trim()) : ['application/octet-stream']) {
|
|
376
|
+
content[type] = { schema: { ...binary, description: `Standard input${tool.schema.stdin?.description ? `: ${tool.schema.stdin.description}` : ''}; the arguments come from the query string` } };
|
|
377
|
+
}
|
|
378
|
+
content['multipart/form-data'] = {
|
|
379
|
+
schema: {
|
|
380
|
+
type: 'object',
|
|
381
|
+
properties: { args: { type: 'string', description: 'The input, as JSON' }, stdin: { ...binary, description: 'Standard input' } },
|
|
382
|
+
additionalProperties: { ...binary, description: 'A file for the path input of the same name' },
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
const properties = (toJsonSchema(tool.schema).properties ?? {});
|
|
386
|
+
op.parameters = [
|
|
387
|
+
...(op.parameters ?? []),
|
|
388
|
+
...Object.entries(properties).map(([name, schema]) => ({
|
|
389
|
+
in: 'query', name, required: false, schema, ...(schema.type === 'array' ? { style: 'form', explode: true } : {}),
|
|
390
|
+
description: `${schema.description ?? ''} (with a non-JSON body)`.trim(),
|
|
391
|
+
})),
|
|
392
|
+
];
|
|
393
|
+
const type = binaryStdout(tool);
|
|
394
|
+
const ok = op.responses?.['200'];
|
|
395
|
+
if (type && ok)
|
|
396
|
+
ok.content = { [type]: { schema: { ...binary, description: tool.schema.stdout?.description ?? '' } }, ...ok.content };
|
|
397
|
+
}
|
|
398
|
+
return doc;
|
|
399
|
+
}
|
|
400
|
+
/** The documented routes for one set of tools, on their own router and registry. */
|
|
401
|
+
function buildRoutes(opts, ctx, tree, tools) {
|
|
402
|
+
const { queue } = ctx;
|
|
403
|
+
const { router, registry } = plus(express.Router(), {
|
|
404
|
+
openApiConfig: {
|
|
405
|
+
openapi: '3.0.0',
|
|
406
|
+
info: { title: tree.name, version: opts.version ?? '0.0.0', description: tree.description || `clyops tools in ${tree.dir}` },
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
if (opts.apiKey || Object.keys(opts.keys ?? {}).length)
|
|
410
|
+
registry.registerSecurityScheme('apiKey', { type: 'http', scheme: 'bearer' });
|
|
411
|
+
const jobView = (record, result) => ({ ...record, ...(result ? { result } : {}) });
|
|
412
|
+
const visible = (res) => {
|
|
413
|
+
const caller = res.locals.caller;
|
|
414
|
+
return {
|
|
415
|
+
tool: (t) => !caller?.filter || allowed(t, caller.filter),
|
|
416
|
+
// With named keys, a key sees only its own jobs.
|
|
417
|
+
job: (r) => !caller?.name || r.key === caller.name,
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
router.get({
|
|
421
|
+
path: '/tools',
|
|
422
|
+
summary: 'List the tools',
|
|
423
|
+
tags: ['tools'],
|
|
424
|
+
responses: { 200: json(z.array(z.object({ name: z.string(), words: z.array(z.string()), path: z.string(), description: z.string() })), 'The tools') },
|
|
425
|
+
}, (_req, res) => {
|
|
426
|
+
res.json(tools.filter(visible(res).tool).map((t) => ({ name: t.name, words: t.words, path: t.path, description: t.description })));
|
|
427
|
+
});
|
|
428
|
+
for (const tool of tools) {
|
|
429
|
+
const inputSchema = toJsonSchema(tool.schema);
|
|
430
|
+
const tag = tool.words.length > 1 ? tool.words.slice(0, -1).join(' ') : 'tools';
|
|
431
|
+
router.get({
|
|
432
|
+
path: tool.path,
|
|
433
|
+
summary: `Describe ${tool.words.join(' ')}`,
|
|
434
|
+
tags: [tag],
|
|
435
|
+
responses: { 200: json(z.object({ schema: z.unknown(), input: z.unknown() }), 'The tool\'s clyops schema and the JSON Schema of its input') },
|
|
436
|
+
}, (_req, res) => {
|
|
437
|
+
res.json({ schema: tool.schema, input: inputSchema });
|
|
438
|
+
});
|
|
439
|
+
router.post({
|
|
440
|
+
path: tool.path,
|
|
441
|
+
operationId: tool.words.join('_').replace(/[^A-Za-z0-9_]/g, '_'),
|
|
442
|
+
summary: tool.description || tool.words.join(' '),
|
|
443
|
+
description: [tool.schema.description, tool.schema.epilog].filter(Boolean).join('\n\n'),
|
|
444
|
+
tags: [tag],
|
|
445
|
+
body: toZod(inputSchema),
|
|
446
|
+
query: z.object({ async: z.enum(['true', 'false']).optional().openapi({ description: 'Return a job id at once instead of waiting' }) }),
|
|
447
|
+
responses: {
|
|
448
|
+
200: json(RunResponseSchema, 'The tool ran; `ok` is false when it exited non-zero'),
|
|
449
|
+
202: json(JobSchema, 'Queued (with `?async=true`); poll `GET /jobs/{id}`'),
|
|
450
|
+
400: json(ErrorSchema, 'Invalid input'),
|
|
451
|
+
403: json(ErrorSchema, 'The API key may not run this tool'),
|
|
452
|
+
500: json(RunResponseSchema, 'Streaming output: the tool failed before writing any'),
|
|
453
|
+
},
|
|
454
|
+
}, async (req, res, next) => {
|
|
455
|
+
try {
|
|
456
|
+
await respond(opts, ctx, tool, (req.body ?? {}), req, res);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
next(err);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const JobParams = z.object({ id: z.string() });
|
|
464
|
+
const find = (req, res) => {
|
|
465
|
+
const job = queue.get(String(req.params.id));
|
|
466
|
+
return job && visible(res).job(job.record) ? job : undefined;
|
|
467
|
+
};
|
|
468
|
+
router.get({ path: '/jobs', summary: 'List jobs', tags: ['jobs'], responses: { 200: json(z.array(JobSchema), 'Recent jobs, without results') } }, (_req, res) => {
|
|
469
|
+
res.json(queue.list().filter(visible(res).job));
|
|
470
|
+
});
|
|
471
|
+
router.get({ path: '/jobs/:id', summary: 'A job, with its result once done', tags: ['jobs'], params: JobParams, responses: { 200: json(JobSchema, 'The job'), 404: json(ErrorSchema, 'No such job') } }, (req, res) => {
|
|
472
|
+
const job = find(req, res);
|
|
473
|
+
if (!job)
|
|
474
|
+
return void res.status(404).json({ error: `no job ${req.params.id}` });
|
|
475
|
+
res.json(jobView(job.record, job.result));
|
|
476
|
+
});
|
|
477
|
+
router.get({
|
|
478
|
+
path: '/jobs/:id/stdout',
|
|
479
|
+
summary: 'A finished job\'s binary output',
|
|
480
|
+
tags: ['jobs'],
|
|
481
|
+
params: JobParams,
|
|
482
|
+
responses: {
|
|
483
|
+
200: { description: 'The output, with the tool\'s declared content type', content: { 'application/octet-stream': { schema: z.string().openapi({ format: 'binary' }) } } },
|
|
484
|
+
404: json(ErrorSchema, 'No such job, or no output (yet)'),
|
|
485
|
+
},
|
|
486
|
+
}, (req, res) => {
|
|
487
|
+
const job = find(req, res);
|
|
488
|
+
const file = job && ctx.jobDirs.get(job.record.job_id) && join(ctx.jobDirs.get(job.record.job_id), 'stdout');
|
|
489
|
+
if (!job || !file || job.record.status !== 'done' || !existsSync(file))
|
|
490
|
+
return void res.status(404).json({ error: `no output for job ${req.params.id}` });
|
|
491
|
+
const tool = tools.find((t) => t.words.join(' ') === job.record.function);
|
|
492
|
+
res.type((tool && binaryStdout(tool)) ?? 'application/octet-stream').sendFile(file);
|
|
493
|
+
});
|
|
494
|
+
router.delete({ path: '/jobs/:id', summary: 'Cancel a job', tags: ['jobs'], params: JobParams, responses: { 202: json(JobSchema, 'Cancelling'), 404: json(ErrorSchema, 'No such job') } }, (req, res) => {
|
|
495
|
+
const job = find(req, res);
|
|
496
|
+
if (!job)
|
|
497
|
+
return void res.status(404).json({ error: `no job ${req.params.id}` });
|
|
498
|
+
job.cancel();
|
|
499
|
+
res.status(202).json(jobView(job.record));
|
|
500
|
+
});
|
|
501
|
+
return { router, registry, tree, tools };
|
|
502
|
+
}
|
|
503
|
+
/** Errors as JSON: validation failures (400) with zod's issues, anything else 500. */
|
|
504
|
+
export function errorHandler(err, _req, res, _next) {
|
|
505
|
+
const status = err.status ?? 500;
|
|
506
|
+
let issues = err.errors;
|
|
507
|
+
if (typeof issues === 'string') {
|
|
508
|
+
try {
|
|
509
|
+
issues = JSON.parse(issues);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
// plain message
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (res.headersSent)
|
|
516
|
+
return void res.end();
|
|
517
|
+
res.status(status).json({ error: err.message, ...(issues !== undefined ? { issues } : {}) });
|
|
518
|
+
}
|
package/dist/zod.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { JsonSchema } from 'clyops-tools';
|
|
2
|
+
import { z } from 'plus-express';
|
|
3
|
+
type Js = JsonSchema & {
|
|
4
|
+
type?: string;
|
|
5
|
+
enum?: string[];
|
|
6
|
+
pattern?: string;
|
|
7
|
+
items?: Js;
|
|
8
|
+
description?: string;
|
|
9
|
+
default?: unknown;
|
|
10
|
+
minimum?: number;
|
|
11
|
+
maximum?: number;
|
|
12
|
+
minLength?: number;
|
|
13
|
+
maxLength?: number;
|
|
14
|
+
properties?: Record<string, Js>;
|
|
15
|
+
required?: string[];
|
|
16
|
+
allOf?: {
|
|
17
|
+
not?: {
|
|
18
|
+
required?: string[];
|
|
19
|
+
properties?: Record<string, Js & {
|
|
20
|
+
const?: unknown;
|
|
21
|
+
not?: unknown;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
description?: string;
|
|
25
|
+
}[];
|
|
26
|
+
};
|
|
27
|
+
export declare function toZod(schema: Js): z.ZodType;
|
|
28
|
+
export {};
|
package/dist/zod.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from 'plus-express';
|
|
2
|
+
/** Whether `value` matches one of the "given" shapes toJsonSchema uses for exclusive options. */
|
|
3
|
+
function given(value, shape) {
|
|
4
|
+
if (value === undefined || value === null)
|
|
5
|
+
return false;
|
|
6
|
+
if (!shape)
|
|
7
|
+
return true;
|
|
8
|
+
if ('const' in shape)
|
|
9
|
+
return value === shape.const;
|
|
10
|
+
if (shape.type === 'array')
|
|
11
|
+
return Array.isArray(value) && value.length >= (shape.minItems ?? 0);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
export function toZod(schema) {
|
|
15
|
+
let out;
|
|
16
|
+
switch (schema.type) {
|
|
17
|
+
case 'integer':
|
|
18
|
+
case 'number': {
|
|
19
|
+
let n = schema.type === 'integer' ? z.number().int() : z.number();
|
|
20
|
+
if (schema.minimum !== undefined)
|
|
21
|
+
n = n.min(schema.minimum);
|
|
22
|
+
if (schema.maximum !== undefined)
|
|
23
|
+
n = n.max(schema.maximum);
|
|
24
|
+
out = n;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case 'boolean':
|
|
28
|
+
out = z.boolean();
|
|
29
|
+
break;
|
|
30
|
+
case 'array':
|
|
31
|
+
out = z.array(toZod(schema.items ?? {}));
|
|
32
|
+
break;
|
|
33
|
+
case 'object': {
|
|
34
|
+
const required = new Set(schema.required ?? []);
|
|
35
|
+
const shape = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, prop]) => [key, required.has(key) ? toZod(prop) : toZod(prop).optional()]));
|
|
36
|
+
out = z.strictObject(shape);
|
|
37
|
+
// Exclusive options (allOf: [{ not: { required: [a, b] } }]) can't both be given.
|
|
38
|
+
const exclusive = (schema.allOf ?? []).filter((c) => c.not?.required?.length);
|
|
39
|
+
if (exclusive.length) {
|
|
40
|
+
out = out.superRefine((value, ctx) => {
|
|
41
|
+
const input = value;
|
|
42
|
+
for (const c of exclusive) {
|
|
43
|
+
const keys = c.not?.required ?? [];
|
|
44
|
+
if (keys.every((k) => given(input[k], c.not?.properties?.[k]))) {
|
|
45
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [keys[keys.length - 1]], message: c.description ?? `${keys.join(' and ')} cannot be used together` });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
default: {
|
|
53
|
+
if (schema.enum) {
|
|
54
|
+
out = z.enum(schema.enum);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
let s = z.string();
|
|
58
|
+
if (schema.minLength !== undefined)
|
|
59
|
+
s = s.min(schema.minLength);
|
|
60
|
+
if (schema.maxLength !== undefined)
|
|
61
|
+
s = s.max(schema.maxLength);
|
|
62
|
+
if (schema.pattern)
|
|
63
|
+
s = s.regex(new RegExp(schema.pattern));
|
|
64
|
+
out = s;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Defaults are documentation only: the tool applies its own, so a value it
|
|
68
|
+
// would get from its config file or environment is not overridden.
|
|
69
|
+
const meta = {};
|
|
70
|
+
if (schema.description)
|
|
71
|
+
meta.description = schema.description;
|
|
72
|
+
if (schema.default !== undefined)
|
|
73
|
+
meta.default = schema.default;
|
|
74
|
+
return Object.keys(meta).length ? out.openapi(meta) : out;
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clyops-api",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Serve a directory of clyops tools as an HTTP API: an endpoint per tool, validated and documented in OpenAPI from its schema, with sync and async (job) runs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/wankdanker/clyops.git",
|
|
9
|
+
"directory": "apps/api"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"clyops",
|
|
13
|
+
"api",
|
|
14
|
+
"openapi",
|
|
15
|
+
"express",
|
|
16
|
+
"cli",
|
|
17
|
+
"tools"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"clyops-api": "./dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"busboy": "^1.6.0",
|
|
40
|
+
"clyops": "0.3.0",
|
|
41
|
+
"clyops-jobs": "0.3.0",
|
|
42
|
+
"clyops-mcp": "0.3.0",
|
|
43
|
+
"clyops-tools": "0.3.0",
|
|
44
|
+
"express": "^5.2.1",
|
|
45
|
+
"plus-express": "^2.1.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
49
|
+
"@types/busboy": "^1.5.4",
|
|
50
|
+
"@types/express": "^5.0.0",
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"typescript": "^5.6.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
|
|
56
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
57
|
+
"test": "node --test test/*.test.mjs"
|
|
58
|
+
}
|
|
6
59
|
}
|