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 +55 -0
- package/README.md +97 -52
- package/bin/syndai.js +13 -29
- package/package.json +6 -11
- package/src/api.js +188 -56
- package/src/auth.js +245 -0
- package/src/changes.js +350 -0
- package/src/errors.js +148 -0
- package/src/http.js +166 -0
- package/src/main.js +249 -0
- package/src/mcp.js +93 -0
- package/src/openapi.js +145 -0
- package/src/projects.js +168 -0
- package/src/routes.js +65 -0
- package/src/schedules.js +377 -0
- package/src/settings.js +130 -0
- package/src/store.js +137 -0
- package/src/ui.js +156 -0
- package/src/watch.js +299 -0
- package/LICENSE +0 -9
- package/src/claude.js +0 -145
- package/src/codex.js +0 -71
- package/src/connect.js +0 -122
- package/src/proc.js +0 -36
- package/src/prompt.js +0 -43
- package/src/tokens.js +0 -45
package/src/errors.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Exit codes and the one place a server problem becomes a sentence plus the next command.
|
|
2
|
+
//
|
|
3
|
+
// The exit codes are a contract with scripts and agents (docs: syndai.ai/docs/cli, "Exit codes"):
|
|
4
|
+
// never renumber one.
|
|
5
|
+
|
|
6
|
+
export const EXIT = Object.freeze({
|
|
7
|
+
OK: 0,
|
|
8
|
+
FAILED: 1, // the change failed, was reverted or cancelled; or the request was refused
|
|
9
|
+
USAGE: 2, // bad arguments, or no project could be resolved
|
|
10
|
+
NEEDS_YOU: 3, // a question, a hold, or an approval only a browser session can give
|
|
11
|
+
UNVERIFIED: 4, // live, but the live check did not confirm it
|
|
12
|
+
AUTH: 5, // 401/402/403: key, scope, role or plan
|
|
13
|
+
TIMEOUT: 6, // --timeout reached; the change keeps going
|
|
14
|
+
UNAVAILABLE: 7, // the service did not answer after retries
|
|
15
|
+
INTERRUPTED: 130, // Ctrl-C; the change keeps going
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** A user-facing failure: `message` is one sentence, `next` the command (or URL) to run next. */
|
|
19
|
+
export class CliError extends Error {
|
|
20
|
+
constructor(exitCode, message, { next = null, result = null } = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.exitCode = exitCode;
|
|
23
|
+
this.next = next;
|
|
24
|
+
// `result` is printed to stdout (JSON for scripts) before the error line.
|
|
25
|
+
this.result = result;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A non-2xx answer from the API. `problem` is the parsed body (problem+json or plain JSON). */
|
|
30
|
+
export class ApiError extends Error {
|
|
31
|
+
constructor(status, problem, headers) {
|
|
32
|
+
super(problem?.detail || problem?.title || `HTTP ${status}`);
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.problem = problem ?? {};
|
|
35
|
+
this.code = this.problem.code ?? null;
|
|
36
|
+
this.headers = headers;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The service did not answer (network error, 5xx or 429) after the retry budget. */
|
|
41
|
+
export class UnavailableError extends Error {}
|
|
42
|
+
|
|
43
|
+
const BILLING_URL = "https://app.syndai.ai/billing";
|
|
44
|
+
export const PORTAL_URL = "https://app.syndai.ai";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A scope `syndai login --scope` may add with one browser approval. Members, keys and
|
|
48
|
+
* unpublish stay portal-only: the approval page promises a CLI key never manages members or
|
|
49
|
+
* keys, and those powers stay out of a device-code grant.
|
|
50
|
+
*/
|
|
51
|
+
export function steppable(scope) {
|
|
52
|
+
return !/:members:|keys|:unpublish$/.test(scope);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// code -> [exit, sentence, next]. Either may be a function of the problem body.
|
|
56
|
+
const KNOWN = {
|
|
57
|
+
INVALID_TOKEN: [EXIT.AUTH, "Your Syndai key is missing, expired or revoked.", "syndai login"],
|
|
58
|
+
INVALID_SCOPE: [
|
|
59
|
+
EXIT.AUTH,
|
|
60
|
+
({ required_scope: scope }) =>
|
|
61
|
+
!scope
|
|
62
|
+
? "This key is not allowed to do that."
|
|
63
|
+
: steppable(scope)
|
|
64
|
+
? `This key does not have the ${scope} scope.`
|
|
65
|
+
: `Only the portal can do this (it needs ${scope}).`,
|
|
66
|
+
({ required_scope: scope }) =>
|
|
67
|
+
!scope ? "syndai login" : steppable(scope) ? `syndai login --scope ${scope}` : PORTAL_URL,
|
|
68
|
+
],
|
|
69
|
+
ROLE_REQUIRED: [EXIT.AUTH, "Only workspace owners and admins can do that.", null],
|
|
70
|
+
PLAN_UPGRADE_REQUIRED: [EXIT.AUTH, "Your plan does not include that.", BILLING_URL],
|
|
71
|
+
BUDGET_EXHAUSTED: [EXIT.AUTH, "This workspace is out of credits.", BILLING_URL],
|
|
72
|
+
QUOTA_EXCEEDED: [EXIT.AUTH, "This workspace is out of credits for now.", BILLING_URL],
|
|
73
|
+
CUSTOMER_DISABLED: [EXIT.AUTH, "This workspace is disabled. Contact Syndai support.", null],
|
|
74
|
+
HUMAN_APPROVAL_REQUIRED: [
|
|
75
|
+
EXIT.NEEDS_YOU,
|
|
76
|
+
"Only you can approve this, in your browser.",
|
|
77
|
+
(p) => p.review_url ?? p.extensions?.review_url ?? null,
|
|
78
|
+
],
|
|
79
|
+
PROTECTED_PATHS_MANAGED_ONLY: [
|
|
80
|
+
EXIT.FAILED,
|
|
81
|
+
"Protected files work on Syndai-hosted projects only; use the code host's branch rules.",
|
|
82
|
+
null,
|
|
83
|
+
],
|
|
84
|
+
CLI_UPGRADE_REQUIRED: [EXIT.FAILED, "This version of syndai is too old.", "npx -y syndai@latest"],
|
|
85
|
+
RUN_CONCURRENCY_LIMIT: [EXIT.FAILED, "Too many changes are running at once.", "syndai status"],
|
|
86
|
+
REPO_NOT_BOUND: [EXIT.USAGE, "That project is not available to this workspace.", "syndai link"],
|
|
87
|
+
INSTALLATION_INCOMPLETE: [
|
|
88
|
+
EXIT.FAILED,
|
|
89
|
+
"A code-host connection needs finishing in the portal.",
|
|
90
|
+
"https://app.syndai.ai",
|
|
91
|
+
],
|
|
92
|
+
HITL_REQUEST_SETTLED: [EXIT.FAILED, "That question is already answered or closed.", null],
|
|
93
|
+
RUN_TERMINATED_BEFORE_RESPONSE: [EXIT.FAILED, "The change ended before your answer arrived.", null],
|
|
94
|
+
TEXT_EDIT_REFUSED: [
|
|
95
|
+
EXIT.FAILED,
|
|
96
|
+
(p) => oneSentence(p.detail || "The text edit was refused."),
|
|
97
|
+
// Text built from code: the server hands back a ready change instruction.
|
|
98
|
+
(p) => (p.refusal === "NOT_LITERAL" && p.prompt ? `syndai change ${shellQuote(p.prompt)}` : null),
|
|
99
|
+
],
|
|
100
|
+
RUN_IN_FLIGHT: [
|
|
101
|
+
EXIT.FAILED,
|
|
102
|
+
"A change is in flight on this site; edit text once it lands.",
|
|
103
|
+
(p) => (p.run_id ? `syndai watch ${p.run_id}` : "syndai status"),
|
|
104
|
+
],
|
|
105
|
+
NOT_FOUND: [EXIT.FAILED, "Not found.", "syndai status"],
|
|
106
|
+
RATE_LIMITED: [EXIT.UNAVAILABLE, "Syndai is rate-limiting this workspace.", null],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Map any thrown error to {exitCode, message, next}. */
|
|
110
|
+
export function explain(err) {
|
|
111
|
+
if (err instanceof CliError) {
|
|
112
|
+
return { exitCode: err.exitCode, message: err.message, next: err.next };
|
|
113
|
+
}
|
|
114
|
+
if (err instanceof UnavailableError) {
|
|
115
|
+
return {
|
|
116
|
+
exitCode: EXIT.UNAVAILABLE,
|
|
117
|
+
message: `Syndai is not answering right now (${err.message}).`,
|
|
118
|
+
next: "try again in a minute",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (err instanceof ApiError) {
|
|
122
|
+
const known = err.code ? KNOWN[err.code] : null;
|
|
123
|
+
if (known) {
|
|
124
|
+
const [exitCode, sentence, next] = known;
|
|
125
|
+
return {
|
|
126
|
+
exitCode,
|
|
127
|
+
message: typeof sentence === "function" ? sentence(err.problem) : sentence,
|
|
128
|
+
next: typeof next === "function" ? next(err.problem) : next,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const auth = err.status === 401 || err.status === 402 || err.status === 403;
|
|
132
|
+
return {
|
|
133
|
+
exitCode: auth ? EXIT.AUTH : EXIT.FAILED,
|
|
134
|
+
message: oneSentence(err.problem.detail || err.problem.title || `HTTP ${err.status}`),
|
|
135
|
+
next: err.status === 401 ? "syndai login" : null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { exitCode: EXIT.FAILED, message: oneSentence(String(err?.message ?? err)), next: null };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function shellQuote(text) {
|
|
142
|
+
return `'${String(text).replace(/'/g, "'\\''")}'`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function oneSentence(text) {
|
|
146
|
+
const line = String(text).split("\n")[0].trim();
|
|
147
|
+
return /[.!?]$/.test(line) ? line : `${line}.`;
|
|
148
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The one HTTP client. Raw `fetch`, no SDK: `test/contract.test.js` checks every route
|
|
2
|
+
// against the published OpenAPI instead.
|
|
3
|
+
//
|
|
4
|
+
// Retry rule (docs: syndai.ai/docs/cli, "Retries"): only GETs and POSTs that carry their own
|
|
5
|
+
// idempotency key are retried — callers opt in with `retry: true`. A 429 waits out
|
|
6
|
+
// `Retry-After`; a 5xx, 408, network error or `MISSION_BUSY_RETRY` backs off from 0.5 s
|
|
7
|
+
// towards 8 s with jitter. Five tries, then `UnavailableError` (exit 7).
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { ApiError, CliError, EXIT, UnavailableError } from "./errors.js";
|
|
11
|
+
import { route } from "./routes.js";
|
|
12
|
+
|
|
13
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
14
|
+
export const USER_AGENT = `syndai-cli/${VERSION}`;
|
|
15
|
+
export const DEFAULT_API_URL = "https://api.syndai.ai";
|
|
16
|
+
|
|
17
|
+
const TRIES = 5;
|
|
18
|
+
const MAX_WAIT_MS = 8_000;
|
|
19
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
export function backoffMs(attempt, random = Math.random) {
|
|
22
|
+
const base = 500 * 2 ** (attempt - 1);
|
|
23
|
+
return Math.min(MAX_WAIT_MS, Math.round(base * (1 + random() / 2)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `Retry-After` as milliseconds (delta-seconds or an HTTP date), or null. */
|
|
27
|
+
export function retryAfterMs(value, now = Date.now()) {
|
|
28
|
+
if (!value) return null;
|
|
29
|
+
const seconds = Number(value);
|
|
30
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
31
|
+
const at = Date.parse(value);
|
|
32
|
+
return Number.isNaN(at) ? null : Math.max(0, at - now);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function interrupted() {
|
|
36
|
+
return new CliError(EXIT.INTERRUPTED, "Interrupted.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
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
|
|
45
|
+
*/
|
|
46
|
+
export function createClient({ baseUrl, getKey, io, onMissingScope }) {
|
|
47
|
+
/** Call a named route from `routes.js`. */
|
|
48
|
+
function call(name, { params, ...opts } = {}) {
|
|
49
|
+
const [method, path] = route(name, params);
|
|
50
|
+
return send(method, path, opts);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function send(
|
|
54
|
+
method,
|
|
55
|
+
path,
|
|
56
|
+
{ query, body, retry = method === "GET", auth = true, timeoutMs = 30_000, raw = false, signal } = {},
|
|
57
|
+
) {
|
|
58
|
+
const url = new URL(baseUrl + path);
|
|
59
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
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));
|
|
62
|
+
}
|
|
63
|
+
const headers = { "user-agent": USER_AGENT, accept: "application/json" };
|
|
64
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
65
|
+
if (auth) headers.authorization = `Bearer ${await getKey()}`;
|
|
66
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
67
|
+
|
|
68
|
+
let steppedUp = false;
|
|
69
|
+
for (let attempt = 1; ; attempt++) {
|
|
70
|
+
let res;
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetch(url, {
|
|
74
|
+
method,
|
|
75
|
+
headers,
|
|
76
|
+
body: payload,
|
|
77
|
+
signal: AbortSignal.any([io.signal, AbortSignal.timeout(timeoutMs), signal].filter(Boolean)),
|
|
78
|
+
});
|
|
79
|
+
parsed = await readBody(res);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (io.signal.aborted) throw interrupted();
|
|
82
|
+
if (signal?.aborted) throw signal.reason;
|
|
83
|
+
if (!retry || attempt >= TRIES) throw new UnavailableError(networkReason(err));
|
|
84
|
+
await pause(backoffMs(attempt), signal);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
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
|
+
}
|
|
98
|
+
const transient =
|
|
99
|
+
problem !== null &&
|
|
100
|
+
(res.status >= 500 ||
|
|
101
|
+
res.status === 408 ||
|
|
102
|
+
(res.status === 429 && (!problem.code || problem.code === "RATE_LIMITED")) ||
|
|
103
|
+
problem.code === "MISSION_BUSY_RETRY");
|
|
104
|
+
if (!transient) {
|
|
105
|
+
if (raw) return { status: res.status, body: parsed, headers: res.headers };
|
|
106
|
+
if (problem) throw problem;
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
if (!retry || attempt >= TRIES) {
|
|
110
|
+
throw new UnavailableError(`HTTP ${res.status}${problem.code ? ` ${problem.code}` : ""}`);
|
|
111
|
+
}
|
|
112
|
+
const hinted = res.status === 429 ? retryAfterMs(res.headers.get("retry-after")) : null;
|
|
113
|
+
const wait = hinted ?? backoffMs(attempt);
|
|
114
|
+
if (wait > MAX_RETRY_AFTER_MS) {
|
|
115
|
+
throw new UnavailableError(`rate-limited for ${Math.ceil(wait / 1000)} s`);
|
|
116
|
+
}
|
|
117
|
+
await pause(wait, signal);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Open a streaming GET (SSE). Returns the Response; non-2xx throws ApiError. */
|
|
122
|
+
async function stream(name, { params, query, headers = {}, signal } = {}) {
|
|
123
|
+
const [, path] = route(name, params);
|
|
124
|
+
const url = new URL(baseUrl + path);
|
|
125
|
+
for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, value);
|
|
126
|
+
const res = await fetch(url, {
|
|
127
|
+
headers: {
|
|
128
|
+
"user-agent": USER_AGENT,
|
|
129
|
+
accept: "text/event-stream",
|
|
130
|
+
authorization: `Bearer ${await getKey()}`,
|
|
131
|
+
...headers,
|
|
132
|
+
},
|
|
133
|
+
signal: AbortSignal.any([io.signal, signal].filter(Boolean)),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) throw new ApiError(res.status, await readBody(res), res.headers);
|
|
136
|
+
return res;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function pause(ms, signal) {
|
|
140
|
+
try {
|
|
141
|
+
await io.sleep(ms, AbortSignal.any([io.signal, signal].filter(Boolean)));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (io.signal.aborted) throw interrupted();
|
|
144
|
+
if (signal?.aborted) throw signal.reason;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { call, send, stream, baseUrl };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readBody(res) {
|
|
153
|
+
const text = await res.text();
|
|
154
|
+
if (!text) return null;
|
|
155
|
+
try {
|
|
156
|
+
return JSON.parse(text);
|
|
157
|
+
} catch {
|
|
158
|
+
return { detail: text.slice(0, 500) };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function networkReason(err) {
|
|
163
|
+
if (err?.name === "TimeoutError") return "timed out";
|
|
164
|
+
const code = err?.cause?.code ?? err?.code;
|
|
165
|
+
return code ? String(code) : String(err?.cause?.message ?? err?.message ?? "network error");
|
|
166
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
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, newProject, 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 } from "./api.js";
|
|
13
|
+
import { repeat, schedule } from "./schedules.js";
|
|
14
|
+
import { settings } from "./settings.js";
|
|
15
|
+
import { closeInput, openBrowser, page } from "./ui.js";
|
|
16
|
+
|
|
17
|
+
const str = { type: "string" };
|
|
18
|
+
const bool = { type: "boolean" };
|
|
19
|
+
const COMMON = { json: bool, "no-input": bool, help: { type: "boolean", short: "h" } };
|
|
20
|
+
|
|
21
|
+
const COMMANDS = {
|
|
22
|
+
change: {
|
|
23
|
+
usage: 'change "<what>" [--app A] [--review] [--image F]... [--no-wait] [--timeout D]',
|
|
24
|
+
summary: "Make a change; it goes live when its checks pass (--review: open a PR instead).",
|
|
25
|
+
options: {
|
|
26
|
+
app: str,
|
|
27
|
+
review: bool,
|
|
28
|
+
image: { type: "string", multiple: true },
|
|
29
|
+
"no-wait": bool,
|
|
30
|
+
timeout: str,
|
|
31
|
+
},
|
|
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,
|
|
41
|
+
},
|
|
42
|
+
watch: {
|
|
43
|
+
usage: "watch <run> [--timeout D] [--logs]",
|
|
44
|
+
summary: "Follow a change to its outcome (Ctrl-C detaches; the change keeps going).",
|
|
45
|
+
options: { timeout: str, logs: bool },
|
|
46
|
+
run: (ctx, [run]) => watch(ctx, run),
|
|
47
|
+
stepUp: false,
|
|
48
|
+
},
|
|
49
|
+
answer: {
|
|
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 },
|
|
53
|
+
run: (ctx, [run]) => answer(ctx, run),
|
|
54
|
+
},
|
|
55
|
+
review: {
|
|
56
|
+
usage: "review <run> [--retry | --close]",
|
|
57
|
+
summary: "See why a change is held, then approve it in your browser.",
|
|
58
|
+
options: { retry: bool, close: bool },
|
|
59
|
+
run: (ctx, [run]) => review(ctx, run),
|
|
60
|
+
},
|
|
61
|
+
"go-live": {
|
|
62
|
+
usage: "go-live <run> [--timeout D]",
|
|
63
|
+
summary: "Put a ready change live.",
|
|
64
|
+
options: { timeout: str },
|
|
65
|
+
run: (ctx, [run]) => goLive(ctx, run),
|
|
66
|
+
stepUp: false,
|
|
67
|
+
},
|
|
68
|
+
revert: {
|
|
69
|
+
usage: "revert <run> [--force] [--timeout D]",
|
|
70
|
+
summary: "Undo a change that went live.",
|
|
71
|
+
options: { force: bool, timeout: str },
|
|
72
|
+
run: (ctx, [run]) => revert(ctx, run),
|
|
73
|
+
stepUp: false,
|
|
74
|
+
},
|
|
75
|
+
cancel: {
|
|
76
|
+
usage: "cancel <run>",
|
|
77
|
+
summary: "Stop a change.",
|
|
78
|
+
options: {},
|
|
79
|
+
run: (ctx, [run]) => cancel(ctx, run),
|
|
80
|
+
},
|
|
81
|
+
status: {
|
|
82
|
+
usage: "status [--app A]",
|
|
83
|
+
summary: "What is running, and everything that needs you.",
|
|
84
|
+
options: { app: str },
|
|
85
|
+
run: (ctx) => status(ctx),
|
|
86
|
+
},
|
|
87
|
+
"edit-text": {
|
|
88
|
+
usage: 'edit-text [--app A] [--page /] --from "Old" --to "New" [--all] | --undo <sha>',
|
|
89
|
+
summary: "Swap exact text on a live site in seconds, free and undoable (--all: every match in its file).",
|
|
90
|
+
options: { app: str, page: str, from: str, to: str, all: bool, undo: str },
|
|
91
|
+
run: (ctx) => editText(ctx),
|
|
92
|
+
},
|
|
93
|
+
login: {
|
|
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 } },
|
|
98
|
+
run: (ctx) => login(ctx),
|
|
99
|
+
},
|
|
100
|
+
logout: {
|
|
101
|
+
usage: "logout",
|
|
102
|
+
summary: "Revoke and forget the stored key.",
|
|
103
|
+
options: {},
|
|
104
|
+
run: (ctx) => logout(ctx),
|
|
105
|
+
},
|
|
106
|
+
whoami: {
|
|
107
|
+
usage: "whoami",
|
|
108
|
+
summary: "Show the workspace and key in use.",
|
|
109
|
+
options: {},
|
|
110
|
+
run: (ctx) => whoami(ctx),
|
|
111
|
+
},
|
|
112
|
+
link: {
|
|
113
|
+
usage: "link [project]",
|
|
114
|
+
summary: "Remember which project this directory changes (stored outside the repo).",
|
|
115
|
+
options: { app: str },
|
|
116
|
+
run: (ctx, [ref]) => link(ctx, ref),
|
|
117
|
+
},
|
|
118
|
+
settings: {
|
|
119
|
+
usage: "settings [--app A] [get | set key=value...]",
|
|
120
|
+
summary: "autonomy, feature_requests, bug_scan, protected_paths (a key can only tighten them).",
|
|
121
|
+
options: { app: str },
|
|
122
|
+
run: (ctx, args) => settings(ctx, args),
|
|
123
|
+
},
|
|
124
|
+
mcp: {
|
|
125
|
+
usage: "mcp install claude|cursor|codex",
|
|
126
|
+
summary: "Add Syndai to an AI coding tool, with its own key.",
|
|
127
|
+
options: {},
|
|
128
|
+
run: (ctx, args) => mcp(ctx, args),
|
|
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
|
+
},
|
|
146
|
+
api: {
|
|
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,
|
|
157
|
+
run: (ctx, args) => api(ctx, args),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export function helpText() {
|
|
162
|
+
const rows = Object.values(COMMANDS).map((c) => ` syndai ${c.usage}\n ${c.summary}`);
|
|
163
|
+
return [
|
|
164
|
+
`syndai ${VERSION}: tell Syndai what to change; it builds it, checks it and puts it live.`,
|
|
165
|
+
"",
|
|
166
|
+
...rows,
|
|
167
|
+
"",
|
|
168
|
+
"Every command: --json (machine output; also when stdout is not a terminal),",
|
|
169
|
+
"--no-input (never prompt), -h/--help.",
|
|
170
|
+
"Environment: SYNDAI_API_KEY (wins over `syndai login`), SYNDAI_APP, SYNDAI_API_URL, NO_COLOR.",
|
|
171
|
+
"Exit codes: 0 done, 1 failed, 2 usage or no project, 3 needs you, 4 live but unverified,",
|
|
172
|
+
"5 auth/scope/plan, 6 --timeout reached, 7 service unavailable, 130 interrupted.",
|
|
173
|
+
"",
|
|
174
|
+
].join("\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The real process as `io` (the bin passes the SIGINT AbortSignal). */
|
|
178
|
+
export function processIo(signal) {
|
|
179
|
+
return {
|
|
180
|
+
env: process.env,
|
|
181
|
+
cwd: process.cwd(),
|
|
182
|
+
stdout: process.stdout,
|
|
183
|
+
stderr: process.stderr,
|
|
184
|
+
stdin: process.stdin,
|
|
185
|
+
signal,
|
|
186
|
+
now: () => Date.now(),
|
|
187
|
+
sleep: (ms, abortSignal) => sleep(ms, undefined, { signal: abortSignal }),
|
|
188
|
+
openUrl: openBrowser,
|
|
189
|
+
page: (text) => page(process, text),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function main(argv, io) {
|
|
194
|
+
const [command, ...rest] = argv;
|
|
195
|
+
if (!command || ["help", "--help", "-h"].includes(command)) {
|
|
196
|
+
io.stdout.write(helpText());
|
|
197
|
+
return EXIT.OK;
|
|
198
|
+
}
|
|
199
|
+
if (["--version", "-v", "version"].includes(command)) {
|
|
200
|
+
io.stdout.write(`${VERSION}\n`);
|
|
201
|
+
return EXIT.OK;
|
|
202
|
+
}
|
|
203
|
+
const spec = COMMANDS[command];
|
|
204
|
+
if (!spec) {
|
|
205
|
+
io.stderr.write(`error: unknown command "${command}"\n\n${helpText()}`);
|
|
206
|
+
return EXIT.USAGE;
|
|
207
|
+
}
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = parseArgs({ args: rest, options: { ...COMMON, ...spec.options }, allowPositionals: true });
|
|
211
|
+
} catch (err) {
|
|
212
|
+
io.stderr.write(`error: ${err.message}\n usage: syndai ${spec.usage}\n`);
|
|
213
|
+
return EXIT.USAGE;
|
|
214
|
+
}
|
|
215
|
+
if (parsed.values.help && !spec.ownHelp) {
|
|
216
|
+
io.stdout.write(`usage: syndai ${spec.usage}\n${spec.summary}\n`);
|
|
217
|
+
return EXIT.OK;
|
|
218
|
+
}
|
|
219
|
+
const ctx = createContext(io, parsed.values, { stepUp: spec.stepUp !== false });
|
|
220
|
+
try {
|
|
221
|
+
return await spec.run(ctx, parsed.positionals);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return report(ctx, err);
|
|
224
|
+
} finally {
|
|
225
|
+
closeInput(io);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** One error line (plus the next command) on stderr; a JSON error on stdout for machines. */
|
|
230
|
+
function report(ctx, err) {
|
|
231
|
+
const { ui, io } = ctx;
|
|
232
|
+
const { exitCode, message, next } = explain(err);
|
|
233
|
+
if (io.env.SYNDAI_DEBUG && err?.stack) io.stderr.write(`${err.stack}\n`);
|
|
234
|
+
const extra = err instanceof CliError ? err.result : null;
|
|
235
|
+
if (exitCode === EXIT.INTERRUPTED) {
|
|
236
|
+
ui.progress(message);
|
|
237
|
+
} else {
|
|
238
|
+
ui.progress(`${ui.red("error:")} ${message}`);
|
|
239
|
+
if (extra?.human) ui.progress(extra.human);
|
|
240
|
+
if (ui.json) {
|
|
241
|
+
const code = err?.code ?? null;
|
|
242
|
+
io.stdout.write(
|
|
243
|
+
`${JSON.stringify({ error: { exit_code: exitCode, code, message, next }, ...(extra?.data ?? {}) })}\n`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (next) ui.progress(` next: ${next}`);
|
|
248
|
+
return exitCode;
|
|
249
|
+
}
|
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
|
+
}
|