@hellcoder/companion 0.122.0 → 0.123.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.
Files changed (27) hide show
  1. package/dist/assets/{AgentsPage-DcOqIP9k.js → AgentsPage-D_ZGmkrU.js} +5 -5
  2. package/dist/assets/CronManager-u5KuicVz.js +1 -0
  3. package/dist/assets/{DashboardPage-B10_oar4.js → DashboardPage-Dab8XyGT.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-C3o8sLpo.js → IntegrationsPage-Bc7SgfqX.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-C181ckxW.js → LinearOAuthSettingsPage-CxyO36qY.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-B-7G76Ns.js → LinearSettingsPage-BWfwYF05.js} +1 -1
  7. package/dist/assets/{MagicUIDashboard-CkEZ3BVa.js → MagicUIDashboard-CfD7iyXm.js} +1 -1
  8. package/dist/assets/{MagicUIView-BmwHDokR.js → MagicUIView-CO6pZB0M.js} +1 -1
  9. package/dist/assets/{Playground-DvE5Hx7h.js → Playground-BiB_VDIl.js} +1 -1
  10. package/dist/assets/{PromptsPage-BFYinvPb.js → PromptsPage-BIE70OT1.js} +1 -1
  11. package/dist/assets/{RunsPage-C_wKk1gx.js → RunsPage-BAblQKAW.js} +1 -1
  12. package/dist/assets/{SandboxManager-DRRe06TY.js → SandboxManager-DsR3hQz1.js} +1 -1
  13. package/dist/assets/SettingsPage-DuzHKBe_.js +1 -0
  14. package/dist/assets/{TailscalePage-DHuSFb11.js → TailscalePage-C3y_bGkT.js} +1 -1
  15. package/dist/assets/index-Cd0HGwTS.js +141 -0
  16. package/dist/assets/{sw-register-D2DuNbEG.js → sw-register-CUhVLAo0.js} +1 -1
  17. package/dist/index.html +1 -1
  18. package/dist/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/server/claude-auth.test.ts +305 -0
  21. package/server/claude-auth.ts +367 -0
  22. package/server/routes/claude-auth-routes.test.ts +147 -0
  23. package/server/routes/claude-auth-routes.ts +55 -0
  24. package/server/routes.ts +2 -0
  25. package/dist/assets/CronManager-DNNKDHYb.js +0 -1
  26. package/dist/assets/SettingsPage-DlqgaG3K.js +0 -1
  27. package/dist/assets/index-ZUC5y0cV.js +0 -141
