projectinator 0.3.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.
Files changed (171) hide show
  1. package/README.md +7 -6
  2. package/bin/projectinator.mjs +22 -15
  3. package/dist/bakeoff.js +163 -0
  4. package/dist/bakeoff.js.map +1 -0
  5. package/dist/build-state.js +26 -0
  6. package/dist/build-state.js.map +1 -0
  7. package/dist/burndown.js +19 -0
  8. package/dist/burndown.js.map +1 -0
  9. package/dist/calibration.js +86 -0
  10. package/dist/calibration.js.map +1 -0
  11. package/dist/cli.js +289 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/cost.js +35 -0
  14. package/dist/cost.js.map +1 -0
  15. package/dist/council.js +147 -0
  16. package/dist/council.js.map +1 -0
  17. package/dist/demo.js +93 -0
  18. package/dist/demo.js.map +1 -0
  19. package/dist/estimate.js +98 -0
  20. package/dist/estimate.js.map +1 -0
  21. package/dist/executor.js +118 -0
  22. package/dist/executor.js.map +1 -0
  23. package/dist/git.js +82 -0
  24. package/dist/git.js.map +1 -0
  25. package/dist/intake.js +98 -0
  26. package/dist/intake.js.map +1 -0
  27. package/dist/models.js +171 -0
  28. package/dist/models.js.map +1 -0
  29. package/dist/narrate.js +76 -0
  30. package/dist/narrate.js.map +1 -0
  31. package/dist/openrouter.js +112 -0
  32. package/dist/openrouter.js.map +1 -0
  33. package/dist/orchestrator.js +273 -0
  34. package/dist/orchestrator.js.map +1 -0
  35. package/dist/pm.js +245 -0
  36. package/dist/pm.js.map +1 -0
  37. package/dist/preview.js +194 -0
  38. package/dist/preview.js.map +1 -0
  39. package/dist/registry-store.js +38 -0
  40. package/dist/registry-store.js.map +1 -0
  41. package/dist/registry.js +115 -0
  42. package/dist/registry.js.map +1 -0
  43. package/dist/research.js +98 -0
  44. package/dist/research.js.map +1 -0
  45. package/dist/retro.js +83 -0
  46. package/dist/retro.js.map +1 -0
  47. package/dist/roles.js +357 -0
  48. package/dist/roles.js.map +1 -0
  49. package/dist/router.js +95 -0
  50. package/dist/router.js.map +1 -0
  51. package/dist/run-bakeoff.js +68 -0
  52. package/dist/run-bakeoff.js.map +1 -0
  53. package/dist/run-build.js +193 -0
  54. package/dist/run-build.js.map +1 -0
  55. package/{src/run-dev.ts → dist/run-dev.js} +23 -39
  56. package/dist/run-dev.js.map +1 -0
  57. package/{src/run-pm.ts → dist/run-pm.js} +34 -46
  58. package/dist/run-pm.js.map +1 -0
  59. package/{src/run-research.ts → dist/run-research.js} +19 -30
  60. package/dist/run-research.js.map +1 -0
  61. package/{src/run-scout.ts → dist/run-scout.js} +17 -27
  62. package/dist/run-scout.js.map +1 -0
  63. package/dist/run-web.js +93 -0
  64. package/dist/run-web.js.map +1 -0
  65. package/dist/scout.js +81 -0
  66. package/dist/scout.js.map +1 -0
  67. package/dist/session-cost.js +15 -0
  68. package/dist/session-cost.js.map +1 -0
  69. package/dist/stack.js +34 -0
  70. package/dist/stack.js.map +1 -0
  71. package/dist/stuck.js +21 -0
  72. package/dist/stuck.js.map +1 -0
  73. package/dist/tui/App.js +1269 -0
  74. package/dist/tui/App.js.map +1 -0
  75. package/dist/tui/BakeOff.js +95 -0
  76. package/dist/tui/BakeOff.js.map +1 -0
  77. package/dist/tui/BoardEditor.js +183 -0
  78. package/dist/tui/BoardEditor.js.map +1 -0
  79. package/dist/tui/EditableBoard.js +140 -0
  80. package/dist/tui/EditableBoard.js.map +1 -0
  81. package/dist/tui/Frame.js +78 -0
  82. package/dist/tui/Frame.js.map +1 -0
  83. package/dist/tui/Intake.js +59 -0
  84. package/dist/tui/Intake.js.map +1 -0
  85. package/dist/tui/Kanban.js +75 -0
  86. package/dist/tui/Kanban.js.map +1 -0
  87. package/dist/tui/Settings.js +299 -0
  88. package/dist/tui/Settings.js.map +1 -0
  89. package/dist/tui/StackPick.js +44 -0
  90. package/dist/tui/StackPick.js.map +1 -0
  91. package/dist/tui/WebAccounts.js +131 -0
  92. package/dist/tui/WebAccounts.js.map +1 -0
  93. package/dist/tui/components.js +178 -0
  94. package/dist/tui/components.js.map +1 -0
  95. package/dist/tui/config.js +129 -0
  96. package/dist/tui/config.js.map +1 -0
  97. package/dist/tui/deploy.js +114 -0
  98. package/dist/tui/deploy.js.map +1 -0
  99. package/dist/tui/engine.js +710 -0
  100. package/dist/tui/engine.js.map +1 -0
  101. package/dist/tui/notify.js +47 -0
  102. package/dist/tui/notify.js.map +1 -0
  103. package/dist/tui/panels.js +42 -0
  104. package/dist/tui/panels.js.map +1 -0
  105. package/dist/tui/templates.js +106 -0
  106. package/dist/tui/templates.js.map +1 -0
  107. package/dist/tui/theme.js +42 -0
  108. package/dist/tui/theme.js.map +1 -0
  109. package/dist/tui/validate.js +51 -0
  110. package/dist/tui/validate.js.map +1 -0
  111. package/{src/tui.tsx → dist/tui.js} +23 -33
  112. package/dist/tui.js.map +1 -0
  113. package/dist/types.js +19 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/web/oauth-anthropic.js +179 -0
  116. package/dist/web/oauth-anthropic.js.map +1 -0
  117. package/dist/web/session.js +274 -0
  118. package/dist/web/session.js.map +1 -0
  119. package/package.json +5 -4
  120. package/src/bakeoff.ts +0 -214
  121. package/src/build-state.ts +0 -47
  122. package/src/burndown.ts +0 -35
  123. package/src/calibration.ts +0 -88
  124. package/src/cli.ts +0 -263
  125. package/src/cost.ts +0 -43
  126. package/src/council.ts +0 -175
  127. package/src/demo.ts +0 -106
  128. package/src/estimate.ts +0 -111
  129. package/src/executor.ts +0 -169
  130. package/src/git.ts +0 -72
  131. package/src/intake.ts +0 -125
  132. package/src/models.ts +0 -175
  133. package/src/narrate.ts +0 -87
  134. package/src/openrouter.ts +0 -119
  135. package/src/orchestrator.ts +0 -310
  136. package/src/pm.ts +0 -302
  137. package/src/preview.ts +0 -206
  138. package/src/registry-store.ts +0 -41
  139. package/src/registry.ts +0 -132
  140. package/src/research.ts +0 -123
  141. package/src/retro.ts +0 -99
  142. package/src/roles.ts +0 -401
  143. package/src/router.ts +0 -120
  144. package/src/run-bakeoff.ts +0 -77
  145. package/src/run-build.ts +0 -203
  146. package/src/run-web.ts +0 -87
  147. package/src/scout.ts +0 -121
  148. package/src/session-cost.ts +0 -17
  149. package/src/stack.ts +0 -46
  150. package/src/tui/App.tsx +0 -1760
  151. package/src/tui/BakeOff.tsx +0 -190
  152. package/src/tui/BoardEditor.tsx +0 -260
  153. package/src/tui/EditableBoard.tsx +0 -179
  154. package/src/tui/Frame.tsx +0 -142
  155. package/src/tui/Intake.tsx +0 -111
  156. package/src/tui/Kanban.tsx +0 -158
  157. package/src/tui/Settings.tsx +0 -513
  158. package/src/tui/StackPick.tsx +0 -79
  159. package/src/tui/WebAccounts.tsx +0 -197
  160. package/src/tui/components.tsx +0 -340
  161. package/src/tui/config.ts +0 -150
  162. package/src/tui/deploy.ts +0 -137
  163. package/src/tui/engine.ts +0 -749
  164. package/src/tui/notify.ts +0 -21
  165. package/src/tui/panels.tsx +0 -89
  166. package/src/tui/templates.ts +0 -119
  167. package/src/tui/theme.ts +0 -44
  168. package/src/tui/validate.ts +0 -53
  169. package/src/types.ts +0 -208
  170. package/src/web/oauth-anthropic.ts +0 -206
  171. package/src/web/session.ts +0 -299
