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/src/ui.js ADDED
@@ -0,0 +1,156 @@
1
+ // Terminal I/O: progress on stderr, the result on stdout, prompts, the browser and the pager.
2
+ //
3
+ // The result is JSON when `--json` is passed or stdout is not a terminal (an agent or a
4
+ // pipe), human text otherwise. Colour only on a terminal, never with NO_COLOR.
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { getRandomValues } from "node:crypto";
8
+ import { readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { createInterface } from "node:readline";
11
+ import { CliError, EXIT } from "./errors.js";
12
+ import { interrupted } from "./http.js";
13
+
14
+ export function createUi(io, { json = false, noInput = false } = {}) {
15
+ const colour = Boolean(io.stderr.isTTY) && !io.env.NO_COLOR && io.env.TERM !== "dumb";
16
+ const paint = (code) => (text) => (colour ? `\x1b[${code}m${text}\x1b[0m` : String(text));
17
+ return {
18
+ json: json || !io.stdout.isTTY,
19
+ interactive: !noInput && Boolean(io.stdin.isTTY) && Boolean(io.stderr.isTTY),
20
+ dim: paint("2"),
21
+ bold: paint("1"),
22
+ green: paint("32"),
23
+ yellow: paint("33"),
24
+ red: paint("31"),
25
+ progress(line) {
26
+ io.stderr.write(`${line}\n`);
27
+ },
28
+ /** Print the result: `data` as one JSON line, or `human` text. */
29
+ result(data, human) {
30
+ io.stdout.write(this.json ? `${JSON.stringify(data)}\n` : `${human}\n`);
31
+ },
32
+ };
33
+ }
34
+
35
+ // One line reader per stdin for the whole run: its async iterator buffers lines, so answers
36
+ // typed (or piped) ahead of a prompt are never dropped between questions.
37
+ const readers = new WeakMap();
38
+
39
+ export async function ask(io, question) {
40
+ io.stderr.write(question);
41
+ let reader = readers.get(io.stdin);
42
+ if (!reader) {
43
+ const rl = createInterface({ input: io.stdin, terminal: false, crlfDelay: Number.POSITIVE_INFINITY });
44
+ reader = { rl, lines: rl[Symbol.asyncIterator]() };
45
+ readers.set(io.stdin, reader);
46
+ }
47
+ let onAbort;
48
+ const aborted = new Promise((_, reject) => {
49
+ onAbort = () => reject(interrupted());
50
+ if (io.signal.aborted) onAbort();
51
+ io.signal.addEventListener("abort", onAbort, { once: true });
52
+ });
53
+ try {
54
+ const next = await Promise.race([reader.lines.next(), aborted]);
55
+ if (next.done) throw new CliError(EXIT.NEEDS_YOU, "No answer: input closed.");
56
+ return next.value.trim();
57
+ } finally {
58
+ io.signal.removeEventListener("abort", onAbort);
59
+ }
60
+ }
61
+
62
+ /** Release stdin so the process can exit (called once a command is done). */
63
+ export function closeInput(io) {
64
+ readers.get(io.stdin)?.rl.close();
65
+ readers.delete(io.stdin);
66
+ }
67
+
68
+ /** Open an http(s) URL in the default browser; the caller always prints it too. */
69
+ export function openBrowser(url) {
70
+ if (!/^https?:\/\//.test(url)) return;
71
+ const [cmd, args] =
72
+ process.platform === "darwin"
73
+ ? ["open", [url]]
74
+ : process.platform === "win32"
75
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
76
+ : ["xdg-open", [url]];
77
+ try {
78
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
79
+ child.on("error", () => {});
80
+ child.unref();
81
+ } catch {
82
+ // No browser here (SSH, container): the printed URL is the fallback.
83
+ }
84
+ }
85
+
86
+ /** Show long text through $PAGER on a terminal; print it plainly otherwise. */
87
+ export function page(proc, text) {
88
+ if (!proc.stdout.isTTY) {
89
+ proc.stdout.write(text);
90
+ return Promise.resolve();
91
+ }
92
+ return new Promise((resolve) => {
93
+ const child = spawn(proc.env.PAGER || "less", {
94
+ shell: true,
95
+ stdio: ["pipe", "inherit", "inherit"],
96
+ env: { LESS: "FRX", ...proc.env },
97
+ });
98
+ child.on("error", () => {
99
+ proc.stdout.write(text);
100
+ resolve();
101
+ });
102
+ child.on("close", resolve);
103
+ child.stdin.on("error", () => {}); // EPIPE when the reader quits early
104
+ child.stdin.end(text);
105
+ });
106
+ }
107
+
108
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
109
+
110
+ /** A ULID: 48-bit millisecond time + 80 random bits, Crockford base32 (26 chars). */
111
+ export function ulid(now = Date.now()) {
112
+ let time = "";
113
+ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = CROCKFORD[t % 32] + time;
114
+ let rand = "";
115
+ for (const byte of getRandomValues(new Uint8Array(16))) rand += CROCKFORD[byte % 32];
116
+ return time + rand;
117
+ }
118
+
119
+ /** "90", "90s", "10m", "2h" -> milliseconds. */
120
+ export function parseDuration(text) {
121
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(String(text).trim());
122
+ if (!match) {
123
+ throw new CliError(EXIT.USAGE, `--timeout takes a duration like 90s, 10m or 1h, not "${text}".`);
124
+ }
125
+ const unit = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[match[2] ?? "s"];
126
+ return Math.round(Number(match[1]) * unit);
127
+ }
128
+
129
+ export function hostOf(url) {
130
+ try {
131
+ return new URL(url).host;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ export async function readAll(stream) {
138
+ let text = "";
139
+ for await (const chunk of stream) text += chunk;
140
+ return text;
141
+ }
142
+
143
+ /** `--input FILE|-` as parsed JSON (`-` reads stdin). */
144
+ export async function readJsonInput(io, source) {
145
+ let text;
146
+ try {
147
+ text = source === "-" ? await readAll(io.stdin) : readFileSync(path.resolve(io.cwd, source), "utf8");
148
+ } catch (err) {
149
+ throw new CliError(EXIT.USAGE, `Cannot read --input ${source} (${err.code ?? err.message}).`);
150
+ }
151
+ try {
152
+ return JSON.parse(text);
153
+ } catch {
154
+ throw new CliError(EXIT.USAGE, `--input ${source} is not valid JSON.`);
155
+ }
156
+ }
package/src/watch.js ADDED
@@ -0,0 +1,299 @@
1
+ // Following a change to its outcome.
2
+ //
3
+ // One loop long-polls `GET runs/{id}/change?wait=45&cursor=` (ChangeState). Long-polling
4
+ // survives proxies and deploys (the server returns early while it drains) and resumes by
5
+ // cursor. Ctrl-C only detaches: it never sends a cancel. `--logs` also tails the run's SSE
6
+ // events (resuming with Last-Event-ID) next to the loop.
7
+
8
+ import { ApiError, CliError, EXIT, explain } from "./errors.js";
9
+ import { backoffMs } from "./http.js";
10
+ import { ask, ulid } from "./ui.js";
11
+
12
+ const POLL_WAIT_S = 45;
13
+ const MIN_POLL_GAP_MS = 1000;
14
+ const DEADLINE_GRACE_MS = 2000;
15
+ const TIMED_OUT = Symbol("timed out");
16
+
17
+ // Exit code per ChangeState state (docs: syndai.ai/docs/cli, "Exit codes").
18
+ const STATE_EXIT = {
19
+ needs_input: EXIT.NEEDS_YOU,
20
+ held: EXIT.NEEDS_YOU,
21
+ ready: EXIT.OK,
22
+ live: EXIT.OK,
23
+ live_unverified: EXIT.UNVERIFIED,
24
+ failed: EXIT.FAILED,
25
+ reverted: EXIT.FAILED,
26
+ cancelled: EXIT.FAILED,
27
+ };
28
+
29
+ export function exitFor(state) {
30
+ return STATE_EXIT[state.state] ?? (Number.isInteger(state.exit_code) ? state.exit_code : EXIT.FAILED);
31
+ }
32
+
33
+ /** The command (or URL) that moves this change on, or null. */
34
+ export function nextFor(state) {
35
+ const id = state.run_id;
36
+ if (state.state === "needs_input") return `syndai answer ${id}`;
37
+ if (state.state === "held") return `syndai review ${id}`;
38
+ if (state.state === "ready") return `syndai go-live ${id}`;
39
+ if (state.state === "live_unverified") return `syndai watch ${id}`;
40
+ return state.done ? null : `syndai watch ${id}`;
41
+ }
42
+
43
+ export function describe(state) {
44
+ const headline = state.headline ? `: ${state.headline}` : "";
45
+ switch (state.state) {
46
+ case "queued":
47
+ return `Queued${state.queue_position ? ` (#${state.queue_position} in line)` : ""}`;
48
+ case "working":
49
+ return `Working${headline}`;
50
+ case "needs_input":
51
+ return `Needs you: ${state.needs_input?.[0]?.summary ?? "a question"}`;
52
+ case "landing":
53
+ return `Checking and going live${headline}`;
54
+ case "held":
55
+ return `Held: ${state.held?.message ?? state.held?.reason ?? "needs your review"}`;
56
+ case "ready":
57
+ return `Ready for review${headline}`;
58
+ case "live":
59
+ return `Live: ${state.live_url ?? state.project?.live_url ?? ""}`.trim();
60
+ case "live_unverified":
61
+ return `Live, but the live check could not confirm it: ${state.live_url ?? ""}`.trim();
62
+ default:
63
+ return `${state.state[0].toUpperCase()}${state.state.slice(1).replace(/_/g, " ")}${headline}`;
64
+ }
65
+ }
66
+
67
+ /** Print the final state (JSON ChangeState + `next`, or a line) and return its exit code. */
68
+ export function finish(ctx, state, { exitCode = exitFor(state), next = nextFor(state) } = {}) {
69
+ const { ui } = ctx;
70
+ const lines = [describe(state)];
71
+ if (state.run_url) lines.push(ui.dim(state.run_url));
72
+ ui.result({ ...state, ...(next ? { next } : {}) }, lines.join("\n"));
73
+ if (next && !ui.json) ui.progress(`Next: ${next}`);
74
+ return exitCode;
75
+ }
76
+
77
+ export async function getChange(ctx, runId) {
78
+ return ctx.client.call("changeState", { params: { run_id: runId }, query: { wait: 0 } });
79
+ }
80
+
81
+ /**
82
+ * Follow `runId` until it is done (or, with `untilNot`, until it leaves that state).
83
+ * Returns the exit code.
84
+ */
85
+ export async function watchChange(ctx, runId, { timeoutMs = null, untilNot = null, logs = false } = {}) {
86
+ const { client, ui, io } = ctx;
87
+ const deadline = timeoutMs === null ? null : io.now() + timeoutMs;
88
+ const deadlineCtl = new AbortController();
89
+ const timer =
90
+ deadline === null ? null : setTimeout(() => deadlineCtl.abort(TIMED_OUT), timeoutMs + DEADLINE_GRACE_MS);
91
+ timer?.unref?.();
92
+ const logsCtl = new AbortController();
93
+ const logsDone = logs ? tailEvents(ctx, runId, logsCtl.signal) : null;
94
+ let cursor;
95
+ let last = null;
96
+ let lastLine = null;
97
+ const answered = new Set(); // a state read before our answer landed must not ask again
98
+ try {
99
+ for (;;) {
100
+ const remaining = deadline === null ? Number.POSITIVE_INFINITY : deadline - io.now();
101
+ if (remaining <= 0) return timedOut(ctx, runId, last);
102
+ const wait = Math.max(0, Math.min(POLL_WAIT_S, Math.floor(remaining / 1000)));
103
+ const started = io.now();
104
+ let state;
105
+ try {
106
+ state = await client.call("changeState", {
107
+ params: { run_id: runId },
108
+ query: { wait, cursor },
109
+ timeoutMs: (wait + 20) * 1000,
110
+ signal: deadlineCtl.signal,
111
+ });
112
+ } catch (err) {
113
+ if (err === TIMED_OUT) return timedOut(ctx, runId, last);
114
+ throw err;
115
+ }
116
+ const line = describe(state);
117
+ const changed = line !== lastLine || state.cursor !== cursor;
118
+ const final =
119
+ (state.done && state.state !== untilNot) || (state.state === "needs_input" && !ui.interactive);
120
+ if (final) return finish(ctx, state);
121
+ if (line !== lastLine) ui.progress(`${ui.dim("·")} ${line}`);
122
+ lastLine = line;
123
+ cursor = state.cursor ?? cursor;
124
+ last = state;
125
+
126
+ const open = (state.needs_input ?? []).filter((q) => !answered.has(q.hitl_request_id));
127
+ if (state.state === "needs_input" && open.length) {
128
+ await answerInteractively(ctx, runId, open);
129
+ for (const q of open) answered.add(q.hitl_request_id);
130
+ continue;
131
+ }
132
+ // A server that answers at once without news (draining, a proxy) must not make us spin.
133
+ if (!changed && io.now() - started < MIN_POLL_GAP_MS) {
134
+ const left = deadline === null ? MIN_POLL_GAP_MS : Math.max(0, deadline - io.now());
135
+ await io.sleep(Math.min(MIN_POLL_GAP_MS, left), io.signal);
136
+ }
137
+ }
138
+ } catch (err) {
139
+ if (io.signal.aborted || (err instanceof CliError && err.exitCode === EXIT.INTERRUPTED)) {
140
+ throw new CliError(EXIT.INTERRUPTED, "Stopped watching; the change keeps going.", {
141
+ next: `syndai watch ${runId}`,
142
+ });
143
+ }
144
+ throw err;
145
+ } finally {
146
+ if (timer) clearTimeout(timer);
147
+ logsCtl.abort();
148
+ await logsDone;
149
+ }
150
+ }
151
+
152
+ function timedOut(ctx, runId, last) {
153
+ const state = last ?? { run_id: runId, state: "unknown", done: false };
154
+ const next = `syndai watch ${runId}`;
155
+ ctx.ui.result({ ...state, next }, `Still going (timeout reached): ${describe(state)}`);
156
+ if (!ctx.ui.json) ctx.ui.progress(`Next: ${next}`);
157
+ return EXIT.TIMEOUT;
158
+ }
159
+
160
+ // --- questions -----------------------------------------------------------------------------
161
+
162
+ export async function answerInteractively(ctx, runId, questions) {
163
+ for (const question of questions) {
164
+ ctx.ui.progress(ctx.ui.bold(question.summary ?? "A question about your change"));
165
+ const entries = [];
166
+ for (const input of question.inputs ?? []) {
167
+ const entry = await askInput(ctx, input);
168
+ if (entry) entries.push(entry);
169
+ }
170
+ await respond(ctx, runId, question.hitl_request_id, entries);
171
+ ctx.ui.progress(ctx.ui.green("Answered."));
172
+ }
173
+ }
174
+
175
+ export function respond(ctx, runId, hitlRequestId, entries) {
176
+ return ctx.client.call("respond", {
177
+ params: { run_id: runId },
178
+ body: { hitl_request_id: hitlRequestId, entries, idempotency_key: `cli:${ulid()}` },
179
+ retry: true,
180
+ });
181
+ }
182
+
183
+ async function askInput(ctx, input) {
184
+ const { io, ui } = ctx;
185
+ if (input.context) ui.progress(ui.dim(input.context));
186
+ const optional = input.required ? "" : ui.dim(" (Enter to skip)");
187
+ if (input.kind === "approval") {
188
+ for (;;) {
189
+ const answer = (await ask(io, `${input.prompt} [y/n]${optional} `)).toLowerCase();
190
+ if (!answer && !input.required) return null;
191
+ if (["y", "yes", "n", "no"].includes(answer)) {
192
+ return { input_id: input.input_id, approved: answer.startsWith("y") };
193
+ }
194
+ }
195
+ }
196
+ const options = input.options ?? [];
197
+ const textAllowed = input.kind === "free_text" || Boolean(input.free_text?.enabled);
198
+ if (options.length === 0) {
199
+ for (;;) {
200
+ const text = await ask(io, `${input.prompt}${optional} `);
201
+ if (text || !input.required) return text ? { input_id: input.input_id, text } : null;
202
+ }
203
+ }
204
+ ui.progress(input.prompt);
205
+ const defaults = new Set(input.default_option_ids ?? []);
206
+ for (const [i, option] of options.entries()) {
207
+ ui.progress(` ${i + 1}. ${defaults.has(option.id) ? "[x] " : ""}${option.label}`);
208
+ }
209
+ const multi = input.kind === "multi_choice";
210
+ const hint = multi ? "numbers, comma-separated" : "a number";
211
+ const keep = defaults.size ? ", Enter keeps [x]" : "";
212
+ for (;;) {
213
+ const answer = await ask(io, `Pick ${hint}${textAllowed ? " or type an answer" : ""}${keep}${optional}: `);
214
+ if (!answer && defaults.size) {
215
+ return { input_id: input.input_id, selected_option_ids: options.filter((o) => defaults.has(o.id)).map((o) => o.id) };
216
+ }
217
+ if (!answer && !input.required) return null;
218
+ const picks = answer.split(",").map((part) => Number(part.trim()) - 1);
219
+ const valid = picks.every((i) => Number.isInteger(i) && options[i]) && (multi || picks.length === 1);
220
+ if (answer && valid) {
221
+ return { input_id: input.input_id, selected_option_ids: picks.map((i) => options[i].id) };
222
+ }
223
+ if (answer && textAllowed) return { input_id: input.input_id, text: answer };
224
+ }
225
+ }
226
+
227
+ // --- logs ----------------------------------------------------------------------------------
228
+
229
+ async function tailEvents(ctx, runId, signal) {
230
+ const { client, ui, io } = ctx;
231
+ let lastEventId = null;
232
+ for (let attempt = 0; !signal.aborted; ) {
233
+ try {
234
+ const res = await client.stream("runEvents", {
235
+ params: { run_id: runId },
236
+ headers: lastEventId ? { "last-event-id": lastEventId } : {},
237
+ signal,
238
+ });
239
+ attempt = 0;
240
+ for await (const event of parseSse(res.body)) {
241
+ if (event.id) lastEventId = event.id;
242
+ if (event.event === "stream_heartbeat") continue;
243
+ if (ui.json) {
244
+ io.stdout.write(`${JSON.stringify({ id: event.id, event: event.event, data: event.data })}\n`);
245
+ } else {
246
+ const payload = event.data?.payload ?? event.data ?? {};
247
+ const text = payload.summary ?? payload.message ?? payload.title ?? "";
248
+ ui.progress(ui.dim(` [${event.event}] ${String(text).slice(0, 200)}`));
249
+ }
250
+ }
251
+ } catch (err) {
252
+ if (signal.aborted || io.signal.aborted) return;
253
+ if (err instanceof ApiError && err.status < 500 && err.status !== 429) {
254
+ ui.progress(ui.yellow(`Logs stopped: ${explain(err).message}`));
255
+ return;
256
+ }
257
+ }
258
+ attempt = Math.min(attempt + 1, 5);
259
+ try {
260
+ await io.sleep(backoffMs(attempt), signal);
261
+ } catch {
262
+ return;
263
+ }
264
+ }
265
+ }
266
+
267
+ /** Server-sent events from a byte stream: yields `{id, event, data}` (data JSON-parsed). */
268
+ export async function* parseSse(body) {
269
+ const decoder = new TextDecoder();
270
+ let buffer = "";
271
+ for await (const chunk of body) {
272
+ buffer += decoder.decode(chunk, { stream: true });
273
+ let split = buffer.search(/\r?\n\r?\n/);
274
+ while (split !== -1) {
275
+ const block = buffer.slice(0, split);
276
+ buffer = buffer.slice(split).replace(/^\r?\n\r?\n/, "");
277
+ const event = { id: null, event: "message", data: "" };
278
+ const data = [];
279
+ for (const line of block.split(/\r?\n/)) {
280
+ const colon = line.indexOf(":");
281
+ if (colon === 0) continue; // comment
282
+ const field = colon === -1 ? line : line.slice(0, colon);
283
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
284
+ if (field === "data") data.push(value);
285
+ else if (field === "id") event.id = value;
286
+ else if (field === "event") event.event = value;
287
+ }
288
+ if (data.length) {
289
+ try {
290
+ event.data = JSON.parse(data.join("\n"));
291
+ } catch {
292
+ event.data = data.join("\n");
293
+ }
294
+ yield event;
295
+ }
296
+ split = buffer.search(/\r?\n\r?\n/);
297
+ }
298
+ }
299
+ }
package/LICENSE DELETED
@@ -1,9 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Solo Zero Labs
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
-
7
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
-
9
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/src/claude.js DELETED
@@ -1,145 +0,0 @@
1
- // Claude plan connect: mint a fresh setup-token, verify it with the official CLI,
2
- // or fall back to a hidden paste when `claude` isn't installed. The token is never
3
- // printed, never written to disk, never put on the clipboard.
4
-
5
- import { spawnSync } from "node:child_process";
6
- import fs from "node:fs";
7
- import os from "node:os";
8
- import path from "node:path";
9
- import { extractOatToken, stripInternalWhitespace } from "./tokens.js";
10
- import { onPath, isBsdScript } from "./proc.js";
11
- import { readHidden } from "./prompt.js";
12
-
13
- // `claude setup-token` draws a full-screen terminal UI, so it needs a PTY; we get
14
- // one from script(1). BSD (macOS) and util-linux take different argument shapes —
15
- // both handled. Injected in tests via `deps` so the pure extraction stays testable.
16
- export function buildScriptArgv() {
17
- if (isBsdScript()) {
18
- return ["script", ["-q", "/dev/null", "claude", "setup-token"]];
19
- }
20
- return ["script", ["-qec", "claude setup-token", "/dev/null"]];
21
- }
22
-
23
- function runSetupToken(deps) {
24
- const [cmd, args] = deps.buildScriptArgv();
25
- const res = deps.spawnSync(cmd, args, { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
26
- const out = (res.stdout || "") + (res.stderr || "");
27
- return deps.extractOatToken(out);
28
- }
29
-
30
- /**
31
- * Verify a Claude plan token by asking the official CLI to answer a one-word
32
- * prompt. A rate-limited token still exits 0, so we DO NOT trust the exit code —
33
- * success is a `result` frame with `is_error === false`. A throwaway
34
- * CLAUDE_CONFIG_DIR and blanked ANTHROPIC_* keep the check hermetic and force the
35
- * token (not a stray key) to authenticate.
36
- */
37
- export function verifyClaudeToken(token, deps = {}) {
38
- const _spawnSync = deps.spawnSync || spawnSync;
39
- const _mkdtemp = deps.mkdtempSync || ((p) => fs.mkdtempSync(p));
40
- const configDir = _mkdtemp(path.join(os.tmpdir(), "syndai-claude-"));
41
- try {
42
- const res = _spawnSync("claude", ["-p", "ping", "--output-format", "json"], {
43
- encoding: "utf8",
44
- stdio: ["ignore", "pipe", "pipe"],
45
- env: {
46
- ...process.env,
47
- CLAUDE_CODE_OAUTH_TOKEN: token,
48
- ANTHROPIC_API_KEY: "",
49
- ANTHROPIC_AUTH_TOKEN: "",
50
- ANTHROPIC_BASE_URL: "",
51
- CLAUDE_CONFIG_DIR: configDir,
52
- },
53
- });
54
- return resultIsError(res.stdout) === false;
55
- } finally {
56
- try {
57
- fs.rmSync(configDir, { recursive: true, force: true });
58
- } catch {
59
- /* ignore */
60
- }
61
- }
62
- }
63
-
64
- // Parse `claude -p --output-format json` stdout and report the result frame's
65
- // is_error. Returns true/false when found, or null when no result frame parsed.
66
- export function resultIsError(stdout) {
67
- const text = String(stdout || "").trim();
68
- if (!text) return null;
69
- // Try a single JSON object first, then fall back to JSON-lines (stream-json).
70
- const candidates = [];
71
- try {
72
- candidates.push(JSON.parse(text));
73
- } catch {
74
- for (const line of text.split(/\r?\n/)) {
75
- const t = line.trim();
76
- if (!t) continue;
77
- try {
78
- candidates.push(JSON.parse(t));
79
- } catch {
80
- /* skip noise */
81
- }
82
- }
83
- }
84
- for (const frame of candidates) {
85
- if (frame && typeof frame === "object" && (frame.type === "result" || "is_error" in frame)) {
86
- return Boolean(frame.is_error);
87
- }
88
- }
89
- return null;
90
- }
91
-
92
- /**
93
- * Obtain a verified Claude plan token, or null if the user declines / it fails.
94
- * - `claude` on PATH: mint via setup-token (PTY), then verify.
95
- * - not installed: print the paste-fallback instruction, read a HIDDEN paste in
96
- * this same command, strip wrapping whitespace, then verify.
97
- * The token is returned to the caller for the single upload; nothing else touches it.
98
- */
99
- export async function connectClaude(deps = {}) {
100
- const _onPath = deps.onPath || onPath;
101
- const _readHidden = deps.readHidden || readHidden;
102
- const log = deps.log || console.log;
103
-
104
- const scriptOnPath = deps.scriptOnPath ?? _onPath("script");
105
-
106
- if (_onPath("claude") && scriptOnPath) {
107
- log("Claude: minting a fresh plan token (approve in your browser when Claude asks)…");
108
- const token = runSetupToken({
109
- buildScriptArgv: deps.buildScriptArgv || buildScriptArgv,
110
- spawnSync: deps.spawnSync || spawnSync,
111
- extractOatToken,
112
- });
113
- if (!token) {
114
- log("Claude: could not read a token from setup-token. Skipping Claude.");
115
- return null;
116
- }
117
- log("Claude: verifying the token with the official CLI…");
118
- if (!verifyClaudeToken(token, deps)) {
119
- log("Claude: the minted token did not verify. Skipping Claude.");
120
- return null;
121
- }
122
- log("Claude: token verified ✓");
123
- return token;
124
- }
125
-
126
- // Paste fallback (no `claude`, or no PTY via script(1) — e.g. Windows w/o WSL).
127
- log("");
128
- log("Claude CLI not found. To connect your Claude plan, in another terminal run:");
129
- log(" claude setup-token");
130
- log("then paste the sk-ant-oat01-… value below (it is hidden and not stored):");
131
- const raw = await _readHidden("Claude plan token: ");
132
- const token = stripInternalWhitespace(raw);
133
- if (!token) {
134
- log("Claude: nothing pasted. Skipping Claude.");
135
- return null;
136
- }
137
- if (deps.onPath ? _onPath("claude") : onPath("claude")) {
138
- // If claude showed up we can still verify; otherwise trust the server verify.
139
- if (!verifyClaudeToken(token, deps)) {
140
- log("Claude: that token did not verify. Skipping Claude.");
141
- return null;
142
- }
143
- }
144
- return token;
145
- }
package/src/codex.js DELETED
@@ -1,71 +0,0 @@
1
- // ChatGPT plan connect: give Syndai its OWN Codex login lineage — `codex login`
2
- // into a throwaway CODEX_HOME, confirm, read the resulting auth.json, then delete
3
- // the directory. Copying the laptop's ~/.codex/auth.json is deliberately NOT done:
4
- // two machines sharing one rotating refresh token log each other out (spec §Q3a).
5
-
6
- import { spawnSync } from "node:child_process";
7
- import fs from "node:fs";
8
- import os from "node:os";
9
- import path from "node:path";
10
- import { onPath } from "./proc.js";
11
-
12
- /**
13
- * Run the Codex own-lineage login and return the parsed auth.json OBJECT, or null if the
14
- * user declines / it fails. The temp CODEX_HOME is always removed. OPENAI_API_KEY
15
- * is unset so a stray key can't shadow the plan login; stdin is closed for any
16
- * non-interactive step.
17
- */
18
- export function connectCodex(deps = {}) {
19
- const _onPath = deps.onPath || onPath;
20
- const _spawnSync = deps.spawnSync || spawnSync;
21
- const log = deps.log || console.log;
22
-
23
- if (!_onPath("codex")) {
24
- return null; // no ChatGPT plan connect without the official CLI
25
- }
26
-
27
- const home = fs.mkdtempSync(path.join(os.tmpdir(), "syndai-codex-"));
28
- const env = { ...process.env, CODEX_HOME: home };
29
- delete env.OPENAI_API_KEY;
30
-
31
- try {
32
- log("ChatGPT: opening a Codex login for Syndai (approve in your browser)…");
33
- const login = _spawnSync("codex", ["login"], { stdio: "inherit", env });
34
- if (login.status !== 0) {
35
- log("ChatGPT: codex login did not complete. Skipping ChatGPT.");
36
- return null;
37
- }
38
- // Confirm the session landed; stdin closed so `status` can't block.
39
- const status = _spawnSync("codex", ["login", "status"], {
40
- stdio: ["ignore", "pipe", "pipe"],
41
- encoding: "utf8",
42
- env,
43
- });
44
- if (status.status !== 0) {
45
- log("ChatGPT: login status check failed. Skipping ChatGPT.");
46
- return null;
47
- }
48
- const authPath = path.join(home, "auth.json");
49
- if (!fs.existsSync(authPath)) {
50
- log("ChatGPT: no auth.json produced. Skipping ChatGPT.");
51
- return null;
52
- }
53
- // Return the PARSED object: the server's upload DTO types codex_auth_json as an object,
54
- // so sending the raw string would be rejected at decode (400) before the credential lands.
55
- let parsed;
56
- try {
57
- parsed = JSON.parse(fs.readFileSync(authPath, "utf8"));
58
- } catch {
59
- log("ChatGPT: auth.json was not valid JSON. Skipping ChatGPT.");
60
- return null;
61
- }
62
- log("ChatGPT: login captured ✓");
63
- return parsed;
64
- } finally {
65
- try {
66
- fs.rmSync(home, { recursive: true, force: true });
67
- } catch {
68
- /* ignore */
69
- }
70
- }
71
- }