@cruxy/cli 0.6.0 → 0.7.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 (61) hide show
  1. package/README.md +33 -16
  2. package/dist/agent/loop.d.ts +2 -0
  3. package/dist/agent/loop.js +1 -0
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +6 -0
  6. package/dist/agent/session.d.ts +24 -0
  7. package/dist/agent/session.js +33 -7
  8. package/dist/cli/commands/init.d.ts +7 -0
  9. package/dist/cli/commands/init.js +40 -0
  10. package/dist/cli/commands/login.d.ts +8 -0
  11. package/dist/cli/commands/login.js +36 -0
  12. package/dist/cli/commands/run.js +31 -53
  13. package/dist/cli/onboard.d.ts +25 -0
  14. package/dist/cli/onboard.js +54 -0
  15. package/dist/cli/program.js +19 -1
  16. package/dist/cli/repl.js +9 -0
  17. package/dist/cli/session-factory.d.ts +12 -0
  18. package/dist/cli/session-factory.js +88 -0
  19. package/dist/config/credentials.d.ts +10 -0
  20. package/dist/config/credentials.js +69 -0
  21. package/dist/config/index.d.ts +1 -0
  22. package/dist/config/index.js +1 -0
  23. package/dist/config/manager.d.ts +6 -1
  24. package/dist/config/manager.js +11 -1
  25. package/dist/config/schema.d.ts +10 -0
  26. package/dist/config/schema.js +2 -0
  27. package/dist/constants.d.ts +6 -0
  28. package/dist/constants.js +6 -0
  29. package/dist/errors/constructors.d.ts +10 -0
  30. package/dist/errors/constructors.js +46 -2
  31. package/dist/errors/types.d.ts +3 -0
  32. package/dist/errors/types.js +6 -0
  33. package/dist/onboarding/detect.d.ts +26 -0
  34. package/dist/onboarding/detect.js +56 -0
  35. package/dist/onboarding/flow.d.ts +28 -0
  36. package/dist/onboarding/flow.js +100 -0
  37. package/dist/onboarding/index.d.ts +5 -0
  38. package/dist/onboarding/index.js +5 -0
  39. package/dist/onboarding/io.d.ts +8 -0
  40. package/dist/onboarding/io.js +133 -0
  41. package/dist/onboarding/steps.d.ts +17 -0
  42. package/dist/onboarding/steps.js +100 -0
  43. package/dist/onboarding/types.d.ts +81 -0
  44. package/dist/onboarding/types.js +6 -0
  45. package/dist/plan/approve.d.ts +16 -0
  46. package/dist/plan/approve.js +46 -0
  47. package/dist/plan/execute.d.ts +20 -0
  48. package/dist/plan/execute.js +31 -0
  49. package/dist/plan/index.d.ts +7 -0
  50. package/dist/plan/index.js +7 -0
  51. package/dist/plan/policy.d.ts +26 -0
  52. package/dist/plan/policy.js +45 -0
  53. package/dist/plan/render.d.ts +5 -0
  54. package/dist/plan/render.js +47 -0
  55. package/dist/plan/service.d.ts +39 -0
  56. package/dist/plan/service.js +118 -0
  57. package/dist/plan/submit-plan.d.ts +33 -0
  58. package/dist/plan/submit-plan.js +57 -0
  59. package/dist/plan/types.d.ts +60 -0
  60. package/dist/plan/types.js +6 -0
  61. package/package.json +1 -1
