@cruxy/cli 0.6.0 → 0.8.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 (78) hide show
  1. package/README.md +39 -16
  2. package/dist/agent/loop.d.ts +9 -5
  3. package/dist/agent/loop.js +53 -10
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +6 -0
  6. package/dist/agent/session.d.ts +29 -3
  7. package/dist/agent/session.js +37 -10
  8. package/dist/approval/prompt.d.ts +9 -0
  9. package/dist/approval/prompt.js +2 -77
  10. package/dist/cli/commands/init.d.ts +7 -0
  11. package/dist/cli/commands/init.js +40 -0
  12. package/dist/cli/commands/login.d.ts +8 -0
  13. package/dist/cli/commands/login.js +36 -0
  14. package/dist/cli/commands/run.js +46 -62
  15. package/dist/cli/onboard.d.ts +25 -0
  16. package/dist/cli/onboard.js +59 -0
  17. package/dist/cli/program.js +19 -1
  18. package/dist/cli/repl.d.ts +9 -4
  19. package/dist/cli/repl.js +32 -12
  20. package/dist/cli/session-factory.d.ts +13 -0
  21. package/dist/cli/session-factory.js +109 -0
  22. package/dist/config/credentials.d.ts +10 -0
  23. package/dist/config/credentials.js +69 -0
  24. package/dist/config/index.d.ts +1 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.d.ts +6 -1
  27. package/dist/config/manager.js +11 -1
  28. package/dist/config/schema.d.ts +10 -0
  29. package/dist/config/schema.js +2 -0
  30. package/dist/constants.d.ts +6 -0
  31. package/dist/constants.js +6 -0
  32. package/dist/errors/constructors.d.ts +10 -0
  33. package/dist/errors/constructors.js +46 -2
  34. package/dist/errors/types.d.ts +3 -0
  35. package/dist/errors/types.js +6 -0
  36. package/dist/onboarding/detect.d.ts +26 -0
  37. package/dist/onboarding/detect.js +56 -0
  38. package/dist/onboarding/flow.d.ts +28 -0
  39. package/dist/onboarding/flow.js +100 -0
  40. package/dist/onboarding/index.d.ts +5 -0
  41. package/dist/onboarding/index.js +5 -0
  42. package/dist/onboarding/io.d.ts +8 -0
  43. package/dist/onboarding/io.js +133 -0
  44. package/dist/onboarding/steps.d.ts +17 -0
  45. package/dist/onboarding/steps.js +100 -0
  46. package/dist/onboarding/types.d.ts +81 -0
  47. package/dist/onboarding/types.js +6 -0
  48. package/dist/plan/approve.d.ts +16 -0
  49. package/dist/plan/approve.js +46 -0
  50. package/dist/plan/execute.d.ts +20 -0
  51. package/dist/plan/execute.js +31 -0
  52. package/dist/plan/index.d.ts +7 -0
  53. package/dist/plan/index.js +7 -0
  54. package/dist/plan/policy.d.ts +26 -0
  55. package/dist/plan/policy.js +45 -0
  56. package/dist/plan/render.d.ts +5 -0
  57. package/dist/plan/render.js +47 -0
  58. package/dist/plan/service.d.ts +40 -0
  59. package/dist/plan/service.js +118 -0
  60. package/dist/plan/submit-plan.d.ts +33 -0
  61. package/dist/plan/submit-plan.js +57 -0
  62. package/dist/plan/types.d.ts +60 -0
  63. package/dist/plan/types.js +6 -0
  64. package/dist/render/capabilities.d.ts +12 -0
  65. package/dist/render/capabilities.js +27 -0
  66. package/dist/render/diff.d.ts +19 -0
  67. package/dist/render/diff.js +80 -0
  68. package/dist/render/highlight.d.ts +47 -0
  69. package/dist/render/highlight.js +265 -0
  70. package/dist/render/index.d.ts +14 -0
  71. package/dist/render/index.js +20 -0
  72. package/dist/render/plain-renderer.d.ts +32 -0
  73. package/dist/render/plain-renderer.js +61 -0
  74. package/dist/render/tty-renderer.d.ts +47 -0
  75. package/dist/render/tty-renderer.js +149 -0
  76. package/dist/render/types.d.ts +76 -0
  77. package/dist/render/types.js +1 -0
  78. package/package.json +1 -1
