archbyte 0.3.3 → 0.3.5

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/bin/archbyte.js CHANGED
@@ -32,17 +32,15 @@ const program = new Command();
32
32
 
33
33
  program
34
34
  .name('archbyte')
35
- .description('ArchByte - See what agents build. As they build it.')
36
- .version(PKG_VERSION)
35
+ .description('ArchByte - AI architecture analysis with an interactive diagram UI')
36
+ .version(PKG_VERSION, '-v, --version', 'Show version number')
37
37
  .addHelpText('after', `
38
38
  Quick start:
39
- 1. Sign up at https://archbyte.heartbyte.io
40
- 2. $ archbyte login Sign in from the CLI
41
- 3. $ archbyte init Configure your model provider
42
- 4. $ archbyte run Analyze → generate → serve
39
+ $ archbyte login Sign in or create a free account
40
+ $ archbyte init Configure your model provider
41
+ $ archbyte run Analyze + open interactive diagram UI
43
42
 
44
43
  https://archbyte.heartbyte.io
45
- Support: archbyte@heartbyte.io
46
44
  `);
47
45
 
48
46
  // — Getting started —
@@ -220,8 +218,9 @@ program
220
218
  .argument('[action]', 'show, set, get, or path')
221
219
  .argument('[key]', 'config key (provider, api-key, model)')
222
220
  .argument('[value]', 'config value')