@@ -0,0 +1,88 @@
1
+ import { createProvider } from "@cruxy/sdk";
2
+ import { loadProjectInstructions } from "../config/index.js";
3
+ import { logger } from "../utils/logger.js";
4
+ import { getGitInfo } from "../utils/git.js";
5
+ import { ApprovalService, InteractivePolicy, SessionAllowlist, defaultPromptIO, } from "../approval/index.js";
6
+ import { shouldUseColor } from "../errors/index.js";
7
+ import { buildDefaultRegistry } from "../tools/index.js";
8
+ import { Session } from "../agent/index.js";
9
+ import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
+ /**
11
+ * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
12
+ * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
13
+ * The approval gate's interactivity tracks the TTY, exactly as in `run`.
14
+ *
15
+ * When `planMode` is on (C.31), it additionally wires a {@link PlanExecutionPolicy}
16
+ * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
17
+ * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
18
+ */
19
+ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false) {
20
+ const provider = createProvider({
21
+ provider: config.model.provider,
22
+ apiKey,
23
+ model: config.model.model,
24
+ maxTokens: config.model.maxTokens,
25
+ temperature: config.model.temperature,
26
+ gatewayUrl: config.cruxy.gatewayUrl,
27
+ });
28
+ const execRegistry = buildDefaultRegistry();
29
+ const git = getGitInfo(cwd);
30
+ const projectInstructions = loadProjectInstructions(cwd);
31
+ if (planMode) {
32
+ // One io + allowlist shared by the plan-approval prompt and the per-action
33
+ // gate, so a grant recorded during execution is honored by U.3's own check.
34
+ const io = defaultPromptIO(shouldUseColor());
35
+ const allowlist = new SessionAllowlist();
36
+ const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io));
37
+ const approval = new ApprovalService({
38
+ cwd,
39
+ interactive: ttyInteractive,
40
+ policy: planPolicy,
41
+ io,
42
+ });
43
+ const ctx = {
44
+ cwd,
45
+ config,
46
+ logger,
47
+ requestApproval: (action) => approval.requestApproval(action),
48
+ };
49
+ const planRunner = ({ messages, projectInstructions, onText, }) => runPlanSession({
50
+ provider,
51
+ config,
52
+ ctx,
53
+ execRegistry,
54
+ planPolicy,
55
+ io,
56
+ interactive: ttyInteractive,
57
+ messages,
58
+ git,
59
+ projectInstructions,
60
+ onText,
61
+ });
62
+ return new Session({
63
+ provider,
64
+ registry: execRegistry,
65
+ config,
66
+ ctx,
67
+ git,
68
+ projectInstructions,
69
+ planMode: true,
70
+ planRunner,
71
+ });
72
+ }
73
+ const approval = new ApprovalService({ cwd, interactive: ttyInteractive });
74
+ const ctx = {
75
+ cwd,
76
+ config,
77
+ logger,
78
+ requestApproval: (action) => approval.requestApproval(action),
79
+ };
80
+ return new Session({
81
+ provider,
82
+ registry: execRegistry,
83
+ config,
84
+ ctx,
85
+ git,
86
+ projectInstructions,
87
+ });
88
+ }
@@ -0,0 +1,10 @@
1
+ /** `~/.cruxy/credentials.json` */
2
+ export declare function credentialsPath(): string;
3
+ /** The stored key for `provider`, or `undefined`. Never throws. */
4
+ export declare function readCredential(provider: string, file?: string): string | undefined;
5
+ /**
6
+ * Persist `key` for `provider`, merging into any existing store. The file is
7
+ * written `0600` and its directory `0700` so the secret is owner-only — enforced
8
+ * with an explicit `chmod` after write (mkdir/write modes are umask-masked).
9
+ */
10
+ export declare function writeCredential(provider: string, key: string, file?: string): void;
@@ -0,0 +1,69 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CREDENTIALS_FILE_NAME } from "../constants.js";
4
+ import { globalDir } from "./paths.js";
5
+ /**
6
+ * The credentials store (U.6) — the one place a provider API key is persisted.
7
+ * It lives **outside** `config.json` on purpose: config is secret-free by design
8
+ * ("the API key comes from env"), so secrets get their own file with restrictive
9
+ * permissions (`0600`, dir `0700`), the same shape as gh/aws/npm. `resolveApiKey`
10
+ * reads it as a fallback after the environment.
11
+ */
12
+ /** Bumped if the on-disk shape ever changes. */
13
+ const CREDENTIALS_VERSION = 1;
14
+ /** `~/.cruxy/credentials.json` */
15
+ export function credentialsPath() {
16
+ return join(globalDir(), CREDENTIALS_FILE_NAME);
17
+ }
18
+ /** Parse the store at `file`, or `null` if absent/unreadable/malformed. */
19
+ function readStore(file) {
20
+ if (!existsSync(file))
21
+ return null;
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
24
+ if (parsed &&
25
+ typeof parsed === "object" &&
26
+ "keys" in parsed &&
27
+ typeof parsed.keys === "object") {
28
+ return parsed;
29
+ }
30
+ }
31
+ catch {
32
+ // A corrupt store is treated as absent — onboarding can rewrite it.
33
+ }
34
+ return null;
35
+ }
36
+ /** The stored key for `provider`, or `undefined`. Never throws. */
37
+ export function readCredential(provider, file = credentialsPath()) {
38
+ const store = readStore(file);
39
+ const key = store?.keys[provider];
40
+ return typeof key === "string" && key !== "" ? key : undefined;
41
+ }
42
+ /**
43
+ * Persist `key` for `provider`, merging into any existing store. The file is
44
+ * written `0600` and its directory `0700` so the secret is owner-only — enforced
45
+ * with an explicit `chmod` after write (mkdir/write modes are umask-masked).
46
+ */
47
+ export function writeCredential(provider, key, file = credentialsPath()) {
48
+ const dir = dirname(file);
49
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ try {
51
+ chmodSync(dir, 0o700);
52
+ }
53
+ catch {
54
+ // Best-effort on platforms without POSIX modes (e.g. Windows).
55
+ }
56
+ const store = readStore(file) ?? { version: CREDENTIALS_VERSION, keys: {} };
57
+ store.version = CREDENTIALS_VERSION;
58
+ store.keys[provider] = key;
59
+ writeFileSync(file, JSON.stringify(store, null, 2) + "\n", {
60
+ encoding: "utf8",
61
+ mode: 0o600,
62
+ });
63
+ try {
64
+ chmodSync(file, 0o600);
65
+ }
66
+ catch {
67
+ // Best-effort (see above).
68
+ }
69
+ }
@@ -2,3 +2,4 @@ export * from "./schema.js";
2
2
  export * from "./paths.js";
