clyops-api 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,163 @@
1
- # Temporary Holding Version
1
+ # clyops-api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Serve a directory of [clyops](https://github.com/wankdanker/clyops) tools as an HTTP API. Each tool
4
+ becomes a `POST` endpoint whose JSON body is validated against the tool's own
5
+ `--help-json-schema` and documented in an OpenAPI document, built with
6
+ [plus-express](https://www.npmjs.com/package/plus-express). Calls wait for the tool by default, or
7
+ return a job to poll.
8
+
9
+ ```sh
10
+ npm install -g clyops-api
11
+ clyops-api --root ~/mytool/scripts # http://127.0.0.1:8080
12
+ ```
13
+
14
+ ```
15
+ scripts/
16
+ check.sh → POST /tools/check
17
+ media/
18
+ to-pcm.sh → POST /tools/media/to-pcm
19
+ ```
20
+
21
+ The directory is read like [clyops-dispatch](../dispatch) reads it (groups, `.clyops` files,
22
+ ignore lists; `--root` may also be a dispatcher definition file). Only programs built on a clyops
23
+ library are served, since their schema is what the endpoint is made from.
24
+
25
+ ## Calling a tool
26
+
27
+ The body has one key per option and argument: the option's long name with `-` as `_` (`dry_run`),
28
+ and the argument's name. Flags are booleans, repeatable options and variadic arguments are arrays,
29
+ and numbers are numbers. The mapping to a command line is [spec §13](../../spec/SPEC.md#13-json-input-toargv).
30
+
31
+ ```sh
32
+ curl -s localhost:8080/tools/media/to-pcm -H content-type:application/json \
33
+ -d '{"input": "in.wav", "rate": 16000, "verbose": true}'
34
+ ```
35
+
36
+ ```json
37
+ { "ok": true, "exitCode": 0, "signal": null, "timedOut": false, "durationMs": 412,
38
+ "stdout": "...", "stderr": "...", "command": ["/home/me/mytool/scripts/media/to-pcm.sh", "..."],
39
+ "json": { "...": "stdout parsed, when it is JSON" } }
40
+ ```
41
+
42
+ A tool that runs but fails answers `200` with `"ok": false` and its exit code and stderr. Input
43
+ that doesn't match the schema answers `400` with the validation issues and doesn't run anything.
44
+ If the client disconnects, the tool is stopped.
45
+
46
+ A program with commands (`tasks db migrate`) has an endpoint per command: `POST /tools/tasks/db/migrate`.
47
+
48
+ **Streaming stdin.** Any body that isn't JSON is streamed to the tool's stdin as it arrives, and
49
+ the input comes from the query string instead (typed by the schema: `?count=3&verbose=true`,
50
+ repeated keys for arrays: `?tag=a&tag=b`). The query is validated before the tool starts.
51
+
52
+ ```sh
53
+ curl -s 'localhost:8080/tools/transcribe?model=base' -H content-type:audio/wav --data-binary @in.wav
54
+ ```
55
+
56
+ **Multipart.** `multipart/form-data` takes the input as JSON in an `args` part, stdin in a `stdin`
57
+ part, and files for path inputs in parts named after them (saved to a temporary directory for the
58
+ run, then removed):
59
+
60
+ ```sh
61
+ curl -s localhost:8080/tools/media/to-pcm -F args='{"rate":16000}' -F input=@in.wav
62
+ ```
63
+
64
+ **Streaming stdout.** When a tool declares binary output (`stdout` with a type such as
65
+ `audio/mpeg`), the response *is* that output, sent as the tool writes it with the declared
66
+ `Content-Type`: `curl ... > out.mp3` works, and a player can start before the tool is done. The
67
+ exit status isn't known when the headers go out, so it follows as HTTP trailers,
68
+ `X-Clyops-Exit-Code` and `X-Clyops-Stderr` (the end of stderr); a tool that fails before writing
69
+ anything answers `500` with the usual JSON. `Accept: application/json` asks for the JSON envelope
70
+ instead (`stdout` base64-encoded, `"stdoutEncoding": "base64"`), and `Accept:
71
+ application/octet-stream` streams any tool's output.
72
+
73
+ **Async.** `POST /tools/...?async=true` answers `202` with a job record and a `Location` header:
74
+
75
+ ```sh
76
+ curl -s -XPOST 'localhost:8080/tools/media/to-pcm?async=true' -H content-type:application/json -d '{"input":"in.wav"}'
77
+ # {"job_id":"job-1f2e3d4c5b6a","status":"pending",...}
78
+ curl -s localhost:8080/jobs/job-1f2e3d4c5b6a
79
+ # {"job_id":"...","status":"done","result":{"ok":true,...},...}
80
+ curl -s -XDELETE localhost:8080/jobs/job-1f2e3d4c5b6a # cancel
81
+ ```
82
+
83
+ Job status moves `pending` → `processing` → `done` | `error` (`error: "cancelled"` when
84
+ cancelled). Jobs are kept in memory (the latest 1000) and run `--concurrency` at a time. A job
85
+ with a streamed body keeps it in a file until it runs; a job with binary output writes it to a
86
+ file served at `GET /jobs/<id>/stdout` (its result has `stdoutUrl`). A job's files are removed when
87
+ it is dropped.
88
+
89
+ ## Endpoints
90
+
91
+ | | |
92
+ | --- | --- |
93
+ | `GET /openapi.json` | OpenAPI 3 document of everything below |
94
+ | `GET /tools` | The tools: name, words, path, description |
95
+ | `GET /tools/<words>` | A tool's clyops schema and the JSON Schema of its input |
96
+ | `POST /tools/<words>[?async=true]` | Run it |
97
+ | `GET /jobs`, `GET /jobs/<id>`, `DELETE /jobs/<id>` | Async jobs |
98
+ | `GET /jobs/<id>/stdout` | A finished job's binary output |
99
+ | `POST /mcp` | The same tools over MCP (streamable HTTP, stateless) for agents; see [clyops-mcp](../mcp). `--no-mcp` turns it off. |
100
+
101
+ ## Options
102
+
103
+ ```
104
+ clyops-api --root DIR [--name NAME] [--cwd DIR] [--timeout SECONDS] [--concurrency N]
105
+ [--host 127.0.0.1] [--port 8080] [--api-key KEY] [--keys FILE] [--no-mcp] [--no-watch]
106
+ [--allow GLOB]... [--deny GLOB]... [--read-only] [--paths-within DIR]...
107
+ [--max-body BYTES] [--max-output BYTES] [--audit FILE]
108
+ ```
109
+
110
+ It listens on localhost unless told otherwise. Every option can also come from the environment as
111
+ `CLYOPS_API_<OPTION>`. Tools run in `--cwd` (default: where the server was started), so relative
112
+ paths in the input resolve there. Each operation in the OpenAPI document carries the tool's
113
+ declared effects as `x-clyops-effects`.
114
+
115
+ ## Security
116
+
117
+ Anyone who can call the API can run every tool it serves with any arguments their schemas accept.
118
+ Serve only what callers should be able to run:
119
+
120
+ - **Keys.** With `--api-key` (or `CLYOPS_API_API_KEY`), every request needs `Authorization: Bearer
121
+ KEY` or `X-API-Key: KEY`; that key may run everything. `--keys FILE` adds named keys, each with
122
+ its own scope:
123
+ ```json
124
+ { "ci": { "key": "…", "allow": ["media/*"] }, "ops": { "key": "…", "deny": ["admin/**"] } }
125
+ ```
126
+ A key sees and runs only its tools (`403` otherwise), in the REST endpoints and at `/mcp`, and
127
+ sees only its own jobs.
128
+ - **Which tools.** `--allow media/*` and `--deny admin/**` (repeatable) are globs over a tool's
129
+ words: `*` within a word, `**` across words. A tool must match an `--allow` pattern when there
130
+ are any, and no `--deny` pattern; `allow:` and `deny:` lines in the root `.clyops` file apply as
131
+ well. `--read-only` serves only tools declaring the `read-only` effect. Hot reload applies the
132
+ same rules to new tools.
133
+ - **Paths.** `--paths-within DIR` (repeatable): path inputs (`path`, `file:*`, `dir:*`) must
134
+ resolve inside one of these directories, symlinks followed, or the request is a `400`.
135
+ - **Limits.** `--timeout`, `--concurrency`, `--max-body` (JSON, multipart and spooled bodies;
136
+ default 10 MiB) and `--max-output` (stdout and stderr kept per run; default 16 MiB).
137
+ - **Secrets.** Options a tool marks secret are passed to it in its environment rather than on
138
+ its command line (where `ps` shows them), and `command` in responses, job records and the audit
139
+ log shows them as `***`.
140
+ - **Audit.** `--audit FILE` (`-` for stderr) appends one JSON line per run: time, key, tool,
141
+ command line, exit status and duration.
142
+
143
+ **Hot reload.** The server watches the tools directory: add, change or remove a tool (or a
144
+ `.clyops` file) and its endpoint, the OpenAPI document and the MCP tool list follow within a
145
+ moment, without a restart. Jobs already running keep running. `--no-watch` reads the tools once
146
+ at startup instead.
147
+
148
+ ## As a library
149
+
150
+ ```js
151
+ import express from 'express';
152
+ import { createApi } from 'clyops-api';
153
+
154
+ const { app, current, close } = await createApi({ root: './scripts', apiKey: process.env.KEY, watch: true });
155
+ app.listen(8080);
156
+ current().tools; // what is being served now
157
+ close(); // stop watching
158
+ ```
159
+
160
+ Watching is off by default in the library (`watch: true` turns it on, `onReload` reports each
161
+ new set) and on by default in the `clyops-api` command.
162
+
163
+ `loadTools(root)` and `runTool(tool, input)` are exported for other servers.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // clyops-api --root DIR: serve a directory of clyops tools over HTTP.
3
+ import { Cli, die, info } from 'clyops';
4
+ import { auditLog } from 'clyops-tools';
5
+ import { readFileSync } from 'node:fs';
6
+ import { createApi } from './server.js';
7
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
8
+ const cli = new Cli({ name: 'clyops-api' });
9
+ cli.setDescription('Serve a directory of clyops tools as an HTTP API: one POST endpoint per tool, validated and documented (OpenAPI at /openapi.json) from each tool\'s --help-json-schema.');
10
+ cli.setEpilog('Every option can also be set in the environment as CLYOPS_API_<OPTION> (CLYOPS_API_API_KEY for the key).\n\nExamples:\n clyops-api --root ~/mytool/scripts\n curl -X POST localhost:8080/tools/media/to-pcm -H content-type:application/json -d \'{"input":"a.wav"}\'');
11
+ cli.opt('CLYOPS_API_ROOT', 'root', 'r', '', 'Tools directory or dispatcher definition file', 'Tools', 'path');
12
+ cli.opt('CLYOPS_API_NAME', 'name', 'n', 'optional', 'API title (default: the directory name)', 'Tools');
13
+ cli.opt('CLYOPS_API_CWD', 'cwd', '', 'optional', 'Working directory for the tools (default: the current one)', 'Tools', 'dir:exists');
14
+ cli.opt('CLYOPS_API_TIMEOUT', 'timeout', 't', '0', 'Kill a tool after this many seconds (0: never)', 'Tools', 'int:0-');
15
+ cli.opt('CLYOPS_API_CONCURRENCY', 'concurrency', 'j', 'optional', 'Async jobs run at the same time (default: CPUs)', 'Tools', 'int:1-');
16
+ cli.opt('CLYOPS_API_MCP', 'mcp', '', 'true', 'Also serve the tools over MCP (streamable HTTP) at /mcp', 'Server', 'bool');
17
+ cli.opt('CLYOPS_API_WATCH', 'watch', 'w', 'true', 'Pick up added, changed and removed tools without a restart', 'Tools', 'bool');
18
+ cli.opt('CLYOPS_API_HOST', 'host', 'H', '127.0.0.1', 'Address to listen on', 'Server');
19
+ cli.opt('CLYOPS_API_PORT', 'port', 'p', '8080', 'Port to listen on', 'Server', 'port');
20
+ cli.opt('CLYOPS_API_API_KEY', 'api-key', 'k', 'optional', 'Require this key (Authorization: Bearer KEY or X-API-Key); it may run every tool', 'Server', 'secret');
21
+ cli.opt('CLYOPS_API_KEYS', 'keys', 'K', 'optional', 'JSON file of named keys, each with its own scope: {"ci": {"key": "...", "allow": ["media/*"]}}', 'Security', 'file:readable');
22
+ cli.optArray('CLYOPS_API_ALLOW', 'allow', 'a', 'Serve only tools matching this glob over their words (media/*, media/**)', 'Security');
23
+ cli.optArray('CLYOPS_API_DENY', 'deny', 'D', 'Leave out tools matching this glob', 'Security');
24
+ cli.opt('CLYOPS_API_READ_ONLY', 'read-only', '', 'flag', 'Serve only tools that declare the read-only effect', 'Security');
25
+ cli.optArray('CLYOPS_API_PATHS_WITHIN', 'paths-within', '', 'Path inputs must resolve inside this directory', 'Security', 'dir:exists');
26
+ cli.opt('CLYOPS_API_MAX_BODY', 'max-body', '', '10485760', 'Largest request body in bytes: JSON, multipart or spooled for an async job', 'Security', 'int:1-');
27
+ cli.opt('CLYOPS_API_MAX_OUTPUT', 'max-output', '', '16777216', 'Keep at most this many bytes of a tool\'s stdout and stderr (0: all)', 'Security', 'int:0-');
28
+ cli.opt('CLYOPS_API_AUDIT', 'audit', '', 'optional', 'Append a JSON line per run to this file (-: stderr)', 'Security', 'path');
29
+ const args = cli.run();
30
+ let keys;
31
+ if (args.CLYOPS_API_KEYS) {
32
+ try {
33
+ keys = JSON.parse(readFileSync(args.CLYOPS_API_KEYS, 'utf8'));
34
+ }
35
+ catch (err) {
36
+ die(1, 'cannot read --keys: %s', err.message);
37
+ }
38
+ }
39
+ const within = args.CLYOPS_API_PATHS_WITHIN;
40
+ const { app, current } = await createApi({
41
+ root: args.CLYOPS_API_ROOT,
42
+ name: args.CLYOPS_API_NAME ?? undefined,
43
+ cwd: args.CLYOPS_API_CWD ?? undefined,
44
+ timeoutMs: args.CLYOPS_API_TIMEOUT * 1000,
45
+ concurrency: args.CLYOPS_API_CONCURRENCY ?? undefined,
46
+ apiKey: args.CLYOPS_API_API_KEY ?? undefined,
47
+ keys,
48
+ filter: { allow: args.CLYOPS_API_ALLOW, deny: args.CLYOPS_API_DENY, readOnly: args.CLYOPS_API_READ_ONLY },
49
+ within: within.length ? within : undefined,
50
+ maxBody: args.CLYOPS_API_MAX_BODY,
51
+ maxOutput: args.CLYOPS_API_MAX_OUTPUT,
52
+ audit: args.CLYOPS_API_AUDIT ? auditLog(args.CLYOPS_API_AUDIT) : undefined,
53
+ mcp: args.CLYOPS_API_MCP,
54
+ watch: args.CLYOPS_API_WATCH,
55
+ onReload: ({ tools }) => info('reloaded: %d tool(s)', tools.length),
56
+ version,
57
+ });
58
+ const server = app.listen(args.CLYOPS_API_PORT, args.CLYOPS_API_HOST, () => {
59
+ info('serving %d tool(s) on http://%s:%d (OpenAPI: /openapi.json%s)', current().tools.length, args.CLYOPS_API_HOST, args.CLYOPS_API_PORT, args.CLYOPS_API_MCP ? ', MCP: /mcp' : '');
60
+ });
61
+ for (const signal of ['SIGINT', 'SIGTERM'])
62
+ process.on(signal, () => server.close(() => process.exit(0)));
@@ -0,0 +1,3 @@
1
+ export * from './server.js';
2
+ export { toZod } from './zod.js';
3
+ export { loadTools, runTool, type Tool, type ToolResult } from 'clyops-tools';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // clyops-api: an HTTP API over a directory of clyops tools.
2
+ export * from './server.js';
3
+ export { toZod } from './zod.js';
4
+ export { loadTools, runTool } from 'clyops-tools';
@@ -0,0 +1,75 @@
1
+ import { type AuditEntry, type Group, type Tool, type ToolFilter, type ToolResult } from 'clyops-tools';
2
+ import { JobQueue } from 'clyops-jobs';
3
+ import { type NextFunction, type Request, type Response } from 'express';
4
+ /** A named API key and what it may run: globs over tool words (see clyops-tools' ToolFilter). */
5
+ export interface ApiKey {
6
+ key: string;
7
+ allow?: string[];
8
+ deny?: string[];
9
+ }
10
+ export interface ApiOptions {
11
+ /** Tools directory or dispatcher definition file. */
12
+ root: string;
13
+ /** API title (default: the root's name). */
14
+ name?: string;
15
+ /** Working directory tools run in (default: the server's). */
16
+ cwd?: string;
17
+ /** Required as `Authorization: Bearer KEY` or `X-API-Key: KEY` when set; it may run every tool. */
18
+ apiKey?: string;
19
+ /** Named keys, each limited to the tools its allow/deny globs let through. */
20
+ keys?: Record<string, ApiKey>;
21
+ /** Which tools to serve at all (with the root's `allow`/`deny` settings). */
22
+ filter?: ToolFilter;
23
+ /** Path-valued inputs must resolve inside these directories. */
24
+ within?: string[];
25
+ /** Kill a tool after this long (0: never). */
26
+ timeoutMs?: number;
27
+ /** Largest request body accepted: JSON, multipart, or spooled for an async job (default 10 MiB). */
28
+ maxBody?: number;
29
+ /** Keep at most this many bytes of a tool's stdout and stderr (0 or unset: all). Streamed stdout is not kept. */
30
+ maxOutput?: number;
31
+ /** Called after every run, for an audit log. */
32
+ audit?: (entry: Omit<AuditEntry, 'time'>) => void;
33
+ /** Async jobs run at the same time (default: the number of CPUs). */
34
+ concurrency?: number;
35
+ /** Also serve the tools over MCP (streamable HTTP) at /mcp (default: true). */
36
+ mcp?: boolean;
37
+ /** Pick up added, changed and removed tools without a restart (default: false; call close() to stop). */
38
+ watch?: boolean;
39
+ /** Called after each reload when watching. */
40
+ onReload?: (loaded: {
41
+ tree: Group;
42
+ tools: ApiTool[];
43
+ }) => void;
44
+ version?: string;
45
+ }
46
+ /** A tool the API serves, with the URL path of its endpoint, `/tools/<group>/.../<name>`. */
47
+ export type ApiTool = Tool & {
48
+ path: string;
49
+ };
50
+ type ApiResult = ToolResult & {
51
+ stdoutEncoding?: 'base64';
52
+ stdoutUrl?: string;
53
+ };
54
+ /**
55
+ * Build the Express app. With `watch`, the tool endpoints, the OpenAPI
56
+ * document and /mcp follow the tools directory as it changes; otherwise they
57
+ * are fixed when the app is built.
58
+ */
59
+ export declare function createApi(opts: ApiOptions): Promise<{
60
+ app: import("express-serve-static-core").Express;
61
+ queue: JobQueue<ApiResult>;
62
+ /** The tree and tools being served now. */
63
+ current: () => {
64
+ tree: Group;
65
+ tools: ApiTool[];
66
+ };
67
+ /** Stop watching (the app keeps serving the last set). */
68
+ close: () => void | undefined;
69
+ }>;
70
+ /** Errors as JSON: validation failures (400) with zod's issues, anything else 500. */
71
+ export declare function errorHandler(err: Error & {
72
+ status?: number;
73
+ errors?: unknown;
74
+ }, _req: Request, res: Response, _next: NextFunction): void;
75
+ export {};