subconscious-cli 4.1.4 → 5.0.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/README.md CHANGED
@@ -378,9 +378,9 @@ passed through the same way and override Subconscious-injected defaults.
378
378
 
379
379
  ## Authentication
380
380
 
381
- `subc login` opens a browser, completes sign-in, and saves the generated API
382
- key to the selected runbook profile with mode `600`. For the default profile it
383
- also maintains `~/.subconscious/config.json`.
381
+ `subc login` opens a browser to a one-time login link, waits for you to sign in, and saves the API key to the selected runbook profile with mode `600`. For the default profile it also maintains `~/.subconscious/config.json`. The same flow works on a laptop, over SSH, and inside a VM: the browser does not need to be on the machine running the CLI.
382
+
383
+ If a window does not open, open the login URL printed in the terminal. That URL includes your code. You can also open the printed `/cli/device` link and type the code. To skip the browser, copy an API key from the dashboard and run `subc update-key`.
384
384
 
385
385
  ```bash
386
386
  subc login
package/bin/agents.js CHANGED
@@ -906,6 +906,7 @@ export function runbookEnv(
906
906
  ? {
907
907
  SUBC_CLAUDE_SETTINGS: JSON.stringify(claudePickerSettings(models, model)),
908
908
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '0',
909
+ CLAUDE_CODE_AUTO_MODE_SERVER: '0',
909
910
  }
910
911
  : {}),
911
912
  SUBC_ENV_FILE: os.devNull,
package/bin/auth.js CHANGED
@@ -1,22 +1,16 @@
1
1
  /**
2
2
  * Authentication + credential storage for the Subconscious CLI.
3
3
  *
4
- * Login flow (localhost callback pattern, similar to Vercel/Supabase CLIs):
5
- * 1. CLI generates a random `state` token (CSRF protection) and starts
6
- * an ephemeral HTTP server on a random port bound to 127.0.0.1.
7
- * 2. Opens the browser to {platformUrl}/cli/auth?port=...&state=...
8
- * 3. The web app authenticates the user, generates an API key, and
9
- * delivers it back to the CLI via a cross-origin fetch to
10
- * localhost:{port}/callback?token=...&state=...
11
- * 4. CLI verifies the `state`, saves the key to ~/.subconscious/config.json,
12
- * and creates a coding-agent profile under ~/.subconscious/profiles/.
4
+ * Login flow (device code):
5
+ * 1. CLI asks the platform for a device code and a short user code.
6
+ * 2. Opens {platformUrl}/cli/device?code=... in any browser.
7
+ * 3. Polls the platform until the signed-in browser approves the code.
8
+ * 4. Saves the key to ~/.subconscious/config.json and a runbook profile.
13
9
  *
14
10
  * Override SUBCONSCIOUS_URL env var for local development
15
11
  * (e.g. http://localhost:3000). Production defaults to platform.subconscious.dev.
16
12
  */
17
13
 
18
- import http from 'node:http';
19
- import crypto from 'node:crypto';
20
14
  import { exec } from 'node:child_process';
21
15
  import { openWindowsBrowser } from './windows/process.js';
22
16
  import fs from 'node:fs/promises';
@@ -26,12 +20,18 @@ import { c } from './colors.js';
26
20
  import { clearProfileApiKey, DEFAULT_PROFILE, ensureProfile } from './profiles.js';
27
21
  import { printLoginUpgradeWarning } from './upgrade.js';
28
22
 
29
- const CONFIG_OVERRIDE = process.env.SUBC_CONFIG_DIR?.trim();
30
- const CONFIG_DIR = CONFIG_OVERRIDE || path.join(os.homedir(), '.subconscious');
31
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
32
- const LEGACY_CONFIG_FILE = CONFIG_OVERRIDE
33
- ? null
34
- : path.join(os.homedir(), '.subcon', 'config.json');
23
+ function configDir() {
24
+ return process.env.SUBC_CONFIG_DIR?.trim() || path.join(os.homedir(), '.subconscious');
25
+ }
26
+
27
+ function configFile() {
28
+ return path.join(configDir(), 'config.json');
29
+ }
30
+
31
+ function legacyConfigFile() {
32
+ if (process.env.SUBC_CONFIG_DIR?.trim()) return null;
33
+ return path.join(os.homedir(), '.subcon', 'config.json');
34
+ }
35
35
  // Defaults to the platform host. Developers set SUBCONSCIOUS_URL for local dev.
36
36
  export const DEFAULT_PLATFORM_URL = 'https://platform.subconscious.dev';
37
37
 
@@ -43,41 +43,16 @@ export function getPlatformUrl(profile) {
43
43
  return DEFAULT_PLATFORM_URL;
44
44
  }
45
45
 
46
- // Login callback CORS. After the marketing/platform split, /cli/auth lives on
47
- // platform. www may still 307 there for older CLIs; keep www so a redirected
48
- // or leftover tab can complete the callback.
49
- const CALLBACK_ORIGINS = new Set([
50
- 'https://www.subconscious.dev',
51
- 'https://platform.subconscious.dev',
52
- 'https://dev.subconscious.dev',
53
- 'https://platform-dev.subconscious.dev',
54
- ]);
55
-
56
- export function isAllowedCallbackOrigin(origin, platformUrl = getPlatformUrl()) {
57
- if (!origin) return false;
58
- if (origin === platformUrl || CALLBACK_ORIGINS.has(origin)) return true;
59
- try {
60
- const { protocol, hostname } = new URL(origin);
61
- return protocol === 'http:' && (hostname === 'localhost' || hostname === '127.0.0.1');
62
- } catch {
63
- return false;
64
- }
65
- }
66
-
67
- // ── Config helpers ──────────────────────────────────────────────────────
68
-
69
46
  async function loadConfig() {
70
47
  try {
71
- const content = await fs.readFile(CONFIG_FILE, 'utf-8');
48
+ const content = await fs.readFile(configFile(), 'utf-8');
72
49
  return JSON.parse(content);
73
50
  } catch (error) {
74
- if (error.code !== 'ENOENT' || !LEGACY_CONFIG_FILE) return {};
51
+ if (error.code !== 'ENOENT' || !legacyConfigFile()) return {};
75
52
  }
76
53
 
77
- // One-way compatibility migration. Keep the old file untouched so this is
78
- // recoverable, but all future writes go to ~/.subconscious.
79
54
  try {
80
- const content = await fs.readFile(LEGACY_CONFIG_FILE, 'utf-8');
55
+ const content = await fs.readFile(legacyConfigFile(), 'utf-8');
81
56
  const config = JSON.parse(content);
82
57
  await saveConfig(config);
83
58
  return config;
@@ -87,16 +62,11 @@ async function loadConfig() {
87
62
  }
88
63
 
89
64
  async function saveConfig(config) {
90
- await fs.mkdir(CONFIG_DIR, { recursive: true });
91
- await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
92
- // 0o600 = owner read/write only — the file contains an API key
93
- await fs.chmod(CONFIG_FILE, 0o600);
65
+ await fs.mkdir(configDir(), { recursive: true });
66
+ await fs.writeFile(configFile(), JSON.stringify(config, null, 2), 'utf-8');
67
+ await fs.chmod(configFile(), 0o600);
94
68
  }
95
69
 
96
- /**
97
- * Resolve the active API key. The env var takes precedence over the saved
98
- * config so CI and per-shell overrides win. Returns null when unauthenticated.
99
- */
100
70
  export async function getApiKey(profile) {
101
71
  const envKey = process.env.SUBCONSCIOUS_API_KEY?.trim();
102
72
  if (envKey) return { key: envKey, source: 'SUBCONSCIOUS_API_KEY env var' };
@@ -104,8 +74,6 @@ export async function getApiKey(profile) {
104
74
  const profileKey = profile?.values?.API_KEY?.trim();
105
75
  if (profileKey) return { key: profileKey, source: profile.path };
106
76
 
107
- // Named profiles are isolated: an empty/missing key must not silently fall
108
- // back to the default credential and send traffic to the wrong account.
109
77
  if (profile?.name && profile.name !== DEFAULT_PROFILE) return null;
110
78
 
111
79
  const config = await loadConfig();
@@ -115,24 +83,6 @@ export async function getApiKey(profile) {
115
83
  return null;
116
84
  }
117
85
 
118
- export async function probeLoginPage(platformUrl = getPlatformUrl(), fetchImpl = fetch) {
119
- const url = `${platformUrl.replace(/\/$/, '')}/cli/auth`;
120
- try {
121
- const res = await fetchImpl(url, {
122
- method: 'GET',
123
- redirect: 'manual',
124
- signal: AbortSignal.timeout(8000),
125
- });
126
- return res.status;
127
- } catch {
128
- return null;
129
- }
130
- }
131
-
132
- export function isLoginMissing(status) {
133
- return status === 404;
134
- }
135
-
136
86
  // ── Browser opener ──────────────────────────────────────────────────────
137
87
 
138
88
  function openBrowser(url) {
@@ -159,159 +109,74 @@ function openBrowser(url) {
159
109
  });
160
110
  }
161
111
 
162
- // ── Localhost callback server ───────────────────────────────────────────
163
-
164
- function startCallbackServer(expectedState) {
165
- return new Promise((resolveSetup) => {
166
- let resolveToken, rejectToken;
167
- const tokenPromise = new Promise((resolve, reject) => {
168
- resolveToken = resolve;
169
- rejectToken = reject;
170
- });
171
-
172
- const server = http.createServer((req, res) => {
173
- // CORS: only allow known web-app origins (or localhost dev).
174
- // This prevents arbitrary websites from hitting this callback.
175
- const origin = req.headers.origin || '';
176
- const allowed = isAllowedCallbackOrigin(origin);
177
- res.setHeader(
178
- 'Access-Control-Allow-Origin',
179
- allowed ? origin : getPlatformUrl(),
180
- );
181
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
182
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
183
- res.setHeader('Connection', 'close');
112
+ const DEVICE_POLL_INTERVAL_MS = 2000;
113
+ const DEVICE_POLL_TIMEOUT_MS = 5 * 60 * 1000;
184
114
 
185
- if (req.method === 'OPTIONS') {
186
- res.writeHead(204);
187
- res.end();
188
- return;
189
- }
190
-
191
- const url = new URL(req.url, 'http://localhost');
192
-
193
- if (url.pathname !== '/callback') {
194
- res.writeHead(404);
195
- res.end();
196
- return;
197
- }
198
-
199
- const token = url.searchParams.get('token');
200
- const state = url.searchParams.get('state');
201
- const error = url.searchParams.get('error');
202
-
203
- // The web app sends `Accept: application/json` via fetch(); direct
204
- // browser visits get the HTML fallback page.
205
- const wantsJson = (req.headers.accept || '').includes('application/json');
206
- const respond = (ok, msg) => {
207
- if (wantsJson) {
208
- res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
209
- res.end(JSON.stringify({ ok, error: msg || undefined }));
210
- } else {
211
- res.writeHead(200, { 'Content-Type': 'text/html' });
212
- res.end(resultPage(ok, msg));
213
- }
214
- };
215
-
216
- if (error) {
217
- respond(false, error);
218
- server.close();
219
- rejectToken(new Error(error));
220
- return;
221
- }
222
-
223
- // CSRF check: state token must match what the CLI generated
224
- if (state !== expectedState) {
225
- respond(false, 'State mismatch — possible CSRF attack. Please try again.');
226
- server.close();
227
- rejectToken(new Error('State mismatch'));
228
- return;
229
- }
230
-
231
- if (!token) {
232
- respond(false, 'No API key received.');
233
- server.close();
234
- rejectToken(new Error('No API key received'));
235
- return;
236
- }
237
-
238
- respond(true);
239
- server.close();
240
- resolveToken({ token });
241
- });
242
-
243
- // Port 0 = OS assigns a random available port. Bound to 127.0.0.1 only.
244
- server.listen(0, '127.0.0.1', () => {
245
- const port = server.address().port;
246
-
247
- const timeout = setTimeout(() => {
248
- server.close();
249
- rejectToken(new Error('Authentication timed out (5 min). Please try again.'));
250
- }, 5 * 60 * 1000);
115
+ function terminalLink(url) {
116
+ return `\u001b]8;;${url}\u0007${url}\u001b]8;;\u0007`;
117
+ }
251
118
 
252
- tokenPromise.finally(() => clearTimeout(timeout));
119
+ export async function registerDeviceLogin(platformUrl, fetchImpl = fetch) {
120
+ const res = await fetchImpl(`${platformUrl}/api/cli/device/register`, {
121
+ method: 'POST',
122
+ signal: AbortSignal.timeout(8000),
123
+ });
124
+ const data = await res.json().catch(() => ({}));
125
+ if (!res.ok) {
126
+ throw new Error(data.message || 'Could not start CLI login.');
127
+ }
128
+ if (!data.device_code || !data.user_code) {
129
+ throw new Error('CLI login response was missing a device code.');
130
+ }
131
+ return data;
132
+ }
253
133
 
254
- resolveSetup({ port, promise: tokenPromise });
255
- });
134
+ export async function pollDeviceLogin(platformUrl, deviceCode, fetchImpl = fetch) {
135
+ const res = await fetchImpl(`${platformUrl}/api/cli/device/token`, {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({ device_code: deviceCode }),
139
+ signal: AbortSignal.timeout(8000),
256
140
  });
141
+ const data = await res.json().catch(() => ({}));
142
+ if (res.ok && data.key) return { status: 'approved', key: data.key };
143
+ if (data.error === 'authorization_pending') return { status: 'pending' };
144
+ if (data.error === 'expired') return { status: 'expired' };
145
+ throw new Error(data.message || data.error || 'CLI login failed.');
257
146
  }
258
147
 
259
- function resultPage(success, message) {
260
- const title = success ? 'Authenticated' : 'Authentication failed';
261
- const subtitle = success
262
- ? 'You can close this tab and return to your terminal.'
263
- : message || 'Something went wrong.';
264
-
265
- const logoSvg = `<svg viewBox="0 0 205 199" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M-4.33e-06 99.93C-4.96e-06 85.57 11.64 73.93 26 73.93c7.88 0 14.94 3.51 19.71 9.04 5.25 6.09 11.36 12.6 19.37 13.33l3.92.36v-.01c.03.01.06.01.09.01L72 96.93c12.69 0 23.98-10.28 24-22.96-.01-7.36-3.48-13.91-8.87-18.11l-1.08-.76c-6.52-4.6-15.3-3.65-23.19-2.46-7.28 1.1-14.97-.88-20.96-6.08C31.06 37.13 29.91 20.71 39.33 9.87 48.75-.96 65.18-2.11 76.01 7.31c5.99 5.21 9.02 12.55 8.94 19.91-.09 7.97.2 16.8 5.66 22.62l.94 1 .11.1.14.12c.22.19.45.37.68.55l.17.13c.25.18.5.36.75.53.64.42 1.31.8 2.01 1.14.48.23.98.44 1.49.62.21.07.42.14.63.21.32.1.64.19.97.27.4.1.8.18 1.2.25.34.06.69.11 1.03.14.58.06 1.17.09 1.77.09 4.2 0 8.03-1.57 10.95-4.16l.94-1c5.46-5.81 5.75-14.65 5.66-22.62-.08-7.36 2.95-14.71 8.94-19.91C139.82-2.11 156.25-.96 165.67 9.87c9.42 10.84 8.27 27.26-2.56 36.68-5.99 5.21-13.69 7.18-20.96 6.08-7.88-1.19-16.67-2.14-23.19 2.46l-1.08.76c-5.39 4.2-8.86 10.75-8.87 18.11.02 12.69 11.31 22.96 24 22.96l2.91-.26.09-.02v.01l3.93-.36c8.01-.73 14.12-7.23 19.37-13.33 4.77-5.54 11.83-9.04 19.71-9.04C193.36 73.93 205 85.57 205 99.93v.07c0 .01 0 .02 0 .03 0 14.36-11.64 26-26 26-7.88 0-14.94-3.51-19.71-9.04-5.25-6.09-11.36-12.6-19.37-13.33l-3.93-.36v.01c-.03-.01-.06-.01-.09-.01L133 103c-12.69 0-23.98 10.28-24 22.97.01 7.36 3.48 13.91 8.87 18.11l1.08.76c6.52 4.6 15.3 3.65 23.19 2.46 7.28-1.1 14.97.88 20.96 6.08 10.84 9.42 11.98 25.84 2.56 36.68-9.42 10.84-25.84 11.98-36.68 2.56-5.99-5.21-9.02-12.55-8.94-19.91.09-7.97-.2-16.8-5.66-22.62l-.94-1c-2.91-2.59-6.75-4.16-10.95-4.16-.6 0-1.19.03-1.77.09-.35.04-.69.09-1.03.14-.34.07-.69.15-1.03.25-.33.08-.65.17-.97.28-.21.07-.42.14-.62.21-.51.18-1.01.39-1.49.62-.7.33-1.37.71-2.01 1.14-.26.17-.5.35-.75.53l-.17.13a11 11 0 0 0-.68.55l-.14.12-.11.1-.94 1.01c-5.46 5.81-5.75 14.65-5.66 22.62.08 7.36-2.95 14.71-8.94 19.91-10.84 9.42-27.26 8.27-36.68-2.56-9.42-10.84-8.27-27.26 2.56-36.68 5.99-5.21 13.69-7.18 20.96-6.08 7.88 1.19 16.67 2.14 23.19-2.46l1.08-.76c5.39-4.2 8.86-10.75 8.87-18.11-.02-12.69-11.31-22.97-24-22.97l-2.91.27c-.03 0-.06.01-.09.01v-.01l-3.93.36c-8.01.73-14.12 7.23-19.37 13.33C40.94 122.49 33.88 126 26 126 11.64 126 0 114.36 0 100l-4.33e-06-.07Z" fill="#FF5C28"/></svg>`;
266
-
267
- const statusIcon = success
268
- ? `<div class="icon success"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>`
269
- : `<div class="icon error"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg></div>`;
270
-
271
- return `<!DOCTYPE html><html lang="en"><head>
272
- <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
273
- <title>Subconscious CLI</title>
274
- <link rel="preconnect" href="https://fonts.googleapis.com">
275
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
276
- <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
277
- <style>
278
- *{margin:0;padding:0;box-sizing:border-box}
279
- body{font-family:'Manrope',system-ui,sans-serif;min-height:100vh;background:#F6F3EF;color:#111}
280
- header{display:flex;align-items:center;gap:10px;padding:20px 24px}
281
- header svg{width:24px;height:24px}
282
- header span{font-size:15px;font-weight:600;letter-spacing:-.01em}
283
- main{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 160px)}
284
- .wrap{width:100%;max-width:400px;padding:0 24px}
285
- .label{text-align:center;font-size:11px;font-weight:500;text-transform:uppercase;
286
- letter-spacing:.1em;color:#9ca3af;margin-bottom:16px}
287
- .card{background:#fff;border:1px solid rgba(0,0,0,.08);border-radius:12px;
288
- padding:32px;text-align:center}
289
- .icon{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;
290
- justify-content:center;margin:0 auto 16px}
291
- .icon.success{background:#f0fdf4}
292
- .icon.error{background:#fef2f2}
293
- h1{font-size:15px;font-weight:600;margin-bottom:4px;letter-spacing:-.01em}
294
- .sub{color:#6b7280;font-size:13px;line-height:1.6;max-width:280px;margin:0 auto}
295
- .footer{text-align:center;margin-top:16px;font-size:11px;color:#9ca3af}
296
- </style></head><body>
297
- <header>${logoSvg}<span>Subconscious</span></header>
298
- <main><div class="wrap">
299
- <div class="label">CLI Authentication</div>
300
- <div class="card">
301
- ${statusIcon}
302
- <h1>${title}</h1>
303
- <p class="sub">${subtitle}</p>
304
- </div>
305
- <div class="footer">subconscious.dev</div>
306
- </div></main>
307
- </body></html>`;
148
+ function printDeviceInstructions(platformUrl, userCode) {
149
+ const deviceUrl = `${platformUrl}/cli/device`;
150
+ const verificationUrl = `${deviceUrl}?code=${encodeURIComponent(userCode)}`;
151
+ console.log(` ${c.dim}Opening browser to sign in...${c.reset}`);
152
+ console.log();
153
+ console.log(` ${c.dim}If a window doesn't open, go to:${c.reset}`);
154
+ console.log(` ${c.underline}${c.cyan}${terminalLink(verificationUrl)}${c.reset}`);
155
+ console.log();
156
+ console.log(
157
+ ` ${c.dim}Or open ${c.reset}${c.underline}${c.cyan}${terminalLink(deviceUrl)}${c.reset}${c.dim} and enter:${c.reset} ${c.bold}${userCode}${c.reset}`,
158
+ );
159
+ console.log();
160
+ return verificationUrl;
308
161
  }