@@ -0,0 +1,367 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { getEnrichedPath, resolveBinary } from "./path-resolver.js";
3
+
4
+ /**
5
+ * Claude Code sign-in driven from the Companion UI.
6
+ *
7
+ * Unlike Codex (which exposes a structured `account/login/start` RPC with a
8
+ * device code), the Claude CLI has no device-code flow. What it does have is
9
+ * better suited to a remote box anyway: `claude auth login` prints an authorize
10
+ * URL whose redirect_uri is Anthropic-hosted
11
+ * (https://platform.claude.com/oauth/code/callback), not a localhost callback.
12
+ * The user opens that URL anywhere, approves, and Anthropic shows them a code —
13
+ * which the CLI then reads from stdin.
14
+ *
15
+ * So the flow we drive is:
16
+ * 1. spawn `claude auth login --claudeai`, scrape the authorize URL
17
+ * 2. hand the URL to the browser; the child stays alive waiting on stdin
18
+ * 3. user pastes the code back into the UI; we write it to the child's stdin
19
+ * 4. verify the outcome with `claude auth status --json`
20
+ *
21
+ * Step 4 matters: the CLI's success text is not a contract, but
22
+ * `claude auth status --json` is machine-readable, so the authoritative answer
23
+ * comes from re-reading state rather than parsing prose. A wrong code is
24
+ * recoverable — the CLI re-prompts on the same process, so the user can paste
25
+ * again without restarting.
26
+ *
27
+ * `claude setup-token` is deliberately not used: it produces no output on a
28
+ * non-TTY and hangs, so it cannot be driven headlessly.
29
+ */
30
+
31
+ /** Overall budget for one login attempt before we abandon the child. */
32
+ const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
33
+ /** How long to wait for the authorize URL to show up on stdout. */
34
+ const URL_WAIT_MS = 30_000;
35
+ /** Budget for a short-lived `auth status` / `auth logout` invocation. */
36
+ const CLI_TIMEOUT_MS = 20_000;
37
+ /** How long to keep re-checking auth status after a code is submitted. */
38
+ const VERIFY_TIMEOUT_MS = 45_000;
39
+ const VERIFY_POLL_MS = 1_000;
40
+
41
+ export type ClaudeLoginState =
42
+ | "idle"
43
+ /** URL issued; waiting for the user to paste the code back. */
44
+ | "awaiting_code"
45
+ /** Code submitted; confirming with `auth status`. */
46
+ | "verifying"
47
+ | "success"
48
+ | "error"
49
+ | "canceled";
50
+
51
+ export interface ClaudeLoginStatus {
52
+ state: ClaudeLoginState;
53
+ /** The URL the user opens to approve. Present from `awaiting_code` on. */
54
+ authUrl?: string;
55
+ /**
56
+ * Failure detail. On a rejected code this is set while state returns to
57
+ * `awaiting_code`, so the UI can show the error and still accept a retry.
58
+ */
59
+ error?: string;
60
+ /** Epoch ms after which the attempt is abandoned. */
61
+ expiresAt?: number;
62
+ }
63
+
64
+ export interface ClaudeAccountStatus {
65
+ cliAvailable: boolean;
66
+ authenticated: boolean;
67
+ /** `authMethod` as reported by the CLI, e.g. "claude.ai" or "console". */
68
+ method: string | null;
69
+ email: string | null;
70
+ orgName: string | null;
71
+ subscriptionType: string | null;
72
+ error?: string;
73
+ }
74
+
75
+ interface ActiveLogin {
76
+ child: ChildProcess;
77
+ status: ClaudeLoginStatus;
78
+ timer: ReturnType<typeof setTimeout>;
79
+ /** Everything stdout/stderr has produced, ANSI-stripped, for URL + error scraping. */
80
+ output: string;
81
+ /** Resolves once the authorize URL has been scraped. */
82
+ onUrl?: (url: string) => void;
83
+ /**
84
+ * Unblocks a pending URL wait when the attempt dies first. Without this,
85
+ * `startLogin` would sit on the full URL_WAIT_MS budget even though the child
86
+ * has already exited and no URL can ever arrive.
87
+ */
88
+ onUrlAbort?: () => void;
89
+ /** Set while a submitted code is being judged. */
90
+ onCodeVerdict?: (err: string | null) => void;
91
+ }
92
+
93
+ let activeLogin: ActiveLogin | null = null;
94
+ /** Terminal result of the last attempt, so a poll after completion still sees it. */
95
+ let lastResult: ClaudeLoginStatus = { state: "idle" };
96
+
97
+ const ANSI_RE = /\u001b\[[0-9;]*[A-Za-z]/g;
98
+
99
+ /**
100
+ * Env for auth subprocesses.
101
+ *
102
+ * CLAUDE_CODE_OAUTH_TOKEN is stripped on purpose. It is a *separate* auth
103
+ * mechanism (a token pasted into Settings and injected into sessions); if it
104
+ * leaked in here, `auth status` would report that token's identity and mask the
105
+ * stored login this panel manages — and post-code verification could report
106
+ * success for a login that never actually landed.
107
+ */
108
+ function authEnv(): NodeJS.ProcessEnv {
109
+ return {
110
+ ...process.env,
111
+ // Never let a parent Claude session leak in (same hygiene as cli-launcher).
112
+ CLAUDECODE: undefined,
113
+ CLAUDE_CODE_OAUTH_TOKEN: undefined,
114
+ PATH: getEnrichedPath(),
115
+ };
116
+ }
117
+
118
+ /** Run a short-lived `claude ...` invocation and collect stdout. */
119
+ function runClaude(args: string[], timeoutMs = CLI_TIMEOUT_MS): Promise<{ ok: boolean; stdout: string; stderr: string }> {
120
+ return new Promise((resolve) => {
121
+ const binary = resolveBinary("claude");
122
+ if (!binary) {
123
+ resolve({ ok: false, stdout: "", stderr: "Claude CLI not found on PATH" });
124
+ return;
125
+ }
126
+
127
+ let child: ChildProcess;
128
+ try {
129
+ child = spawn(binary, args, { env: authEnv(), stdio: ["ignore", "pipe", "pipe"] });
130
+ } catch (e) {
131
+ resolve({ ok: false, stdout: "", stderr: e instanceof Error ? e.message : String(e) });
132
+ return;
133
+ }
134
+
135
+ let stdout = "";
136
+ let stderr = "";
137
+ let settled = false;
138
+ const finish = (ok: boolean) => {
139
+ if (settled) return;
140
+ settled = true;
141
+ clearTimeout(timer);
142
+ try { child.kill(); } catch { /* already dead */ }
143
+ resolve({ ok, stdout, stderr });
144
+ };
145
+ const timer = setTimeout(() => finish(false), timeoutMs);
146
+
147
+ child.stdout?.on("data", (c: Buffer) => { stdout += c.toString(); });
148
+ child.stderr?.on("data", (c: Buffer) => { stderr += c.toString(); });
149
+ child.on("error", (e) => { stderr += String(e); finish(false); });
150
+ child.on("exit", (code) => finish(code === 0));
151
+ });
152
+ }
153
+
154
+ /** Read the current Claude account via `claude auth status --json`. */
155
+ export async function getAccountStatus(): Promise<ClaudeAccountStatus> {
156
+ const base: ClaudeAccountStatus = {
157
+ cliAvailable: true, authenticated: false, method: null,
158
+ email: null, orgName: null, subscriptionType: null,
159
+ };
160
+
161
+ if (!resolveBinary("claude")) {
162
+ return { ...base, cliAvailable: false, error: "Claude CLI not found on PATH" };
163
+ }
164
+
165
+ const { stdout, stderr } = await runClaude(["auth", "status", "--json"]);
166
+
167
+ let parsed: Record<string, unknown>;
168
+ try {
169
+ parsed = JSON.parse(stdout.replace(ANSI_RE, "").trim()) as Record<string, unknown>;
170
+ } catch {
171
+ return { ...base, error: stderr.trim() || "Could not read Claude auth status" };
172
+ }
173
+
174
+ const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
175
+ return {
176
+ ...base,
177
+ authenticated: parsed.loggedIn === true,
178
+ method: str(parsed.authMethod) === "none" ? null : str(parsed.authMethod),
179
+ email: str(parsed.email),
180
+ orgName: str(parsed.orgName),
181
+ subscriptionType: str(parsed.subscriptionType),
182
+ };
183
+ }
184
+
185
+ /** Current login status: the in-flight attempt, else the last terminal result. */
186
+ export function getLoginStatus(): ClaudeLoginStatus {
187
+ return activeLogin ? { ...activeLogin.status } : { ...lastResult };
188
+ }
189
+
190
+ function killChild(child: ChildProcess): void {
191
+ try { child.kill(); } catch { /* already dead */ }
192
+ }
193
+
194
+ function finishLogin(state: ClaudeLoginState, error?: string): void {
195
+ if (!activeLogin) return;
196
+ clearTimeout(activeLogin.timer);
197
+ killChild(activeLogin.child);
198
+ activeLogin.onUrlAbort?.();
199
+ lastResult = { state, authUrl: activeLogin.status.authUrl, error };
200
+ activeLogin = null;
201
+ }
202
+
203
+ /** Scrape the authorize URL out of CLI output. */
204
+ export function extractAuthUrl(output: string): string | null {
205
+ const match = output.replace(ANSI_RE, "").match(/https?:\/\/[^\s]*oauth\/authorize\?[^\s]+/);
206
+ return match ? match[0] : null;
207
+ }
208
+
209
+ /**
210
+ * Start a login: spawn the CLI and return once the authorize URL is known.
211
+ * Idempotent while an attempt is in flight — restarting would invalidate a URL
212
+ * the user may already have open.
213
+ */
214
+ export async function startLogin(): Promise<ClaudeLoginStatus> {
215
+ if (activeLogin) return { ...activeLogin.status };
216
+
217
+ const binary = resolveBinary("claude");
218
+ if (!binary) throw new Error("Claude CLI not found on PATH");
219
+
220
+ const child = spawn(binary, ["auth", "login", "--claudeai"], {
221
+ env: authEnv(),
222
+ stdio: ["pipe", "pipe", "pipe"],
223
+ });
224
+
225
+ const login: ActiveLogin = {
226
+ child,
227
+ status: { state: "awaiting_code", expiresAt: Date.now() + LOGIN_TIMEOUT_MS },
228
+ timer: setTimeout(
229
+ () => finishLogin("error", "Sign-in timed out. Start again to get a fresh link."),
230
+ LOGIN_TIMEOUT_MS,
231
+ ),
232
+ output: "",
233
+ };
234
+ activeLogin = login;
235
+
236
+ const handleChunk = (chunk: Buffer) => {
237
+ login.output += chunk.toString().replace(ANSI_RE, "");
238
+
239
+ if (!login.status.authUrl) {
240
+ const url = extractAuthUrl(login.output);
241
+ if (url) {
242
+ login.status.authUrl = url;
243
+ login.onUrl?.(url);
244
+ }
245
+ }
246
+
247
+ // The CLI re-prompts on a bad code, so this is recoverable: report it and
248
+ // go back to awaiting_code rather than tearing the attempt down.
249
+ if (login.onCodeVerdict && /Invalid code/i.test(login.output)) {
250
+ const verdict = login.onCodeVerdict;
251
+ login.onCodeVerdict = undefined;
252
+ login.output = "";
253
+ verdict("That code wasn't accepted. Make sure you copied all of it, then try again.");
254
+ }
255
+ };
256
+
257
+ child.stdout?.on("data", handleChunk);
258
+ child.stderr?.on("data", handleChunk);
259
+ child.on("error", (e) => finishLogin("error", e instanceof Error ? e.message : String(e)));
260
+ child.on("exit", () => {
261
+ // Exiting before we reached a terminal state means the login can't complete.
262
+ if (activeLogin === login && login.status.state !== "verifying") {
263
+ finishLogin("error", "Claude exited before sign-in completed");
264
+ }
265
+ });
266
+
267
+ // Wait for the URL — without it there is nothing to show the user.
268
+ const url = await new Promise<string | null>((resolve) => {
269
+ if (login.status.authUrl) {
270
+ resolve(login.status.authUrl);
271
+ return;
272
+ }
273
+ const timer = setTimeout(() => resolve(null), URL_WAIT_MS);
274
+ login.onUrl = (u) => { clearTimeout(timer); resolve(u); };
275
+ login.onUrlAbort = () => { clearTimeout(timer); resolve(null); };
276
+ });
277
+
278
+ // The child may have died while we waited (handled by its exit/error hooks,
279
+ // which already recorded the failure) — don't overwrite that reason.
280
+ if (activeLogin !== login) return { ...lastResult };
281
+
282
+ if (!url) {
283
+ finishLogin("error", "Claude did not return a sign-in link. Check that the CLI is up to date.");
284
+ return { ...lastResult };
285
+ }
286
+
287
+ return { ...login.status };
288
+ }
289
+
290
+ /**
291
+ * Submit the code the user pasted. Resolves to `success`, or back to
292
+ * `awaiting_code` with an error so they can retry on the same attempt.
293
+ */
294
+ export async function submitCode(code: string): Promise<ClaudeLoginStatus> {
295
+ const trimmed = code.trim();
296
+ if (!trimmed) return { state: "awaiting_code", authUrl: activeLogin?.status.authUrl, error: "Enter the code first." };
297
+ if (!activeLogin) return { ...lastResult, error: "No sign-in in progress. Start again." };
298
+
299
+ const login = activeLogin;
300
+ login.status.state = "verifying";
301
+ delete login.status.error;
302
+ login.output = "";
303
+
304
+ const rejection = new Promise<string | null>((resolve) => { login.onCodeVerdict = resolve; });
305
+
306
+ try {
307
+ login.child.stdin?.write(trimmed + "\n");
308
+ } catch {
309
+ finishLogin("error", "Could not send the code to the Claude CLI.");
310
+ return { ...lastResult };
311
+ }
312
+
313
+ // Race an explicit rejection against the CLI actually becoming logged in.
314
+ // Status is the authoritative signal; the printed text is only a fast path.
315
+ const verified = (async () => {
316
+ const deadline = Date.now() + VERIFY_TIMEOUT_MS;
317
+ while (Date.now() < deadline) {
318
+ await new Promise((r) => setTimeout(r, VERIFY_POLL_MS));
319
+ const account = await getAccountStatus();
320
+ if (account.authenticated) return true;
321
+ if (activeLogin !== login) return false; // canceled or timed out meanwhile
322
+ }
323
+ return false;
324
+ })();
325
+
326
+ const outcome = await Promise.race([
327
+ rejection.then((err) => ({ kind: "rejected" as const, err })),
328
+ verified.then((ok) => ({ kind: "verified" as const, ok })),
329
+ ]);
330
+
331
+ if (outcome.kind === "verified" && outcome.ok) {
332
+ finishLogin("success");
333
+ return { ...lastResult };
334
+ }
335
+
336
+ if (activeLogin !== login) return { ...lastResult };
337
+
338
+ // Recoverable: keep the child alive so the user can paste a corrected code.
339
+ login.status.state = "awaiting_code";
340
+ login.status.error = outcome.kind === "rejected" && outcome.err
341
+ ? outcome.err
342
+ : "Couldn't confirm the sign-in. Check the code and try again.";
343
+ return { ...login.status };
344
+ }
345
+
346
+ /** Abandon an in-flight login. */
347
+ export function cancelLogin(): ClaudeLoginStatus {
348
+ if (!activeLogin) return { ...lastResult };
349
+ finishLogin("canceled");
350
+ return { ...lastResult };
351
+ }
352
+
353
+ /** Sign out via `claude auth logout`. */
354
+ export async function logout(): Promise<{ ok: boolean; error?: string }> {
355
+ const { ok, stderr } = await runClaude(["auth", "logout"]);
356
+ return ok ? { ok: true } : { ok: false, error: stderr.trim() || "Sign out failed" };
357
+ }
358
+
359
+ /** Test helper: drop all in-memory login state. */
360
+ export function _resetClaudeAuthState(): void {
361
+ if (activeLogin) {
362
+ clearTimeout(activeLogin.timer);
363
+ killChild(activeLogin.child);
364
+ }
365
+ activeLogin = null;
366
+ lastResult = { state: "idle" };
367
+ }
@@ -0,0 +1,147 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ vi.mock("../claude-auth.js", () => ({
4
+ getAccountStatus: vi.fn(),
5
+ getLoginStatus: vi.fn(),
6
+ startLogin: vi.fn(),
7
+ submitCode: vi.fn(),
8
+ cancelLogin: vi.fn(),
9
+ logout: vi.fn(),
10
+ }));
11
+
12
+ import { Hono } from "hono";
13
+ import {
14
+ getAccountStatus, getLoginStatus, startLogin, submitCode, cancelLogin, logout,
15
+ } from "../claude-auth.js";
16
+ import { registerClaudeAuthRoutes } from "./claude-auth-routes.js";
17
+
18
+ const mockAccount = vi.mocked(getAccountStatus);
19
+ const mockLoginStatus = vi.mocked(getLoginStatus);
20
+ const mockStart = vi.mocked(startLogin);
21
+ const mockSubmit = vi.mocked(submitCode);
22
+ const mockCancel = vi.mocked(cancelLogin);
23
+ const mockLogout = vi.mocked(logout);
24
+
25
+ function createApp() {
26
+ const api = new Hono();
27
+ registerClaudeAuthRoutes(api);
28
+ return api;
29
+ }
30
+
31
+ const AUTH_URL = "https://claude.com/cai/oauth/authorize?code=true";
32
+
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ describe("GET /claude/auth/account", () => {
38
+ it("returns the account status", async () => {
39
+ const status = {
40
+ cliAvailable: true, authenticated: true, method: "claude.ai",
41
+ email: "user@example.com", orgName: "Acme Inc", subscriptionType: "max",
42
+ };
43
+ mockAccount.mockResolvedValue(status);
44
+
45
+ const res = await createApp().request("/claude/auth/account");
46
+
47
+ expect(res.status).toBe(200);
48
+ expect(await res.json()).toEqual(status);
49
+ });
50
+ });
51
+
52
+ describe("GET /claude/auth/login", () => {
53
+ it("returns the in-flight login status for polling/resume", async () => {
54
+ mockLoginStatus.mockReturnValue({ state: "awaiting_code", authUrl: AUTH_URL });
55
+
56
+ const res = await createApp().request("/claude/auth/login");
57
+
58
+ expect(await res.json()).toEqual({ state: "awaiting_code", authUrl: AUTH_URL });
59
+ });
60
+ });
61
+
62
+ describe("POST /claude/auth/login", () => {
63
+ it("starts a login and returns the authorize URL", async () => {
64
+ mockStart.mockResolvedValue({ state: "awaiting_code", authUrl: AUTH_URL });
65
+
66
+ const res = await createApp().request("/claude/auth/login", { method: "POST" });
67
+
68
+ expect(res.status).toBe(200);
69
+ expect(await res.json()).toMatchObject({ state: "awaiting_code", authUrl: AUTH_URL });
70
+ });
71
+
72
+ it("returns 200 with an error field when starting fails", async () => {
73
+ // Non-2xx would make the frontend post() helper discard the detail.
74
+ mockStart.mockRejectedValue(new Error("Claude CLI not found on PATH"));
75
+
76
+ const res = await createApp().request("/claude/auth/login", { method: "POST" });
77
+
78
+ expect(res.status).toBe(200);
79
+ expect(await res.json()).toEqual({ state: "error", error: "Claude CLI not found on PATH" });
80
+ });
81
+ });
82
+
83
+ describe("POST /claude/auth/login/code", () => {
84
+ it("forwards the pasted code and reports success", async () => {
85
+ mockSubmit.mockResolvedValue({ state: "success" });
86
+
87
+ const res = await createApp().request("/claude/auth/login/code", {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({ code: "abc123" }),
91
+ });
92
+
93
+ expect(await res.json()).toEqual({ state: "success" });
94
+ expect(mockSubmit).toHaveBeenCalledWith("abc123");
95
+ });
96
+
97
+ it("passes a rejected code back as a retryable awaiting_code", async () => {
98
+ mockSubmit.mockResolvedValue({
99
+ state: "awaiting_code", authUrl: AUTH_URL, error: "That code wasn't accepted.",
100
+ });
101
+
102
+ const res = await createApp().request("/claude/auth/login/code", {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ code: "nope" }),
106
+ });
107
+
108
+ expect(await res.json()).toMatchObject({ state: "awaiting_code", error: expect.stringMatching(/accepted/) });
109
+ });
110
+
111
+ it("treats a missing body as an empty code rather than erroring", async () => {
112
+ mockSubmit.mockResolvedValue({ state: "awaiting_code", error: "Enter the code first." });
113
+
114
+ const res = await createApp().request("/claude/auth/login/code", { method: "POST" });
115
+
116
+ expect(res.status).toBe(200);
117
+ expect(mockSubmit).toHaveBeenCalledWith("");
118
+ });
119
+ });
120
+
121
+ describe("POST /claude/auth/login/cancel", () => {
122
+ it("cancels the attempt", async () => {
123
+ mockCancel.mockReturnValue({ state: "canceled" });
124
+
125
+ const res = await createApp().request("/claude/auth/login/cancel", { method: "POST" });
126
+
127
+ expect(await res.json()).toEqual({ state: "canceled" });
128
+ });
129
+ });
130
+
131
+ describe("POST /claude/auth/logout", () => {
132
+ it("signs out", async () => {
133
+ mockLogout.mockResolvedValue({ ok: true });
134
+
135
+ const res = await createApp().request("/claude/auth/logout", { method: "POST" });
136
+
137
+ expect(await res.json()).toEqual({ ok: true });
138
+ });
139
+
140
+ it("passes through a failure reason", async () => {
141
+ mockLogout.mockResolvedValue({ ok: false, error: "not logged in" });
142
+
143
+ const res = await createApp().request("/claude/auth/logout", { method: "POST" });
144
+
145
+ expect(await res.json()).toEqual({ ok: false, error: "not logged in" });
146
+ });
147
+ });
@@ -0,0 +1,55 @@
1
+ import type { Hono } from "hono";
2
+ import {
3
+ cancelLogin,
4
+ getAccountStatus,
5
+ getLoginStatus,
6
+ logout,
7
+ startLogin,
8
+ submitCode,
9
+ } from "../claude-auth.js";
10
+
11
+ /**
12
+ * Claude Code sign-in from Settings instead of a terminal.
13
+ *
14
+ * Two-step by nature: POST /login returns an authorize URL, the user approves
15
+ * out-of-band, then POST /login/code carries the code they were shown back.
16
+ *
17
+ * Following the tailscale/codex-auth convention, failures that carry useful
18
+ * detail are returned as 200 with an `error` field rather than a non-2xx
19
+ * status, so the frontend `post()` helper doesn't throw the detail away.
20
+ */
21
+ export function registerClaudeAuthRoutes(api: Hono): void {
22
+ api.get("/claude/auth/account", async (c) => {
23
+ return c.json(await getAccountStatus());
24
+ });
25
+
26
+ api.get("/claude/auth/login", (c) => {
27
+ return c.json(getLoginStatus());
28
+ });
29
+
30
+ api.post("/claude/auth/login", async (c) => {
31
+ try {
32
+ return c.json(await startLogin());
33
+ } catch (e) {
34
+ return c.json({ state: "error" as const, error: e instanceof Error ? e.message : String(e) });
35
+ }
36
+ });
37
+
38
+ api.post("/claude/auth/login/code", async (c) => {
39
+ const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
40
+ const code = typeof body.code === "string" ? body.code : "";
41
+ try {
42
+ return c.json(await submitCode(code));
43
+ } catch (e) {
44
+ return c.json({ state: "error" as const, error: e instanceof Error ? e.message : String(e) });
45
+ }
46
+ });
47
+
48
+ api.post("/claude/auth/login/cancel", (c) => {
49
+ return c.json(cancelLogin());
50
+ });
51
+
52
+ api.post("/claude/auth/logout", async (c) => {
53
+ return c.json(await logout());
54
+ });
55
+ }
package/server/routes.ts CHANGED
@@ -37,6 +37,7 @@ import { registerLinearConnectionRoutes } from "./routes/linear-connection-route
37
37
  import { getConnection, resolveApiKey } from "./linear-connections.js";