3
3
  export * from "./manager.js";
4
4
  export * from "./project.js";
5
+ export * from "./credentials.js";
@@ -2,3 +2,4 @@ export * from "./schema.js";
2
2
  export * from "./paths.js";
3
3
  export * from "./manager.js";
4
4
  export * from "./project.js";
5
+ export * from "./credentials.js";
@@ -30,5 +30,10 @@ export declare function initConfig(file: string): {
30
30
  path: string;
31
31
  created: boolean;
32
32
  };
33
- /** Provider API key, read from the environment only (never persisted). */
33
+ /**
34
+ * Provider API key, resolved **env → credentials store → undefined**. The
35
+ * environment always wins (CI / one-off overrides); the credentials store
36
+ * (`~/.cruxy/credentials.json`, written by onboarding / `cruxy login`) is the
37
+ * persistent fallback. The key is never read from `config.json`.
38
+ */
34
39
  export declare function resolveApiKey(provider: string): string | undefined;
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
3
3
  import { configInvalid, configParse } from "../errors/index.js";
4
4
  import { CruxyConfigSchema } from "./schema.js";
5
5
  import { globalConfigPath, findProjectConfig } from "./paths.js";
6
+ import { readCredential } from "./credentials.js";
6
7
  function isPlainObject(v) {
7
8
  return typeof v === "object" && v !== null && !Array.isArray(v);
8
9
  }
@@ -137,8 +138,17 @@ export function initConfig(file) {
137
138
  writeFileSync(file, JSON.stringify(defaults, null, 2) + "\n", "utf8");
138
139
  return { path: file, created: true };
139
140
  }
140
- /** Provider API key, read from the environment only (never persisted). */
141
+ /**
142
+ * Provider API key, resolved **env → credentials store → undefined**. The
143
+ * environment always wins (CI / one-off overrides); the credentials store
144
+ * (`~/.cruxy/credentials.json`, written by onboarding / `cruxy login`) is the
145
+ * persistent fallback. The key is never read from `config.json`.
146
+ */
141
147
  export function resolveApiKey(provider) {
148
+ return envApiKey(provider) ?? readCredential(provider);
149
+ }
150
+ /** The API key from the environment for `provider`, or `undefined`. */
151
+ function envApiKey(provider) {
142
152
  switch (provider) {
143
153
  case "cruxy":
144
154
  return process.env.CRUXY_API_KEY;
@@ -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";