309
162
 
310
- // ── Commands ────────────────────────────────────────────────────────────
163
+ function printLoginFallback() {
164
+ console.error(` ${c.dim}You can also copy an API key from the dashboard and run${c.reset} ${c.cyan}subc update-key <your-api-key>${c.reset}${c.dim}.${c.reset}`);
165
+ }
166
+
167
+ async function saveLoginKey(profileName, token) {
168
+ if (profileName === DEFAULT_PROFILE) {
169
+ const config = await loadConfig();
170
+ config.subconscious_api_key = token;
171
+ await saveConfig(config);
172
+ }
173
+ return ensureProfile(profileName, token);
174
+ }
311
175
 
312
176
  export async function loginCommand(_argv = [], options = {}) {
313
177
  const profileName = options.profileName || DEFAULT_PROFILE;
314
178
  const existing = await getApiKey(options.profile);
179
+ const fetchImpl = options.fetchImpl || fetch;
315
180
 
316
181
  if (existing) {
317
182
  const profile = await ensureProfile(profileName, existing.key);
@@ -321,40 +186,29 @@ export async function loginCommand(_argv = [], options = {}) {
321
186
  console.log(`\n${c.yellow}Already logged in.${c.reset}`);
322
187
  console.log(` Key: ${c.dim}${masked}${c.reset}`);
323
188
  console.log(` Profile: ${c.dim}${profile.path}${c.reset}`);
324
- console.log(
325
- `\n Run ${c.cyan}${logout}${c.reset} first to switch accounts.\n`,
326
- );
189
+ console.log(`\n Run ${c.cyan}${logout}${c.reset} first to switch accounts.\n`);
327
190
  return;
328
191
  }
329
192
 
330
193
  console.log();
331
- console.log(
332
- ` ${c.magenta}${c.bold}Subconscious${c.reset} ${c.dim}— CLI Login${c.reset}`,
333
- );
194
+ console.log(` ${c.magenta}${c.bold}Subconscious${c.reset} ${c.dim}— CLI Login${c.reset}`);
334
195
  console.log();
335
196
 
336
197
  const platformUrl = getPlatformUrl(options.profile);
337
- const loginStatus = await probeLoginPage(platformUrl);
338
- if (isLoginMissing(loginStatus)) {
339
- printLoginUpgradeWarning();
198
+ let registered;
199
+ try {
200
+ registered = await registerDeviceLogin(platformUrl, fetchImpl);
201
+ } catch (error) {
202
+ console.error(` ${c.red}✗ ${error.message}${c.reset}`);
203
+ printLoginFallback();
204
+ console.log();
340
205
  process.exitCode = 1;
341
206
  return;
342
207
  }
343
208
 
344
- const state = crypto.randomBytes(16).toString('hex');
345
- const { port, promise } = await startCallbackServer(state);
346
-
347
- const authUrl = `${platformUrl}/cli/auth?port=${port}&state=${state}`;
209
+ const verificationUrl = printDeviceInstructions(platformUrl, registered.user_code);
210
+ (options.openBrowser || openBrowser)(verificationUrl);
348
211
 
349
- console.log(` ${c.dim}Opening browser to sign in...${c.reset}`);
350
- console.log();
351
- console.log(` ${c.dim}If it doesn't open, visit:${c.reset}`);
352
- console.log(` ${c.underline}${c.cyan}${authUrl}${c.reset}`);
353
- console.log();
354
-
355
- openBrowser(authUrl);
356
-
357
- // Spinner while waiting
358
212
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
359
213
  let i = 0;
360
214
  const spinner = setInterval(() => {
@@ -363,39 +217,53 @@ export async function loginCommand(_argv = [], options = {}) {
363
217
  );
364
218
  }, 80);
365
219
 
366
- process.on('SIGINT', () => {
367
- clearInterval(spinner);
368
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
369
- console.log(`\n ${c.dim}Login cancelled.${c.reset}\n`);
370
- process.exit(0);
371
- });
220
+ const started = Date.now();
221
+ let cancelled = false;
222
+ const onSigint = () => {
223
+ cancelled = true;
224
+ };
225
+ process.on('SIGINT', onSigint);
226
+
372
227
  try {
373
- const result = await promise;
228
+ while (!cancelled) {
229
+ if (Date.now() - started > DEVICE_POLL_TIMEOUT_MS) {
230
+ throw new Error('Authentication timed out (5 min). Please try again.');
231
+ }
232
+ const polled = await pollDeviceLogin(platformUrl, registered.device_code, fetchImpl);
233
+ if (polled.status === 'approved') {
234
+ clearInterval(spinner);
235
+ process.stdout.write('\r' + ' '.repeat(50) + '\r');
236
+ const profile = await saveLoginKey(profileName, polled.key);
237
+ const masked = polled.key.slice(0, 8) + '...' + polled.key.slice(-4);
238
+ console.log(` ${c.green}${c.bold}✓ Logged in successfully!${c.reset}`);
239
+ console.log(` ${c.dim}Key: ${masked}${c.reset}`);
240
+ if (profileName === DEFAULT_PROFILE) {
241
+ console.log(` ${c.dim}Saved to ~/.subconscious/config.json${c.reset}`);
242
+ }
243
+ console.log(` ${c.dim}Runbook profile: ${profile.path}${c.reset}`);
244
+ console.log(` ${c.dim}Launch a terminal agent with ${c.reset}${c.cyan}subc claude${c.reset}${c.dim}, or install editor hooks with ${c.reset}${c.cyan}subc cursor install${c.reset}${c.dim}.${c.reset}`);
245
+ console.log(` ${c.dim}Pi needs ${c.reset}${c.cyan}subc pi install${c.reset}${c.dim} first. List profiles with ${c.reset}${c.cyan}subc config${c.reset}${c.dim}.${c.reset}`);
246
+ console.log();
247
+ return;
248
+ }
249
+ if (polled.status === 'expired') {
250
+ throw new Error('Login code expired. Please try again.');
251
+ }
252
+ await new Promise((resolve) => setTimeout(resolve, DEVICE_POLL_INTERVAL_MS));
253
+ }
374
254
  clearInterval(spinner);
375
255
  process.stdout.write('\r' + ' '.repeat(50) + '\r');
376
-
377
- if (profileName === DEFAULT_PROFILE) {
378
- const config = await loadConfig();
379
- config.subconscious_api_key = result.token;
380
- await saveConfig(config);
381
- }
382
- const profile = await ensureProfile(profileName, result.token);
383
-
384
- const masked = result.token.slice(0, 8) + '...' + result.token.slice(-4);
385
- console.log(` ${c.green}${c.bold}✓ Logged in successfully!${c.reset}`);
386
- console.log(` ${c.dim}Key: ${masked}${c.reset}`);
387
- if (profileName === DEFAULT_PROFILE) {
388
- console.log(` ${c.dim}Saved to ~/.subconscious/config.json${c.reset}`);
389
- }
390
- console.log(` ${c.dim}Runbook profile: ${profile.path}${c.reset}`);
391
- console.log(` ${c.dim}Launch a terminal agent with ${c.reset}${c.cyan}subc claude${c.reset}${c.dim}, or install editor hooks with ${c.reset}${c.cyan}subc cursor install${c.reset}${c.dim}.${c.reset}`);
392
- console.log(` ${c.dim}Pi needs ${c.reset}${c.cyan}subc pi install${c.reset}${c.dim} first. List profiles with ${c.reset}${c.cyan}subc config${c.reset}${c.dim}.${c.reset}`);
393
- console.log();
256
+ console.log(`\n ${c.dim}Login cancelled.${c.reset}\n`);
257
+ process.exitCode = 1;
394
258
  } catch (error) {
395
259
  clearInterval(spinner);
396
260
  process.stdout.write('\r' + ' '.repeat(50) + '\r');
397
- console.error(` ${c.red}✗ ${error.message}${c.reset}\n`);
398
- process.exit(1);
261
+ console.error(` ${c.red}✗ ${error.message}${c.reset}`);
262
+ printLoginFallback();
263
+ console.log();
264
+ process.exitCode = 1;
265
+ } finally {
266
+ process.off('SIGINT', onSigint);
399
267
  }
400
268
  }
401
269
 
package/bin/cli.js CHANGED
@@ -119,6 +119,8 @@ Usage:
119
119
  subc login help
120
120
 
121
121
  Authenticate and save an API key to the selected profile.
122
+ Opens a browser to a one-time login link. If the browser does not open, use the URL printed in the terminal.
123
+ To sign in without a browser, copy an API key from the dashboard and run subc update-key <your-api-key>.
122
124
  `,
123
125
  logout: `
124
126
  Usage:
@@ -280,10 +282,20 @@ async function main() {
280
282
  return;
281
283
  }
282
284
 
283
- const update = await showUpdateNotice();
284
- if (update?.action === 'updated' || update?.action === 'cancel') return;
285
+ const launchTui =
286
+ !command &&
287
+ process.stdin.isTTY === true &&
288
+ process.stdout.isTTY === true &&
289
+ process.env.TERM !== 'dumb';
290
+
291
+ // The TUI opens immediately and surfaces catalog, session, and upgrade
292
+ // status itself. Keep the blocking npm prompt for explicit commands.
293
+ if (!launchTui) {
294
+ const update = await showUpdateNotice();
295
+ if (update?.action === 'updated' || update?.action === 'cancel') return;
296
+ }
285
297
 
286
- if (!command && process.stdin.isTTY === true && process.stdout.isTTY === true && process.env.TERM !== 'dumb') {
298
+ if (launchTui) {
287
299
  const selection = await runTui({ profileName });
288
300
  if (!selection?.args?.length) return;
289
301
  if (selection.baseUrl?.trim()) {