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/src/main.js ADDED
@@ -0,0 +1,212 @@
1
+ // Command table, argument parsing and the one error report. `main(argv, io)` takes all
2
+ // process I/O as `io`, so tests drive it in-process with a fake server and fake clocks.
3
+
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { parseArgs } from "node:util";
6
+ import { createContext, login, logout, whoami } from "./auth.js";
7
+ import { answer, cancel, change, editText, goLive, review, revert, status, watch } from "./changes.js";
8
+ import { CliError, EXIT, explain } from "./errors.js";
9
+ import { VERSION } from "./http.js";
10
+ import { mcp } from "./mcp.js";
11
+ import { link } from "./projects.js";
12
+ import { api, settings } from "./settings.js";
13
+ import { closeInput, openBrowser, page } from "./ui.js";
14
+
15
+ const str = { type: "string" };
16
+ const bool = { type: "boolean" };
17
+ const COMMON = { json: bool, "no-input": bool, help: { type: "boolean", short: "h" } };
18
+
19
+ const COMMANDS = {
20
+ change: {
21
+ usage: 'change "<what>" [--app A] [--review] [--image F]... [--no-wait] [--timeout D]',
22
+ summary: "Make a change; it goes live when its checks pass (--review: open a PR instead).",
23
+ options: {
24
+ app: str,
25
+ review: bool,
26
+ image: { type: "string", multiple: true },
27
+ "no-wait": bool,
28
+ timeout: str,
29
+ },
30
+ run: (ctx, args) => change(ctx, args),
31
+ },
32
+ watch: {
33
+ usage: "watch <run> [--timeout D] [--logs]",
34
+ summary: "Follow a change to its outcome (Ctrl-C detaches; the change keeps going).",
35
+ options: { timeout: str, logs: bool },
36
+ run: (ctx, [run]) => watch(ctx, run),
37
+ },
38
+ answer: {
39
+ usage: "answer <run> [--json '{\"entries\":[...]}']",
40
+ summary: "Answer a change's open question.",
41
+ options: { json: str },
42
+ run: (ctx, [run]) => answer(ctx, run),
43
+ },
44
+ review: {
45
+ usage: "review <run> [--retry | --close]",
46
+ summary: "See why a change is held, then approve it in your browser.",
47
+ options: { retry: bool, close: bool },
48
+ run: (ctx, [run]) => review(ctx, run),
49
+ },
50
+ "go-live": {
51
+ usage: "go-live <run> [--timeout D]",
52
+ summary: "Put a ready change live.",
53
+ options: { timeout: str },
54
+ run: (ctx, [run]) => goLive(ctx, run),
55
+ },
56
+ revert: {
57
+ usage: "revert <run> [--force] [--timeout D]",
58
+ summary: "Undo a change that went live.",
59
+ options: { force: bool, timeout: str },
60
+ run: (ctx, [run]) => revert(ctx, run),
61
+ },
62
+ cancel: {
63
+ usage: "cancel <run>",
64
+ summary: "Stop a change.",
65
+ options: {},
66
+ run: (ctx, [run]) => cancel(ctx, run),
67
+ },
68
+ status: {
69
+ usage: "status [--app A]",
70
+ summary: "What is running, and everything that needs you.",
71
+ options: { app: str },
72
+ run: (ctx) => status(ctx),
73
+ },
74
+ "edit-text": {
75
+ usage: 'edit-text [--app A] [--page /] --from "Old" --to "New" [--all] | --undo <sha>',
76
+ summary: "Swap exact text on a live site in seconds, free and undoable (--all: every match in its file).",
77
+ options: { app: str, page: str, from: str, to: str, all: bool, undo: str },
78
+ run: (ctx) => editText(ctx),
79
+ },
80
+ 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 },
84
+ run: (ctx) => login(ctx),
85
+ },
86
+ logout: {
87
+ usage: "logout",
88
+ summary: "Revoke and forget the stored key.",
89
+ options: {},
90
+ run: (ctx) => logout(ctx),
91
+ },
92
+ whoami: {
93
+ usage: "whoami",
94
+ summary: "Show the workspace and key in use.",
95
+ options: {},
96
+ run: (ctx) => whoami(ctx),
97
+ },
98
+ link: {
99
+ usage: "link [project]",
100
+ summary: "Remember which project this directory changes (stored outside the repo).",
101
+ options: { app: str },
102
+ run: (ctx, [ref]) => link(ctx, ref),
103
+ },
104
+ settings: {
105
+ usage: "settings [--app A] [get | set key=value...]",
106
+ summary: "autonomy, feature_requests, bug_scan, protected_paths (a key can only tighten them).",
107
+ options: { app: str },
108
+ run: (ctx, args) => settings(ctx, args),
109
+ },
110
+ mcp: {
111
+ usage: "mcp install claude|cursor|codex",
112
+ summary: "Add Syndai to an AI coding tool, with its own key.",
113
+ options: {},
114
+ run: (ctx, args) => mcp(ctx, args),
115
+ },
116
+ api: {
117
+ usage: "api <METHOD> <path> [--json body]",
118
+ summary: "Call any Syndai API route with your key.",
119
+ options: { json: str },
120
+ run: (ctx, args) => api(ctx, args),
121
+ },
122
+ };
123
+
124
+ export function helpText() {
125
+ const rows = Object.values(COMMANDS).map((c) => ` syndai ${c.usage}\n ${c.summary}`);
126
+ return [
127
+ `syndai ${VERSION}: tell Syndai what to change; it builds it, checks it and puts it live.`,
128
+ "",
129
+ ...rows,
130
+ "",
131
+ "Every command: --json (machine output; also when stdout is not a terminal),",
132
+ "--no-input (never prompt), -h/--help.",
133
+ "Environment: SYNDAI_API_KEY (wins over `syndai login`), SYNDAI_APP, SYNDAI_API_URL, NO_COLOR.",
134
+ "Exit codes: 0 done, 1 failed, 2 usage or no project, 3 needs you, 4 live but unverified,",
135
+ "5 auth/scope/plan, 6 --timeout reached, 7 service unavailable, 130 interrupted.",
136
+ "",
137
+ ].join("\n");
138
+ }
139
+
140
+ /** The real process as `io` (the bin passes the SIGINT AbortSignal). */
141
+ export function processIo(signal) {
142
+ return {
143
+ env: process.env,
144
+ cwd: process.cwd(),
145
+ stdout: process.stdout,
146
+ stderr: process.stderr,
147
+ stdin: process.stdin,
148
+ signal,
149
+ now: () => Date.now(),
150
+ sleep: (ms, abortSignal) => sleep(ms, undefined, { signal: abortSignal }),
151
+ openUrl: openBrowser,
152
+ page: (text) => page(process, text),
153
+ };
154
+ }
155
+
156
+ export async function main(argv, io) {
157
+ const [command, ...rest] = argv;
158
+ if (!command || ["help", "--help", "-h"].includes(command)) {
159
+ io.stdout.write(helpText());
160
+ return EXIT.OK;
161
+ }
162
+ if (["--version", "-v", "version"].includes(command)) {
163
+ io.stdout.write(`${VERSION}\n`);
164
+ return EXIT.OK;
165
+ }
166
+ const spec = COMMANDS[command];
167
+ if (!spec) {
168
+ io.stderr.write(`error: unknown command "${command}"\n\n${helpText()}`);
169
+ return EXIT.USAGE;
170
+ }
171
+ let parsed;
172
+ try {
173
+ parsed = parseArgs({ args: rest, options: { ...COMMON, ...spec.options }, allowPositionals: true });
174
+ } catch (err) {
175
+ io.stderr.write(`error: ${err.message}\n usage: syndai ${spec.usage}\n`);
176
+ return EXIT.USAGE;
177
+ }
178
+ if (parsed.values.help) {
179
+ io.stdout.write(`usage: syndai ${spec.usage}\n${spec.summary}\n`);
180
+ return EXIT.OK;
181
+ }
182
+ const ctx = createContext(io, parsed.values);
183
+ try {
184
+ return await spec.run(ctx, parsed.positionals);
185
+ } catch (err) {
186
+ return report(ctx, err);
187
+ } finally {
188
+ closeInput(io);
189
+ }
190
+ }
191
+
192
+ /** One error line (plus the next command) on stderr; a JSON error on stdout for machines. */
193
+ function report(ctx, err) {
194
+ const { ui, io } = ctx;
195
+ const { exitCode, message, next } = explain(err);
196
+ if (io.env.SYNDAI_DEBUG && err?.stack) io.stderr.write(`${err.stack}\n`);
197
+ const extra = err instanceof CliError ? err.result : null;
198
+ if (exitCode === EXIT.INTERRUPTED) {
199
+ ui.progress(message);
200
+ } else {
201
+ ui.progress(`${ui.red("error:")} ${message}`);
202
+ if (extra?.human) ui.progress(extra.human);
203
+ if (ui.json) {
204
+ const code = err?.code ?? null;
205
+ io.stdout.write(
206
+ `${JSON.stringify({ error: { exit_code: exitCode, code, message, next }, ...(extra?.data ?? {}) })}\n`,
207
+ );
208
+ }
209
+ }
210
+ if (next) ui.progress(` next: ${next}`);
211
+ return exitCode;
212
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,93 @@
1
+ // `syndai mcp install claude|cursor|codex`: the same browser approval as `syndai login`,
2
+ // but it mints a SEPARATE key per client (audit rows say which door acted; one door can be
3
+ // revoked alone) and writes it into that client's USER-scope config only. Never a project
4
+ // file such as `.mcp.json`: a repo is shared, and `.syndai/**`-style paths are protected.
5
+ //
6
+ // Config locations (checked against each client's docs, 2026-10):
7
+ // Claude Code $CLAUDE_CONFIG_DIR/.claude.json or ~/.claude.json, top-level "mcpServers"
8
+ // Cursor ~/.cursor/mcp.json, "mcpServers"
9
+ // Codex $CODEX_HOME/config.toml or ~/.codex/config.toml, [mcp_servers.<name>]
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { deviceApproval } from "./auth.js";
14
+ import { CliError, EXIT } from "./errors.js";
15
+ import { MCP_PATH } from "./routes.js";
16
+ import { readJson, writeFileAtomic, writeJson } from "./store.js";
17
+
18
+ const SERVER_NAME = "syndai";
19
+
20
+ export const CLIENTS = {
21
+ claude: {
22
+ label: "Claude Code",
23
+ file: (env, home) => path.join(env.CLAUDE_CONFIG_DIR || home, ".claude.json"),
24
+ write: (file, url, key) =>
25
+ upsertJsonServer(file, { type: "http", url, headers: { Authorization: `Bearer ${key}` } }),
26
+ },
27
+ cursor: {
28
+ label: "Cursor",
29
+ file: (_env, home) => path.join(home, ".cursor", "mcp.json"),
30
+ write: (file, url, key) => upsertJsonServer(file, { url, headers: { Authorization: `Bearer ${key}` } }),
31
+ },
32
+ codex: {
33
+ label: "Codex",
34
+ file: (env, home) => path.join(env.CODEX_HOME || path.join(home, ".codex"), "config.toml"),
35
+ write: (file, url, key) => upsertTomlServer(file, url, key),
36
+ },
37
+ };
38
+
39
+ export async function mcp(ctx, [action, clientName]) {
40
+ const target = CLIENTS[clientName];
41
+ if (action !== "install" || !target) {
42
+ throw new CliError(EXIT.USAGE, "Usage: syndai mcp install claude|cursor|codex");
43
+ }
44
+ const token = await deviceApproval(ctx, `MCP · ${target.label}`);
45
+ const file = target.file(ctx.io.env, ctx.paths.home);
46
+ target.write(file, ctx.baseUrl + MCP_PATH, token.api_key);
47
+ ctx.ui.result(
48
+ {
49
+ client: clientName,
50
+ config_file: file,
51
+ customer_id: token.customer_id,
52
+ key_id: token.key_id,
53
+ expires_at: token.expires_at,
54
+ },
55
+ `Syndai is set up in ${target.label} (${file}). Restart ${target.label} to load it.`,
56
+ );
57
+ return EXIT.OK;
58
+ }
59
+
60
+ /** Set `mcpServers.syndai` in a JSON config, keeping everything else. 0600: it holds a key. */
61
+ export function upsertJsonServer(file, entry) {
62
+ const data = readJson(file, {});
63
+ data.mcpServers = { ...(data.mcpServers ?? {}), [SERVER_NAME]: entry };
64
+ writeJson(file, data, 0o600);
65
+ }
66
+
67
+ /** Replace (or append) the `[mcp_servers.syndai]` table in a TOML config. */
68
+ export function upsertTomlServer(file, url, key) {
69
+ let text = "";
70
+ try {
71
+ text = fs.readFileSync(file, "utf8");
72
+ } catch (err) {
73
+ if (err.code !== "ENOENT") throw err;
74
+ }
75
+ const kept = [];
76
+ let inOurs = false;
77
+ for (const line of text.split("\n")) {
78
+ const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/.exec(line);
79
+ if (header) {
80
+ const name = header[1].replace(/"/g, "");
81
+ inOurs = name === `mcp_servers.${SERVER_NAME}` || name.startsWith(`mcp_servers.${SERVER_NAME}.`);
82
+ }
83
+ if (!inOurs) kept.push(line);
84
+ }
85
+ const rest = kept.join("\n").replace(/\s+$/, "");
86
+ // JSON string escaping is valid TOML basic-string escaping for these ASCII values.
87
+ const table = [
88
+ `[mcp_servers.${SERVER_NAME}]`,
89
+ `url = ${JSON.stringify(url)}`,
90
+ `http_headers = { Authorization = ${JSON.stringify(`Bearer ${key}`)} }`,
91
+ ].join("\n");
92
+ writeFileAtomic(file, `${rest ? `${rest}\n\n` : ""}${table}\n`, 0o600);
93
+ }
@@ -0,0 +1,168 @@
1
+ // Which project a command acts on. Order (docs: cli.mdx "Choosing the project"):
2
+ // --app, SYNDAI_APP, the link for this directory or a parent, a git remote that matches a
3
+ // connected repo, the only project, a picker on a terminal. Otherwise exit 2 with the list.
4
+ //
5
+ // A project is a connected repository, joined with its app (live URL, slug) when it has one.
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import path from "node:path";
9
+ import { CliError, EXIT } from "./errors.js";
10
+ import { findLink, saveLink } from "./store.js";
11
+ import { ask, hostOf } from "./ui.js";
12
+
13
+ export async function listProjects(ctx) {
14
+ const customerId = await ctx.customerId();
15
+ const [{ repos }, { items }] = await Promise.all([
16
+ ctx.client.call("listRepos"),
17
+ ctx.client.call("listApps", { params: { customer_id: customerId } }),
18
+ ]);
19
+ const appByRepo = new Map(items.map((app) => [app.repository_id, app]));
20
+ return repos.map((repo) => {
21
+ const app = appByRepo.get(repo.id);
22
+ return {
23
+ id: repo.id,
24
+ name: repo.display_name || app?.repository_name || repo.full_name,
25
+ full_name: repo.full_name,
26
+ live_url: app?.url ?? null,
27
+ slug: app?.publication?.slug ?? null,
28
+ repo_urls: [repo.repo_url, repo.clone_url, repo.html_url].filter(Boolean),
29
+ protected_paths: repo.protected_paths ?? [],
30
+ customer_id: customerId,
31
+ };
32
+ });
33
+ }
34
+
35
+ export function projectLabel(project) {
36
+ const host = project.live_url ? hostOf(project.live_url) : null;
37
+ return host && host !== project.name ? `${project.name} (${host})` : project.name;
38
+ }
39
+
40
+ /** The project's live host for "Changing <host>", or its name before it is live. */
41
+ export function projectHost(project) {
42
+ return (project.live_url && hostOf(project.live_url)) || project.name;
43
+ }
44
+
45
+ /** Projects matching a ref: id, slug, live host, name or repo name (case-insensitive). */
46
+ export function matchRef(projects, ref) {
47
+ const wanted = ref.trim().toLowerCase();
48
+ const wantedHost = hostOf(wanted.includes("://") ? wanted : `https://${wanted}`);
49
+ return projects.filter((p) => {
50
+ const names = [p.id, p.slug, p.name, p.full_name, p.full_name.split("/").pop()];
51
+ if (names.some((n) => n?.toLowerCase() === wanted)) return true;
52
+ return Boolean(p.live_url && wantedHost && hostOf(p.live_url)?.toLowerCase() === wantedHost);
53
+ });
54
+ }
55
+
56
+ /**
57
+ * @param {object} ctx
58
+ * @param {{app?: string, required?: boolean, picker?: boolean, useLink?: boolean}} options
59
+ */
60
+ export async function resolveProject(ctx, { app, required = true, picker = true, useLink = true } = {}) {
61
+ const { io, ui, paths } = ctx;
62
+ const projects = await listProjects(ctx);
63
+ const pick = (ref, source) => {
64
+ const hits = matchRef(projects, ref);
65
+ if (hits.length === 1) return hits[0];
66
+ throw noProject(
67
+ projects,
68
+ hits.length
69
+ ? `"${ref}" (${source}) matches ${hits.length} projects; use the id.`
70
+ : `No project matches "${ref}" (${source}).`,
71
+ );
72
+ };
73
+ if (app) return pick(app, "--app");
74
+ if (io.env.SYNDAI_APP) return pick(io.env.SYNDAI_APP, "SYNDAI_APP");
75
+ if (useLink) {
76
+ const link = findLink(paths, io.cwd);
77
+ if (link) {
78
+ const linked = projects.find((p) => p.id === link.project_id);
79
+ if (linked) return linked;
80
+ throw noProject(projects, `The project linked to ${link.dir} is no longer in this workspace.`);
81
+ }
82
+ }
83
+ const remotes = gitRemotes(io.cwd);
84
+ const byRemote = projects.filter((p) => p.repo_urls.some((u) => remotes.has(normalizeRemote(u))));
85
+ if (byRemote.length === 1) return byRemote[0];
86
+ if (projects.length === 1) return projects[0];
87
+ if (picker && ui.interactive && projects.length > 1) return pickInteractively(ctx, projects);
88
+ if (!required) return null;
89
+ throw noProject(
90
+ projects,
91
+ projects.length
92
+ ? "Which project? Pass --app or link this directory."
93
+ : "This workspace has no projects yet.",
94
+ );
95
+ }
96
+
97
+ function noProject(projects, message) {
98
+ const human = projects.length
99
+ ? ["Projects:", ...projects.map((p) => ` ${projectLabel(p)} ${p.id}`)].join("\n")
100
+ : "Start one at https://app.syndai.ai";
101
+ return new CliError(EXIT.USAGE, message, {
102
+ next: projects.length ? "syndai link <project>" : null,
103
+ result: {
104
+ data: { projects: projects.map(({ id, name, live_url }) => ({ id, name, live_url })) },
105
+ human,
106
+ },
107
+ });
108
+ }
109
+
110
+ async function pickInteractively(ctx, projects) {
111
+ for (const [i, p] of projects.entries()) ctx.ui.progress(` ${i + 1}. ${projectLabel(p)}`);
112
+ for (;;) {
113
+ const answer = await ask(ctx.io, `Which project? [1-${projects.length}] `);
114
+ const index = Number(answer) - 1;
115
+ if (Number.isInteger(index) && projects[index]) return projects[index];
116
+ }
117
+ }
118
+
119
+ /** Normalised remotes of the git checkout at `cwd` (empty outside one). */
120
+ export function gitRemotes(cwd) {
121
+ let out = "";
122
+ try {
123
+ out = execFileSync("git", ["remote", "-v"], {
124
+ cwd,
125
+ encoding: "utf8",
126
+ stdio: ["ignore", "pipe", "ignore"],
127
+ timeout: 3000,
128
+ });
129
+ } catch {
130
+ return new Set();
131
+ }
132
+ return new Set(
133
+ out
134
+ .split("\n")
135
+ .map((line) => line.split(/\s+/)[1])
136
+ .filter(Boolean)
137
+ .map(normalizeRemote),
138
+ );
139
+ }
140
+
141
+ /** `git@host:o/r.git`, `ssh://git@host:22/o/r`, `https://tok@host/o/r.git` -> `host/o/r`. */
142
+ export function normalizeRemote(url) {
143
+ const text = url.trim().toLowerCase();
144
+ const scp = /^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)(.+)$/.exec(text);
145
+ let normal = text;
146
+ if (scp && !text.includes("://")) {
147
+ normal = `${scp[1]}/${scp[2]}`;
148
+ } else {
149
+ try {
150
+ const parsed = new URL(text);
151
+ normal = parsed.hostname + parsed.pathname;
152
+ } catch {
153
+ // Not a URL: compare as typed.
154
+ }
155
+ }
156
+ return normal.replace(/\.git$/, "").replace(/\/+$/, "");
157
+ }
158
+
159
+ export async function link(ctx, ref) {
160
+ const project = await resolveProject(ctx, { app: ref ?? ctx.flags.app, useLink: false });
161
+ const dir = path.resolve(ctx.io.cwd);
162
+ saveLink(ctx.paths, dir, { project_id: project.id, name: project.name, customer_id: project.customer_id });
163
+ ctx.ui.result(
164
+ { dir, project_id: project.id, name: project.name },
165
+ `Linked ${dir} to ${projectLabel(project)}.`,
166
+ );
167
+ return EXIT.OK;
168
+ }
package/src/routes.js ADDED
@@ -0,0 +1,56 @@
1
+ // Every REST route the CLI calls, in one table. Code builds paths only through `path()`,
2
+ // so `test/contract.test.js` can prove each one exists, with the same method, in the
3
+ // published OpenAPI (`docs/api/external-coding-v1.openapi.json`). `syndai api` is the one
4
+ // escape hatch that bypasses this table.
5
+
6
+ const EXT = "/api/v1/external/coding";
7
+
8
+ export const ROUTES = Object.freeze({
9
+ deviceStart: ["POST", "/api/v1/cli/device"],
10
+ deviceToken: ["POST", "/api/v1/cli/device/token"],
11
+ me: ["GET", `${EXT}/me`],
12
+ revokeMe: ["DELETE", `${EXT}/me`],
13
+ listRepos: ["GET", `${EXT}/repos`],
14
+ updateRepo: ["PATCH", `${EXT}/repos/{coding_repository_id}`],
15
+ textEdit: ["POST", `${EXT}/repos/{coding_repository_id}/text-edits`],
16
+ undoTextEdit: ["POST", `${EXT}/repos/{coding_repository_id}/text-edits/{sha}/undo`],
17
+ listApps: ["GET", `${EXT}/customers/{customer_id}/apps`],
18
+ getPublication: ["GET", `${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publication`],
19
+ updatePublication: [
20
+ "PATCH",
21
+ `${EXT}/customers/{customer_id}/repositories/{coding_repository_id}/publication`,
22
+ ],
23
+ getBugScan: ["GET", `${EXT}/repositories/{repository_id}/bug-scan`],
24
+ setBugScan: ["PUT", `${EXT}/repositories/{repository_id}/bug-scan`],
25
+ getPolicy: ["GET", `${EXT}/policy/defaults`],
26
+ putPolicy: ["PUT", `${EXT}/policy/defaults`],
27
+ imageIngest: ["POST", `${EXT}/runs/image-ingest`],
28
+ createRun: ["POST", `${EXT}/runs`],
29
+ listRuns: ["GET", `${EXT}/runs`],
30
+ changeState: ["GET", `${EXT}/runs/{run_id}/change`],
31
+ runEvents: ["GET", `${EXT}/runs/{run_id}/events`],
32
+ respond: ["POST", `${EXT}/runs/{run_id}/responses`],
33
+ cancel: ["POST", `${EXT}/runs/{run_id}/cancel`],
34
+ revert: ["POST", `${EXT}/runs/{run_id}/revert`],
35
+ landRetry: ["POST", `${EXT}/runs/{run_id}/land/retry`],
36
+ landClose: ["POST", `${EXT}/runs/{run_id}/land/close`],
37
+ goLive: ["POST", `${EXT}/runs/{run_id}/land/go-live`],
38
+ heldDiff: ["GET", `${EXT}/runs/{run_id}/land/held-diff`],
39
+ });
40
+
41
+ /** The MCP endpoint `syndai mcp install` points clients at (JSON-RPC, not REST). */
42
+ export const MCP_PATH = "/api/v1/coding/mcp";
43
+
44
+ /** `[method, path]` for a route, with `{name}` placeholders filled and URL-encoded. */
45
+ export function route(name, params = {}) {
46
+ const entry = ROUTES[name];
47
+ if (!entry) throw new Error(`unknown route ${name}`);
48
+ const [method, template] = entry;
49
+ const path = template.replace(/\{(\w+)\}/g, (_, key) => {
50
+ if (params[key] === undefined || params[key] === null) {
51
+ throw new Error(`route ${name} needs ${key}`);
52
+ }
53
+ return encodeURIComponent(String(params[key]));
54
+ });
55
+ return [method, path];
56
+ }
@@ -0,0 +1,160 @@
1
+ // `syndai settings` (one project's owner settings) and `syndai api` (the escape hatch).
2
+ //
3
+ // Settings live in four places server-side:
4
+ // autonomy repo-scoped policy default GET/PUT policy/defaults
5
+ // feature_requests the app's publication GET/PATCH .../publication
6
+ // bug_scan the repo's Bug Finder schedule GET/PUT repositories/{id}/bug-scan
7
+ // protected_paths the repo row PATCH repos/{id} (a key may only tighten)
8
+
9
+ import { ApiError, CliError, EXIT, explain } from "./errors.js";
10
+ import { projectLabel, resolveProject } from "./projects.js";
11
+
12
+ const VALUES = {
13
+ autonomy: ["gated", "auto", "merge"],
14
+ feature_requests: ["off", "members", "public"],
15
+ bug_scan: ["on", "off"],
16
+ };
17
+
18
+ export async function settings(ctx, args) {
19
+ const { flags, ui } = ctx;
20
+ const [action = "get", ...pairs] = args;
21
+ if (!["get", "set"].includes(action) || (action === "set" && pairs.length === 0)) {
22
+ throw new CliError(EXIT.USAGE, "Usage: syndai settings [get | set key=value...]", {
23
+ next: "syndai settings set autonomy=auto",
24
+ });
25
+ }
26
+ const project = await resolveProject(ctx, { app: flags.app });
27
+ for (const pair of pairs) await apply(ctx, project, parsePair(pair));
28
+ const current = await read(ctx, project);
29
+ const show = (value) =>
30
+ value === null ? "-" : Array.isArray(value) ? value.join(", ") || "(none)" : value;
31
+ const lines = [ui.bold(projectLabel(project))];
32
+ for (const key of ["autonomy", "feature_requests", "bug_scan", "protected_paths"]) {
33
+ const why = current.unavailable[key];
34
+ lines.push(` ${key.padEnd(17)}${why ? ui.dim(why) : show(current[key])}`);
35
+ }
36
+ ui.result({ project_id: project.id, ...current }, lines.join("\n"));
37
+ return EXIT.OK;
38
+ }
39
+
40
+ function parsePair(pair) {
41
+ const match = /^([a-z_]+)(\+?=)(.*)$/.exec(pair);
42
+ const known = match && (match[1] in VALUES || match[1] === "protected_paths");
43
+ if (!known || (match[2] === "+=" && match[1] !== "protected_paths")) {
44
+ throw new CliError(
45
+ EXIT.USAGE,
46
+ `Cannot set "${pair}". Keys: autonomy, feature_requests, bug_scan, protected_paths (= or +=).`,
47
+ );
48
+ }
49
+ const [, key, op, value] = match;
50
+ if (VALUES[key] && !VALUES[key].includes(value)) {
51
+ throw new CliError(EXIT.USAGE, `${key} is one of ${VALUES[key].join(", ")}.`);
52
+ }
53
+ return { key, op, value };
54
+ }
55
+
56
+ async function apply(ctx, project, { key, op, value }) {
57
+ const { client } = ctx;
58
+ if (key === "autonomy") {
59
+ const current = await orNull(client.call("getPolicy", { query: { coding_repository_id: project.id } }));
60
+ await client.call("putPolicy", {
61
+ body: { coding_repository_id: project.id, config: { ...(current?.config ?? {}), autonomy: value } },
62
+ });
63
+ } else if (key === "feature_requests") {
64
+ await client.call("updatePublication", {
65
+ params: { customer_id: project.customer_id, coding_repository_id: project.id },
66
+ body: { feature_requests: value },
67
+ });
68
+ } else if (key === "bug_scan") {
69
+ await client.call("setBugScan", {
70
+ params: { repository_id: project.id },
71
+ body: { enabled: value === "on" },
72
+ });
73
+ } else {
74
+ const added = value
75
+ .split(",")
76
+ .map((p) => p.trim())
77
+ .filter(Boolean);
78
+ const next = op === "+=" ? [...project.protected_paths, ...added] : added;
79
+ // The server refuses a loosening from a key with HUMAN_APPROVAL_REQUIRED + review_url.
80
+ const repo = await client.call("updateRepo", {
81
+ params: { coding_repository_id: project.id },
82
+ body: { protected_paths: next },
83
+ });
84
+ project.protected_paths = repo.protected_paths ?? next;
85
+ }
86
+ }
87
+
88
+ async function read(ctx, project) {
89
+ const { client } = ctx;
90
+ const ids = { customer_id: project.customer_id, coding_repository_id: project.id };
91
+ const [policy, publication, bugScan] = await Promise.all([
92
+ settle(client.call("getPolicy", { query: { coding_repository_id: project.id } })),
93
+ settle(client.call("getPublication", { params: ids })),
94
+ settle(client.call("getBugScan", { params: { repository_id: project.id } })),
95
+ ]);
96
+ const unavailable = {};
97
+ for (const [key, got] of [
98
+ ["autonomy", policy],
99
+ ["feature_requests", publication],
100
+ ["bug_scan", bugScan],
101
+ ]) {
102
+ if (got.error) unavailable[key] = got.error;
103
+ }
104
+ return {
105
+ autonomy: policy.value?.config?.autonomy ?? null,
106
+ feature_requests: publication.value?.feature_requests ?? null,
107
+ bug_scan: bugScan.value ? (bugScan.value.enabled ? "on" : "off") : null,
108
+ protected_paths: project.protected_paths,
109
+ unavailable,
110
+ };
111
+ }
112
+
113
+ /** A read that may be missing (404 -> null) or not allowed for this key or plan (-> error). */
114
+ async function settle(promise) {
115
+ try {
116
+ return { value: await promise };
117
+ } catch (err) {
118
+ if (!(err instanceof ApiError)) throw err;
119
+ return err.status === 404 ? { value: null } : { error: explain(err).message };
120
+ }
121
+ }
122
+
123
+ async function orNull(promise) {
124
+ try {
125
+ return await promise;
126
+ } catch (err) {
127
+ if (err instanceof ApiError && err.status === 404) return null;
128
+ throw err;
129
+ }
130
+ }
131
+
132
+ export async function api(ctx, [method, target]) {
133
+ const { client, flags, io, ui } = ctx;
134
+ const verb = method?.toUpperCase();
135
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(verb) || !target) {
136
+ throw new CliError(EXIT.USAGE, "Usage: syndai api <METHOD> <path> [--json body]", {
137
+ next: "syndai api GET /runs",
138
+ });
139
+ }
140
+ const full = target.startsWith("/api/") ? target : `/api/v1/external/coding/${target.replace(/^\/+/, "")}`;
141
+ const [pathOnly, search = ""] = full.split("?");
142
+ let body;
143
+ if (typeof flags.json === "string") {
144
+ try {
145
+ body = JSON.parse(flags.json);
146
+ } catch {
147
+ throw new CliError(EXIT.USAGE, "--json must be valid JSON.");
148
+ }
149
+ }
150
+ const res = await client.send(verb, pathOnly, {
151
+ query: Object.fromEntries(new URLSearchParams(search)),
152
+ body,
153
+ raw: true,
154
+ });
155
+ io.stdout.write(`${JSON.stringify(res.body, null, ui.json ? 0 : 2)}\n`);
156
+ if (res.status < 400) return EXIT.OK;
157
+ const { exitCode, message } = explain(new ApiError(res.status, res.body, res.headers));
158
+ ui.progress(`error: ${message}`);
159
+ return exitCode;
160
+ }