syndai 0.3.0 → 0.4.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/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.mdx "Retries"): only GETs and POSTs that carry their own
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,13 @@ export function interrupted() {
37
37
  }
38
38
 
39
39
  /**
40
- * @param {{baseUrl: string, getKey: () => Promise<string>, io: object}} options
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.
43
+ *
44
+ * @param {{baseUrl: string, getKey: () => Promise<string>, io: object, onMissingScope?: (scope: string) => Promise<boolean>}} options
41
45
  */
42
- export function createClient({ baseUrl, getKey, io }) {
46
+ export function createClient({ baseUrl, getKey, io, onMissingScope }) {
43
47
  /** Call a named route from `routes.js`. */
44
48
  function call(name, { params, ...opts } = {}) {
45
49
  const [method, path] = route(name, params);
@@ -53,15 +57,15 @@ export function createClient({ baseUrl, getKey, io }) {
53
57
  ) {
54
58
  const url = new URL(baseUrl + path);
55
59
  for (const [key, value] of Object.entries(query ?? {})) {
56
- if (value !== undefined && value !== null && value !== "") {
57
- url.searchParams.set(key, String(value));
58
- }
60
+ if (Array.isArray(value)) for (const item of value) url.searchParams.append(key, String(item));
61
+ else if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
59
62
  }
60
63
  const headers = { "user-agent": USER_AGENT, accept: "application/json" };
61
64
  if (body !== undefined) headers["content-type"] = "application/json";
62
65
  if (auth) headers.authorization = `Bearer ${await getKey()}`;
63
66
  const payload = body === undefined ? undefined : JSON.stringify(body);
64
67
 
68
+ let steppedUp = false;
65
69
  for (let attempt = 1; ; attempt++) {
66
70
  let res;
67
71
  let parsed;
@@ -82,6 +86,15 @@ export function createClient({ baseUrl, getKey, io }) {
82
86
  }
83
87
 
84
88
  const problem = res.ok ? null : new ApiError(res.status, parsed, res.headers);
89
+ const scope = problem?.code === "INVALID_SCOPE" ? problem.problem.required_scope : null;
90
+ if (scope && auth && onMissingScope && !steppedUp) {
91
+ steppedUp = true;
92
+ if (await onMissingScope(scope)) {
93
+ headers.authorization = `Bearer ${await getKey()}`;
94
+ attempt = 0;
95
+ continue;
96
+ }
97
+ }
85
98
  const transient =
86
99
  problem !== null &&
87
100
  (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, settings } from "./settings.js";
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> [--json '{\"entries\":[...]}']",
40
- summary: "Answer a change's open question.",
41
- options: { json: str },
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: "Log in by approving this computer in your browser (--with-token reads a key from stdin).",
83
- options: { "with-token": bool },
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> [--json body]",
118
- summary: "Call any Syndai API route with your key.",
119
- options: { json: str },
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.mdx "Choosing the project"):
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
- // escape hatch that bypasses this table.
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). */