223
- .action(async (action, key, value) => {
224
- await handleConfig({ args: [action, key, value].filter(Boolean) });
221
+ .option('--raw', 'Show unmasked values (for scripting)')
222
+ .action(async (action, key, value, options) => {
223
+ await handleConfig({ args: [action, key, value].filter(Boolean), raw: options.raw });
225
224
  });
226
225
 
227
226
  program
@@ -34,6 +34,9 @@ export declare function getVerifiedTier(): "free" | "premium";
34
34
  /**
35
35
  * Check if an offline action is allowed. Returns true if within limits.
36
36
  * Increments the counter when allowed.
37
+ *
38
+ * Uses atomic write-to-temp-then-rename to prevent race conditions
39
+ * between concurrent CLI invocations.
37
40
  */
38
41
  export declare function checkOfflineAction(): {
39
42
  allowed: boolean;
package/dist/cli/auth.js CHANGED
@@ -178,8 +178,17 @@ function saveCredentials(creds) {
178
178
  // Windows doesn't support chmod
179
179
  }
180
180
  }
181
+ /**
182
+ * Check if credentials are expired. Treats invalid/unparseable dates
183
+ * as expired (fail-closed) to prevent corrupted files from bypassing
184
+ * expiry checks.
185
+ */
181
186
  function isExpired(creds) {
182
- return new Date(creds.expiresAt) < new Date();
187
+ const expiry = new Date(creds.expiresAt);
188
+ // Invalid Date — treat as expired (fail-closed)
189
+ if (isNaN(expiry.getTime()))
190
+ return true;
191
+ return expiry < new Date();
183
192
  }
184
193
  function parseJWTPayload(token) {
185
194
  try {
@@ -268,6 +277,9 @@ const OFFLINE_MAX_FREE = 0; // Free users: 0 offline actions (must verify online
268
277
  /**
269
278
  * Check if an offline action is allowed. Returns true if within limits.
270
279
  * Increments the counter when allowed.
280
+ *
281
+ * Uses atomic write-to-temp-then-rename to prevent race conditions
282
+ * between concurrent CLI invocations.
271
283
  */
272
284
  export function checkOfflineAction() {
273
285
  const tier = getVerifiedTier();
@@ -293,13 +305,15 @@ export function checkOfflineAction() {
293
305
  reason: `Offline action limit reached (${maxActions}/${maxActions}). Reconnect to the license server to continue.`,
294
306
  };
295
307
  }
296
- // Increment and save
308
+ // Increment and save atomically (write to temp file, then rename)
297
309
  data.count++;
298
- fs.writeFileSync(OFFLINE_ACTIONS_PATH, JSON.stringify(data), "utf-8");
310
+ const tmpPath = OFFLINE_ACTIONS_PATH + `.${process.pid}.tmp`;
311
+ fs.writeFileSync(tmpPath, JSON.stringify(data), "utf-8");
299
312
  try {
300
- fs.chmodSync(OFFLINE_ACTIONS_PATH, 0o600);
313
+ fs.chmodSync(tmpPath, 0o600);
301
314
  }
302
315
  catch { /* Windows */ }
316
+ fs.renameSync(tmpPath, OFFLINE_ACTIONS_PATH);
303
317
  return { allowed: true };
304
318
  }
305
319
  catch {
@@ -322,51 +336,66 @@ export function resetOfflineActions() {
322
336
  // === OAuth Flow ===
323
337
  function startOAuthFlow(provider = "github") {
324
338
  return new Promise((resolve, reject) => {
339
+ let resolved = false;
325
340
  const timeout = setTimeout(() => {
326
341
  server.close();
327
- reject(new Error("Login timed out (60s). Try again or use --token."));
342
+ if (!resolved) {
343
+ resolved = true;
344
+ reject(new Error("Login timed out (60s). Try again or use --token."));
345
+ }
328
346
  }, OAUTH_TIMEOUT_MS);
329
347
  const server = http.createServer(async (req, res) => {
330
348
  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
- }
349
+ // Only handle /callback — return 404 for everything else
350
+ if (url.pathname !== "/callback") {
351
+ res.writeHead(404, { "Content-Type": "text/html" });
352
+ res.end("<h1>Not found</h1><p>This server only handles OAuth callbacks.</p>");
353
+ return;
354
+ }
355
+ // Prevent double-processing (e.g. browser retry, double-click)
356
+ if (resolved) {
342
357
  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);
358
+ res.end("<h1>Already processed</h1><p>You can close this window.</p>");
359
+ return;
360
+ }
361
+ // Extract token from raw query string, not URLSearchParams
362
+ // (URLSearchParams decodes '+' as space per x-www-form-urlencoded, corrupting JWT signatures)
363
+ const rawQuery = (req.url ?? "").split("?")[1] ?? "";
364
+ const tokenMatch = rawQuery.match(/(?:^|&)token=([^&]+)/);
365
+ const token = tokenMatch ? decodeURIComponent(tokenMatch[1]) : null;
366
+ if (!token) {
367
+ res.writeHead(400, { "Content-Type": "text/html" });
368
+ res.end("<h1>Login failed</h1><p>No token received. Close this window and try again.</p>");
369
+ return;
370
+ }
371
+ res.writeHead(200, { "Content-Type": "text/html" });
372
+ res.end("<h1>Login successful!</h1><p>You can close this window and return to your terminal.</p>");
373
+ resolved = true;
374
+ clearTimeout(timeout);
375
+ server.close();
376
+ // Fetch user info with the token
377
+ try {
378
+ const meRes = await fetch(`${API_BASE}/api/v1/me`, {
379
+ headers: { Authorization: `Bearer ${token}` },
380
+ });
381
+ if (!meRes.ok) {
382
+ const errBody = await meRes.text().catch(() => "");
383
+ reject(new Error(`Failed to fetch user info (${meRes.status}: ${errBody})`));
384
+ return;
369
385
  }
386
+ const { user } = (await meRes.json());
387
+ const payload = parseJWTPayload(token);
388
+ resolve({
389
+ token,
390
+ email: user.email,
391
+ tier: user.tier,
392
+ expiresAt: payload?.exp
393
+ ? new Date(payload.exp * 1000).toISOString()
394
+ : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
395
+ });
396
+ }
397
+ catch (err) {
398
+ reject(err);
370
399
  }
371
400
  });
372
401
  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,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import chalk from "chalk";
3
3
  import { CONFIG_DIR, CONFIG_PATH } from "./constants.js";
4
+ import { maskKey } from "./utils.js";
4
5
  const VALID_PROVIDERS = ["anthropic", "openai", "google"];
5
6
  export async function handleConfig(options) {
6
7
  const [action, key, value] = options.args;
@@ -22,7 +23,7 @@ export async function handleConfig(options) {
22
23
  console.error(chalk.red("Usage: archbyte config get <key>"));
23
24
  process.exit(1);
24
25
  }
25
- getConfig(key);
26
+ getConfig(key, options.raw);
26
27
  return;
27
28
  }
28
29
  if (action === "path") {
@@ -52,6 +53,13 @@ function saveConfig(config) {
52
53
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
53
54
  }
54
55
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
56
+ // Restrict permissions — config contains API keys in profiles
57
+ try {
58
+ fs.chmodSync(CONFIG_PATH, 0o600);
59
+ }
60
+ catch {
61
+ // Windows doesn't support chmod
62
+ }
55
63
  }
56
64
  function showConfig() {
57
65
  const config = loadConfig();
@@ -149,7 +157,7 @@ function setConfig(key, value) {
149
157
  console.log(chalk.green(`Set ${key} = ${key.includes("key") ? maskKey(value) : value}`));
150
158
  }
151
159
  }
152
- function getConfig(key) {
160
+ function getConfig(key, raw = false) {
153
161
  const config = loadConfig();
154
162
  const profiles = (config.profiles ?? {});
155
163
  const active = config.provider;
@@ -160,9 +168,13 @@ function getConfig(key) {
160
168
  break;
161
169
  case "api-key":
162
170
  case "apiKey":
163
- case "key":
164
- console.log(profile?.apiKey ?? config.apiKey ?? "");
171
+ case "key": {
172
+ const apiKey = profile?.apiKey ?? config.apiKey ?? "";
173
+ // Mask by default to prevent accidental exposure in logs/recordings.
174
+ // Use `archbyte config get api-key --raw` for the unmasked value.
175
+ console.log(raw ? apiKey : (apiKey ? maskKey(apiKey) : ""));
165
176
  break;
177
+ }
166
178
  case "model":
167
179
  console.log(profile?.model ?? config.model ?? "");
168
180
  break;
@@ -171,11 +183,6 @@ function getConfig(key) {
171
183
  process.exit(1);
172
184
  }
173
185
  }
174
- function maskKey(key) {
175
- if (key.length <= 8)
176
- return "****";
177
- return key.slice(0, 6) + "..." + key.slice(-4);
178
- }
179
186
  /**
180
187
  * Resolve the full ArchByteConfig from config file + env vars.
181
188
  * Supports profiles (new) and legacy flat config (backward compat).
@@ -184,8 +191,11 @@ function maskKey(key) {
184
191
  export function resolveConfig() {
185
192
  const config = loadConfig();
186
193
  const provider = process.env.ARCHBYTE_PROVIDER ?? config.provider;
187
- // Reject unknown providers
194
+ // Reject unknown providers with a helpful message
188
195
  if (provider && !VALID_PROVIDERS.includes(provider)) {
196
+ if (process.env.ARCHBYTE_PROVIDER) {
197
+ console.error(chalk.red(`Invalid ARCHBYTE_PROVIDER="${provider}". Must be: ${VALID_PROVIDERS.join(", ")}`));
198
+ }
189
199
  return null;
190
200
  }
191
201
  // Resolve API key + model from profiles first, then legacy flat keys
@@ -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 */
@@ -1,4 +1,4 @@
1
- type GatedAction = "scan" | "analyze" | "generate";
1
+ type GatedAction = "analyze" | "generate";
2
2
  /**
3
3
  * Pre-flight license check. Must be called before scan/analyze/generate.
4
4
  *
@@ -28,8 +28,9 @@ export async function requireLicense(action) {
28
28
  console.error(chalk.gray("Free tier includes unlimited scans. No credit card required."));
29
29
  process.exit(1);
30
30
  }
31
- // Token expired locally
32
- if (new Date(creds.expiresAt) < new Date()) {
31
+ // Token expired locally (treat invalid dates as expired — fail-closed)
32
+ const expiry = new Date(creds.expiresAt);
33
+ if (isNaN(expiry.getTime()) || expiry < new Date()) {
33
34
  console.error();
34
35
  console.error(chalk.red("Session expired."));
35
36
  console.error(chalk.gray("Run `archbyte login` to refresh your session."));
package/dist/cli/mcp.js CHANGED
@@ -1,16 +1,9 @@
1
- import { execSync, spawnSync } from "child_process";
1
+ import { spawnSync } from "child_process";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import chalk from "chalk";
5
- function isInPath(cmd) {
6
- try {
7
- execSync(`which ${cmd}`, { stdio: "ignore" });
8
- return true;
9
- }
10
- catch {
11
- return false;
12
- }
13
- }
5
+ import { isInPath } from "./utils.js";
6
+ import { CONFIG_DIR } from "./constants.js";
14
7
  export async function handleMcpInstall() {
15
8
  console.log();
16
9
  console.log(chalk.bold.cyan("ArchByte MCP Setup"));
@@ -30,7 +23,7 @@ export async function handleMcpInstall() {
30
23
  console.log();
31
24
  }
32
25
  // ─── Codex CLI ───
33
- const codexDir = path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".codex");
26
+ const codexDir = path.join(CONFIG_DIR, "../.codex");
34
27
  const codexConfig = path.join(codexDir, "config.toml");
35
28
  if (fs.existsSync(codexDir)) {
36
29
  console.log(chalk.white("Detected Codex CLI."));
@@ -46,7 +39,10 @@ export async function handleMcpInstall() {
46
39
  configured = true;
47
40
  }
48
41
  else {
49
- const block = `
42
+ // Ensure a trailing newline before appending the TOML block
43
+ // so headers don't merge with the last line of the existing file.
44
+ const needsNewline = existing.length > 0 && !existing.endsWith("\n");
45
+ const block = `${needsNewline ? "\n" : ""}
50
46
  [mcp_servers.archbyte]
51
47
  type = "stdio"
52
48
  command = "npx"
package/dist/cli/setup.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { execSync } from "child_process";
5
4
  import chalk from "chalk";
6
5
  import { resolveModel } from "../agents/runtime/types.js";
7
6
  import { createProvider } from "../agents/providers/router.js";
8
7
  import { select, spinner, confirm } from "./ui.js";
9
8
  import { CONFIG_DIR, CONFIG_PATH } from "./constants.js";
9
+ import { isInPath, maskKey, isTTY, isValidEmail } from "./utils.js";
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = path.dirname(__filename);
12
12
  const PROVIDERS = [
@@ -49,13 +49,23 @@ function saveConfig(config) {
49
49
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
50
50
  }
51
51
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
52
+ // Restrict permissions — config contains API keys in profiles
53
+ try {
54
+ fs.chmodSync(CONFIG_PATH, 0o600);
55
+ }
56
+ catch {
57
+ // Windows doesn't support chmod
58
+ }
52
59
  }
53
- function maskKey(key) {
54
- if (key.length <= 8)
55
- return "****";
56
- return key.slice(0, 6) + "..." + key.slice(-4);
57
- }
60
+ /**
61
+ * Read a line from stdin with character masking (for API keys).
62
+ * Non-TTY fallback: reads from stdin as a line (for piped input / CI).
63
+ */
58
64
  function askHidden(prompt) {
65
+ if (!isTTY()) {
66
+ process.stdout.write(prompt);
67
+ return readLineFromStdin();
68
+ }
59
69
  return new Promise((resolve) => {
60
70
  process.stdout.write(prompt);
61
71
  const stdin = process.stdin;
@@ -93,7 +103,15 @@ function askHidden(prompt) {
93
103
  stdin.on("data", onData);
94
104
  });
95
105
  }
106
+ /**
107
+ * Read a line of visible text from stdin.
108
+ * Non-TTY fallback: reads from stdin as a line (for piped input / CI).
109
+ */
96
110
  function askText(prompt) {
111
+ if (!isTTY()) {
112
+ process.stdout.write(prompt);
113
+ return readLineFromStdin();
114
+ }
97
115
  return new Promise((resolve) => {
98
116
  process.stdout.write(prompt);
99
117
  const stdin = process.stdin;
@@ -131,6 +149,32 @@ function askText(prompt) {
131
149
  stdin.on("data", onData);
132
150
  });
133
151
  }
152
+ /**
153
+ * Non-TTY line reader. Reads a single line from stdin (for piped input).
154
+ */
155
+ function readLineFromStdin() {
156
+ return new Promise((resolve) => {
157
+ const stdin = process.stdin;
158
+ stdin.resume();
159
+ stdin.setEncoding("utf8");
160
+ let buf = "";
161
+ const onData = (data) => {
162
+ buf += data;
163
+ const nl = buf.indexOf("\n");
164
+ if (nl !== -1) {
165
+ stdin.removeListener("data", onData);
166
+ stdin.pause();
167
+ resolve(buf.slice(0, nl).trim());
168
+ }
169
+ };
170
+ const onEnd = () => {
171
+ stdin.removeListener("data", onData);
172
+ resolve(buf.trim());
173
+ };
174
+ stdin.on("data", onData);
175
+ stdin.once("end", onEnd);
176
+ });
177
+ }
134
178
  async function validateProviderSilent(providerName, apiKey, model) {
135
179
  try {
136
180
  const provider = createProvider({ provider: providerName, apiKey });
@@ -157,22 +201,13 @@ async function validateProviderSilent(providerName, apiKey, model) {
157
201
  function getProfiles(config) {
158
202
  return config.profiles ?? {};
159
203
  }
160
- function isInPath(cmd) {
161
- try {
162
- execSync(`which ${cmd}`, { stdio: "ignore" });
163
- return true;
164
- }
165
- catch {
166
- return false;
167
- }
168
- }
169
204
  export async function handleSetup() {
170
205
  console.log();
171
206
  console.log(chalk.bold.cyan("ArchByte Setup"));
172
207
  console.log(chalk.gray("Configure your model provider and API key.\n"));
173
208
  // Detect AI coding tools — suggest MCP instead of BYOK
174
209
  const hasClaude = isInPath("claude");
175
- const codexDir = path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".codex");
210
+ const codexDir = path.join(CONFIG_DIR, "../.codex");
176
211
  const hasCodex = fs.existsSync(codexDir);
177
212
  if (hasClaude || hasCodex) {
178
213
  const tools = [hasClaude && "Claude Code", hasCodex && "Codex CLI"].filter(Boolean).join(" and ");
@@ -258,7 +293,7 @@ export async function handleSetup() {
258
293
  if (!profiles[provider])
259
294
  profiles[provider] = { apiKey: "" };
260
295
  profiles[provider].apiKey = apiKey;
261
- // Step 2b: Email for this provider account (optional)
296
+ // Step 2b: Email for this provider account (optional, with validation)
262
297
  const existingEmail = profiles[provider].email;
263
298
  if (existingEmail) {
264
299
  console.log(chalk.gray(`\n Account email: ${existingEmail}`));
@@ -270,16 +305,26 @@ export async function handleSetup() {
270
305
  if (emailIdx === 1) {
271
306
  const newEmail = await askText(chalk.bold(" Email: "));
272
307
  if (newEmail) {
273
- profiles[provider].email = newEmail;
274
- console.log(chalk.green(` ✓ Email: ${newEmail}`));
308
+ if (!isValidEmail(newEmail)) {
309
+ console.log(chalk.yellow(` "${newEmail}" doesn't look like a valid email. Skipping.`));
310
+ }
311
+ else {
312
+ profiles[provider].email = newEmail;
313
+ console.log(chalk.green(` ✓ Email: ${newEmail}`));
314
+ }
275
315
  }
276
316
  }
277
317
  }
278
318
  else {
279
319
  const email = await askText(chalk.bold(` ${selected.label} account email ${chalk.gray("(optional, Enter to skip)")}: `));
280
320
  if (email) {
281
- profiles[provider].email = email;
282
- console.log(chalk.green(` ✓ Email: ${email}`));
321
+ if (!isValidEmail(email)) {
322
+ console.log(chalk.yellow(` "${email}" doesn't look like a valid email. Skipping.`));
323
+ }
324
+ else {
325
+ profiles[provider].email = email;
326
+ console.log(chalk.green(` ✓ Email: ${email}`));
327
+ }
283
328
  }
284
329
  }
285
330
  // Step 3: Model selection
@@ -323,11 +368,14 @@ export async function handleSetup() {
323
368
  if (result === false) {
324
369
  let retries = 0;
325
370
  while (result === false && retries < 2) {
326
- if (!await confirm(" Retry?"))
371
+ if (!await confirm(" Retry with a different key?"))
327
372
  break;
328
373
  retries++;
329
374
  const newKey = await askHidden(chalk.bold(" API key: "));
330
- if (newKey) {
375
+ if (!newKey) {
376
+ console.log(chalk.yellow(" No key entered. Retrying with existing key."));
377
+ }
378
+ else {
331
379
  profiles[provider].apiKey = newKey;
332
380
  console.log(chalk.green(` ✓ API key: ${maskKey(newKey)}`));
333
381
  }
@@ -340,8 +388,71 @@ export async function handleSetup() {
340
388
  s2.stop(result ? "valid" : "invalid", result ? "green" : "red");
341
389
  }
342
390
  }
391
+ // After retries exhausted — offer to switch providers
343
392
  if (result === false) {
344
- console.log(chalk.yellow(" Warning: credentials unverified. Saving anyway."));
393
+ const others = PROVIDERS.filter((p) => p.name !== provider);
394
+ if (others.length > 0) {
395
+ console.log();
396
+ console.log(chalk.yellow(" Key didn't work. Try a different provider?"));
397
+ const switchIdx = await select(" Switch provider:", [
398
+ ...others.map((p) => `${p.label} ${chalk.gray(p.hint)}`),
399
+ chalk.gray("Skip — save as-is"),
400
+ ]);
401
+ if (switchIdx < others.length) {
402
+ const alt = others[switchIdx];
403
+ config.provider = alt.name;
404
+ console.log(chalk.green(`\n ✓ Provider: ${alt.label}`));
405
+ const altKey = await askHidden(chalk.bold(" API key: "));
406
+ if (!altKey) {
407
+ console.log(chalk.yellow(" No key entered. Keeping original provider."));
408
+ config.provider = provider;
409
+ }
410
+ else {
411
+ if (!profiles[alt.name])
412
+ profiles[alt.name] = { apiKey: "" };
413
+ profiles[alt.name].apiKey = altKey;
414
+ console.log(chalk.green(` ✓ API key: ${maskKey(altKey)}`));
415
+ // Model selection for the new provider
416
+ const altModels = PROVIDER_MODELS[alt.name];
417
+ if (altModels) {
418
+ const mIdx = await select("\n Choose a model:", altModels.map((m) => `${m.label} ${chalk.gray(m.hint)}`));
419
+ const chosen = altModels[mIdx];
420
+ if (chosen.id) {
421
+ profiles[alt.name].model = chosen.id;
422
+ console.log(chalk.green(` ✓ Model: ${chosen.label}`));
423
+ }
424
+ else {
425
+ const defaults = {
426
+ anthropic: "claude-opus-4-6",
427
+ openai: "gpt-5.2",
428
+ google: "gemini-2.5-pro",
429
+ };
430
+ const dm = defaults[alt.name] ?? resolveModel(alt.name, "standard");
431
+ profiles[alt.name].model = dm;
432
+ console.log(chalk.green(` ✓ Model: ${dm} (default)`));
433
+ }
434
+ }
435
+ const altValidationModel = profiles[alt.name].model ?? resolveModel(alt.name, "fast");
436
+ const s3 = spinner(`Validating credentials with ${altValidationModel}`);
437
+ result = await validateProviderSilent(alt.name, altKey, altValidationModel);
438
+ if (result === "quota") {
439
+ s3.stop("valid (quota exceeded — key accepted)", "yellow");
440
+ }
441
+ else {
442
+ s3.stop(result ? "valid" : "invalid", result ? "green" : "red");
443
+ }
444
+ if (result === false) {
445
+ console.log(chalk.yellow(" Warning: credentials unverified. Saving anyway."));
446
+ }
447
+ }
448
+ }
449
+ else {
450
+ console.log(chalk.yellow(" Warning: credentials unverified. Saving anyway."));
451
+ }
452
+ }
453
+ else {
454
+ console.log(chalk.yellow(" Warning: credentials unverified. Saving anyway."));
455
+ }
345
456
  }
346
457
  }
347
458
  // Clean up legacy top-level keys
@@ -370,10 +481,16 @@ export async function handleSetup() {
370
481
  catch { /* ignore */ }
371
482
  }
372
483
  const templatePath = path.resolve(__dirname, "../../templates/archbyte.yaml");
373
- let template = fs.readFileSync(templatePath, "utf-8");
374
- template = template.replace("name: my-project", `name: ${projectName}`);
375
- fs.writeFileSync(yamlPath, template, "utf-8");
376
- yamlCreated = true;
484
+ if (!fs.existsSync(templatePath)) {
485
+ console.log(chalk.yellow(" Could not find archbyte.yaml template. Skipping."));
486
+ console.log(chalk.gray(` Expected at: ${templatePath}`));
487
+ }
488
+ else {
489
+ let template = fs.readFileSync(templatePath, "utf-8");
490
+ template = template.replace("name: my-project", `name: ${projectName}`);
491
+ fs.writeFileSync(yamlPath, template, "utf-8");
492
+ yamlCreated = true;
493
+ }
377
494
  }
378
495
  // Generate README.md in .archbyte/
379
496
  writeArchbyteReadme(archbyteDir);
@@ -402,15 +519,19 @@ export async function handleSetup() {
402
519
  console.log();
403
520
  console.log(sep);
404
521
  console.log();
405
- console.log(" " + chalk.bold("Next"));
522
+ console.log(" " + chalk.bold("Next steps"));
523
+ console.log();
406
524
  console.log(" " + chalk.cyan("archbyte run") + " Analyze your codebase");
407
525
  if (hasClaude || hasCodex) {
408
526
  console.log(" " + chalk.cyan("archbyte mcp install") + " Use from your AI tool");
409
527
  }
528
+ console.log(" " + chalk.cyan("archbyte status") + " Check account and usage");
529
+ console.log(" " + chalk.cyan("archbyte --help") + " See all commands");
410
530
  if (result === false) {
411
- console.log(chalk.yellow(" Warning: credentials unverified. Saving anyway."));
412
531
  console.log();
532
+ console.log(chalk.yellow(" Warning: credentials unverified. Saving anyway."));
413
533
  }
534
+ console.log();
414
535
  }
415
536
  function writeArchbyteReadme(archbyteDir) {
416
537
  const readmePath = path.join(archbyteDir, "README.md");