syndai 0.1.1 → 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/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `syndai` npm package. Versions follow semver.
4
+
5
+ ## 0.4.0
6
+
7
+ Every published API operation, and a project's recurring runs.
8
+
9
+ - `api <operationId>` calls any operation in the live API description (`/openapi.json`,
10
+ cached for 24 hours). `api` lists them; `api <operationId> --help` shows the parameters
11
+ and body. Fields come gh-style: `-f key=value` (string), `-F key=value` (true, false,
12
+ null, a number, or `@file`), `key[]=value` for a list. `--input FILE|-` sends a whole
13
+ body. `--paginate` follows `next_cursor` and prints one list. The workspace and this
14
+ directory's project fill required path parameters. `api <METHOD> <path>` still works.
15
+ - `schedule list|show|add|edit|pause|resume|run-now|delete`: a project's recurring runs,
16
+ with cadence presets (`--every hourly|daily|weekdays|weekly` and `--at`, or a cron),
17
+ `--tz`, and a per-run cap (`--budget`).
18
+ - `repeat <run> --every ...` repeats a run you liked. The server copies its original
19
+ prompt and project; the first run is the next scheduled time.
20
+ - `login --scope S` asks for more access. A command that needs a scope the stored key
21
+ lacks asks once, on a terminal, to approve it in the browser and then retries.
22
+ `SYNDAI_API_KEY` never does this. Re-login now revokes the key it replaces.
23
+
24
+ Breaking:
25
+
26
+ - `--json` is machine output on every command. The request body on `api` and `answer`
27
+ is now `--input FILE|-` (the old string `--json` body is gone).
28
+
29
+ ## 0.3.0
30
+
31
+ The package becomes the Syndai owner CLI.
32
+
33
+ - `login` (browser approval, RFC 8628 device flow), `login --with-token`, `logout`, `whoami`.
34
+ Keys live in `$XDG_CONFIG_HOME/syndai/credentials.json` (0600), one per workspace;
35
+ `SYNDAI_API_KEY` wins. There is no `--api-key` flag.
36
+ - `change` creates a change and follows it to the outcome. Its request id is saved before
37
+ the request, so a retry or a rerun after a crash never makes a second change.
38
+ - `watch` (with `--logs`), `answer`, `review`, `go-live`, `revert`, `cancel`, `status`,
39
+ `edit-text`, `link`, `settings`, `api`.
40
+ - `mcp install claude|cursor|codex` writes the Syndai MCP server into that tool's user
41
+ config with its own key.
42
+ - Exit codes 0-7 and 130 are a stable contract; `--json` (or a non-terminal stdout) prints
43
+ machine-readable results.
44
+ - Node 22 or newer. Zero dependencies. No telemetry.
45
+
46
+ Removed: the 0.2.0 `connect` pointer (connecting a Claude or ChatGPT plan happens in the
47
+ portal, under Connections).
48
+
49
+ ## 0.2.0
50
+
51
+ Never published. `connect` printed a pointer to the portal.
52
+
53
+ ## 0.1.1
54
+
55
+ Published from the `solozerolabs/syndai-connect` mirror: the retired `connect` pairing flow.
package/README.md CHANGED
@@ -1,62 +1,107 @@
1
- # syndai connect
1
+ # syndai
2
2
 
3
- Connect your **Claude** and **ChatGPT** plans to a Syndai workspace with one
4
- command:
3
+ The Syndai owner CLI. Say what to change; Syndai builds it, runs every check, puts
4
+ it live and tells you the URL. Nobody babysits.
5
5
 
6
6
  ```bash
7
- npx -y syndai connect <code>
7
+ npx -y syndai@latest login
8
+ npx -y syndai@latest change "make the header blue"
9
+ # Changing bakery.syndai.app · goes live when checks pass
8
10
  ```
9
11
 