@@ -30,12 +30,16 @@ export declare const AgentConfigSchema: z.ZodObject<{
30
30
  maxIterations: z.ZodDefault<z.ZodNumber>;
31
31
  /** Skip per-action confirmation prompts. */
32
32
  autoApprove: z.ZodDefault<z.ZodBoolean>;
33
+ /** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
34
+ planMode: z.ZodDefault<z.ZodBoolean>;
33
35
  }, "strict", z.ZodTypeAny, {
34
36
  maxIterations: number;
35
37
  autoApprove: boolean;
38
+ planMode: boolean;
36
39
  }, {
37
40
  maxIterations?: number | undefined;
38
41
  autoApprove?: boolean | undefined;
42
+ planMode?: boolean | undefined;
39
43
  }>;
40
44
  export declare const ToolsConfigSchema: z.ZodObject<{
41
45
  fileEdit: z.ZodDefault<z.ZodBoolean>;
@@ -242,12 +246,16 @@ export declare const CruxyConfigSchema: z.ZodObject<{
242
246
  maxIterations: z.ZodDefault<z.ZodNumber>;
243
247
  /** Skip per-action confirmation prompts. */
244
248
  autoApprove: z.ZodDefault<z.ZodBoolean>;
249
+ /** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
250
+ planMode: z.ZodDefault<z.ZodBoolean>;
245
251
  }, "strict", z.ZodTypeAny, {
246
252
  maxIterations: number;
247
253
  autoApprove: boolean;
254
+ planMode: boolean;
248
255
  }, {
249
256
  maxIterations?: number | undefined;
250
257
  autoApprove?: boolean | undefined;
258
+ planMode?: boolean | undefined;
251
259
  }>>;
252
260
  tools: z.ZodDefault<z.ZodObject<{
253
261
  fileEdit: z.ZodDefault<z.ZodBoolean>;
@@ -428,6 +436,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
428
436
  agent: {
429
437
  maxIterations: number;
430
438
  autoApprove: boolean;
439
+ planMode: boolean;
431
440
  };
432
441
  tools: {
433
442
  fileEdit: boolean;
@@ -485,6 +494,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
485
494
  agent?: {
486
495
  maxIterations?: number | undefined;
487
496
  autoApprove?: boolean | undefined;
497
+ planMode?: boolean | undefined;
488
498
  } | undefined;
489
499
  tools?: {
490
500
  fileEdit?: boolean | undefined;
@@ -28,6 +28,8 @@ export const AgentConfigSchema = z
28
28
  maxIterations: z.number().int().positive().default(25),
29
29
  /** Skip per-action confirmation prompts. */
30
30
  autoApprove: z.boolean().default(false),
31
+ /** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
32
+ planMode: z.boolean().default(false),
31
33
  })
32
34
  .strict();
33
35
  export const ToolsConfigSchema = z
@@ -5,6 +5,12 @@ export declare const APP_DESCRIPTION: string;
5
5
  /** Directory/file names cruxy looks for. */
6
6
  export declare const GLOBAL_DIR_NAME = ".cruxy";
7
7
  export declare const CONFIG_FILE_NAME = "config.json";
8
+ /** Secret store under the global dir — `0600`, never holds non-secret config. */
9
+ export declare const CREDENTIALS_FILE_NAME = "credentials.json";
10
+ /** Onboarding state + completion marker under the global dir (U.6). */
11
+ export declare const ONBOARDING_FILE_NAME = "onboarding.json";
12
+ /** Where a user creates a Cruxy gateway key (printed during onboarding). */
13
+ export declare const CREATE_KEY_URL = "https://app.cruxy.in";
8
14
  /** Project-level config filenames, checked in order. */
9
15
  export declare const PROJECT_CONFIG_FILENAMES: string[];
10
16
  /** Project-instruction filenames, checked in order (first match wins). */
package/dist/constants.js CHANGED
@@ -22,6 +22,12 @@ export const APP_DESCRIPTION = pkg.description ?? "an agentic coding CLI";
22
22
  /** Directory/file names cruxy looks for. */
23
23
  export const GLOBAL_DIR_NAME = ".cruxy";
24
24
  export const CONFIG_FILE_NAME = "config.json";
25
+ /** Secret store under the global dir — `0600`, never holds non-secret config. */
26
+ export const CREDENTIALS_FILE_NAME = "credentials.json";
27
+ /** Onboarding state + completion marker under the global dir (U.6). */
28
+ export const ONBOARDING_FILE_NAME = "onboarding.json";
29
+ /** Where a user creates a Cruxy gateway key (printed during onboarding). */
30
+ export const CREATE_KEY_URL = "https://app.cruxy.in";
25
31
  /** Project-level config filenames, checked in order. */
26
32
  export const PROJECT_CONFIG_FILENAMES = [
27
33
  "cruxy.config.json",
@@ -43,6 +43,16 @@ export declare function forgeApi(title: string, underlying?: unknown, meta?: Rec
43
43
  * `--no-verify`, so the underlying reason is surfaced verbatim.
44
44
  */
45
45
  export declare function gitPushFailed(branch: string, stderr?: string): CruxyError;
46
+ /** The agent's proposed plan was missing or malformed (plan mode, C.31). */
47
+ export declare function planInvalid(reason: string): CruxyError;
48
+ /** The plan was rejected too many times without converging (plan mode, C.31). */
49
+ export declare function planRevisionLimit(limit: number): CruxyError;
50
+ /**
51
+ * Plan mode needs interactive approval but cruxy is running non-interactively.
52
+ * Default-deny — a plan is never auto-approved. Distinct code (exit 10) so CI can
53
+ * tell it apart from a per-action approval requirement.
54
+ */
55
+ export declare function planApprovalRequired(): CruxyError;
46
56
  export declare function internal(underlying?: unknown): CruxyError;
47
57
  /**
48
58
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
@@ -72,9 +72,10 @@ export function authMissingKey(provider, envVar) {
72
72
  return new CruxyError({
73
73
  code: ErrorCode.AuthMissingKey,
74
74
  title: `no API key for provider "${provider}"`,
75
- cause: `the ${envVar} environment variable is not set`,
75
+ cause: `no key in the environment (${envVar}) or the credentials store`,
76
76
  nextSteps: [
77
- `export ${envVar}=… in your shell (keys are read from the environment, never from config)`,
77
+ "run `cruxy login` to set a key interactively (saved to ~/.cruxy, owner-only)",
78
+ `or export ${envVar}=… in your shell (env always wins; never written to config)`,
78
79
  ],
79
80
  meta: { provider, envVar },
80
81
  });
@@ -291,6 +292,49 @@ export function gitPushFailed(branch, stderr) {
291
292
  meta: { branch },
292
293
  });
293
294
  }
295
+ // ── plan mode (exit 2 / 10) ───────────────────────────────────────────────────
296
+ /** The agent's proposed plan was missing or malformed (plan mode, C.31). */
297
+ export function planInvalid(reason) {
298
+ return new CruxyError({
299
+ code: ErrorCode.PlanInvalid,
300
+ title: "the agent did not produce a valid plan",
301
+ cause: reason,
302
+ nextSteps: [
303
+ "retry the task — the model must call `submit_plan` with at least one step",
304
+ "or run without `--plan` to execute directly",
305
+ ],
306
+ meta: { reason },
307
+ });
308
+ }
309
+ /** The plan was rejected too many times without converging (plan mode, C.31). */
310
+ export function planRevisionLimit(limit) {
311
+ return new CruxyError({
312
+ code: ErrorCode.PlanRevisionLimit,
313
+ title: `plan not approved after ${limit} revision${limit === 1 ? "" : "s"}`,
314
+ cause: "the revision limit was reached without an approved plan",
315
+ nextSteps: [
316
+ "restate the task more concretely, or split it into smaller tasks",
317
+ "run without `--plan` to execute directly",
318
+ ],
319
+ meta: { limit },
320
+ });
321
+ }
322
+ /**
323
+ * Plan mode needs interactive approval but cruxy is running non-interactively.
324
+ * Default-deny — a plan is never auto-approved. Distinct code (exit 10) so CI can
325
+ * tell it apart from a per-action approval requirement.
326
+ */
327
+ export function planApprovalRequired() {
328
+ return new CruxyError({
329
+ code: ErrorCode.PlanApprovalRequired,
330
+ title: "plan mode needs your approval, but cruxy is running non-interactively",
331
+ cause: "a plan can only be approved in an interactive terminal",
332
+ nextSteps: [
333
+ "run cruxy in an interactive terminal to review and approve the plan",
334
+ "or drop `--plan` (and `agent.planMode`) to execute directly",
335
+ ],
336
+ });
337
+ }
294
338
  // ── internal (exit 1) ─────────────────────────────────────────────────────────
295
339
  export function internal(underlying) {
296
340
  return new CruxyError({
@@ -17,6 +17,8 @@ export declare const ErrorCode: {
17
17
  readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
18
18
  readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
19
19
  readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
20
+ readonly PlanInvalid: "CRUXY_E_PLAN_INVALID";
21
+ readonly PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT";
20
22
  readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
21
23
  readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
22
24
  readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
@@ -38,6 +40,7 @@ export declare const ErrorCode: {
38
40
  readonly SkillInvalid: "CRUXY_E_SKILL_INVALID";
39
41
  readonly SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND";
40
42
  readonly ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED";
43
+ readonly PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED";
41
44
  };
42
45
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
43
46
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -19,6 +19,8 @@ export const ErrorCode = {
19
19
  ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
20
20
  ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
21
21
  GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
22
+ PlanInvalid: "CRUXY_E_PLAN_INVALID",
23
+ PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT",
22
24
  // config (exit 3)
23
25
  ConfigParse: "CRUXY_E_CONFIG_PARSE",
24
26
  ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
@@ -48,6 +50,7 @@ export const ErrorCode = {
48
50
  SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND",
49
51
  // approval (exit 10)
50
52
  ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED",
53
+ PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED",
51
54
  };
52
55
  /**
53
56
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -59,6 +62,8 @@ const EXIT_CODES = {
59
62
  [ErrorCode.ConfigKeyUnknown]: 2,
60
63
  [ErrorCode.ProviderUnsupported]: 2,
61
64
  [ErrorCode.GitProtectedBranch]: 2,
65
+ [ErrorCode.PlanInvalid]: 2,
66
+ [ErrorCode.PlanRevisionLimit]: 2,
62
67
  [ErrorCode.ConfigParse]: 3,
63
68
  [ErrorCode.ConfigInvalid]: 3,
64
69
  [ErrorCode.AuthMissingKey]: 4,
@@ -80,6 +85,7 @@ const EXIT_CODES = {
80
85
  [ErrorCode.SkillInvalid]: 9,
81
86
  [ErrorCode.SkillNotFound]: 9,
82
87
  [ErrorCode.ApprovalRequired]: 10,
88
+ [ErrorCode.PlanApprovalRequired]: 10,
83
89
  };
84
90
  /** The process exit code for an error code (defaults to 1 for safety). */
85
91
  export function exitCodeFor(code) {
@@ -0,0 +1,26 @@
1
+ import type { OnboardingState } from "./types.js";
2
+ /** `~/.cruxy/onboarding.json` */
3
+ export declare function onboardingStatePath(): string;
4
+ /** Read persisted onboarding state, or `null` if absent/unreadable. */
5
+ export declare function readOnboardingState(file?: string): OnboardingState | null;
6
+ /** Persist onboarding state (creates `~/.cruxy` if needed). */
7
+ export declare function writeOnboardingState(state: OnboardingState, file?: string): void;
8
+ /** A fresh state object. */
9
+ export declare function newOnboardingState(): OnboardingState;
10
+ /** Whether onboarding has been completed (the marker exists). */
11
+ export declare function onboardingCompleted(file?: string): boolean;
12
+ export interface FirstRunInput {
13
+ readonly provider: string;
14
+ /** Whether stdin is a TTY (the gate — non-TTY is never first-run). */
15
+ readonly interactive: boolean;
16
+ /** Override key resolution (tests); defaults to {@link resolveApiKey}. */
17
+ readonly resolveKey?: (provider: string) => string | undefined;
18
+ /** Override completion check (tests); defaults to {@link onboardingCompleted}. */
19
+ readonly completed?: () => boolean;
20
+ }
21
+ /**
22
+ * True iff we should launch the guided first-run flow: interactive **and** no
23
+ * resolvable key **and** no completion marker. Any one being false means we do
24
+ * not onboard (non-TTY fails loud elsewhere; a marker means "don't nag again").
25
+ */
26
+ export declare function isFirstRun(input: FirstRunInput): boolean;
@@ -0,0 +1,56 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { ONBOARDING_FILE_NAME } from "../constants.js";
4
+ import { globalDir, resolveApiKey } from "../config/index.js";
5
+ /**
6
+ * First-run detection + onboarding-state persistence (U.6). "First run" is
7
+ * deliberately derived from observable facts (no key + no completion marker)
8
+ * rather than a flag, and is **TTY-gated** so a non-interactive run never
9
+ * branches into an interactive flow.
10
+ */
11
+ const ONBOARDING_VERSION = 1;
12
+ /** `~/.cruxy/onboarding.json` */
13
+ export function onboardingStatePath() {
14
+ return join(globalDir(), ONBOARDING_FILE_NAME);
15
+ }
16
+ /** Read persisted onboarding state, or `null` if absent/unreadable. */
17
+ export function readOnboardingState(file = onboardingStatePath()) {
18
+ if (!existsSync(file))
19
+ return null;
20
+ try {
21
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
22
+ if (parsed && typeof parsed === "object")
23
+ return parsed;
24
+ }
25
+ catch {
26
+ // A corrupt marker is treated as absent — onboarding can rewrite it.
27
+ }
28
+ return null;
29
+ }
30
+ /** Persist onboarding state (creates `~/.cruxy` if needed). */
31
+ export function writeOnboardingState(state, file = onboardingStatePath()) {
32
+ mkdirSync(dirname(file), { recursive: true });
33
+ writeFileSync(file, JSON.stringify(state, null, 2) + "\n", "utf8");
34
+ }
35
+ /** A fresh state object. */
36
+ export function newOnboardingState() {
37
+ return { version: ONBOARDING_VERSION };
38
+ }
39
+ /** Whether onboarding has been completed (the marker exists). */
40
+ export function onboardingCompleted(file = onboardingStatePath()) {
41
+ return Boolean(readOnboardingState(file)?.completedAt);
42
+ }
43
+ /**
44
+ * True iff we should launch the guided first-run flow: interactive **and** no
45
+ * resolvable key **and** no completion marker. Any one being false means we do
46
+ * not onboard (non-TTY fails loud elsewhere; a marker means "don't nag again").
47
+ */
48
+ export function isFirstRun(input) {
49
+ if (!input.interactive)
50
+ return false;
51
+ const resolveKey = input.resolveKey ?? resolveApiKey;
52
+ if (resolveKey(input.provider))
53
+ return false;
54
+ const completed = input.completed ?? onboardingCompleted;
55
+ return !completed();
56
+ }
@@ -0,0 +1,28 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { OnboardingDeps, OnboardingOptions, OnboardingResult, ValidationOutcome } from "./types.js";
3
+ /**
4
+ * Orchestrate the onboarding steps (U.6) — resumable and idempotent. The key
5
+ * step is skipped when a key already resolves; the completion marker is written
6
+ * only once a key is in place. An abort during the (mandatory) key step returns
7
+ * `{ aborted: true }` and writes **no** marker, so it resumes next time.
8
+ */
9
+ export declare function runOnboarding(opts: OnboardingOptions): Promise<OnboardingResult>;
10
+ /** Dependencies for the default (production) wiring. */
11
+ export interface DefaultDepsOptions {
12
+ config: CruxyConfig;
13
+ cwd: string;
14
+ /** Runs the first-win task; omit to disable that step. */
15
+ runTask?: (prompt: string) => Promise<void>;
16
+ }
17
+ /**
18
+ * Build the production {@link OnboardingDeps}: live gateway validation, the
19
+ * credentials store, real state persistence, and a wall-clock timestamp.
20
+ */
21
+ export declare function createDefaultDeps(opts: DefaultDepsOptions): OnboardingDeps;
22
+ /**
23
+ * Validate a key with one cheap live call: start a 1-token stream and look at the
24
+ * first event. `AuthError` ⇒ invalid (bad key), `NetworkError` ⇒ unreachable;
25
+ * anything else (a token, rate-limit, transient API error) means the key was
26
+ * accepted, so ⇒ valid.
27
+ */
28
+ export declare function validateKeyLive(provider: string, apiKey: string, config: CruxyConfig): Promise<ValidationOutcome>;
@@ -0,0 +1,100 @@
1
+ import { AuthError, NetworkError, createProvider } from "@cruxy/sdk";
2
+ import pc from "picocolors";
3
+ import { resolveApiKey, writeCredential } from "../config/index.js";
4
+ import { newOnboardingState, readOnboardingState, writeOnboardingState, } from "./detect.js";
5
+ import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
6
+ /**
7
+ * Orchestrate the onboarding steps (U.6) — resumable and idempotent. The key
8
+ * step is skipped when a key already resolves; the completion marker is written
9
+ * only once a key is in place. An abort during the (mandatory) key step returns
10
+ * `{ aborted: true }` and writes **no** marker, so it resumes next time.
11
+ */
12
+ export async function runOnboarding(opts) {
13
+ const { io, deps, provider } = opts;
14
+ const col = pc.createColors(io.color);
15
+ io.write(`${col.cyan(col.bold("Welcome to cruxy"))} — let's get you set up.\n`);
16
+ let state = deps.readState() ?? newOnboardingState();
17
+ let apiKey = deps.resolveApiKey(provider);
18
+ // ── key (mandatory; skipped if already resolvable unless forceKey) ─────────
19
+ if (!apiKey || opts.forceKey) {
20
+ const result = await acquireKeyStep(io, deps, provider);
21
+ if (result.status === "aborted") {
22
+ return { completed: false, aborted: true };
23
+ }
24
+ if (result.status !== "ok") {
25
+ // Failed (unreachable / rejected) — surface guidance, no marker.
26
+ if (result.message)
27
+ io.write(`${col.dim(result.message)}\n`);
28
+ return { completed: false, aborted: false };
29
+ }
30
+ apiKey = result.apiKey;
31
+ state = { ...state, keyConfigured: true };
32
+ deps.writeState(state);
33
+ }
34
+ else {
35
+ io.write(`${col.green("✓")} using your existing API key.\n`);
36
+ state = { ...state, keyConfigured: true };
37
+ }
38
+ // ── optional steps (Ctrl-C here just skips them; the key is already safe) ───
39
+ if (opts.offerScaffold)
40
+ await scaffoldStep(io, deps.cwd);
41
+ if (opts.offerFirstWin)
42
+ await firstWinStep(io, deps);
43
+ // ── complete ───────────────────────────────────────────────────────────────
44
+ state = { ...state, completedAt: deps.now() };
45
+ deps.writeState(state);
46
+ io.write(`${col.green(col.bold("✓ all set"))} — happy hacking.\n`);
47
+ return { completed: true, aborted: false, apiKey };
48
+ }
49
+ /**
50
+ * Build the production {@link OnboardingDeps}: live gateway validation, the
51
+ * credentials store, real state persistence, and a wall-clock timestamp.
52
+ */
53
+ export function createDefaultDeps(opts) {
54
+ return {
55
+ validateKey: (provider, apiKey) => validateKeyLive(provider, apiKey, opts.config),
56
+ writeCredential,
57
+ resolveApiKey,
58
+ readState: () => readOnboardingState(),
59
+ writeState: (state) => writeOnboardingState(state),
60
+ runTask: opts.runTask,
61
+ cwd: opts.cwd,
62
+ now: () => new Date().toISOString(),
63
+ };
64
+ }
65
+ /**
66
+ * Validate a key with one cheap live call: start a 1-token stream and look at the
67
+ * first event. `AuthError` ⇒ invalid (bad key), `NetworkError` ⇒ unreachable;
68
+ * anything else (a token, rate-limit, transient API error) means the key was
69
+ * accepted, so ⇒ valid.
70
+ */
71
+ export async function validateKeyLive(provider, apiKey, config) {
72
+ try {
73
+ const client = createProvider({
74
+ provider,
75
+ apiKey,
76
+ model: config.model.model,
77
+ maxTokens: 1,
78
+ gatewayUrl: config.cruxy.gatewayUrl,
79
+ });
80
+ for await (const ev of client.stream({
81
+ messages: [{ role: "user", content: "hi" }],
82
+ })) {
83
+ if (ev.type === "error")
84
+ return classifyValidation(ev.error);
85
+ return "valid"; // any non-error event ⇒ the key was accepted
86
+ }
87
+ return "valid";
88
+ }
89
+ catch (err) {
90
+ return classifyValidation(err);
91
+ }
92
+ }
93
+ function classifyValidation(err) {
94
+ if (err instanceof AuthError)
95
+ return "invalid";
96
+ if (err instanceof NetworkError)
97
+ return "unreachable";
98
+ // Rate-limit / overloaded / other API errors mean the key authenticated.
99
+ return "valid";
100
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./detect.js";
3
+ export * from "./io.js";
4
+ export * from "./steps.js";
5
+ export * from "./flow.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./detect.js";
3
+ export * from "./io.js";
4
+ export * from "./steps.js";
5
+ export * from "./flow.js";
@@ -0,0 +1,8 @@
1
+ import type { OnboardingIO } from "./types.js";
2
+ /**
3
+ * The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
4
+ * stays clean for piping); the secret reader echoes `*` per keystroke and never
5
+ * the real character. All readers restore cooked mode on the way out, even on
6
+ * Ctrl-C / EOF — the terminal is never left in raw mode.
7
+ */
8
+ export declare function defaultOnboardingIO(color?: boolean): OnboardingIO;
@@ -0,0 +1,133 @@
1
+ import { shouldUseColor } from "../errors/index.js";
2
+ /**
3
+ * The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
4
+ * stays clean for piping); the secret reader echoes `*` per keystroke and never
5
+ * the real character. All readers restore cooked mode on the way out, even on
6
+ * Ctrl-C / EOF — the terminal is never left in raw mode.
7
+ */
8
+ export function defaultOnboardingIO(color = shouldUseColor()) {
9
+ return {
10
+ write: (text) => void process.stderr.write(text),
11
+ readLine: readLineFromStdin,
12
+ readKey: readKeyFromStdin,
13
+ readSecret: readSecretFromStdin,
14
+ color,
15
+ };
16
+ }
17
+ const CTRL_C = 0x03;
18
+ const CTRL_D = 0x04;
19
+ const BACKSPACE = 0x08;
20
+ const DELETE = 0x7f;
21
+ const LF = 0x0a;
22
+ const CR = 0x0d;
23
+ /** Read one keypress in raw mode; "" on EOF / Ctrl-C / Ctrl-D. Restores cooked mode. */
24
+ function readKeyFromStdin() {
25
+ const stdin = process.stdin;
26
+ return new Promise((resolve) => {
27
+ const cleanup = () => {
28
+ stdin.removeListener("data", onData);
29
+ stdin.removeListener("end", onEnd);
30
+ if (stdin.isTTY)
31
+ stdin.setRawMode(false);
32
+ stdin.pause();
33
+ };
34
+ const onData = (buf) => {
35
+ cleanup();
36
+ const code = buf[0];
37
+ resolve(code === CTRL_C || code === CTRL_D
38
+ ? ""
39
+ : buf.toString("utf8").slice(0, 1));
40
+ };
41
+ const onEnd = () => {
42
+ cleanup();
43
+ resolve("");
44
+ };
45
+ if (stdin.isTTY)
46
+ stdin.setRawMode(true);
47
+ stdin.resume();
48
+ stdin.once("data", onData);
49
+ stdin.once("end", onEnd);
50
+ });
51
+ }
52
+ /** Read one line in cooked mode; "" on EOF. */
53
+ function readLineFromStdin() {
54
+ const stdin = process.stdin;
55
+ return new Promise((resolve) => {
56
+ let buf = "";
57
+ const cleanup = () => {
58
+ stdin.removeListener("data", onData);
59
+ stdin.removeListener("end", onEnd);
60
+ stdin.pause();
61
+ };
62
+ const onData = (chunk) => {
63
+ buf += chunk.toString("utf8");
64
+ const nl = buf.indexOf("\n");
65
+ if (nl !== -1) {
66
+ cleanup();
67
+ resolve(buf.slice(0, nl).replace(/\r$/, ""));
68
+ }
69
+ };
70
+ const onEnd = () => {
71
+ cleanup();
72
+ resolve(buf.replace(/\r$/, ""));
73
+ };
74
+ if (stdin.isTTY)
75
+ stdin.setRawMode(false);
76
+ stdin.resume();
77
+ stdin.on("data", onData);
78
+ stdin.once("end", onEnd);
79
+ });
80
+ }
81
+ /**
82
+ * Read a secret with no echo: each printable keystroke shows a `*`, backspace
83
+ * erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The real
84
+ * characters are never written anywhere.
85
+ */
86
+ function readSecretFromStdin() {
87
+ const stdin = process.stdin;
88
+ const out = process.stderr;
89
+ return new Promise((resolve) => {
90
+ let buf = "";
91
+ let done = false;
92
+ const cleanup = () => {
93
+ stdin.removeListener("data", onData);
94
+ stdin.removeListener("end", onEnd);
95
+ if (stdin.isTTY)
96
+ stdin.setRawMode(false);
97
+ stdin.pause();
98
+ };
99
+ const finish = (value) => {
100
+ if (done)
101
+ return;
102
+ done = true;
103
+ cleanup();
104
+ out.write("\n");
105
+ resolve(value);
106
+ };
107
+ const onData = (chunk) => {
108
+ for (const byte of chunk) {
109
+ if (byte === CR || byte === LF)
110
+ return finish(buf); // Enter → submit
111
+ if (byte === CTRL_C || byte === CTRL_D)
112
+ return finish(""); // abort
113
+ if (byte === DELETE || byte === BACKSPACE) {
114
+ if (buf.length > 0) {
115
+ buf = buf.slice(0, -1);
116
+ out.write("\b \b"); // erase one star
117
+ }
118
+ continue;
119
+ }
120
+ if (byte < 0x20)
121
+ continue; // ignore other control chars
122
+ buf += String.fromCharCode(byte);
123
+ out.write("*");
124
+ }
125
+ };
126
+ const onEnd = () => finish("");
127
+ if (stdin.isTTY)
128
+ stdin.setRawMode(true);
129
+ stdin.resume();
130
+ stdin.on("data", onData);
131
+ stdin.once("end", onEnd);
132
+ });
133
+ }
@@ -0,0 +1,17 @@
1
+ import type { OnboardingDeps, OnboardingIO, StepResult } from "./types.js";
2
+ /**
3
+ * Acquire and persist a provider key: print the create-key URL, read it masked,
4
+ * validate it live, and **only then** write it to the credentials store. Loops on
5
+ * a rejected key (up to 3 tries); a network failure or an empty entry stops.
6
+ */
7
+ export declare function acquireKeyStep(io: OnboardingIO, deps: OnboardingDeps, provider: string): Promise<StepResult>;
8
+ /**
9
+ * Offer to scaffold a project `CRUXY.md`. Skipped silently when one already
10
+ * exists (or `AGENTS.md`); otherwise a `y` confirmation writes the template.
11
+ */
12
+ export declare function scaffoldStep(io: OnboardingIO, cwd: string): Promise<StepResult>;
13
+ /**
14
+ * Offer the first-win demo run. Skipped when no runner is wired or the user
15
+ * declines; otherwise runs one real task so they see value immediately.
16
+ */
17
+ export declare function firstWinStep(io: OnboardingIO, deps: OnboardingDeps): Promise<StepResult>;