syndai 0.3.0 → 0.4.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/CHANGELOG.md +30 -0
- package/README.md +30 -9
- package/package.json +1 -1
- package/src/api.js +202 -0
- package/src/auth.js +96 -21
- package/src/changes.js +57 -14
- package/src/errors.js +22 -2
- package/src/http.js +30 -6
- package/src/main.js +50 -13
- package/src/openapi.js +145 -0
- package/src/projects.js +1 -1
- package/src/routes.js +10 -1
- package/src/schedules.js +377 -0
- package/src/settings.js +1 -31
- package/src/store.js +3 -0
- package/src/ui.js +23 -0
- package/src/watch.js +10 -3
package/src/http.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The one HTTP client. Raw `fetch`, no SDK: `test/contract.test.js` checks every route
|
|
2
2
|
// against the published OpenAPI instead.
|
|
3
3
|
//
|
|
4
|
-
// Retry rule (docs: cli
|
|
4
|
+
// Retry rule (docs: syndai.ai/docs/cli, "Retries"): only GETs and POSTs that carry their own
|
|
5
5
|
// idempotency key are retried — callers opt in with `retry: true`. A 429 waits out
|
|
6
6
|
// `Retry-After`; a 5xx, 408, network error or `MISSION_BUSY_RETRY` backs off from 0.5 s
|
|
7
7
|
// towards 8 s with jitter. Five tries, then `UnavailableError` (exit 7).
|
|
@@ -37,9 +37,15 @@ export function interrupted() {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
40
|
+
* `onMissingScope(scope)` is asked once per request when a 403 names the scope it needs
|
|
41
|
+
* (`required_scope`); when it answers true (a new key was approved), the request is sent
|
|
42
|
+
* again with the new key. `onUnauthorized()` is asked once per request on a 401; when it
|
|
43
|
+
* answers true (another `syndai` process rotated the stored key), the request is sent again
|
|
44
|
+
* with the new key, so a long watch or MCP server outlives a login elsewhere.
|
|
45
|
+
*
|
|
46
|
+
* @param {{baseUrl: string, getKey: () => Promise<string>, io: object, onMissingScope?: (scope: string) => Promise<boolean>, onUnauthorized?: () => Promise<boolean>}} options
|
|
41
47
|
*/
|
|
42
|
-
export function createClient({ baseUrl, getKey, io }) {
|
|
48
|
+
export function createClient({ baseUrl, getKey, io, onMissingScope, onUnauthorized }) {
|
|
43
49
|
/** Call a named route from `routes.js`. */
|
|
44
50
|
function call(name, { params, ...opts } = {}) {
|
|
45
51
|
const [method, path] = route(name, params);
|
|
@@ -53,15 +59,16 @@ export function createClient({ baseUrl, getKey, io }) {
|
|
|
53
59
|
) {
|
|
54
60
|
const url = new URL(baseUrl + path);
|
|
55
61
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
56
|
-
if (value
|
|
57
|
-
|
|
58
|
-
}
|
|
62
|
+
if (Array.isArray(value)) for (const item of value) url.searchParams.append(key, String(item));
|
|
63
|
+
else if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
59
64
|
}
|
|
60
65
|
const headers = { "user-agent": USER_AGENT, accept: "application/json" };
|
|
61
66
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
62
67
|
if (auth) headers.authorization = `Bearer ${await getKey()}`;
|
|
63
68
|
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
64
69
|
|
|
70
|
+
let steppedUp = false;
|
|
71
|
+
let reloaded = false;
|
|
65
72
|
for (let attempt = 1; ; attempt++) {
|
|
66
73
|
let res;
|
|
67
74
|
let parsed;
|
|
@@ -82,6 +89,23 @@ export function createClient({ baseUrl, getKey, io }) {
|
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
const problem = res.ok ? null : new ApiError(res.status, parsed, res.headers);
|
|
92
|
+
const scope = problem?.code === "INVALID_SCOPE" ? problem.problem.required_scope : null;
|
|
93
|
+
if (scope && auth && onMissingScope && !steppedUp) {
|
|
94
|
+
steppedUp = true;
|
|
95
|
+
if (await onMissingScope(scope)) {
|
|
96
|
+
headers.authorization = `Bearer ${await getKey()}`;
|
|
97
|
+
attempt = 0;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (res.status === 401 && auth && onUnauthorized && !reloaded) {
|
|
102
|
+
reloaded = true;
|
|
103
|
+
if (await onUnauthorized()) {
|
|
104
|
+
headers.authorization = `Bearer ${await getKey()}`;
|
|
105
|
+
attempt = 0;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
85
109
|
const transient =
|
|
86
110
|
problem !== null &&
|
|
87
111
|
(res.status >= 500 ||
|
package/src/main.js
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { createContext, login, logout, whoami } from "./auth.js";
|
|
7
|
-
import { answer, cancel, change, editText, goLive, review, revert, status, watch } from "./changes.js";
|
|
7
|
+
import { answer, cancel, change, editText, goLive, newProject, review, revert, status, watch } from "./changes.js";
|
|
8
8
|
import { CliError, EXIT, explain } from "./errors.js";
|
|
9
9
|
import { VERSION } from "./http.js";
|
|
10
10
|
import { mcp } from "./mcp.js";
|
|
11
11
|
import { link } from "./projects.js";
|
|
12
|
-
import { api
|
|
12
|
+
import { api } from "./api.js";
|
|
13
|
+
import { repeat, schedule } from "./schedules.js";
|
|
14
|
+
import { settings } from "./settings.js";
|
|
13
15
|
import { closeInput, openBrowser, page } from "./ui.js";
|
|
14
16
|
|
|
15
17
|
const str = { type: "string" };
|
|
@@ -28,17 +30,26 @@ const COMMANDS = {
|
|
|
28
30
|
timeout: str,
|
|
29
31
|
},
|
|
30
32
|
run: (ctx, args) => change(ctx, args),
|
|
33
|
+
stepUp: false,
|
|
34
|
+
},
|
|
35
|
+
new: {
|
|
36
|
+
usage: 'new "<what>" [--no-learn] [--no-wait] [--timeout D]',
|
|
37
|
+
summary: "Start a new project; a product URL in the text builds your own version of it.",
|
|
38
|
+
options: { "no-learn": bool, "no-wait": bool, timeout: str },
|
|
39
|
+
run: (ctx, args) => newProject(ctx, args),
|
|
40
|
+
stepUp: false,
|
|
31
41
|
},
|
|
32
42
|
watch: {
|
|
33
43
|
usage: "watch <run> [--timeout D] [--logs]",
|
|
34
44
|
summary: "Follow a change to its outcome (Ctrl-C detaches; the change keeps going).",
|
|
35
45
|
options: { timeout: str, logs: bool },
|
|
36
46
|
run: (ctx, [run]) => watch(ctx, run),
|
|
47
|
+
stepUp: false,
|
|
37
48
|
},
|
|
38
49
|
answer: {
|
|
39
|
-
usage: "answer <run> [--
|
|
40
|
-
summary:
|
|
41
|
-
options: {
|
|
50
|
+
usage: "answer <run> [--input FILE|-]",
|
|
51
|
+
summary: 'Answer a change\'s open question (--input: {"entries":[...]} from a file or stdin).',
|
|
52
|
+
options: { input: str },
|
|
42
53
|
run: (ctx, [run]) => answer(ctx, run),
|
|
43
54
|
},
|
|
44
55
|
review: {
|
|
@@ -52,12 +63,14 @@ const COMMANDS = {
|
|
|
52
63
|
summary: "Put a ready change live.",
|
|
53
64
|
options: { timeout: str },
|
|
54
65
|
run: (ctx, [run]) => goLive(ctx, run),
|
|
66
|
+
stepUp: false,
|
|
55
67
|
},
|
|
56
68
|
revert: {
|
|
57
69
|
usage: "revert <run> [--force] [--timeout D]",
|
|
58
70
|
summary: "Undo a change that went live.",
|
|
59
71
|
options: { force: bool, timeout: str },
|
|
60
72
|
run: (ctx, [run]) => revert(ctx, run),
|
|
73
|
+
stepUp: false,
|
|
61
74
|
},
|
|
62
75
|
cancel: {
|
|
63
76
|
usage: "cancel <run>",
|
|
@@ -78,9 +91,10 @@ const COMMANDS = {
|
|
|
78
91
|
run: (ctx) => editText(ctx),
|
|
79
92
|
},
|
|
80
93
|
login: {
|
|
81
|
-
usage: "login [--with-token]",
|
|
82
|
-
summary:
|
|
83
|
-
|
|
94
|
+
usage: "login [--with-token] [--scope S]...",
|
|
95
|
+
summary:
|
|
96
|
+
"Log in by approving this computer in your browser (--scope adds access to the key's; --with-token reads a key from stdin).",
|
|
97
|
+
options: { "with-token": bool, scope: { type: "string", multiple: true } },
|
|
84
98
|
run: (ctx) => login(ctx),
|
|
85
99
|
},
|
|
86
100
|
logout: {
|
|
@@ -113,10 +127,33 @@ const COMMANDS = {
|
|
|
113
127
|
options: {},
|
|
114
128
|
run: (ctx, args) => mcp(ctx, args),
|
|
115
129
|
},
|
|
130
|
+
schedule: {
|
|
131
|
+
usage:
|
|
132
|
+
'schedule list [--app A | --all] | show <id> | add "<what>" --every E [--at T] [--tz Z] [--budget USD] [--app A]\n' +
|
|
133
|
+
" | edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget USD|none]\n" +
|
|
134
|
+
" | pause | resume | run-now | delete <id> [--yes]",
|
|
135
|
+
summary:
|
|
136
|
+
'A project\'s recurring runs. E: hourly (--at :MM), daily or weekdays (--at HH:MM), weekly (--at "mon HH:MM"), or a 5-field cron.',
|
|
137
|
+
options: { app: str, all: bool, every: str, at: str, tz: str, budget: str, prompt: str, yes: bool },
|
|
138
|
+
run: (ctx, args) => schedule(ctx, args),
|
|
139
|
+
},
|
|
140
|
+
repeat: {
|
|
141
|
+
usage: "repeat <run> --every E [--at T] [--tz Z] [--budget USD]",
|
|
142
|
+
summary: "Run a change you liked again on a schedule (its first run is the next scheduled time).",
|
|
143
|
+
options: { every: str, at: str, tz: str, budget: str },
|
|
144
|
+
run: (ctx, args) => repeat(ctx, args),
|
|
145
|
+
},
|
|
116
146
|
api: {
|
|
117
|
-
usage: "api <METHOD> <path> [--
|
|
118
|
-
summary: "Call any Syndai API
|
|
119
|
-
options: {
|
|
147
|
+
usage: "api [<operationId> | <METHOD> <path>] [-f k=v]... [-F k=v]... [--input FILE|-] [--paginate] [--app A]",
|
|
148
|
+
summary: "Call any Syndai API operation with your key (no arguments: list them; <operationId> --help: its fields).",
|
|
149
|
+
options: {
|
|
150
|
+
"raw-field": { type: "string", short: "f", multiple: true },
|
|
151
|
+
field: { type: "string", short: "F", multiple: true },
|
|
152
|
+
input: str,
|
|
153
|
+
paginate: bool,
|
|
154
|
+
app: str,
|
|
155
|
+
},
|
|
156
|
+
ownHelp: true,
|
|
120
157
|
run: (ctx, args) => api(ctx, args),
|
|
121
158
|
},
|
|
122
159
|
};
|
|
@@ -175,11 +212,11 @@ export async function main(argv, io) {
|
|
|
175
212
|
io.stderr.write(`error: ${err.message}\n usage: syndai ${spec.usage}\n`);
|
|
176
213
|
return EXIT.USAGE;
|
|
177
214
|
}
|
|
178
|
-
if (parsed.values.help) {
|
|
215
|
+
if (parsed.values.help && !spec.ownHelp) {
|
|
179
216
|
io.stdout.write(`usage: syndai ${spec.usage}\n${spec.summary}\n`);
|
|
180
217
|
return EXIT.OK;
|
|
181
218
|
}
|
|
182
|
-
const ctx = createContext(io, parsed.values);
|
|
219
|
+
const ctx = createContext(io, parsed.values, { stepUp: spec.stepUp !== false });
|
|
183
220
|
try {
|
|
184
221
|
return await spec.run(ctx, parsed.positionals);
|
|
185
222
|
} catch (err) {
|
package/src/openapi.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The live API description behind `syndai api <operationId>`. The server's public
|
|
2
|
+
// `/openapi.json` is the one source, so a new route is callable the day it deploys. It is
|
|
3
|
+
// cached per host for 24 hours ($XDG_CACHE_HOME/syndai/openapi-<host>.json) and fetched again
|
|
4
|
+
// once when an operationId is not in the cached copy. The server sends no ETag, and a ~27 KB
|
|
5
|
+
// gzipped fetch a day does not justify one.
|
|
6
|
+
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { CliError, EXIT } from "./errors.js";
|
|
9
|
+
import { readJson, writeFileAtomic } from "./store.js";
|
|
10
|
+
import { hostOf } from "./ui.js";
|
|
11
|
+
|
|
12
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
export const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
14
|
+
const EXT_PREFIX = /^\/api\/v1\/(external\/coding\/)?/;
|
|
15
|
+
|
|
16
|
+
function cacheFile(ctx) {
|
|
17
|
+
const host = (hostOf(ctx.baseUrl) ?? "api").replace(/[^\w.-]/g, "_");
|
|
18
|
+
return path.join(ctx.paths.cache, `openapi-${host}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The operations, from the cache when it is fresh (or `refresh` is false and it exists). */
|
|
22
|
+
export async function loadOperations(ctx, { refresh = false } = {}) {
|
|
23
|
+
const file = cacheFile(ctx);
|
|
24
|
+
let cached = null;
|
|
25
|
+
try {
|
|
26
|
+
cached = readJson(file, null);
|
|
27
|
+
} catch {
|
|
28
|
+
// A corrupt cache is refetched, never an error.
|
|
29
|
+
}
|
|
30
|
+
if (!refresh && cached?.spec && ctx.io.now() - cached.fetched_at < TTL_MS) {
|
|
31
|
+
return { operations: index(cached.spec), fresh: false };
|
|
32
|
+
}
|
|
33
|
+
const spec = await ctx.client.send("GET", "/openapi.json", { auth: false });
|
|
34
|
+
writeFileAtomic(file, JSON.stringify({ fetched_at: ctx.io.now(), spec }), 0o644);
|
|
35
|
+
return { operations: index(spec), fresh: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One operation by id (exact, or ignoring case, `-` and `_`); refetches once when unknown. */
|
|
39
|
+
export async function findOperation(ctx, id) {
|
|
40
|
+
let { operations, fresh } = await loadOperations(ctx);
|
|
41
|
+
let op = lookup(operations, id);
|
|
42
|
+
if (!op && !fresh) {
|
|
43
|
+
({ operations } = await loadOperations(ctx, { refresh: true }));
|
|
44
|
+
op = lookup(operations, id);
|
|
45
|
+
}
|
|
46
|
+
if (op) return op;
|
|
47
|
+
const near = closest(operations, id);
|
|
48
|
+
throw new CliError(EXIT.USAGE, `No API operation is called "${id}".`, {
|
|
49
|
+
next: near.length ? `did you mean: ${near.join(", ")}? (syndai api lists them all)` : "syndai api",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The operation a concrete METHOD + path calls, or null. */
|
|
54
|
+
export function matchPath(operations, method, concretePath) {
|
|
55
|
+
return (
|
|
56
|
+
operations.find(
|
|
57
|
+
(op) =>
|
|
58
|
+
op.method === method &&
|
|
59
|
+
new RegExp(`^${op.path.replace(/[.*+?^$()|[\]\\]/g, "\\$&").replace(/\\?\{[^}]+\\?\}/g, "[^/]+")}$`).test(
|
|
60
|
+
concretePath,
|
|
61
|
+
),
|
|
62
|
+
) ?? null
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const norm = (name) => name.toLowerCase().replace(/[-_]/g, "");
|
|
67
|
+
|
|
68
|
+
function lookup(operations, id) {
|
|
69
|
+
return operations.find((op) => op.id === id) ?? operations.find((op) => norm(op.id) === norm(id)) ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function closest(operations, id, count = 3) {
|
|
73
|
+
const wanted = norm(id);
|
|
74
|
+
return operations
|
|
75
|
+
.map((op) => [distance(norm(op.id), wanted), op.id])
|
|
76
|
+
.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]))
|
|
77
|
+
.slice(0, count)
|
|
78
|
+
.map(([, name]) => name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function distance(a, b) {
|
|
82
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
83
|
+
for (let i = 1; i <= a.length; i++) {
|
|
84
|
+
const row = [i];
|
|
85
|
+
for (let j = 1; j <= b.length; j++) {
|
|
86
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
87
|
+
}
|
|
88
|
+
prev = row;
|
|
89
|
+
}
|
|
90
|
+
return prev[b.length];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Flatten an OpenAPI document to `[{id, method, path, group, summary, params, body}]`. */
|
|
94
|
+
export function index(spec) {
|
|
95
|
+
const resolve = (schema) => {
|
|
96
|
+
const ref = schema?.$ref;
|
|
97
|
+
return ref ? (spec.components?.schemas?.[ref.split("/").pop()] ?? {}) : (schema ?? {});
|
|
98
|
+
};
|
|
99
|
+
const operations = [];
|
|
100
|
+
for (const [opPath, item] of Object.entries(spec.paths ?? {})) {
|
|
101
|
+
for (const method of HTTP_METHODS) {
|
|
102
|
+
const op = item[method.toLowerCase()];
|
|
103
|
+
if (!op?.operationId) continue;
|
|
104
|
+
const params = [...(item.parameters ?? []), ...(op.parameters ?? [])].map((p) => ({
|
|
105
|
+
name: p.name,
|
|
106
|
+
in: p.in,
|
|
107
|
+
required: Boolean(p.required),
|
|
108
|
+
}));
|
|
109
|
+
const schemaRef = op.requestBody?.content?.["application/json"]?.schema;
|
|
110
|
+
const schema = resolve(schemaRef);
|
|
111
|
+
const required = new Set(schema.required ?? []);
|
|
112
|
+
const body = schemaRef
|
|
113
|
+
? {
|
|
114
|
+
schema: schemaRef.$ref?.split("/").pop() ?? null,
|
|
115
|
+
fields: Object.entries(schema.properties ?? {}).map(([name, prop]) => ({
|
|
116
|
+
name,
|
|
117
|
+
type: typeName(prop),
|
|
118
|
+
required: required.has(name),
|
|
119
|
+
})),
|
|
120
|
+
}
|
|
121
|
+
: null;
|
|
122
|
+
operations.push({
|
|
123
|
+
id: op.operationId,
|
|
124
|
+
method,
|
|
125
|
+
path: opPath,
|
|
126
|
+
group: opPath.replace(EXT_PREFIX, "").split("/")[0] || "other",
|
|
127
|
+
summary: op.summary ?? "",
|
|
128
|
+
params,
|
|
129
|
+
body,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return operations;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function typeName(schema) {
|
|
137
|
+
const options = schema.oneOf ?? schema.anyOf;
|
|
138
|
+
if (options) return options.map((o) => o.type ?? o.$ref?.split("/").pop() ?? "object").join(" | ");
|
|
139
|
+
return schema.type ?? schema.$ref?.split("/").pop() ?? "object";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `/api/v1/external/coding/runs` -> `/runs`, for compact listings. */
|
|
143
|
+
export function shortPath(opPath) {
|
|
144
|
+
return opPath.startsWith("/api/v1/external/coding/") ? opPath.slice("/api/v1/external/coding".length) : opPath;
|
|
145
|
+
}
|
package/src/projects.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Which project a command acts on. Order (docs: cli
|
|
1
|
+
// Which project a command acts on. Order (docs: syndai.ai/docs/cli, "Choosing the project"):
|
|
2
2
|
// --app, SYNDAI_APP, the link for this directory or a parent, a git remote that matches a
|
|
3
3
|
// connected repo, the only project, a picker on a terminal. Otherwise exit 2 with the list.
|
|
4
4
|
//
|
package/src/routes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Every REST route the CLI calls, in one table. Code builds paths only through `path()`,
|
|
2
2
|
// so `test/contract.test.js` can prove each one exists, with the same method, in the
|
|
3
3
|
// published OpenAPI (`docs/api/external-coding-v1.openapi.json`). `syndai api` is the one
|
|
4
|
-
//
|
|
4
|
+
// command that bypasses this table: it reads the live OpenAPI instead (openapi.js).
|
|
5
5
|
|
|
6
6
|
const EXT = "/api/v1/external/coding";
|
|
7
7
|
|
|
@@ -27,6 +27,7 @@ export const ROUTES = Object.freeze({
|
|
|
27
27
|
imageIngest: ["POST", `${EXT}/runs/image-ingest`],
|
|
28
28
|
createRun: ["POST", `${EXT}/runs`],
|
|
29
29
|
listRuns: ["GET", `${EXT}/runs`],
|
|
30
|
+
getRun: ["GET", `${EXT}/runs/{coding_run_id}`],
|
|
30
31
|
changeState: ["GET", `${EXT}/runs/{run_id}/change`],
|
|
31
32
|
runEvents: ["GET", `${EXT}/runs/{run_id}/events`],
|
|
32
33
|
respond: ["POST", `${EXT}/runs/{run_id}/responses`],
|
|
@@ -36,6 +37,14 @@ export const ROUTES = Object.freeze({
|
|
|
36
37
|
landClose: ["POST", `${EXT}/runs/{run_id}/land/close`],
|
|
37
38
|
goLive: ["POST", `${EXT}/runs/{run_id}/land/go-live`],
|
|
38
39
|
heldDiff: ["GET", `${EXT}/runs/{run_id}/land/held-diff`],
|
|
40
|
+
listSchedules: ["GET", `${EXT}/schedules`],
|
|
41
|
+
createSchedule: ["POST", `${EXT}/schedules`],
|
|
42
|
+
getSchedule: ["GET", `${EXT}/schedules/{schedule_id}`],
|
|
43
|
+
updateSchedule: ["PATCH", `${EXT}/schedules/{schedule_id}`],
|
|
44
|
+
deleteSchedule: ["DELETE", `${EXT}/schedules/{schedule_id}`],
|
|
45
|
+
pauseSchedule: ["POST", `${EXT}/schedules/{schedule_id}/pause`],
|
|
46
|
+
resumeSchedule: ["POST", `${EXT}/schedules/{schedule_id}/resume`],
|
|
47
|
+
runScheduleNow: ["POST", `${EXT}/schedules/{schedule_id}/run-now`],
|
|
39
48
|
});
|
|
40
49
|
|
|
41
50
|
/** The MCP endpoint `syndai mcp install` points clients at (JSON-RPC, not REST). */
|