38
38
  import { registerLinearOAuthConnectionRoutes } from "./routes/linear-oauth-connection-routes.js";
39
39
  import { registerCodexAuthRoutes } from "./routes/codex-auth-routes.js";
40
+ import { registerClaudeAuthRoutes } from "./routes/claude-auth-routes.js";
40
41
  import { getSettings } from "./settings-manager.js";
41
42
  import { discoverClaudeSessions } from "./claude-session-discovery.js";
42
43
  import { getClaudeSessionHistoryPage } from "./claude-session-history.js";
@@ -1278,6 +1279,7 @@ export function createRoutes(
1278
1279
  registerPromptRoutes(api);
1279
1280
  registerSettingsRoutes(api);
1280
1281
  registerCodexAuthRoutes(api);
1282
+ registerClaudeAuthRoutes(api);
1281
1283
  registerDashboardRoutes(api, {
1282
1284
  listCompanionSessions: () => launcher.listSessions(),
1283
1285
  });
@@ -1 +0,0 @@
1
- import{r as a,a as j,j as e,t as z,J as _,K as B,N as K,O as U,F as Y}from"./index-ZUC5y0cV.js";function W(s,p,c=!0){a.useEffect(()=>{if(!c)return;function d(o){s.some(m=>m.current&&m.current.contains(o.target))||p()}return document.addEventListener("pointerdown",d),()=>document.removeEventListener("pointerdown",d)},[s,p,c])}function J(s){const p=s-Date.now();if(p<=0)return"now";const c=Math.floor(p/6e4);if(c<60)return`in ${c}m`;const d=Math.floor(c/60);return d<24?`in ${d}h`:`in ${Math.floor(d/24)}d`}function F(s,p){if(!p)return"One-time";const c=s.trim().split(/\s+/);if(c.length!==5)return s;const[d,o,g,m,x]=c;if(s==="* * * * *")return"Every minute";if(o==="*"&&g==="*"&&m==="*"&&x==="*"&&d.startsWith("*/")){const l=parseInt(d.slice(2),10);return l===1?"Every minute":`Every ${l} minutes`}if(d==="0"&&g==="*"&&m==="*"&&x==="*"){if(o==="*")return"Every hour";if(o.startsWith("*/")){const l=parseInt(o.slice(2),10);return l===1?"Every hour":`Every ${l} hours`}}if(g==="*"&&m==="*"&&d!=="*"&&o!=="*"&&!o.includes("/")&&!o.includes(",")){const l=parseInt(o,10),y=parseInt(d,10);if(!isNaN(l)&&!isNaN(y)){const u=l>=12?"PM":"AM",w=l===0?12:l>12?l-12:l,r=y.toString().padStart(2,"0"),v=`${w}:${r} ${u}`;if(x==="*")return`Every day at ${v}`;if(x==="1-5")return`Weekdays at ${v}`;if(x==="0,6")return`Weekends at ${v}`}}return s}const $={name:"",prompt:"",recurring:!0,schedule:"0 8 * * *",oneTimeDate:"",backendType:"claude",model:B("claude"),cwd:""},q=[{label:"Every hour",value:"0 * * * *"},{label:"Every day at 8am",value:"0 8 * * *"},{label:"Every 2 hours",value:"0 */2 * * *"},{label:"Weekdays at 9am",value:"0 9 * * 1-5"}];function X({onClose:s,embedded:p=!1}){const[c,d]=a.useState([]),[o,g]=a.useState(!0),[m,x]=a.useState(""),[l,y]=a.useState(null),[u,w]=a.useState($),[r,v]=a.useState($),[h,M]=a.useState(!1),[f,S]=a.useState(!1),[k,E]=a.useState(new Set),n=a.useCallback(()=>{j.listCronJobs().then(d).catch(()=>{}).finally(()=>g(!1))},[]);a.useEffect(()=>{n();const t=setInterval(n,1e4);return()=>clearInterval(t)},[n]);async function C(){const t=r.name.trim(),i=r.prompt.trim();if(!t||!i)return;M(!0),x("");let N=r.schedule;!r.recurring&&r.oneTimeDate&&(N=new Date(r.oneTimeDate).toISOString());try{await j.createCronJob({name:t,prompt:i,schedule:N,recurring:r.recurring,backendType:r.backendType,model:r.model.trim()||void 0,cwd:r.cwd.trim()||void 0}),v($),S(!1),n()}catch(b){x(b instanceof Error?b.message:String(b))}finally{M(!1)}}function R(t){y(t.id),w({name:t.name,prompt:t.prompt,recurring:t.recurring,schedule:t.schedule,oneTimeDate:"",backendType:t.backendType,model:t.model,cwd:t.cwd}),x("")}function D(){y(null),x("")}async function L(){if(!l)return;const t=u.name.trim(),i=u.prompt.trim();if(!t||!i)return;let N=u.schedule;!u.recurring&&u.oneTimeDate&&(N=new Date(u.oneTimeDate).toISOString());try{await j.updateCronJob(l,{name:t,prompt:i,schedule:N,recurring:u.recurring,backendType:u.backendType,model:u.model.trim()||void 0,cwd:u.cwd.trim()||void 0}),y(null),x(""),n()}catch(b){x(b instanceof Error?b.message:String(b))}}async function A(t){try{await j.deleteCronJob(t),l===t&&y(null),n()}catch(i){x(i instanceof Error?i.message:String(i))}}async function I(t){try{await j.toggleCronJob(t),n()}catch(i){x(i instanceof Error?i.message:String(i))}}async function P(t){E(i=>new Set(i).add(t));try{await j.runCronJob(t),n()}catch(i){x(i instanceof Error?i.message:String(i))}finally{E(i=>{const N=new Set(i);return N.delete(t),N})}}if(p)return e.jsx("div",{className:"h-full bg-cc-bg text-cc-fg font-sans-ui antialiased overflow-y-auto",children:e.jsxs("div",{className:"max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-10 pb-safe",children:[e.jsx("div",{className:"flex items-start justify-between gap-3 mb-2",children:e.jsxs("div",{className:"min-w-0",children:[e.jsx("h1",{className:"text-lg font-semibold text-cc-fg",children:"Scheduled Tasks"}),e.jsx("p",{className:"mt-0.5 text-[13px] text-cc-muted leading-relaxed",children:"Run autonomous Claude Code or Codex sessions on a schedule."})]})}),e.jsxs("div",{className:"flex items-center gap-2 mt-4 mb-5",children:[e.jsx("div",{className:"flex-1"}),e.jsxs("button",{onClick:()=>S(!f),className:`flex items-center gap-1.5 px-3.5 py-2.5 min-h-[44px] rounded-lg text-sm font-medium transition-colors cursor-pointer shrink-0 ${f?"bg-cc-active text-cc-fg":"bg-cc-primary hover:bg-cc-primary-hover text-white"}`,children:[e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"w-4 h-4",children:f?e.jsx("path",{d:"M18 6 6 18M6 6l12 12"}):e.jsx("path",{d:"M12 5v14M5 12h14"})}),e.jsx("span",{className:"hidden sm:inline",children:f?"Cancel":"New Task"})]})]}),f&&e.jsxs("div",{className:"mb-6 rounded-xl bg-cc-card p-4 sm:p-5 space-y-3",style:{animation:"fadeSlideIn 150ms ease-out"},children:[e.jsx(T,{form:r,onChange:v}),e.jsx("p",{className:"text-[10px] text-cc-muted",children:"Scheduled tasks run with full autonomy (bypassPermissions)"}),m&&f&&e.jsx("div",{className:"px-3 py-2 rounded-lg bg-cc-error/10 text-xs text-cc-error",children:m}),e.jsx("div",{className:"flex items-center justify-end pt-1",children:e.jsx("button",{onClick:C,disabled:!r.name.trim()||!r.prompt.trim()||h,className:`px-4 py-2.5 min-h-[44px] rounded-lg text-sm font-medium transition-colors ${r.name.trim()&&r.prompt.trim()&&!h?"bg-cc-primary hover:bg-cc-primary-hover text-white cursor-pointer":"bg-cc-hover text-cc-muted cursor-not-allowed"}`,children:h?"Creating...":"Create"})})]}),e.jsxs("div",{className:"flex items-center gap-2 mb-3 text-[12px] text-cc-muted",children:[e.jsxs("span",{children:[c.length," task",c.length!==1?"s":""]}),c.filter(t=>t.enabled).length>0&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"text-cc-border",children:"·"}),e.jsxs("span",{children:[c.filter(t=>t.enabled).length," active"]})]})]}),o?e.jsx("div",{className:"py-12 text-center text-sm text-cc-muted",children:"Loading scheduled tasks..."}):c.length===0?e.jsx("div",{className:"py-12 text-center text-sm text-cc-muted",children:"No scheduled tasks yet."}):e.jsx("div",{className:"space-y-1",children:c.map(t=>l===t.id?e.jsxs("div",{className:"rounded-xl bg-cc-card p-4 space-y-3",style:{animation:"fadeSlideIn 150ms ease-out"},children:[e.jsx(T,{form:u,onChange:w}),e.jsxs("div",{className:"flex justify-end gap-2",children:[e.jsx("button",{onClick:D,className:"px-3 py-2.5 min-h-[44px] text-sm rounded-lg text-cc-muted hover:text-cc-fg hover:bg-cc-hover transition-colors cursor-pointer",children:"Cancel"}),e.jsx("button",{onClick:()=>void L(),className:"px-4 py-2.5 min-h-[44px] text-sm rounded-lg font-medium bg-cc-primary hover:bg-cc-primary-hover text-white transition-colors cursor-pointer",children:"Save"})]})]},t.id):e.jsx(G,{job:t,isRunning:k.has(t.id),onStartEdit:()=>R(t),onDelete:()=>void A(t.id),onToggle:()=>void I(t.id),onRunNow:()=>void P(t.id)},t.id))}),m&&!f&&e.jsx("div",{className:"mt-4 px-3 py-2 rounded-lg bg-cc-error/10 text-xs text-cc-error",children:m})]})});const O=m&&e.jsx("div",{className:"px-3 py-2 rounded-lg bg-cc-error/10 text-xs text-cc-error",children:m}),H=o?e.jsx("div",{className:"text-sm text-cc-muted text-center py-6",children:"Loading scheduled tasks..."}):c.length===0?e.jsx("div",{className:"text-sm text-cc-muted text-center py-6",children:"No scheduled tasks yet."}):e.jsx("div",{className:"space-y-3",children:c.map(t=>e.jsxs("div",{className:"rounded-xl bg-cc-card overflow-hidden",children:[e.jsxs("div",{className:"flex items-center gap-2 px-3 py-2.5",children:[e.jsx("span",{className:"text-sm font-medium text-cc-fg flex-1 truncate",children:t.name}),e.jsx("span",{className:`text-[9px] font-medium px-1.5 rounded-full leading-[16px] shrink-0 ${t.backendType==="codex"?"text-blue-500 bg-blue-500/10":"text-[#5BA8A0] bg-[#5BA8A0]/10"}`,children:t.backendType==="codex"?"Codex":"Claude"}),t.consecutiveFailures>0&&e.jsxs("span",{className:"text-[9px] font-medium px-1.5 rounded-full leading-[16px] shrink-0 text-cc-error bg-cc-error/10",children:[t.consecutiveFailures," fail",t.consecutiveFailures!==1?"s":""]}),e.jsx("button",{onClick:()=>I(t.id),className:`relative w-10 h-6 rounded-full transition-colors cursor-pointer shrink-0 ${t.enabled?"bg-cc-primary":"bg-cc-border"}`,title:t.enabled?"Disable":"Enable",children:e.jsx("span",{className:`absolute top-[3px] w-[18px] h-[18px] rounded-full bg-white transition-transform ${t.enabled?"left-[19px]":"left-[3px]"}`})}),l===t.id?e.jsx("button",{onClick:D,className:"text-xs px-2 py-1.5 min-h-[44px] text-cc-muted hover:text-cc-fg cursor-pointer",children:"Cancel"}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>P(t.id),disabled:k.has(t.id),className:`text-xs px-2 py-1.5 min-h-[44px] cursor-pointer ${k.has(t.id)?"text-cc-muted cursor-not-allowed":"text-cc-primary hover:text-cc-primary-hover"}`,children:k.has(t.id)?"Running...":"Run Now"}),e.jsx("button",{onClick:()=>R(t),className:"text-xs px-2 py-1.5 min-h-[44px] text-cc-muted hover:text-cc-fg cursor-pointer",children:"Edit"}),e.jsx("button",{onClick:()=>A(t.id),className:"text-xs px-2 py-1.5 min-h-[44px] text-cc-muted hover:text-cc-error cursor-pointer",children:"Delete"})]})]}),l===t.id&&e.jsxs("div",{className:"px-3 py-3 space-y-2.5",children:[e.jsx(T,{form:u,onChange:w}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("button",{onClick:L,className:"px-4 py-2.5 min-h-[44px] text-sm font-medium bg-cc-primary hover:bg-cc-primary-hover text-white rounded-lg transition-colors cursor-pointer",children:"Save"}),e.jsx("button",{onClick:D,className:"px-3 py-2.5 min-h-[44px] text-sm font-medium text-cc-muted hover:text-cc-fg rounded-lg transition-colors cursor-pointer",children:"Cancel"})]})]}),l!==t.id&&e.jsxs("div",{className:"px-3 py-2.5 space-y-1.5",children:[e.jsx("div",{className:"text-xs text-cc-muted truncate",title:t.prompt,children:t.prompt}),e.jsxs("div",{className:"flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-cc-muted",children:[e.jsx("span",{children:F(t.schedule,t.recurring)}),t.nextRunAt!=null&&t.enabled&&e.jsxs("span",{children:["Next: ",J(t.nextRunAt)]}),t.lastRunAt!=null&&e.jsxs("span",{className:"flex items-center gap-1",children:["Last: ",z(t.lastRunAt),t.consecutiveFailures===0?e.jsx("svg",{viewBox:"0 0 16 16",fill:"currentColor",className:"w-3 h-3 text-cc-success",children:e.jsx("path",{d:"M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2.5-2.5a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z"})}):e.jsx("svg",{viewBox:"0 0 16 16",fill:"currentColor",className:"w-3 h-3 text-cc-error",children:e.jsx("path",{d:"M4.646 4.646a.5.5 0 01.708 0L8 7.293l2.646-2.647a.5.5 0 01.708.708L8.707 8l2.647 2.646a.5.5 0 01-.708.708L8 8.707l-2.646 2.647a.5.5 0 01-.708-.708L7.293 8 4.646 5.354a.5.5 0 010-.708z"})})]}),t.totalRuns>0&&e.jsxs("span",{children:[t.totalRuns," run",t.totalRuns!==1?"s":""]}),t.cwd&&e.jsx("span",{className:"font-mono-code truncate max-w-[200px]",title:t.cwd,children:t.cwd})]})]})]},t.id))}),V=e.jsxs("div",{className:"rounded-xl bg-cc-card overflow-hidden",children:[e.jsxs("button",{onClick:()=>S(!f),className:"w-full flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-cc-hover transition-colors",children:[e.jsx("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",className:`w-3 h-3 text-cc-muted transition-transform ${f?"rotate-90":""}`,children:e.jsx("path",{d:"M6 4l4 4-4 4",strokeLinecap:"round",strokeLinejoin:"round"})}),e.jsx("span",{className:"text-sm font-medium text-cc-fg",children:"New Scheduled Task"})]}),f&&e.jsxs("div",{className:"px-3 py-3 space-y-2.5",children:[e.jsx(T,{form:r,onChange:v}),e.jsx("div",{className:"text-[10px] text-cc-muted",children:"Scheduled tasks run with full autonomy (bypassPermissions)"}),e.jsx("button",{onClick:C,disabled:!r.name.trim()||!r.prompt.trim()||h,className:`px-4 py-2.5 min-h-[44px] text-sm font-medium rounded-lg transition-colors ${r.name.trim()&&r.prompt.trim()&&!h?"bg-cc-primary hover:bg-cc-primary-hover text-white cursor-pointer":"bg-cc-hover text-cc-muted cursor-not-allowed"}`,children:h?"Creating...":"Create"})]})]}),Z=e.jsxs("div",{className:"w-full max-w-2xl max-h-[90dvh] sm:max-h-[80dvh] mx-0 sm:mx-4 flex flex-col bg-cc-bg rounded-t-[14px] sm:rounded-[14px] shadow-2xl overflow-hidden",onClick:t=>t.stopPropagation(),children:[e.jsxs("div",{className:"flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4",children:[e.jsxs("div",{children:[e.jsx("h2",{className:"text-sm font-semibold text-cc-fg",children:"Scheduled Tasks"}),e.jsx("p",{className:"text-xs text-cc-muted mt-0.5",children:"Run autonomous Claude Code or Codex sessions on a schedule"})]}),s&&e.jsx("button",{onClick:s,className:"w-8 h-8 flex items-center justify-center rounded-md text-cc-muted hover:text-cc-fg hover:bg-cc-hover transition-colors cursor-pointer",children:e.jsx("svg",{viewBox:"0 0 16 16",fill:"none",stroke:"currentColor",strokeWidth:"2",className:"w-3.5 h-3.5",children:e.jsx("path",{d:"M4 4l8 8M12 4l-8 8",strokeLinecap:"round"})})})]}),e.jsxs("div",{className:"flex-1 overflow-y-auto px-3 sm:px-5 py-3 sm:py-4 pb-safe space-y-4",children:[O,H,V]})]});return _.createPortal(e.jsx("div",{className:"fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50",onClick:s,children:Z}),document.body)}function G({job:s,isRunning:p,onStartEdit:c,onDelete:d,onToggle:o,onRunNow:g}){return e.jsxs("div",{className:"group flex items-start gap-3 px-3 py-3 min-h-[44px] rounded-lg hover:bg-cc-hover/60 transition-colors",children:[e.jsx("div",{className:"shrink-0 mt-0.5 w-7 h-7 rounded-md bg-cc-primary/10 flex items-center justify-center",children:e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.5",className:"w-3.5 h-3.5 text-cc-primary",children:[e.jsx("circle",{cx:"12",cy:"12",r:"10"}),e.jsx("path",{d:"M12 6v6l4 2"})]})}),e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"text-sm font-medium text-cc-fg truncate",children:s.name}),e.jsx("span",{className:`text-[9px] font-medium px-1.5 rounded-full leading-[16px] shrink-0 ${s.backendType==="codex"?"text-blue-500 bg-blue-500/10":"text-[#5BA8A0] bg-[#5BA8A0]/10"}`,children:s.backendType==="codex"?"Codex":"Claude"}),s.consecutiveFailures>0&&e.jsxs("span",{className:"text-[9px] font-medium px-1.5 rounded-full leading-[16px] shrink-0 text-cc-error bg-cc-error/10",children:[s.consecutiveFailures," fail",s.consecutiveFailures!==1?"s":""]}),!s.enabled&&e.jsx("span",{className:"text-[9px] font-medium px-1.5 rounded-full leading-[16px] shrink-0 text-cc-muted bg-cc-hover",children:"Paused"})]}),e.jsx("p",{className:"mt-0.5 text-xs text-cc-muted line-clamp-1 leading-relaxed",children:s.prompt}),e.jsxs("div",{className:"flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1 text-[11px] text-cc-muted",children:[e.jsx("span",{children:F(s.schedule,s.recurring)}),s.nextRunAt!=null&&s.enabled&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"text-cc-border",children:"·"}),e.jsxs("span",{children:["Next ",J(s.nextRunAt)]})]}),s.lastRunAt!=null&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"text-cc-border",children:"·"}),e.jsxs("span",{className:"flex items-center gap-0.5",children:["Last ",z(s.lastRunAt),s.consecutiveFailures===0?e.jsx("svg",{viewBox:"0 0 16 16",fill:"currentColor",className:"w-2.5 h-2.5 text-cc-success",children:e.jsx("path",{d:"M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2.5-2.5a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z"})}):e.jsx("svg",{viewBox:"0 0 16 16",fill:"currentColor",className:"w-2.5 h-2.5 text-cc-error",children:e.jsx("path",{d:"M4.646 4.646a.5.5 0 01.708 0L8 7.293l2.646-2.647a.5.5 0 01.708.708L8.707 8l2.647 2.646a.5.5 0 01-.708.708L8 8.707l-2.646 2.647a.5.5 0 01-.708-.708L7.293 8 4.646 5.354a.5.5 0 010-.708z"})})]})]}),s.totalRuns>0&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"text-cc-border",children:"·"}),e.jsxs("span",{children:[s.totalRuns," run",s.totalRuns!==1?"s":""]})]})]})]}),e.jsxs("div",{className:"shrink-0 flex items-center gap-0.5 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity",children:[e.jsx("button",{onClick:o,className:`relative w-9 h-5 rounded-full transition-colors cursor-pointer shrink-0 ${s.enabled?"bg-cc-primary":"bg-cc-border"}`,title:s.enabled?"Disable":"Enable",children:e.jsx("span",{className:`absolute top-[2px] w-4 h-4 rounded-full bg-white transition-transform ${s.enabled?"left-[17px]":"left-[2px]"}`})}),e.jsx("button",{onClick:g,disabled:p,className:`p-2 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 sm:p-1.5 rounded-md transition-colors cursor-pointer ${p?"text-cc-muted cursor-not-allowed":"text-cc-primary hover:bg-cc-primary/10"}`,"aria-label":"Run now",title:p?"Running...":"Run now",children:e.jsx("svg",{viewBox:"0 0 24 24",fill:"currentColor",className:"w-3.5 h-3.5",children:e.jsx("path",{d:"M8 5v14l11-7z"})})}),e.jsx("button",{onClick:c,className:"p-2 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 sm:p-1.5 rounded-md text-cc-muted hover:text-cc-fg hover:bg-cc-active transition-colors cursor-pointer","aria-label":"Edit",children:e.jsxs("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.5",className:"w-3.5 h-3.5",children:[e.jsx("path",{d:"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"}),e.jsx("path",{d:"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5Z"})]})}),e.jsx("button",{onClick:d,className:"p-2 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 sm:p-1.5 rounded-md text-cc-muted hover:text-cc-error hover:bg-cc-error/10 transition-colors cursor-pointer","aria-label":"Delete",children:e.jsx("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.5",className:"w-3.5 h-3.5",children:e.jsx("path",{d:"M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14Z"})})})]})]})}function T({form:s,onChange:p}){const c=n=>p({...s,...n}),[d,o]=a.useState(null),[g,m]=a.useState(!1),x=a.useRef(null),[l,y]=a.useState(!1),[u,w]=a.useState(!1),r=a.useRef(null),v=d||K(s.backendType),h=v.find(n=>n.value===s.model)||v[0];a.useEffect(()=>{o(null),s.backendType==="codex"&&j.getBackendModels(s.backendType).then(n=>{if(n.length>0){const C=U(n);o(C),C.some(R=>R.value===s.model)||c({model:C[0].value})}}).catch(()=>{})},[s.backendType]),a.useEffect(()=>{s.model||c({model:B(s.backendType)})},[]);const M=a.useMemo(()=>[x],[]),f=a.useCallback(()=>m(!1),[]);W(M,f,g);const S=a.useMemo(()=>[r],[]),k=a.useCallback(()=>w(!1),[]);W(S,k,u);const E=s.cwd?s.cwd.split("/").pop()||s.cwd:"Select folder";return e.jsxs("div",{className:"space-y-2.5",children:[e.jsx("input",{type:"text",value:s.name,onChange:n=>c({name:n.target.value}),placeholder:"Task name (e.g. Daily test suite)",className:"w-full px-3 py-2.5 min-h-[44px] text-sm bg-cc-bg rounded-lg text-cc-fg placeholder:text-cc-muted focus:outline-none focus:ring-1 focus:ring-cc-primary/40 transition-shadow"}),e.jsx("textarea",{value:s.prompt,onChange:n=>c({prompt:n.target.value}),placeholder:"Prompt for the session (e.g. Run the test suite and fix any failures)",rows:4,className:"w-full px-3 py-2.5 text-sm bg-cc-bg rounded-lg text-cc-fg placeholder:text-cc-muted focus:outline-none focus:ring-1 focus:ring-cc-primary/40 resize-y transition-shadow",style:{minHeight:"100px"}}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs("div",{className:"flex items-center gap-1.5",children:[e.jsx("button",{onClick:()=>c({recurring:!0}),className:`px-2.5 py-2 min-h-[44px] text-xs font-medium rounded-lg transition-colors cursor-pointer ${s.recurring?"bg-cc-primary text-white":"bg-cc-hover text-cc-muted hover:text-cc-fg"}`,children:"Recurring"}),e.jsx("button",{onClick:()=>c({recurring:!1}),className:`px-2.5 py-2 min-h-[44px] text-xs font-medium rounded-lg transition-colors cursor-pointer ${s.recurring?"bg-cc-hover text-cc-muted hover:text-cc-fg":"bg-cc-primary text-white"}`,children:"One-time"})]}),s.recurring?e.jsxs("div",{className:"space-y-1.5",children:[e.jsx("div",{className:"flex flex-wrap gap-1.5",children:q.map(n=>e.jsx("button",{onClick:()=>c({schedule:n.value}),className:`px-2.5 py-2 min-h-[44px] text-xs font-medium rounded-md transition-colors cursor-pointer ${s.schedule===n.value?"bg-cc-primary/20 text-cc-primary":"bg-cc-hover text-cc-muted hover:text-cc-fg"}`,children:n.label},n.value))}),e.jsx("input",{type:"text",value:s.schedule,onChange:n=>c({schedule:n.target.value}),placeholder:"Cron expression (e.g. 0 8 * * *)",className:"w-full px-3 py-2.5 min-h-[44px] text-sm font-mono-code bg-cc-bg rounded-lg text-cc-fg placeholder:text-cc-muted focus:outline-none focus:ring-1 focus:ring-cc-primary/40 transition-shadow"}),e.jsx("div",{className:"text-[10px] text-cc-muted",children:F(s.schedule,!0)})]}):e.jsx("input",{type:"datetime-local",value:s.oneTimeDate,onChange:n=>c({oneTimeDate:n.target.value}),className:"w-full px-3 py-2.5 min-h-[44px] text-sm bg-cc-bg rounded-lg text-cc-fg placeholder:text-cc-muted focus:outline-none focus:ring-1 focus:ring-cc-primary/40 transition-shadow"})]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-1.5",children:[e.jsxs("div",{className:"relative",ref:r,children:[e.jsxs("button",{onClick:()=>w(!u),className:"flex items-center gap-1.5 px-2.5 py-2 min-h-[44px] text-xs font-medium text-cc-fg rounded-lg hover:bg-cc-hover transition-colors cursor-pointer",children:[e.jsx("span",{children:s.backendType==="codex"?"Codex":"Claude Code"}),e.jsx("svg",{viewBox:"0 0 16 16",fill:"currentColor",className:"w-3 h-3 opacity-50",children:e.jsx("path",{d:"M4 6l4 4 4-4"})})]}),u&&e.jsx("div",{className:"absolute left-0 bottom-full mb-1 w-40 bg-cc-card rounded-xl shadow-lg z-10 py-1",children:[{value:"claude",label:"Claude Code"},{value:"codex",label:"Codex"}].map(n=>e.jsx("button",{onClick:()=>{c({backendType:n.value,model:B(n.value)}),w(!1)},className:`w-full px-3 py-2.5 min-h-[44px] text-xs text-left hover:bg-cc-hover transition-colors cursor-pointer ${n.value===s.backendType?"text-cc-primary font-medium":"text-cc-fg"}`,children:n.label},n.value))})]}),e.jsxs("div",{className:"relative",ref:x,children:[e.jsxs("button",{onClick:()=>m(!g),className:"flex items-center gap-1.5 px-2.5 py-2 min-h-[44px] text-xs text-cc-muted hover:text-cc-fg rounded-lg hover:bg-cc-hover transition-colors cursor-pointer",children:[e.jsx("span",{children:h==null?void 0:h.icon}),e.jsx("span",{children:h==null?void 0:h.label}),e.jsx("svg",{viewBox:"0 0 16 16",fill:"currentColor",className:"w-3 h-3 opacity-50",children:e.jsx("path",{d:"M4 6l4 4 4-4"})})]}),g&&e.jsx("div",{className:"absolute left-0 bottom-full mb-1 w-52 bg-cc-card rounded-xl shadow-lg z-10 py-1",children:v.map(n=>e.jsxs("button",{onClick:()=>{c({model:n.value}),m(!1)},className:`w-full px-3 py-2.5 min-h-[44px] text-xs text-left hover:bg-cc-hover transition-colors cursor-pointer flex items-center gap-2 ${n.value===s.model?"text-cc-primary font-medium":"text-cc-fg"}`,children:[e.jsx("span",{children:n.icon}),n.label]},n.value))})]}),e.jsxs("button",{onClick:()=>y(!0),className:"flex items-center gap-1.5 px-2.5 py-2 min-h-[44px] text-xs text-cc-muted hover:text-cc-fg rounded-lg hover:bg-cc-hover transition-colors cursor-pointer",children:[e.jsx("svg",{viewBox:"0 0 16 16",fill:"currentColor",className:"w-3.5 h-3.5 opacity-60",children:e.jsx("path",{d:"M1 3.5A1.5 1.5 0 012.5 2h3.379a1.5 1.5 0 011.06.44l.622.621a.5.5 0 00.353.146H13.5A1.5 1.5 0 0115 4.707V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"})}),e.jsx("span",{className:"max-w-[200px] truncate font-mono-code",children:E}),e.jsx("svg",{viewBox:"0 0 16 16",fill:"currentColor",className:"w-3 h-3 opacity-50",children:e.jsx("path",{d:"M4 6l4 4 4-4"})})]}),l&&e.jsx(Y,{initialPath:s.cwd||"",onSelect:n=>c({cwd:n}),onClose:()=>y(!1)})]})]})}export{X as CronManager};