archbyte 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/auth.js CHANGED
@@ -4,6 +4,7 @@ import * as http from "http";
4
4
  import { spawn } from "child_process";
5
5
  import chalk from "chalk";
6
6
  import { CONFIG_DIR, CREDENTIALS_PATH, API_BASE, CLI_CALLBACK_PORT, OAUTH_TIMEOUT_MS, } from "./constants.js";
7
+ import { confirm, select, textInput } from "./ui.js";
7
8
  export async function handleLogin(provider) {
8
9
  console.log();
9
10
  console.log(chalk.bold.cyan("ArchByte Login"));
@@ -32,10 +33,39 @@ export async function handleLogin(provider) {
32
33
  // Offline — use cached tier
33
34
  }
34
35
  console.log(chalk.green(`Already logged in as ${chalk.bold(existing.email)} (${existing.tier} tier)`));
35
- console.log(chalk.gray("Run `archbyte logout` to sign out."));
36
+ console.log();
37
+ const addAnother = await confirm("Add a different account?");
38
+ if (!addAnother) {
39
+ console.log(chalk.gray("Run `archbyte logout` to sign out."));
40
+ return;
41
+ }
42
+ console.log();
43
+ }
44
+ // Pick provider: use explicit flag, or show interactive picker
45
+ let selectedProvider = provider ?? "github";
46
+ if (!provider) {
47
+ const providers = ["GitHub", "Google", "Email & Password"];
48
+ const idx = await select("Sign in with:", providers);
49
+ selectedProvider = ["github", "google", "email"][idx];
50
+ console.log();
51
+ }
52
+ // Email/password flow — direct API call, no browser
53
+ if (selectedProvider === "email") {
54
+ try {
55
+ const credentials = await emailPasswordFlow();
56
+ saveCredentials(credentials);
57
+ cacheVerifiedTier(credentials.tier === "premium" ? "premium" : "free", credentials.email);
58
+ console.log();
59
+ console.log(chalk.green(`Logged in as ${chalk.bold(credentials.email)} (${credentials.tier} tier)`));
60
+ console.log(chalk.gray(`Credentials saved to ${CREDENTIALS_PATH}`));
61
+ }
62
+ catch (err) {
63
+ console.error(chalk.red(`Login failed: ${err instanceof Error ? err.message : "Unknown error"}`));
64
+ process.exit(1);
65
+ }
36
66
  return;
37
67
  }
38
- const selectedProvider = provider ?? "github";
68
+ // OAuth flow (GitHub / Google)
39
69
  console.log(chalk.gray(`Opening browser for ${selectedProvider} sign-in...`));
40
70
  console.log();
41
71
  try {
@@ -92,26 +122,57 @@ export async function handleLoginWithToken(token) {
92
122
  }
93
123
  }
94
124
  // === Logout ===