10
- Get `<code>` from your Syndai portal → **Connections → Connect a plan**. It is a
11
- single-use code with a 15-minute TTL.
12
-
13
- ## What it does
14
-
15
- 1. **Claims** the code and shows you where it will connect:
16
- `Connect to workspace "Acme" (s••@acme.com)? Phrase: amber-falcon-42 [y/N]` —
17
- default **No**, no `--yes` flag. Confirm the phrase matches what the portal
18
- shows, then the portal owner clicks **Approve**.
19
- 2. **Claude:** if `claude` is on your PATH, mints a fresh plan token with
20
- `claude setup-token` (under a PTY, so its full-screen UI works) and verifies it
21
- with the official CLI before uploading. If `claude` is not installed, it tells
22
- you to run `claude setup-token` yourself and paste the `sk-ant-oat01-…` value —
23
- the paste is hidden.
24
- 3. **ChatGPT:** if `codex` is on your PATH, runs `codex login` into a throwaway
25
- `CODEX_HOME` (Syndai keeps its own login lineage — copying your laptop's
26
- `auth.json` would log one of you out), captures the resulting `auth.json`, and
27
- deletes the temp directory.
28
- 4. **Uploads** whatever it collected in exactly one call. The portal flips to
29
- **Connected ✓**.
30
-
31
- One code connects both providers if both CLIs are present.
32
-
33
- ## It stores nothing, and echoes no token
34
-
35
- The tool holds your token only in memory for the single upload. It never writes it
36
- to disk, never puts it on the clipboard, and never prints it to your terminal or
37
- scrollback. Pasted tokens are read with terminal echo off.
38
-
39
- ## Configuration
40
-
41
- - `SYNDAI_API_URL` — override the API base (default `https://api.syndai.ai`).
42
-
43
- ## Requirements
44
-
45
- - Node.js **>= 18** (dependency-free — Node builtins only).
46
- - `claude` and/or `codex` on your PATH for the one-command path; otherwise the
47
- Claude paste fallback still works.
48
-
49
- ## Source & provenance
50
-
51
- This package is developed in the Syndai monorepo under `tools/syndai-connect/`
52
- and **mirrored to the public repo
53
- [`solozerolabs/syndai-connect`](https://github.com/solozerolabs/syndai-connect)**
54
- for the npm provenance publish (owner action **O1**) — a private monorepo cannot
55
- publish with sigstore provenance. Published as `syndai` with
56
- `publishConfig.provenance`.
12
+ Node 22 or newer, zero dependencies, no telemetry. The customer guide is the docs
13
+ site's **CLI** page, https://syndai.ai/docs/cli (`web/content/docs/cli.mdx`); this
14
+ README is for whoever works on the package.
15
+
16
+ ## Commands
17
+
18
+ | command | does |
19
+ |---|---|
20
+ | `login [--with-token] [--scope S]...`, `logout`, `whoami` | browser-approved key (device flow); `--scope` asks for access on top of the key's current scopes; `--with-token` reads a key from stdin. Re-login saves the new key, then revokes the old one |
21
+ | `link [project]` | remember which project this directory changes (`$XDG_CONFIG_HOME/syndai/links.json`, never in the repo) |
22
+ | `change "<what>" [--app A] [--review] [--image F]... [--no-wait] [--timeout D]` | start a change and follow it to the outcome |
23
+ | `new "<what>" [--no-learn] [--no-wait] [--timeout D]` | start a New project; a product URL in the text builds your own version of it (its feature pick is asked inline, Enter keeps the pre-checked core) |
24
+ | `edit-text [--app A] [--page /] --from "..." --to "..."` / `--undo <sha>` | instant, free, undoable text swap on a live site |
25
+ | `watch <run> [--timeout D] [--logs]` | follow any change; `--logs` tails its events |
26
+ | `answer <run> [--input FILE\|-]` | answer an open question (`--input`: `{"entries":[...]}` from a file or stdin) |
27
+ | `review <run> [--retry \| --close]` | see why a change is held, then approve it in the browser |
28
+ | `go-live`, `revert`, `cancel <run>` | land actions |
29
+ | `status [--app A]` | what is running, and everything that needs you |
30
+ | `settings [--app A] [get \| set k=v...]` | `autonomy`, `feature_requests`, `bug_scan`, `protected_paths` (`+=` adds) |
31
+ | `mcp install claude\|cursor\|codex` | add the Syndai MCP server to that tool's user config, with its own key |
32
+ | `schedule list [--app A \| --all]`, `show <id>` | a project's recurring runs: cadence, next fires, History |
33
+ | `schedule add "<what>" --every E [--at T] [--tz Z] [--budget USD] [--app A]` | create one; `E` is `hourly` (`--at :MM`), `daily`/`weekdays` (`--at HH:MM`), `weekly` (`--at "mon HH:MM"`) or a 5-field cron |
34
+ | `schedule edit <id> [--prompt P] [--every E] [--at T] [--tz Z] [--budget USD\|none]` | change only what differs |
35
+ | `schedule pause \| resume \| run-now \| delete <id> [--yes]` | manage one (`delete` asks on a terminal, needs `--yes` off one) |
36
+ | `repeat <run> --every E [--at T] [--tz Z] [--budget USD]` | repeat a run you liked; the server copies its prompt and project; the first run is the next scheduled time |
37
+ | `api` / `api <operationId> --help` | list every published operation / show one's parameters and body |
38
+ | `api <operationId> \| <METHOD> <path> [-f k=v]... [-F k=v]... [--input FILE\|-] [--paginate] [--app A]` | call it with your key (`-f` string, `-F` typed, `k[]=v` lists) |
39
+
40
+ A 403 that names a missing scope asks, on a terminal with a stored login, to approve
41
+ it in the browser and retries once. `SYNDAI_API_KEY` never steps up; off a terminal the
42
+ error names `syndai login --scope <scope>`. Members, keys and unpublish are portal-only.
43
+
44
+ Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs you,
45
+ 4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
46
+ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
47
+
48
+ ## Layout
49
+
50
+ - `bin/syndai.js`: the entrypoint; wires SIGINT to an AbortSignal.
51
+ - `src/main.js`: the command table, argument parsing (`util.parseArgs`), the one error report.
52
+ - `src/routes.js`: every REST route the CLI calls. Code builds paths only through it.
53
+ - `src/http.js`: `fetch`, retries (GETs and keyed POSTs only), `User-Agent: syndai-cli/<v>`.
54
+ - `src/errors.js`: exit codes; a problem+json `code` becomes one sentence plus the next command.
55
+ - `src/auth.js`, `src/store.js`: credentials (0600, by workspace), links, pending change ids.
56
+ - `src/projects.js`: which project a command acts on.
57
+ - `src/watch.js`: the ChangeState long-poll loop, questions, `--logs`.
58
+ - `src/openapi.js`: the live `/openapi.json` (cached 24h per host in `$XDG_CACHE_HOME/syndai`).
59
+ - `src/api.js`: `syndai api` (operations by id, fields, `--paginate`).
60
+ - `src/schedules.js`: `schedule` and `repeat` (the preset to cron mapping of `apps/portal-v2/src/lib/cadence.ts`).
61
+ - `src/changes.js`, `src/settings.js`, `src/mcp.js`: the other commands.
57
62
 
58
63
  ## Development
59
64
 
60
65
  ```bash
61
- node --test test/ # pure-helper unit tests (no network, no subprocess)
66
+ cd tools/syndai-connect && node --test
62
67
  ```
68
+
69
+ CI runs it in its own **CLI Checks** job (`pr-ci.yml`, `main-ci.yml`) whenever
70
+ `tools/syndai-connect/**`, `docs/api/**` or the portal's `cadence.ts` changes; it is
71
+ part of **PR Gate** and deploys nothing.
72
+
73
+ The suite runs a fake API on 127.0.0.1 (serving `/openapi.json` from the snapshot) and
74
+ drives the CLI in-process (plus two child-process tests for Ctrl-C and a clean exit).
75
+ `test/schedule.test.js` imports `apps/portal-v2/src/lib/cadence.ts` to prove the
76
+ `--every` presets give the portal's cron; that needs a Node that strips TypeScript
77
+ types (23.6+, as `.nvmrc`), and is skipped on Node 22. `test/contract.test.js` checks
78
+ every route in `src/routes.js` against the published OpenAPI,
79
+ `docs/api/external-coding-v1.openapi.json`. It goes red when a backend route the
80
+ CLI uses is renamed or not yet published; regenerate the snapshot with
81
+ `cd backend && uv run python scripts/dump_external_openapi.py`, or check a fresh
82
+ render without touching it:
83
+ `SYNDAI_OPENAPI_JSON=/tmp/dump.json node --test test/contract.test.js`.
84
+
85
+ ## Publishing (maintainers)
86
+
87
+ Never `npm publish` by hand. `.github/workflows/publish-cli.yml` publishes on a
88
+ `cli-v<version>` tag, with the same trust boundary as `publish-sync.yml`:
89
+
90
+ 1. Bump `version` in `package.json` and add a `CHANGELOG.md` entry in a gated commit.
91
+ 2. Once that commit is on `main` and CI is green, push a lightweight tag on exactly
92
+ that commit: `git tag cli-v<version> <sha> && git push Syndai refs/tags/cli-v<version>`.
93
+ 3. The `build` job (no publish credential) checks the tag matches the version and
94
+ sits on `main`, skips a version already on npm, runs `node --test`, packs with
95
+ `npm pack` and checks the tarball. The `publish` job only downloads that tarball
96
+ and publishes it tokenlessly (npm trusted publishing). No provenance: npm does
97
+ not generate it from a private repository.
98
+
99
+ One-time owner setup (this retires the `solozerolabs/syndai-connect` mirror, which
100
+ published 0.1.1):
101
+
102
+ 1. GitHub: add `cli-v*` to the `npm-publish` environment's deployment rule, and a
103
+ tag ruleset on `refs/tags/cli-v*` restricting create, update and delete (admins
104
+ bypass), as for `sync-v*`.
105
+ 2. npm: on the `syndai` package, replace the trusted publisher with
106
+ `solozerolabs/Syndai`, workflow `publish-cli.yml`, environment `npm-publish`,
107
+ and keep "disallow tokens".
package/bin/syndai.js CHANGED
@@ -1,33 +1,17 @@
1
1
  #!/usr/bin/env node
2
- // Entrypoint for the `syndai` CLI. One subcommand today: `connect <code>`.
2
+ // Entrypoint for the `syndai` owner CLI (`npx -y syndai@latest <command>`).
3
+ //
4
+ // Ctrl-C aborts the current wait and the command exits 130 after printing how to resume;
5
+ // it never cancels a change. A second Ctrl-C exits at once.
3
6
 
4
- import { runConnect } from "../src/connect.js";
7
+ import { main, processIo } from "../src/main.js";
5
8
 
6
- async function main(argv) {
7
- const [subcommand, ...rest] = argv;
8
- switch (subcommand) {
9
- case "connect":
10
- return runConnect(rest[0]);
11
- case undefined:
12
- case "-h":
13
- case "--help":
14
- case "help":
15
- console.log("syndai — connect your Claude and ChatGPT plans to a Syndai workspace");
16
- console.log("");
17
- console.log("usage: npx -y syndai connect <code>");
18
- console.log("");
19
- console.log("Get <code> from your Syndai portal: Connections → Connect a plan.");
20
- return subcommand ? 0 : 2;
21
- default:
22
- console.error(`syndai: unknown command "${subcommand}"`);
23
- console.error("usage: npx -y syndai connect <code>");
24
- return 2;
25
- }
26
- }
9
+ const interrupt = new AbortController();
10
+ process.once("SIGINT", () => {
11
+ interrupt.abort();
12
+ process.once("SIGINT", () => process.exit(130));
13
+ });
27
14
 
28
- main(process.argv.slice(2))
29
- .then((code) => process.exit(code || 0))
30
- .catch((e) => {
31
- console.error(`syndai: ${e && e.message ? e.message : e}`);
32
- process.exit(1);
33
- });
15
+ process.exitCode = await main(process.argv.slice(2), processIo(interrupt.signal));
16
+ // Idle keep-alive sockets or a stray child must not hold the result back.
17
+ setTimeout(() => process.exit(), 1000).unref();
package/package.json CHANGED
@@ -1,30 +1,25 @@
1
1
  {
2
2
  "name": "syndai",
3
- "version": "0.1.1",
4
- "description": "Connect your Claude and ChatGPT plans to a Syndai workspace with one command. Stores nothing locally; never echoes a token.",
3
+ "version": "0.4.0",
4
+ "description": "The Syndai owner CLI: say what to change, and Syndai builds it, checks it and puts it live. Also installs the Syndai MCP server into Claude Code, Cursor and Codex.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "syndai": "bin/syndai.js"
8
8
  },
9
9
  "engines": {
10
- "node": ">=18"
10
+ "node": ">=22"
11
11
  },
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/solozerolabs/syndai-connect.git"
15
- },
16
- "homepage": "https://github.com/solozerolabs/syndai-connect#readme",
12
+ "homepage": "https://syndai.ai",
17
13
  "license": "MIT",