@@ -1,206 +0,0 @@
1
- // Anthropic OAuth (PKCE) — "connect with your Claude Pro/Max subscription".
2
- //
3
- // This reuses Claude Code's own OAuth client_id, so every call must be disguised
4
- // as Claude Code to be accepted. As of Jan 2026 Anthropic enforces this
5
- // server-side (consumer tokens are rejected outside Claude Code / claude.ai), and
6
- // third-party use violates their Consumer ToS (account-ban risk). We build it
7
- // because you asked for the connect-link UX and to test whether the token still
8
- // works — treat any 401/403 on the test as "enforcement won, fall back".
9
- //
10
- // Flow: buildAuthorizeUrl() → user approves in browser → copies the code shown on
11
- // the callback page → exchangeCode() → tokens stored → refresh() as needed.
12
-
13
- import { createHash, randomBytes } from "node:crypto";
14
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
- import { homedir } from "node:os";
16
- import { join } from "node:path";
17
-
18
- const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
19
- const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
20
- const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
21
- // The only redirect registered for this client's manual flow. The callback page
22
- // on console.anthropic.com displays the code for you to copy.
23
- const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
24
- const SCOPES = "org:create_api_key user:profile user:inference";
25
-
26
- // Claude Code's request signature — required or the token is rejected.
27
- const CC_BETA = "oauth-2025-04-20,claude-code-20250219";
28
- const CC_UA = "claude-cli/1.0.0 (external, cli)";
29
- const CC_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
30
-
31
- interface StoredTokens {
32
- access_token: string;
33
- refresh_token: string;
34
- expires_at: number; // epoch ms
35
- }
36
-
37
- function tokenPath(): string {
38
- const dir = join(homedir(), ".projectinator", "web", "claude");
39
- mkdirSync(dir, { recursive: true });
40
- return join(dir, "oauth.json");
41
- }
42
-
43
- export function hasOAuth(): boolean {
44
- return existsSync(tokenPath());
45
- }
46
-
47
- export function clearOAuth(): void {
48
- try { rmSync(tokenPath(), { force: true }); } catch { /* non-fatal */ }
49
- }
50
-
51
- function saveTokens(t: StoredTokens): void {
52
- const p = tokenPath();
53
- writeFileSync(p, JSON.stringify(t, null, 2));
54
- try { chmodSync(p, 0o600); } catch { /* best effort */ }
55
- }
56
-
57
- function loadTokens(): StoredTokens | null {
58
- try {
59
- return JSON.parse(readFileSync(tokenPath(), "utf8")) as StoredTokens;
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
- // ---- PKCE ----
66
- function b64url(buf: Buffer): string {
67
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
68
- }
69
-
70
- export interface PendingAuth {
71
- url: string;
72
- verifier: string;
73
- state: string;
74
- }
75
-
76
- /** Build the authorize URL + PKCE material. Param order matches Claude Code and
77
- * scope spaces are %20-encoded (claude.ai rejects the '+' that URLSearchParams
78
- * emits → "Invalid request format"). */
79
- export function buildAuthorizeUrl(): PendingAuth {
80
- const verifier = b64url(randomBytes(32));
81
- const challenge = b64url(createHash("sha256").update(verifier).digest());
82
- const state = b64url(randomBytes(16));
83
- const params = new URLSearchParams({
84
- client_id: CLIENT_ID,
85
- response_type: "code",
86
- redirect_uri: REDIRECT_URI,
87
- scope: SCOPES,
88
- code_challenge_method: "S256",
89
- state,
90
- code_challenge: challenge,
91
- });
92
- return { url: `${AUTHORIZE_URL}?${params.toString().replace(/\+/g, "%20")}`, verifier, state };
93
- }
94
-
95
- /** Exchange the code copied from the callback page. It may arrive as "CODE#STATE". */
96
- export async function exchangeCode(rawCode: string, pending: PendingAuth): Promise<void> {
97
- const [code, stateFromCode] = rawCode.trim().split("#");
98
- const res = await fetch(TOKEN_URL, {
99
- method: "POST",
100
- headers: { "Content-Type": "application/json" },
101
- body: JSON.stringify({
102
- grant_type: "authorization_code",
103
- code,
104
- state: stateFromCode ?? pending.state,
105
- client_id: CLIENT_ID,
106
- redirect_uri: REDIRECT_URI,
107
- code_verifier: pending.verifier,
108
- }),
109
- });
110
- if (!res.ok) {
111
- throw new Error(`Token exchange failed (${res.status}): ${(await res.text()).slice(0, 200)}`);
112
- }
113
- const j = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number };
114
- saveTokens({
115
- access_token: j.access_token,
116
- refresh_token: j.refresh_token,
117
- expires_at: nowMs() + (j.expires_in ?? 3600) * 1000,
118
- });
119
- }
120
-
121
- async function refresh(t: StoredTokens): Promise<StoredTokens> {
122
- const res = await fetch(TOKEN_URL, {
123
- method: "POST",
124
- headers: { "Content-Type": "application/json" },
125
- body: JSON.stringify({
126
- grant_type: "refresh_token",
127
- refresh_token: t.refresh_token,
128
- client_id: CLIENT_ID,
129
- }),
130
- });
131
- if (!res.ok) {
132
- throw new Error(`Token refresh failed (${res.status}): ${(await res.text()).slice(0, 200)}`);
133
- }
134
- const j = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
135
- const next: StoredTokens = {
136
- access_token: j.access_token,
137
- refresh_token: j.refresh_token ?? t.refresh_token,
138
- expires_at: nowMs() + (j.expires_in ?? 3600) * 1000,
139
- };
140
- saveTokens(next);
141
- return next;
142
- }
143
-
144
- async function validAccessToken(): Promise<string> {
145
- const t = loadTokens();
146
- if (!t) throw new Error("Not connected. Run the Claude OAuth connect flow first.");
147
- if (nowMs() > t.expires_at - 60_000) {
148
- return (await refresh(t)).access_token;
149
- }
150
- return t.access_token;
151
- }
152
-
153
- // nowMs isolated so the "no Date.now in workflow scripts" rule never bites here
154
- // (this module runs in the app/CLI, not a workflow script).
155
- function nowMs(): number {
156
- return Date.now();
157
- }
158
-
159
- export interface OAuthMessage {
160
- role: "user" | "assistant";
161
- content: string;
162
- }
163
-
164
- /** Call the Messages API with the OAuth token, disguised as Claude Code.
165
- * Returns the assistant text. Tool-calling can be layered on later. */
166
- export async function oauthComplete(
167
- messages: OAuthMessage[],
168
- opts: { model?: string; system?: string; maxTokens?: number } = {},
169
- ): Promise<string> {
170
- const token = await validAccessToken();
171
- // Claude Code always leads the system prompt with its identity line; the token
172
- // is rejected without it. Any extra system text is appended after.
173
- const system = [
174
- { type: "text", text: CC_SYSTEM },
175
- ...(opts.system ? [{ type: "text", text: opts.system }] : []),
176
- ];
177
- const res = await fetch("https://api.anthropic.com/v1/messages", {
178
- method: "POST",
179
- headers: {
180
- "Content-Type": "application/json",
181
- "Authorization": `Bearer ${token}`,
182
- "anthropic-version": "2023-06-01",
183
- "anthropic-beta": CC_BETA,
184
- "anthropic-dangerous-direct-browser-access": "true",
185
- "x-app": "cli",
186
- "user-agent": CC_UA,
187
- },
188
- body: JSON.stringify({
189
- model: opts.model ?? "claude-sonnet-5",
190
- max_tokens: opts.maxTokens ?? 1024,
191
- system,
192
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
193
- }),
194
- });
195
- if (!res.ok) {
196
- const body = (await res.text()).slice(0, 300);
197
- throw new Error(`Messages API ${res.status}: ${body}\n(401/403 here = Jan-2026 enforcement rejecting a non-Claude-Code client)`);
198
- }
199
- const j = (await res.json()) as { content?: { type: string; text?: string }[] };
200
- return (j.content ?? []).filter((b) => b.type === "text").map((b) => b.text ?? "").join("").trim();
201
- }
202
-
203
- /** Quick connectivity/enforcement probe. Returns the reply or throws with the status. */
204
- export async function oauthTest(): Promise<string> {
205
- return oauthComplete([{ role: "user", content: "Reply with exactly one word: hello" }], { maxTokens: 16 });
206
- }
@@ -1,299 +0,0 @@
1
- // EXPERIMENTAL web-login backend. Drives a provider's WEB UI (logged into your paid
2
- // subscription) in a persistent Chromium profile to get ~free completions.
3
- //
4
- // Caveats you accepted: brittle (site DOM changes break selectors), against provider
5
- // ToS (account-ban risk), TEXT ONLY (no tool-calling / no file tools), slow.
6
- //
7
- // Selectors live in PROVIDERS below — when a site changes, edit them there.
8
-
9
- import { chromium, type BrowserContext, type Page } from "playwright";
10
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
- import { homedir } from "node:os";
12
- import { join } from "node:path";
13
- import { createInterface } from "node:readline";
14
-
15
- export type WebProvider = "chatgpt" | "claude" | "gemini";
16
-
17
- interface ProviderConfig {
18
- label: string;
19
- newChatUrl: string;
20
- /** The message input (contenteditable or textarea). */
21
- inputSelector: string;
22
- /** Selector matching every assistant message; we read the last one. */
23
- assistantSelector: string;
24
- /** Selector for the "stop generating" control — present while streaming. */
25
- stopSelector: string;
26
- }
27
-
28
- const PROVIDERS: Record<WebProvider, ProviderConfig> = {
29
- claude: {
30
- label: "Claude (claude.ai)",
31
- newChatUrl: "https://claude.ai/new",
32
- inputSelector: 'div[contenteditable="true"]',
33
- assistantSelector: '[class*="claude"]',
34
- stopSelector: '[data-is-streaming="true"]',
35
- },
36
- chatgpt: {
37
- label: "ChatGPT (chatgpt.com)",
38
- newChatUrl: "https://chatgpt.com/",
39
- inputSelector: '#prompt-textarea, div[contenteditable="true"], textarea',
40
- assistantSelector: '[data-message-author-role="assistant"]',
41
- stopSelector: 'button[data-testid="stop-button"], button[aria-label*="Stop"]',
42
- },
43
- gemini: {
44
- label: "Gemini (gemini.google.com)",
45
- newChatUrl: "https://gemini.google.com/app",
46
- inputSelector: 'rich-textarea div[contenteditable="true"], div[contenteditable="true"], textarea',
47
- assistantSelector: "message-content, .model-response-text",
48
- stopSelector: 'button[aria-label*="Stop"], button[aria-label*="stop"]',
49
- },
50
- };
51
-
52
- function profileDir(provider: WebProvider): string {
53
- const dir = join(homedir(), ".projectinator", "web", provider);
54
- mkdirSync(dir, { recursive: true });
55
- return dir;
56
- }
57
-
58
- function connectedMarker(provider: WebProvider): string {
59
- return join(profileDir(provider), ".connected");
60
- }
61
-
62
- /** Has this provider been logged in at least once (session saved)? */
63
- export function isConnected(provider: WebProvider): boolean {
64
- return existsSync(connectedMarker(provider));
65
- }
66
-
67
- /** Which providers have a saved session. */
68
- export function connectedProviders(): WebProvider[] {
69
- return (Object.keys(PROVIDERS) as WebProvider[]).filter(isConnected);
70
- }
71
-
72
- function markConnected(provider: WebProvider): void {
73
- try {
74
- writeFileSync(connectedMarker(provider), new Date().toISOString());
75
- } catch {
76
- /* non-fatal */
77
- }
78
- }
79
-
80
- /** Log out: wipe the saved session (cookies + profile) for a provider. */
81
- export function disconnect(provider: WebProvider): void {
82
- try {
83
- rmSync(profileDir(provider), { recursive: true, force: true });
84
- } catch {
85
- /* non-fatal */
86
- }
87
- }
88
-
89
- // Push the run window far off-screen so it drives the session without stealing
90
- // focus or cluttering the desktop. Login windows stay on-screen (you interact).
91
- const OFFSCREEN_ARGS = ["--window-position=-3200,-3200", "--window-size=1280,900"];
92
-
93
- async function openContext(
94
- provider: WebProvider,
95
- opts: { headless: boolean; offscreen?: boolean },
96
- ): Promise<BrowserContext> {
97
- return chromium.launchPersistentContext(profileDir(provider), {
98
- headless: opts.headless,
99
- viewport: { width: 1280, height: 900 },
100
- args: [
101
- "--disable-blink-features=AutomationControlled",
102
- ...(opts.offscreen ? OFFSCREEN_ARGS : []),
103
- ],
104
- });
105
- }
106
-
107
- // One long-lived background browser per provider, reused across completions so
108
- // nothing pops up per task. Launched on first webComplete, closed on app exit.
109
- const runContexts = new Map<WebProvider, BrowserContext>();
110
-
111
- async function runContext(provider: WebProvider): Promise<BrowserContext> {
112
- const existing = runContexts.get(provider);
113
- if (existing) return existing;
114
- const ctx = await openContext(provider, { headless: false, offscreen: true });
115
- ctx.on("close", () => {
116
- if (runContexts.get(provider) === ctx) runContexts.delete(provider);
117
- });
118
- runContexts.set(provider, ctx);
119
- return ctx;
120
- }
121
-
122
- /** Close every background browser. Call on app exit. */
123
- export async function closeWebSessions(): Promise<void> {
124
- const ctxs = [...runContexts.values()];
125
- runContexts.clear();
126
- await Promise.all(ctxs.map((c) => c.close().catch(() => {})));
127
- }
128
-
129
- /** Open a real browser window so you can log in once; the session is saved to disk. */
130
- export async function webLogin(provider: WebProvider): Promise<void> {
131
- const cfg = PROVIDERS[provider];
132
- const ctx = await openContext(provider, { headless: false });
133
- const page = ctx.pages()[0] ?? (await ctx.newPage());
134
- // Non-fatal: even if the page is slow / behind a challenge, the window is open
135
- // and you can log in (or navigate) manually.
136
- try {
137
- await page.goto(cfg.newChatUrl, { waitUntil: "commit", timeout: 60_000 });
138
- } catch {
139
- /* keep the window open regardless */
140
- }
141
- process.stdout.write(`\n A browser opened for ${cfg.label}.\n Log in there (wait for it to load), then press Enter here to save the session…`);
142
- await new Promise<void>((resolve) => {
143
- const rl = createInterface({ input: process.stdin, output: process.stdout });
144
- rl.question("", () => {
145
- rl.close();
146
- resolve();
147
- });
148
- });
149
- await ctx.close();
150
- markConnected(provider);
151
- process.stdout.write(" Session saved.\n");
152
- }
153
-
154
- /** Non-interactive login for the TUI: open the login window and resolve once the
155
- * caller signals done (e.g. user pressed a key in the app). Returns a handle to
156
- * finish. The window shows the real site so you can log in. */
157
- export async function webLoginBegin(provider: WebProvider): Promise<{
158
- finish: () => Promise<void>;
159
- cancel: () => Promise<void>;
160
- }> {
161
- const cfg = PROVIDERS[provider];
162
- const ctx = await openContext(provider, { headless: false });
163
- const page = ctx.pages()[0] ?? (await ctx.newPage());
164
- try {
165
- await page.goto(cfg.newChatUrl, { waitUntil: "commit", timeout: 60_000 });
166
- } catch {
167
- /* keep the window open regardless */
168
- }
169
- return {
170
- finish: async () => {
171
- await ctx.close();
172
- markConnected(provider);
173
- },
174
- cancel: async () => {
175
- await ctx.close();
176
- },
177
- };
178
- }
179
-
180
- async function lastAssistantText(page: Page, cfg: ProviderConfig): Promise<string> {
181
- const nodes = page.locator(cfg.assistantSelector);
182
- const count = await nodes.count();
183
- if (count === 0) return "";
184
- return (await nodes.nth(count - 1).innerText()).trim();
185
- }
186
-
187
- /** Send one prompt to the provider's web UI and return the reply text.
188
- * Runs HEADED by default — claude.ai/chatgpt block headless browsers. Set
189
- * opts.headless=true to try headless (often challenged). */
190
- export async function webComplete(
191
- provider: WebProvider,
192
- prompt: string,
193
- opts: { headless?: boolean; timeoutMs?: number } = {},
194
- ): Promise<string> {
195
- const cfg = PROVIDERS[provider];
196
- const timeoutMs = opts.timeoutMs ?? 90_000;
197
- // Reuse the one background browser; open a fresh page (= fresh chat) per ask.
198
- const ctx = await runContext(provider);
199
- const page = await ctx.newPage();
200
- try {
201
- try {
202
- await page.goto(cfg.newChatUrl, { waitUntil: "commit", timeout: 60_000 });
203
- } catch {
204
- /* proceed — the input wait below is the real readiness check */
205
- }
206
-
207
- const input = page.locator(cfg.inputSelector).first();
208
- try {
209
- await input.waitFor({ state: "visible", timeout: 45_000 });
210
- } catch (e) {
211
- // Diagnose: dump what actually loaded so we can fix selectors / spot a challenge.
212
- const shot = join(profileDir(provider), "debug.png");
213
- try { await page.screenshot({ path: shot, fullPage: false }); } catch { /* ignore */ }
214
- const url = page.url();
215
- const title = await page.title().catch(() => "");
216
- const body = (await page.locator("body").innerText().catch(() => "")).slice(0, 200).replace(/\s+/g, " ");
217
- throw new Error(
218
- `Input never appeared.\n url: ${url}\n title: ${title}\n page text: "${body}…"\n screenshot: ${shot}\n (looks like a login/challenge page? → the session may not be carrying over, or the selector changed)`,
219
- );
220
- }
221
- await input.click();
222
- await input.fill(prompt);
223
- await page.keyboard.press("Enter");
224
-
225
- // Wait for a reply to appear, then for it to stop changing.
226
- const deadline = Date.now() + timeoutMs;
227
- let last = "";
228
- let stable = 0;
229
- while (Date.now() < deadline) {
230
- try {
231
- await page.waitForTimeout(700);
232
- const text = await lastAssistantText(page, cfg);
233
- const streaming = (await page.locator(cfg.stopSelector).count()) > 0;
234
- if (text && text === last && !streaming) {
235
- stable++;
236
- if (stable >= 3) return text; // ~2s unchanged and not streaming → done
237
- } else {
238
- stable = 0;
239
- }
240
- last = text;
241
- } catch {
242
- break; // page/context closed — return whatever we captured
243
- }
244
- }
245
- if (last) return last;
246
- throw new Error("No response captured (selectors may be stale, or you're not logged in).");
247
- } finally {
248
- await page.close().catch(() => {}); // keep the context alive for the next ask
249
- }
250
- }
251
-
252
- /** Diagnostic: send a prompt, wait, then dump candidate selectors so we can find the
253
- * right one for the assistant reply. Headed. */
254
- export async function webDump(provider: WebProvider, prompt: string): Promise<void> {
255
- const cfg = PROVIDERS[provider];
256
- const ctx = await openContext(provider, { headless: false });
257
- try {
258
- const page = ctx.pages()[0] ?? (await ctx.newPage());
259
- try { await page.goto(cfg.newChatUrl, { waitUntil: "commit", timeout: 60_000 }); } catch { /* ignore */ }
260
- const input = page.locator(cfg.inputSelector).first();
261
- await input.waitFor({ state: "visible", timeout: 45_000 });
262
- await input.click();
263
- await input.fill(prompt);
264
- await page.keyboard.press("Enter");
265
- console.log(" sent — waiting 18s for the reply to render…");
266
- await page.waitForTimeout(18_000);
267
-
268
- const report = await page.evaluate(() => {
269
- const cands = [
270
- // chatgpt
271
- '[data-message-author-role="assistant"]', ".markdown",
272
- // gemini
273
- "message-content", ".model-response-text", "model-response",
274
- // claude
275
- '[class*="claude"]', ".font-claude-message", "[data-is-streaming]",
276
- // generic
277
- ".prose", '[class*="message"]', "[data-testid]", "main div[tabindex]",
278
- ];
279
- const out: { sel: string; count: number; lastText: string }[] = [];
280
- for (const sel of cands) {
281
- const els = document.querySelectorAll(sel);
282
- const last = els[els.length - 1] as HTMLElement | undefined;
283
- out.push({ sel, count: els.length, lastText: (last?.innerText ?? "").slice(0, 100).replace(/\s+/g, " ") });
284
- }
285
- return { url: location.href, title: document.title, cands: out };
286
- });
287
- console.log("\n url:", report.url, "\n title:", report.title, "\n --- candidate selectors ---");
288
- for (const c of report.cands) console.log(` [${c.count}] ${c.sel}\n last: "${c.lastText}"`);
289
- console.log("");
290
- } finally {
291
- await ctx.close();
292
- }
293
- }
294
-
295
- export function isWebProvider(s: string): s is WebProvider {
296
- return s === "chatgpt" || s === "claude" || s === "gemini";
297
- }
298
-
299
- export { PROVIDERS };