95
- export async function handleLogout() {
125
+ export async function handleLogout(options) {
96
126
  console.log();
97
- const creds = loadCredentials();
98
- if (!creds) {
127
+ const store = loadStore();
128
+ if (!store) {
99
129
  console.log(chalk.gray("Not logged in."));
100
130
  return;
101
131
  }
102
- try {
103
- fs.unlinkSync(CREDENTIALS_PATH);
132
+ const emails = Object.keys(store.accounts);
133
+ // Logout all accounts
134
+ if (options?.all) {
135
+ try {
136
+ fs.unlinkSync(CREDENTIALS_PATH);
137
+ }
138
+ catch { /* Ignore */ }
139
+ try {
140
+ fs.unlinkSync(TIER_CACHE_PATH);
141
+ }
142
+ catch { /* Ignore */ }
143
+ resetOfflineActions();
144
+ console.log(chalk.green(`Logged out of ${emails.length} account${emails.length === 1 ? "" : "s"}.`));
145
+ return;
104
146
  }
105
- catch {
106
- // Ignore
147
+ // Determine which account to remove
148
+ const targetEmail = options?.email ?? store.active;
149
+ if (!store.accounts[targetEmail]) {
150
+ console.log(chalk.yellow(`No account found for ${targetEmail}.`));
151
+ return;
107
152
  }
108
- // Clean up tier cache and offline action tracker
109
- try {
110
- fs.unlinkSync(TIER_CACHE_PATH);
153
+ delete store.accounts[targetEmail];
154
+ const remaining = Object.keys(store.accounts);
155
+ if (remaining.length === 0) {
156
+ // Last account — delete the file entirely
157
+ try {
158
+ fs.unlinkSync(CREDENTIALS_PATH);
159
+ }
160
+ catch { /* Ignore */ }
161
+ try {
162
+ fs.unlinkSync(TIER_CACHE_PATH);
163
+ }
164
+ catch { /* Ignore */ }
165
+ resetOfflineActions();
111
166
  }
112
- catch { /* Ignore */ }
113
- resetOfflineActions();
114
- console.log(chalk.green(`Logged out (was ${creds.email}).`));
167
+ else {
168
+ // Auto-switch if we removed the active account
169
+ if (store.active === targetEmail) {
170
+ store.active = remaining[0];
171
+ console.log(chalk.gray(`Switched active account to ${store.active}`));
172
+ }
173
+ saveStore(store);
174
+ }
175
+ console.log(chalk.green(`Logged out (was ${targetEmail}).`));
115
176
  }
116
177
  // === Status ===
117
178
  export async function handleStatus() {
@@ -145,32 +206,105 @@ export async function handleStatus() {
145
206
  }
146
207
  console.log();
147
208
  }
209
+ // === Accounts ===
210
+ export async function handleAccounts() {
211
+ console.log();
212
+ console.log(chalk.bold.cyan("ArchByte Accounts"));
213
+ console.log();
214
+ const store = loadStore();
215
+ if (!store || Object.keys(store.accounts).length === 0) {
216
+ console.log(chalk.yellow("No accounts. Run `archbyte login` to sign in."));
217
+ return;
218
+ }
219
+ for (const [email, creds] of Object.entries(store.accounts)) {
220
+ const isActive = email === store.active;
221
+ const marker = isActive ? chalk.green("*") : " ";
222
+ const tier = creds.tier === "premium" ? chalk.green("Pro") : "Basic";
223
+ const expired = isExpired(creds) ? chalk.red(" (expired)") : "";
224
+ console.log(` ${marker} ${chalk.bold(email)} ${tier}${expired}`);
225
+ }
226
+ console.log();
227
+ console.log(chalk.gray("* = active account"));
228
+ console.log(chalk.gray("Switch: archbyte accounts switch [email]"));
229
+ console.log();
230
+ }
231
+ export async function handleAccountSwitch(email) {
232
+ console.log();
233
+ const store = loadStore();
234
+ if (!store || Object.keys(store.accounts).length === 0) {
235
+ console.log(chalk.yellow("No accounts. Run `archbyte login` to sign in."));
236
+ return;
237
+ }
238
+ const emails = Object.keys(store.accounts);
239
+ if (emails.length === 1) {
240
+ console.log(chalk.gray(`Only one account: ${emails[0]}`));
241
+ return;
242
+ }
243
+ let target = email;
244
+ if (!target) {
245
+ // Interactive selection
246
+ const idx = await select("Switch to account:", emails.map((e) => {
247
+ const active = e === store.active ? chalk.green(" (active)") : "";
248
+ const tier = store.accounts[e].tier === "premium" ? " Pro" : "";
249
+ return `${e}${tier}${active}`;
250
+ }));
251
+ target = emails[idx];
252
+ }
253
+ if (!store.accounts[target]) {
254
+ console.log(chalk.red(`Account not found: ${target}`));
255
+ console.log(chalk.gray(`Available: ${emails.join(", ")}`));
256
+ return;
257
+ }
258
+ if (store.active === target) {
259
+ console.log(chalk.gray(`Already active: ${target}`));
260
+ return;
261
+ }
262
+ store.active = target;
263
+ saveStore(store);
264
+ console.log(chalk.green(`Switched to ${chalk.bold(target)}`));
265
+ }
148
266
  // === Credential Helpers (exported for license gate) ===
149
- export function loadCredentials() {
267
+ function isMultiAccountStore(data) {
268
+ return (typeof data === "object" &&
269
+ data !== null &&
270
+ data.version === 2 &&
271
+ typeof data.active === "string" &&
272
+ typeof data.accounts === "object" &&
273
+ data.accounts !== null);
274
+ }
275
+ function loadStore() {
150
276
  try {
151
- if (fs.existsSync(CREDENTIALS_PATH)) {
152
- const data = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
153
- // Validate structure reject malformed credentials
154
- if (typeof data?.token !== "string" ||
155
- typeof data?.email !== "string" ||
156
- typeof data?.tier !== "string" ||
157
- typeof data?.expiresAt !== "string") {
158
- return null;
159
- }
160
- return data;
277
+ if (!fs.existsSync(CREDENTIALS_PATH))
278
+ return null;
279
+ const raw = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf-8"));
280
+ // Already multi-account format
281
+ if (isMultiAccountStore(raw))
282
+ return raw;
283
+ // Auto-migrate old single-account format
284
+ if (typeof raw?.token === "string" &&
285
+ typeof raw?.email === "string" &&
286
+ typeof raw?.tier === "string" &&
287
+ typeof raw?.expiresAt === "string") {
288
+ const creds = raw;
289
+ const store = {
290
+ version: 2,
291
+ active: creds.email,
292
+ accounts: { [creds.email]: creds },
293
+ };
294
+ saveStore(store);
295
+ return store;
161
296
  }
297
+ return null;
162
298
  }
163
299
  catch {
164
- // Ignore — corrupt file treated as no credentials
300
+ return null;
165
301
  }
166
- return null;
167
302
  }
168
- function saveCredentials(creds) {
303
+ function saveStore(store) {
169
304
  if (!fs.existsSync(CONFIG_DIR)) {
170
305
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
171
306
  }
172
- fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), "utf-8");
173
- // Restrict permissions (owner-only read/write)
307
+ fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(store, null, 2), "utf-8");
174
308
  try {
175
309
  fs.chmodSync(CREDENTIALS_PATH, 0o600);
176
310
  }
@@ -178,8 +312,41 @@ function saveCredentials(creds) {
178
312
  // Windows doesn't support chmod
179
313
  }
180
314
  }
315
+ export function loadCredentials() {
316
+ const store = loadStore();
317
+ if (!store)
318
+ return null;
319
+ const creds = store.accounts[store.active];
320
+ if (!creds ||
321
+ typeof creds.token !== "string" ||
322
+ typeof creds.email !== "string" ||
323
+ typeof creds.tier !== "string" ||
324
+ typeof creds.expiresAt !== "string") {
325
+ return null;
326
+ }
327
+ return creds;
328
+ }
329
+ function saveCredentials(creds) {
330
+ const store = loadStore() ?? {
331
+ version: 2,
332
+ active: creds.email,
333
+ accounts: {},
334
+ };
335
+ store.accounts[creds.email] = creds;
336
+ store.active = creds.email;
337
+ saveStore(store);
338
+ }
339
+ /**
340
+ * Check if credentials are expired. Treats invalid/unparseable dates
341
+ * as expired (fail-closed) to prevent corrupted files from bypassing
342
+ * expiry checks.
343
+ */
181
344
  function isExpired(creds) {
182
- return new Date(creds.expiresAt) < new Date();
345
+ const expiry = new Date(creds.expiresAt);
346
+ // Invalid Date — treat as expired (fail-closed)
347
+ if (isNaN(expiry.getTime()))
348
+ return true;
349
+ return expiry < new Date();
183
350
  }
184
351
  function parseJWTPayload(token) {
185
352
  try {
@@ -268,6 +435,9 @@ const OFFLINE_MAX_FREE = 0; // Free users: 0 offline actions (must verify online
268
435
  /**
269
436
  * Check if an offline action is allowed. Returns true if within limits.
270
437
  * Increments the counter when allowed.
438
+ *
439
+ * Uses atomic write-to-temp-then-rename to prevent race conditions
440
+ * between concurrent CLI invocations.
271
441
  */
272
442
  export function checkOfflineAction() {
273
443
  const tier = getVerifiedTier();
@@ -293,13 +463,15 @@ export function checkOfflineAction() {
293
463
  reason: `Offline action limit reached (${maxActions}/${maxActions}). Reconnect to the license server to continue.`,
294
464
  };
295
465
  }
296
- // Increment and save
466
+ // Increment and save atomically (write to temp file, then rename)
297
467
  data.count++;
298
- fs.writeFileSync(OFFLINE_ACTIONS_PATH, JSON.stringify(data), "utf-8");
468
+ const tmpPath = OFFLINE_ACTIONS_PATH + `.${process.pid}.tmp`;
469
+ fs.writeFileSync(tmpPath, JSON.stringify(data), "utf-8");
299
470
  try {
300
- fs.chmodSync(OFFLINE_ACTIONS_PATH, 0o600);
471
+ fs.chmodSync(tmpPath, 0o600);
301
472
  }
302
473
  catch { /* Windows */ }
474
+ fs.renameSync(tmpPath, OFFLINE_ACTIONS_PATH);
303
475
  return { allowed: true };
304
476
  }
305
477
  catch {
@@ -319,54 +491,121 @@ export function resetOfflineActions() {
319
491
  // Non-critical
320
492
  }
321
493
  }
494
+ // === Email/Password Flow ===
495
+ async function emailPasswordFlow() {
496
+ const email = await textInput("Email");
497
+ if (!email)
498
+ throw new Error("Email is required.");
499
+ const password = await textInput("Password", { mask: true });
500
+ if (!password)
501
+ throw new Error("Password is required.");
502
+ const res = await fetch(`${API_BASE}/api/v1/auth/login`, {
503
+ method: "POST",
504
+ headers: { "Content-Type": "application/json" },
505
+ body: JSON.stringify({ email, password }),
506
+ });
507
+ if (res.status === 401) {
508
+ console.log();
509
+ console.log(chalk.yellow("No account found with those credentials."));
510
+ const shouldSignUp = await confirm("Create a new account?");
511
+ if (!shouldSignUp)
512
+ throw new Error("Login cancelled.");
513
+ return emailSignupFlow(email, password);
514
+ }
515
+ if (!res.ok) {
516
+ const body = await res.json().catch(() => ({ error: "Unknown error" }));
517
+ throw new Error(body.error ?? `Login failed (${res.status})`);
518
+ }
519
+ return parseTokenResponse(await res.json());
520
+ }
521
+ async function emailSignupFlow(email, password) {
522
+ const name = await textInput("Name (optional)");
523
+ const res = await fetch(`${API_BASE}/api/v1/auth/signup`, {
524
+ method: "POST",
525
+ headers: { "Content-Type": "application/json" },
526
+ body: JSON.stringify({ email, password, name: name || undefined }),
527
+ });
528
+ if (!res.ok) {
529
+ const body = await res.json().catch(() => ({ error: "Unknown error" }));
530
+ throw new Error(body.error ?? `Signup failed (${res.status})`);
531
+ }
532
+ return parseTokenResponse(await res.json());
533
+ }
534
+ function parseTokenResponse(data) {
535
+ const { token, user } = data;
536
+ const payload = parseJWTPayload(token);
537
+ return {
538
+ token,
539
+ email: user.email,
540
+ tier: user.tier,
541
+ expiresAt: payload?.exp
542
+ ? new Date(payload.exp * 1000).toISOString()
543
+ : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
544
+ };
545
+ }
322
546
  // === OAuth Flow ===
323
547
  function startOAuthFlow(provider = "github") {
324
548
  return new Promise((resolve, reject) => {
549
+ let resolved = false;
325
550
  const timeout = setTimeout(() => {
326
551
  server.close();
327
- reject(new Error("Login timed out (60s). Try again or use --token."));
552
+ if (!resolved) {
553
+ resolved = true;
554
+ reject(new Error("Login timed out (60s). Try again or use --token."));
555
+ }
328
556
  }, OAUTH_TIMEOUT_MS);
329
557
  const server = http.createServer(async (req, res) => {
330
558
  const url = new URL(req.url ?? "/", `http://localhost:${CLI_CALLBACK_PORT}`);
331
- if (url.pathname === "/callback") {
332
- // Extract token from raw query string, not URLSearchParams
333
- // (URLSearchParams decodes '+' as space per x-www-form-urlencoded, corrupting JWT signatures)
334
- const rawQuery = (req.url ?? "").split("?")[1] ?? "";
335
- const tokenMatch = rawQuery.match(/(?:^|&)token=([^&]+)/);
336
- const token = tokenMatch ? decodeURIComponent(tokenMatch[1]) : null;
337
- if (!token) {
338
- res.writeHead(400, { "Content-Type": "text/html" });
339
- res.end("<h1>Login failed</h1><p>No token received. Close this window and try again.</p>");
340
- return;
341
- }
559
+ // Only handle /callback — return 404 for everything else
560
+ if (url.pathname !== "/callback") {
561
+ res.writeHead(404, { "Content-Type": "text/html" });
562
+ res.end("<h1>Not found</h1><p>This server only handles OAuth callbacks.</p>");
563
+ return;
564
+ }
565
+ // Prevent double-processing (e.g. browser retry, double-click)
566
+ if (resolved) {
342
567
  res.writeHead(200, { "Content-Type": "text/html" });
343
- res.end("<h1>Login successful!</h1><p>You can close this window and return to your terminal.</p>");
344
- clearTimeout(timeout);
345
- server.close();
346
- // Fetch user info with the token
347
- try {
348
- const meRes = await fetch(`${API_BASE}/api/v1/me`, {
349
- headers: { Authorization: `Bearer ${token}` },
350
- });
351
- if (!meRes.ok) {
352
- const errBody = await meRes.text().catch(() => "");
353
- reject(new Error(`Failed to fetch user info (${meRes.status}: ${errBody})`));
354
- return;
355
- }
356
- const { user } = (await meRes.json());
357
- const payload = parseJWTPayload(token);
358
- resolve({
359
- token,
360
- email: user.email,
361
- tier: user.tier,
362
- expiresAt: payload?.exp
363
- ? new Date(payload.exp * 1000).toISOString()
364
- : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
365
- });
366
- }
367
- catch (err) {
368
- reject(err);
568
+ res.end("<h1>Already processed</h1><p>You can close this window.</p>");
569
+ return;
570
+ }
571
+ // Extract token from raw query string, not URLSearchParams
572
+ // (URLSearchParams decodes '+' as space per x-www-form-urlencoded, corrupting JWT signatures)
573
+ const rawQuery = (req.url ?? "").split("?")[1] ?? "";
574
+ const tokenMatch = rawQuery.match(/(?:^|&)token=([^&]+)/);
575
+ const token = tokenMatch ? decodeURIComponent(tokenMatch[1]) : null;
576
+ if (!token) {
577
+ res.writeHead(400, { "Content-Type": "text/html" });
578
+ res.end("<h1>Login failed</h1><p>No token received. Close this window and try again.</p>");
579
+ return;
580
+ }
581
+ res.writeHead(200, { "Content-Type": "text/html" });
582
+ res.end("<h1>Login successful!</h1><p>You can close this window and return to your terminal.</p>");
583
+ resolved = true;
584
+ clearTimeout(timeout);
585
+ server.close();
586
+ // Fetch user info with the token
587
+ try {
588
+ const meRes = await fetch(`${API_BASE}/api/v1/me`, {
589
+ headers: { Authorization: `Bearer ${token}` },
590
+ });
591
+ if (!meRes.ok) {
592
+ const errBody = await meRes.text().catch(() => "");
593
+ reject(new Error(`Failed to fetch user info (${meRes.status}: ${errBody})`));
594
+ return;
369
595
  }
596
+ const { user } = (await meRes.json());
597
+ const payload = parseJWTPayload(token);
598
+ resolve({
599
+ token,
600
+ email: user.email,
601
+ tier: user.tier,
602
+ expiresAt: payload?.exp
603
+ ? new Date(payload.exp * 1000).toISOString()
604
+ : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
605
+ });
606
+ }
607
+ catch (err) {
608
+ reject(err);
370
609
  }
371
610
  });
372
611
  server.listen(CLI_CALLBACK_PORT, "127.0.0.1", () => {
@@ -1,6 +1,7 @@
1
1
  import type { ArchByteConfig } from "../agents/runtime/types.js";
2
2
  interface ConfigOptions {
3
3
  args: string[];
4
+ raw?: boolean;
4
5
  }
5
6
  export declare function handleConfig(options: ConfigOptions): Promise<void>;
6
7
  /**
@@ -1,7 +1,9 @@
1
1
  import * as fs from "fs";
2
+ import { execSync } from "child_process";
2
3
  import chalk from "chalk";
3
4
  import { CONFIG_DIR, CONFIG_PATH } from "./constants.js";
4
- const VALID_PROVIDERS = ["anthropic", "openai", "google"];
5
+ import { maskKey } from "./utils.js";
6
+ const VALID_PROVIDERS = ["anthropic", "openai", "google", "claude-sdk"];
5
7
  export async function handleConfig(options) {
6
8
  const [action, key, value] = options.args;
7
9
  if (!action || action === "show") {
@@ -22,7 +24,7 @@ export async function handleConfig(options) {
22
24
  console.error(chalk.red("Usage: archbyte config get <key>"));
23
25
  process.exit(1);
24
26
  }
25
- getConfig(key);
27
+ getConfig(key, options.raw);
26
28
  return;
27
29
  }
28
30
  if (action === "path") {
@@ -52,6 +54,13 @@ function saveConfig(config) {
52
54
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
53
55
  }
54
56
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
57
+ // Restrict permissions — config contains API keys in profiles
58
+ try {
59
+ fs.chmodSync(CONFIG_PATH, 0o600);
60
+ }
61
+ catch {
62
+ // Windows doesn't support chmod
63
+ }
55
64
  }
56
65
  function showConfig() {
57
66
  const config = loadConfig();
@@ -66,7 +75,8 @@ function showConfig() {
66
75
  console.log(chalk.gray(" archbyte init"));
67
76
  return;
68
77
  }
69
- console.log(` ${chalk.bold("provider")}: ${config.provider ?? chalk.gray("not set")}`);
78
+ const providerDisplay = config.provider === "claude-sdk" ? "Claude Code (SDK)" : config.provider;
79
+ console.log(` ${chalk.bold("provider")}: ${providerDisplay ?? chalk.gray("not set")}`);
70
80
  // Show profiles
71
81
  if (configured.length > 0) {
72
82
  console.log();
@@ -100,8 +110,10 @@ function setConfig(key, value) {
100
110
  process.exit(1);
101
111
  }
102
112
  config.provider = value;
103
- // Check if this provider has a stored profile
104
- if (profiles[value]?.apiKey) {
113
+ if (value === "claude-sdk") {
114
+ console.log(chalk.green("Switched to Claude Code (SDK) — no API key needed."));
115
+ }
116
+ else if (profiles[value]?.apiKey) {
105
117
  console.log(chalk.green(`Switched to ${value} (credentials on file)`));
106
118
  }
107
119
  else {
@@ -149,7 +161,7 @@ function setConfig(key, value) {
149
161
  console.log(chalk.green(`Set ${key} = ${key.includes("key") ? maskKey(value) : value}`));
150
162
  }
151
163
  }
152
- function getConfig(key) {
164
+ function getConfig(key, raw = false) {
153
165
  const config = loadConfig();
154
166
  const profiles = (config.profiles ?? {});
155
167
  const active = config.provider;
@@ -160,9 +172,13 @@ function getConfig(key) {
160
172
  break;
161
173
  case "api-key":
162
174
  case "apiKey":
163
- case "key":
164
- console.log(profile?.apiKey ?? config.apiKey ?? "");
175
+ case "key": {
176
+ const apiKey = profile?.apiKey ?? config.apiKey ?? "";
177
+ // Mask by default to prevent accidental exposure in logs/recordings.
178
+ // Use `archbyte config get api-key --raw` for the unmasked value.
179
+ console.log(raw ? apiKey : (apiKey ? maskKey(apiKey) : ""));
165
180
  break;
181
+ }
166
182
  case "model":
167
183
  console.log(profile?.model ?? config.model ?? "");
168
184
  break;
@@ -171,11 +187,6 @@ function getConfig(key) {
171
187
  process.exit(1);
172
188
  }
173
189
  }
174
- function maskKey(key) {
175
- if (key.length <= 8)
176
- return "****";
177
- return key.slice(0, 6) + "..." + key.slice(-4);
178
- }
179
190
  /**
180
191
  * Resolve the full ArchByteConfig from config file + env vars.
181
192
  * Supports profiles (new) and legacy flat config (backward compat).
@@ -184,8 +195,11 @@ function maskKey(key) {
184
195
  export function resolveConfig() {
185
196
  const config = loadConfig();
186
197
  const provider = process.env.ARCHBYTE_PROVIDER ?? config.provider;
187
- // Reject unknown providers
198
+ // Reject unknown providers with a helpful message
188
199
  if (provider && !VALID_PROVIDERS.includes(provider)) {
200
+ if (process.env.ARCHBYTE_PROVIDER) {
201
+ console.error(chalk.red(`Invalid ARCHBYTE_PROVIDER="${provider}". Must be: ${VALID_PROVIDERS.join(", ")}`));
202
+ }
189
203
  return null;
190
204
  }
191
205
  // Resolve API key + model from profiles first, then legacy flat keys
@@ -193,8 +207,20 @@ export function resolveConfig() {
193
207
  const profile = provider ? profiles[provider] : undefined;
194
208
  const apiKey = process.env.ARCHBYTE_API_KEY ?? profile?.apiKey ?? config.apiKey;
195
209
  const model = process.env.ARCHBYTE_MODEL ?? profile?.model ?? config.model;
196
- // Auto-detect from known env vars if nothing explicit
210
+ // claude-sdk provider doesn't need an API key
211
+ if (provider === "claude-sdk") {
212
+ return {
213
+ provider,
214
+ model: model,
215
+ };
216
+ }
217
+ // Auto-detect if nothing explicitly configured
197
218
  if (!provider && !apiKey) {
219
+ // Claude Code on PATH → zero-config, preferred
220
+ if (isClaudeCodeOnPath()) {
221
+ return { provider: "claude-sdk" };
222
+ }
223
+ // Fall back to API key env vars
198
224
  if (process.env.ANTHROPIC_API_KEY) {
199
225
  return { provider: "anthropic", apiKey: process.env.ANTHROPIC_API_KEY };
200
226
  }
@@ -217,3 +243,13 @@ export function resolveConfig() {
217
243
  model: model,
218
244
  };
219
245
  }
246
+ function isClaudeCodeOnPath() {
247
+ try {
248
+ const cmd = process.platform === "win32" ? "where claude" : "which claude";
249
+ execSync(cmd, { stdio: "pipe" });
250
+ return true;
251
+ }
252
+ catch {
253
+ return false;
254
+ }
255
+ }
@@ -1,6 +1,7 @@
1
1
  // Shared constants for the ArchByte CLI.
2
2
  // Single source of truth for URLs, ports, paths, and timeouts.
3
3
  import * as path from "path";
4
+ import { resolveHome } from "./utils.js";
4
5
  // ─── API ───
5
6
  export const API_BASE = process.env.ARCHBYTE_API_URL ?? "https://api.heartbyte.io";
6
7
  export const SITE_URL = "https://archbyte.heartbyte.io";
@@ -8,7 +9,9 @@ export const SITE_URL = "https://archbyte.heartbyte.io";
8
9
  export const DEFAULT_PORT = 3847;
9
10
  export const CLI_CALLBACK_PORT = 19274;
10
11
  // ─── Paths ───
11
- export const CONFIG_DIR = path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".archbyte");
12
+ // resolveHome() throws if HOME/USERPROFILE is unset (e.g. in bare containers),
13
+ // giving a clear error instead of silently writing to "./.archbyte".
14
+ export const CONFIG_DIR = path.join(resolveHome(), ".archbyte");
12
15
  export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
13
16
  export const CREDENTIALS_PATH = path.join(CONFIG_DIR, "credentials.json");
14
17
  /** Project-local .archbyte directory name */