18
14
  "files": [
19
15
  "bin/",
20
16
  "src/",
21
- "README.md"
17
+ "CHANGELOG.md"
22
18
  ],
23
19
  "scripts": {
24
- "test": "node --test test/"
20
+ "test": "node --test"
25
21
  },
26
22
  "publishConfig": {
27
- "provenance": true,
28
23
  "access": "public"
29
24
  }
30
25
  }
package/src/api.js CHANGED
@@ -1,70 +1,202 @@
1
- // Minimal JSON-over-HTTPS client for the pairing routes, built on node:https so
2
- // the package stays dependency-free. Every helper returns { status, body } where
3
- // body is the parsed JSON (or the raw string when it isn't JSON) — callers decide
4
- // what a non-2xx means, per route.
1
+ // `syndai api`: every published operation, by operationId (from the live OpenAPI, see
2
+ // openapi.js) or as a raw `METHOD path`. Fields come gh-style: `-f key=value` is a string,
3
+ // `-F key=value` is typed (true, false, null, a number, or @file for a file's text), and
4
+ // `key[]=value` builds a list. A path-parameter field fills the path; any other field goes to
5
+ // the query on GET and DELETE and to the JSON body otherwise. `--input FILE|-` is the whole
6
+ // body instead. The step-up on a missing scope happens in http.js, so it covers this too.
5
7
 
