syndai 0.1.0 → 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/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `syndai` npm package. Versions follow semver.
4
+
5
+ ## 0.3.0
6
+
7
+ The package becomes the Syndai owner CLI.
8
+
9
+ - `login` (browser approval, RFC 8628 device flow), `login --with-token`, `logout`, `whoami`.
10
+ Keys live in `$XDG_CONFIG_HOME/syndai/credentials.json` (0600), one per workspace;
11
+ `SYNDAI_API_KEY` wins. There is no `--api-key` flag.
12
+ - `change` creates a change and follows it to the outcome. Its request id is saved before
13
+ the request, so a retry or a rerun after a crash never makes a second change.
14
+ - `watch` (with `--logs`), `answer`, `review`, `go-live`, `revert`, `cancel`, `status`,
15
+ `edit-text`, `link`, `settings`, `api`.
16
+ - `mcp install claude|cursor|codex` writes the Syndai MCP server into that tool's user
17
+ config with its own key.
18
+ - Exit codes 0-7 and 130 are a stable contract; `--json` (or a non-terminal stdout) prints
19
+ machine-readable results.
20
+ - Node 22 or newer. Zero dependencies. No telemetry.
21
+
22
+ Removed: the 0.2.0 `connect` pointer (connecting a Claude or ChatGPT plan happens in the
23
+ portal, under Connections).
24
+
25
+ ## 0.2.0
26
+
27
+ Never published. `connect` printed a pointer to the portal.
28
+
29
+ ## 0.1.1
30
+
31
+ Published from the `solozerolabs/syndai-connect` mirror: the retired `connect` pairing flow.
package/README.md CHANGED
@@ -1,62 +1,86 @@
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@1 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
13
+ `apps/portal/content/docs/cli.mdx` (rendered as the docs site's **CLI** page);
14
+ this README is for whoever works on the package.
15
+
16
+ ## Commands
17
+
18
+ | command | does |
19
+ |---|---|
20
+ | `login [--with-token]`, `logout`, `whoami` | browser-approved key (device flow); `--with-token` reads a key from stdin |
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
+ | `edit-text [--app A] [--page /] --from "..." --to "..."` / `--undo <sha>` | instant, free, undoable text swap on a live site |
24
+ | `watch <run> [--timeout D] [--logs]` | follow any change; `--logs` tails its events |
25
+ | `answer <run> [--json body]` | answer an open question |
26
+ | `review <run> [--retry \| --close]` | see why a change is held, then approve it in the browser |
27
+ | `go-live`, `revert`, `cancel <run>` | land actions |
28
+ | `status [--app A]` | what is running, and everything that needs you |
29
+ | `settings [--app A] [get \| set k=v...]` | `autonomy`, `feature_requests`, `bug_scan`, `protected_paths` (`+=` adds) |
30
+ | `mcp install claude\|cursor\|codex` | add the Syndai MCP server to that tool's user config, with its own key |
31
+ | `api <METHOD> <path> [--json body]` | call any route with your key |
32
+
33
+ Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs you,
34
+ 4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
35
+ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
36
+
37
+ ## Layout
38
+
39
+ - `bin/syndai.js`: the entrypoint; wires SIGINT to an AbortSignal.
40
+ - `src/main.js`: the command table, argument parsing (`util.parseArgs`), the one error report.
41
+ - `src/routes.js`: every REST route the CLI calls. Code builds paths only through it.
42
+ - `src/http.js`: `fetch`, retries (GETs and keyed POSTs only), `User-Agent: syndai-cli/<v>`.
43
+ - `src/errors.js`: exit codes; a problem+json `code` becomes one sentence plus the next command.
44
+ - `src/auth.js`, `src/store.js`: credentials (0600, by workspace), links, pending change ids.
45
+ - `src/projects.js`: which project a command acts on.
46
+ - `src/watch.js`: the ChangeState long-poll loop, questions, `--logs`.
47
+ - `src/changes.js`, `src/settings.js`, `src/mcp.js`: the commands.
57
48
 
58
49
  ## Development
59
50
 
60
51
  ```bash
61
- node --test test/ # pure-helper unit tests (no network, no subprocess)
52
+ cd tools/syndai-connect && node --test
62
53
  ```
54
+
55
+ The suite runs a fake API on 127.0.0.1 and drives the CLI in-process (plus two
56
+ child-process tests for Ctrl-C and a clean exit). `test/contract.test.js` checks
57
+ every route in `src/routes.js` against the published OpenAPI,
58
+ `docs/api/external-coding-v1.openapi.json`. It goes red when a backend route the
59
+ CLI uses is renamed or not yet published; regenerate the snapshot with
60
+ `cd backend && uv run python scripts/dump_external_openapi.py`, or check a fresh
61
+ render without touching it:
62
+ `SYNDAI_OPENAPI_JSON=/tmp/dump.json node --test test/contract.test.js`.
63
+
64
+ ## Publishing (maintainers)
65
+
66
+ Never `npm publish` by hand. `.github/workflows/publish-cli.yml` publishes on a
67
+ `cli-v<version>` tag, with the same trust boundary as `publish-sync.yml`:
68
+
69
+ 1. Bump `version` in `package.json` and add a `CHANGELOG.md` entry in a gated commit.
70
+ 2. Once that commit is on `main` and CI is green, push a lightweight tag on exactly
71
+ that commit: `git tag cli-v<version> <sha> && git push Syndai refs/tags/cli-v<version>`.
72
+ 3. The `build` job (no publish credential) checks the tag matches the version and
73
+ sits on `main`, skips a version already on npm, runs `node --test`, packs with
74
+ `npm pack` and checks the tarball. The `publish` job only downloads that tarball
75
+ and publishes it tokenlessly (npm trusted publishing). No provenance: npm does
76
+ not generate it from a private repository.
77
+
78
+ One-time owner setup (this retires the `solozerolabs/syndai-connect` mirror, which
79
+ published 0.1.1):
80
+
81
+ 1. GitHub: add `cli-v*` to the `npm-publish` environment's deployment rule, and a
82
+ tag ruleset on `refs/tags/cli-v*` restricting create, update and delete (admins
83
+ bypass), as for `sync-v*`.
84
+ 2. npm: on the `syndai` package, replace the trusted publisher with
85
+ `solozerolabs/Syndai`, workflow `publish-cli.yml`, environment `npm-publish`,
86
+ 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@1 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@1 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.0",
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.3.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/auth.js ADDED
@@ -0,0 +1,179 @@
1
+ // Credentials and the browser-approved login (RFC 8628 device flow, like `gh auth login`).
2
+ //
3
+ // Every door uses one credential type, an `sk_` key. `syndai login` mints one only after an
4
+ // owner or admin approves the request in a browser session at app.syndai.ai/cli. The key is
5
+ // stored per workspace in credentials.json (0600). `SYNDAI_API_KEY` wins over it (CI).
6
+ // There is no --api-key flag, so a key never lands in shell history.
7
+
8
+ import { hostname } from "node:os";
9
+ import { ApiError, CliError, EXIT } from "./errors.js";
10
+ import { createClient, DEFAULT_API_URL, interrupted } from "./http.js";
11
+ import { findLink, loadCredentials, removeCredential, saveCredential, storePaths } from "./store.js";
12
+ import { createUi } from "./ui.js";
13
+
14
+ /** The key this invocation uses: env first, then the cwd link's workspace, then the default. */
15
+ export function resolveCredential(io, paths) {
16
+ const envKey = io.env.SYNDAI_API_KEY?.trim();
17
+ if (envKey) return { apiKey: envKey, customerId: null, source: "SYNDAI_API_KEY" };
18
+ return storedCredential(io, paths);
19
+ }
20
+
21
+ function storedCredential(io, paths) {
22
+ const creds = loadCredentials(paths);
23
+ const link = findLink(paths, io.cwd);
24
+ const customerId = link && creds.customers[link.customer_id] ? link.customer_id : creds.default;
25
+ const entry = customerId ? creds.customers[customerId] : null;
26
+ if (!entry) return null;
27
+ return { apiKey: entry.api_key, customerId, source: paths.credentials };
28
+ }
29
+
30
+ /** Per-invocation state shared by every command. */
31
+ export function createContext(io, flags) {
32
+ const paths = storePaths(io.env);
33
+ const ui = createUi(io, { json: flags.json === true, noInput: flags["no-input"] === true });
34
+ const baseUrl = (io.env.SYNDAI_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
35
+ let cached;
36
+ const credential = () => {
37
+ cached ??= resolveCredential(io, paths);
38
+ if (!cached) throw new CliError(EXIT.AUTH, "You are not logged in.", { next: "syndai login" });
39
+ return cached;
40
+ };
41
+ const clientFor = (getKey) => createClient({ baseUrl, getKey, io });
42
+ const client = clientFor(async () => credential().apiKey);
43
+ let me;
44
+ return {
45
+ io,
46
+ ui,
47
+ paths,
48
+ flags,
49
+ baseUrl,
50
+ client,
51
+ clientFor,
52
+ credential,
53
+ async me() {
54
+ me ??= await client.call("me");
55
+ return me;
56
+ },
57
+ async customerId() {
58
+ return credential().customerId ?? (await this.me()).customer_id;
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Ask for a key and wait until an owner approves it in the browser. Returns the token
65
+ * response `{api_key, customer_id, key_id, expires_at}`.
66
+ */
67
+ export async function deviceApproval(ctx, clientName) {
68
+ const { client, ui, io } = ctx;
69
+ const start = await client.call("deviceStart", {
70
+ body: { client_name: clientName, host: hostname() },
71
+ auth: false,
72
+ retry: true,
73
+ });
74
+ const url = start.verification_uri_complete ?? start.verification_uri;
75
+ ui.progress(`Your one-time code: ${ui.bold(start.user_code)}`);
76
+ ui.progress(`Approve it in your browser: ${url}`);
77
+ if (ui.interactive) io.openUrl(url);
78
+ ui.progress(ui.dim("Waiting for approval (Ctrl-C to stop)..."));
79
+
80
+ let intervalMs = (start.interval ?? 5) * 1000;
81
+ const expiresAt = io.now() + (start.expires_in ?? 600) * 1000;
82
+ for (;;) {
83
+ try {
84
+ await io.sleep(intervalMs, io.signal);
85
+ } catch (err) {
86
+ if (io.signal.aborted) throw interrupted();
87
+ throw err;
88
+ }
89
+ const res = await client.call("deviceToken", {
90
+ body: { device_code: start.device_code },
91
+ auth: false,
92
+ retry: true,
93
+ raw: true,
94
+ });
95
+ if (res.status === 200) return res.body;
96
+ const error = res.body?.error;
97
+ if (error === "authorization_pending" && io.now() < expiresAt) continue;
98
+ if (error === "slow_down") {
99
+ intervalMs += 5000; // RFC 8628 section 3.5
100
+ continue;
101
+ }
102
+ if (error === "access_denied") {
103
+ throw new CliError(EXIT.AUTH, "The request was denied in the browser.", {
104
+ next: "syndai login",
105
+ });
106
+ }
107
+ if (error === "expired_token" || error === "authorization_pending") {
108
+ throw new CliError(EXIT.AUTH, "The code expired before it was approved.", {
109
+ next: "syndai login",
110
+ });
111
+ }
112
+ throw new ApiError(res.status, res.body, res.headers);
113
+ }
114
+ }
115
+
116
+ export async function login(ctx) {
117
+ const { io, ui, paths, flags } = ctx;
118
+ let token;
119
+ if (flags["with-token"]) {
120
+ if (io.stdin.isTTY) ui.progress("Paste a Syndai API key, then press Ctrl-D:");
121
+ const apiKey = (await readAll(io.stdin)).trim();
122
+ if (!apiKey) throw new CliError(EXIT.USAGE, "No key on stdin.", { next: "syndai login" });
123
+ const me = await ctx.clientFor(async () => apiKey).call("me");
124
+ token = { api_key: apiKey, customer_id: me.customer_id, key_id: me.key_id, expires_at: me.expires_at };
125
+ } else {
126
+ token = await deviceApproval(ctx, "CLI");
127
+ }
128
+ saveCredential(paths, token);
129
+ if (io.env.SYNDAI_API_KEY) ui.progress(ui.yellow("Note: SYNDAI_API_KEY is set and wins over this login."));
130
+ const expires = token.expires_at ? ` until ${token.expires_at.slice(0, 10)}` : "";
131
+ ui.result(
132
+ { customer_id: token.customer_id, key_id: token.key_id, expires_at: token.expires_at },
133
+ `Logged in to workspace ${token.customer_id}${expires}.`,
134
+ );
135
+ return EXIT.OK;
136
+ }
137
+
138
+ export async function logout(ctx) {
139
+ const { io, ui, paths } = ctx;
140
+ const stored = storedCredential(io, paths);
141
+ if (!stored) {
142
+ ui.result({ logged_out: false }, "Not logged in.");
143
+ return EXIT.OK;
144
+ }
145
+ try {
146
+ await ctx.clientFor(async () => stored.apiKey).call("revokeMe");
147
+ } catch (err) {
148
+ // Already revoked or expired: nothing left to revoke, so just forget it.
149
+ if (!(err instanceof ApiError && err.status === 401)) throw err;
150
+ }
151
+ removeCredential(paths, stored.customerId);
152
+ if (io.env.SYNDAI_API_KEY) ui.progress(ui.yellow("Note: SYNDAI_API_KEY is still set in this shell."));
153
+ ui.result(
154
+ { logged_out: true, customer_id: stored.customerId },
155
+ `Logged out of workspace ${stored.customerId}; its key is revoked.`,
156
+ );
157
+ return EXIT.OK;
158
+ }
159
+
160
+ export async function whoami(ctx) {
161
+ const me = await ctx.me();
162
+ const { source } = ctx.credential();
163
+ ctx.ui.result(
164
+ { ...me, source },
165
+ [
166
+ `Workspace ${me.customer_id}`,
167
+ `Key ${me.key_name ?? me.key_id}${me.expires_at ? ` (expires ${me.expires_at.slice(0, 10)})` : ""}`,
168
+ `Scopes ${(me.scopes ?? []).join(", ")}`,
169
+ `From ${source}`,
170
+ ].join("\n"),
171
+ );
172
+ return EXIT.OK;
173
+ }
174
+
175
+ async function readAll(stream) {
176
+ let text = "";
177
+ for await (const chunk of stream) text += chunk;
178
+ return text;
179
+ }