6
- import https from "node:https";
7
- import http from "node:http";
8
- import { URL } from "node:url";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { ApiError, CliError, EXIT, explain } from "./errors.js";
11
+ import { findOperation, HTTP_METHODS, loadOperations, matchPath, shortPath } from "./openapi.js";
12
+ import { resolveProject } from "./projects.js";
13
+ import { readJsonInput } from "./ui.js";
9
14
 
10
- // Production API host. Overridable with SYNDAI_API_URL for staging/dev. Matches
11
- // tools/syndai-pickup (_DEFAULT_API_BASE) and apps/portal (NEXT_PUBLIC_API_BASE).
12
- export function apiBase() {
13
- return (process.env.SYNDAI_API_URL || "https://api.syndai.ai").replace(/\/+$/, "");
15
+ const EXT = "/api/v1/external/coding";
16
+ const MAX_PAGES = 50;
17
+ const USAGE = "syndai api [<operationId> | <METHOD> <path>] [-f k=v]... [-F k=v]... [--input F] [--paginate]";
18
+
19
+ export async function api(ctx, args) {
20
+ const { flags } = ctx;
21
+ const [first, second, ...extra] = args;
22
+ if (!first) return listOperations(ctx);
23
+ const raw = HTTP_METHODS.includes(first.toUpperCase());
24
+ if (extra.length || (raw && !second) || (!raw && second)) {
25
+ throw new CliError(EXIT.USAGE, `Usage: ${USAGE}`, { next: "syndai api" });
26
+ }
27
+
28
+ let op = null;
29
+ let method;
30
+ let template;
31
+ const query = {};
32
+ if (raw) {
33
+ method = first.toUpperCase();
34
+ const full = second.startsWith("/api/") ? second : `${EXT}/${second.replace(/^\/+/, "")}`;
35
+ const [pathOnly, search = ""] = full.split("?");
36
+ template = pathOnly;
37
+ Object.assign(query, Object.fromEntries(new URLSearchParams(search)));
38
+ if (flags.help) {
39
+ ctx.io.stdout.write(`usage: ${USAGE}\n`);
40
+ return EXIT.OK;
41
+ }
42
+ if (flags.paginate) op = matchPath((await loadOperations(ctx)).operations, method, pathOnly);
43
+ } else {
44
+ op = await findOperation(ctx, first);
45
+ if (flags.help) return describeOperation(ctx, op);
46
+ method = op.method;
47
+ template = op.path;
48
+ }
49
+
50
+ const pathParams = new Set(
51
+ op && !raw ? op.params.filter((p) => p.in === "path").map((p) => p.name) : [],
52
+ );
53
+ const filled = {};
54
+ const body = {};
55
+ for (const [key, value] of Object.entries(collectFields(ctx))) {
56
+ if (pathParams.has(key)) filled[key] = value;
57
+ else if (method === "GET" || method === "DELETE") query[key] = value;
58
+ else body[key] = value;
59
+ }
60
+ let payload = Object.keys(body).length ? body : undefined;
61
+ if (flags.input !== undefined) {
62
+ if (payload) throw new CliError(EXIT.USAGE, "--input is the whole body; drop the body fields or --input.");
63
+ payload = await readJsonInput(ctx.io, flags.input);
64
+ }
65
+ for (const name of pathParams) {
66
+ if (filled[name] === undefined) filled[name] = await autoFill(ctx, op, name);
67
+ }
68
+ const target = template.replace(/\{(\w+)\}/g, (_, key) => encodeURIComponent(String(filled[key])));
69
+
70
+ if (flags.paginate) return paginate(ctx, op, method, target, query);
71
+ const res = await ctx.client.send(method, target, { query, body: payload, raw: true });
72
+ return printResponse(ctx, res);
14
73
  }
15
74
 
16
- function request(method, path, payload) {
17
- const url = new URL(apiBase() + path);
18
- const body = payload === undefined ? undefined : JSON.stringify(payload);
19
- const transport = url.protocol === "http:" ? http : https;
20
- const options = {
21
- method,
22
- hostname: url.hostname,
23
- port: url.port || undefined,
24
- path: url.pathname + url.search,
25
- headers: {
26
- accept: "application/json",
27
- "user-agent": "syndai-connect",
28
- },
75
+ /** `-f`/`-F` pairs as one object; a repeated key without `[]` is a usage error. */
76
+ function collectFields(ctx) {
77
+ const fields = {};
78
+ const add = (pair, typed) => {
79
+ const eq = pair.indexOf("=");
80
+ if (eq < 1) throw new CliError(EXIT.USAGE, `Fields are key=value, not "${pair}".`);
81
+ let key = pair.slice(0, eq);
82
+ const value = typed ? typedValue(ctx, pair.slice(eq + 1)) : pair.slice(eq + 1);
83
+ const list = key.endsWith("[]");
84
+ if (list) key = key.slice(0, -2);
85
+ if (list && (fields[key] === undefined || Array.isArray(fields[key]))) {
86
+ fields[key] = [...(fields[key] ?? []), value];
87
+ } else if (key in fields) {
88
+ throw new CliError(EXIT.USAGE, `${key} is given twice; use ${key}[]=... for a list.`);
89
+ } else {
90
+ fields[key] = value;
91
+ }
29
92
  };
30
- if (body !== undefined) {
31
- options.headers["content-type"] = "application/json";
32
- options.headers["content-length"] = Buffer.byteLength(body);
93
+ for (const pair of ctx.flags["raw-field"] ?? []) add(pair, false);
94
+ for (const pair of ctx.flags.field ?? []) add(pair, true);
95
+ return fields;
96
+ }
97
+
98
+ function typedValue(ctx, text) {
99
+ if (text === "true") return true;
100
+ if (text === "false") return false;
101
+ if (text === "null") return null;
102
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
103
+ if (text.startsWith("@")) {
104
+ try {
105
+ return fs.readFileSync(path.resolve(ctx.io.cwd, text.slice(1)), "utf8");
106
+ } catch (err) {
107
+ throw new CliError(EXIT.USAGE, `Cannot read ${text.slice(1)} (${err.code ?? err.message}).`);
108
+ }
33
109
  }
34
- return new Promise((resolve, reject) => {
35
- const req = transport.request(options, (res) => {
36
- const chunks = [];
37
- res.on("data", (c) => chunks.push(c));
38
- res.on("end", () => {
39
- const text = Buffer.concat(chunks).toString("utf8");
40
- let parsed = text;
41
- try {
42
- parsed = text ? JSON.parse(text) : {};
43
- } catch {
44
- /* leave as raw text */
45
- }
46
- resolve({ status: res.statusCode || 0, body: parsed });
47
- });
48
- });
49
- req.on("error", reject);
50
- if (body !== undefined) req.write(body);
51
- req.end();
52
- });
110
+ return text;
53
111
  }
54
112
 
55
- export function post(path, payload) {
56
- return request("POST", path, payload);
113
+ /** Only required path parameters fill themselves: the workspace, and this directory's project. */
114
+ async function autoFill(ctx, op, name) {
115
+ if (name === "customer_id") return ctx.customerId();
116
+ if (name === "coding_repository_id" || name === "repository_id") {
117
+ return (await resolveProject(ctx, { app: ctx.flags.app, picker: false })).id;
118
+ }
119
+ throw new CliError(EXIT.USAGE, `${op.id} needs ${name}.`, { next: `syndai api ${op.id} -f ${name}=...` });
120
+ }
121
+
122
+ async function paginate(ctx, op, method, target, query) {
123
+ if (method !== "GET") throw new CliError(EXIT.USAGE, "--paginate works on GET operations only.");
124
+ const names = new Set((op?.params ?? []).map((p) => p.name));
125
+ const cursorParam = ["cursor", "after"].find((name) => names.has(name));
126
+ if (!cursorParam) {
127
+ throw new CliError(EXIT.USAGE, `${op?.id ?? target} does not page (no cursor or after parameter).`);
128
+ }
129
+ const items = [];
130
+ for (let page = 1; ; page++) {
131
+ const res = await ctx.client.send(method, target, { query, raw: true });
132
+ if (res.status >= 400) return printResponse(ctx, res);
133
+ const arrays = Object.entries(res.body ?? {}).filter(([, value]) => Array.isArray(value));
134
+ if (arrays.length !== 1) {
135
+ throw new CliError(EXIT.FAILED, `${op.id} does not answer with one list, so it cannot be paged.`);
136
+ }
137
+ items.push(...arrays[0][1]);
138
+ const next = res.body.next_cursor;
139
+ if (!next) break;
140
+ if (page >= MAX_PAGES) {
141
+ ctx.ui.progress(ctx.ui.yellow(`Stopped after ${MAX_PAGES} pages; narrow the query to see the rest.`));
142
+ break;
143
+ }
144
+ query[cursorParam] = next;
145
+ }
146
+ ctx.io.stdout.write(`${JSON.stringify(items, null, ctx.ui.json ? 0 : 2)}\n`);
147
+ return EXIT.OK;
57
148
  }
58
149
 
59
- /** True for any 2xx status. */
60
- export function ok(status) {
61
- return status >= 200 && status < 300;
150
+ /** The body as JSON on stdout (problem bodies too); an error line and its next step on stderr. */
151
+ function printResponse(ctx, res) {
152
+ const { io, ui } = ctx;
153
+ if (res.body !== null && res.body !== undefined) {
154
+ io.stdout.write(`${JSON.stringify(res.body, null, ui.json ? 0 : 2)}\n`);
155
+ }
156
+ if (res.status < 400) return EXIT.OK;
157
+ const { exitCode, message, next } = explain(new ApiError(res.status, res.body, res.headers));
158
+ ui.progress(`${ui.red("error:")} ${message}`);
159
+ if (next) ui.progress(` next: ${next}`);
160
+ return exitCode;
62
161
  }
63
162
 
64
- /** Best-effort human message out of a JSON error body. */
65
- export function errorMessage(body) {
66
- if (body && typeof body === "object") {
67
- return body.detail || body.message || body.error || JSON.stringify(body);
163
+ async function listOperations(ctx) {
164
+ const { ui } = ctx;
165
+ const { operations } = await loadOperations(ctx);
166
+ const sorted = [...operations].sort((a, b) => a.group.localeCompare(b.group) || a.id.localeCompare(b.id));
167
+ const width = Math.max(...sorted.map((op) => op.id.length));
168
+ const lines = [];
169
+ let group = null;
170
+ for (const op of sorted) {
171
+ if (op.group !== group) {
172
+ group = op.group;
173
+ lines.push(`${lines.length ? "\n" : ""}${ui.bold(group)}`);
174
+ }
175
+ lines.push(` ${op.id.padEnd(width)} ${op.method.padEnd(6)} ${shortPath(op.path)}`);
176
+ }
177
+ lines.push(
178
+ "",
179
+ `usage: ${USAGE}`,
180
+ "Details of one: syndai api <operationId> --help. Paths are relative to /api/v1/external/coding.",
181
+ );
182
+ ui.result(
183
+ { operations: sorted.map(({ id, method, path: opPath, summary }) => ({ operation_id: id, method, path: opPath, summary })) },
184
+ lines.join("\n"),
185
+ );
186
+ return EXIT.OK;
187
+ }
188
+
189
+ function describeOperation(ctx, op) {
190
+ const lines = [`${ctx.ui.bold(op.id)} ${op.method} ${op.path}`];
191
+ if (op.summary) lines.push(op.summary);
192
+ if (op.params.length) {
193
+ lines.push("", "Parameters (-f name=value):");
194
+ for (const p of op.params) lines.push(` ${p.name} ${p.in}${p.required ? ", required" : ""}`);
195
+ }
196
+ if (op.body) {
197
+ lines.push("", `Body${op.body.schema ? ` (${op.body.schema})` : ""}: fields, or --input FILE`);
198
+ for (const f of op.body.fields) lines.push(` ${f.name} ${f.type}${f.required ? ", required" : ""}`);
68
199
  }
69
- return String(body || "unknown error");
200
+ ctx.ui.result(op, lines.join("\n"));
201
+ return EXIT.OK;
70
202
  }