@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
package/README.md CHANGED
@@ -16,14 +16,26 @@ an approval gate before anything touches disk.
16
16
  ```bash
17
17
  npm install -g @cruxy/cli
18
18
 
19
- export CRUXY_API_KEY=cxy_live_... # from app.cruxy.in
19
+ cruxy run "explain this codebase" # first run guides you through setup
20
+ ```
21
+
22
+ On your first interactive run with no key, cruxy walks you through getting one,
23
+ validates it, and saves it to `~/.cruxy` (owner-only) — no env var needed. You
24
+ can also run setup on demand:
20
25
 
21
- cruxy run "explain this codebase" # one-shot
22
- cruxy run # interactive session
26
+ ```bash
27
+ cruxy login # set or replace your API key
28
+ cruxy init # key + a project CRUXY.md + a first run
29
+ export CRUXY_API_KEY=cxy_live_... # …or just use an env var (always wins)
23
30
  ```
24
31
 
25
32
  ## Features
26
33
 
34
+ - **Onboarding** — a guided first run (`cruxy login` / `cruxy init` on demand):
35
+ get a key, validate it live, and save it to `~/.cruxy/credentials.json`
36
+ (`0600`) — never to `config.json`, the project, or env files. Keys resolve
37
+ env → credentials store. Non-interactive runs stay fail-loud
38
+ (`CRUXY_E_AUTH_MISSING_KEY`), never blocking on input.
27
39
  - **Tools** — `read_file`, `write_file`, `edit_file`, `glob`, `list_files`,
28
40
  `grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase`,
29
41
  `list_skills`, `load_skill`, `create_pull_request`.
@@ -46,6 +58,11 @@ cruxy run # interactive session
46
58
  validated, and never auto-executed.
47
59
  - **Agent** — streaming output, multi-turn interactive sessions, context
48
60
  compaction, and awareness of git state and project instructions (`CRUXY.md`).
61
+ - **Plan mode** (opt-in: `cruxy run --plan`, `/plan`, or `agent.planMode`) — the
62
+ agent proposes a structured, step-by-step plan; you approve it once, then it
63
+ executes with live per-step status. Approving consents to the _shape_ of the
64
+ work — every action still passes the approval gate, and destructive/ungrantable
65
+ actions always re-confirm even after approval.
49
66
  - **Safety** — a single approval gate with diff previews that fails closed;
50
67
  read-only tools never prompt; file access is confined to the project root;
51
68
  shell commands run bounded.
@@ -101,19 +118,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
101
118
  `NO_COLOR` disables color. Exit codes are stable per category, so scripts can
102
119
  branch on them:
103
120
 
104
- | Exit | Category | Example codes |
105
- | ---- | ---------- | ------------------------------------------------------------------------------------------------------------------ |
106
- | `0` | success | — |
107
- | `1` | internal | `CRUXY_E_INTERNAL` |
108
- | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH` |
109
- | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
110
- | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
111
- | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
112
- | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
113
- | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
114
- | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
115
- | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
116
- | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED` |
121
+ | Exit | Category | Example codes |
122
+ | ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `0` | success | — |
124
+ | `1` | internal | `CRUXY_E_INTERNAL` |
125
+ | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT` |
126
+ | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
127
+ | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
128
+ | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
129
+ | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
130
+ | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
131
+ | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
132
+ | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
133
+ | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED` |
117
134
 
118
135
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
119
136
  provider-agnostic, built over `fetch`, with no vendor SDKs.
@@ -30,6 +30,8 @@ export interface RunAgentArgs {
30
30
  } | null;
31
31
  /** Project instructions (e.g. from CRUXY.md) folded into the system prompt. */
32
32
  projectInstructions?: string | null;
33
+ /** Plan mode's propose phase (C.31): inject the plan-first system directive. */
34
+ planMode?: boolean;
33
35
  }
34
36
  export interface AgentResult {
35
37
  /** The full conversation, including assistant tool calls and tool results. */
@@ -32,6 +32,7 @@ export async function runAgent(args) {
32
32
  .map((tool) => ({ name: tool.name, description: tool.description })),
33
33
  git: args.git ?? null,
34
34
  projectInstructions: args.projectInstructions ?? null,
35
+ planMode: args.planMode ?? false,
35
36
  });
36
37
  let iterations = 0;
37
38
  for (let i = 0; i < maxIterations; i++) {
@@ -28,6 +28,8 @@ export interface PromptContext {
28
28
  } | null;
29
29
  /** Optional extra instructions (e.g. from a project CRUXY.md). */
30
30
  projectInstructions?: string | null;
31
+ /** Plan mode's propose phase (C.31): inject the plan-first directive. */
32
+ planMode?: boolean;
31
33
  }
32
34
  /** Assemble the full system prompt for a session. */
33
35
  export declare function buildSystemPrompt(ctx: PromptContext): string;
@@ -6,6 +6,10 @@
6
6
  * dynamic environment block, so the model always knows where it is, what it
7
7
  * can do, and how it's expected to behave.
8
8
  */
9
+ /** The plan-mode directive, injected during the propose phase only. */
10
+ const PLAN_MODE_SECTION = `## Plan mode
11
+ You are in plan mode. Do NOT edit files, run commands, or take any side-effecting action yet.
12
+ First investigate with the read-only tools if you need to, then call \`submit_plan\` with an ordered list of steps — each with a title, a one-line rationale, and a kind (read | mutate | destructive). Cover the whole task; keep steps concrete and minimal. After you call \`submit_plan\`, stop and end your turn — the user reviews and approves the plan before you execute it.`;
9
13
  /**
10
14
  * The static core of cruxy's behaviour. Phrased as direct instruction to the
11
15
  * model. Keep this tight — every line earns its place; vague prose dilutes the
@@ -74,6 +78,8 @@ export function buildSystemPrompt(ctx) {
74
78
  const approval = "Side-effecting actions (file writes, shell commands) require the user's approval; destructive or irreversible actions are flagged distinctly.";
75
79
  const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
76
80
  const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
81
+ if (ctx.planMode)
82
+ sections.push(PLAN_MODE_SECTION);
77
83
  if (ctx.projectInstructions?.trim()) {
78
84
  sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
79
85
  }
@@ -3,6 +3,17 @@ import type { CruxyConfig } from "../config/index.js";
3
3
  import type { ToolContext } from "../tools/index.js";
4
4
  import type { ToolRegistry } from "../tools/index.js";
5
5
  import { type AgentResult } from "./loop.js";
6
+ /**
7
+ * Plan-mode turn runner (C.31), injected so the agent package doesn't depend on
8
+ * the plan package. When plan mode is on, `send` delegates the whole turn to
9
+ * this instead of a single `runAgent` call. Receives the running history (with
10
+ * the user turn already appended) and returns the extended history to adopt.
11
+ */
12
+ export type PlanRunner = (args: {
13
+ messages: Message[];
14
+ projectInstructions: string | null;
15
+ onText?: (delta: string) => void;
16
+ }) => Promise<AgentResult>;
6
17
  export interface SessionArgs {
7
18
  /** A constructed provider to stream from. */
8
19
  provider: Provider;
@@ -19,6 +30,10 @@ export interface SessionArgs {
19
30
  } | null;
20
31
  /** Project instructions (e.g. CRUXY.md) folded into every turn's system prompt. */
21
32
  projectInstructions?: string | null;
33
+ /** Start in plan mode (C.31). Toggleable at runtime via `setPlanMode`. */
34
+ planMode?: boolean;
35
+ /** The plan-mode turn runner; required for plan mode to actually engage. */
36
+ planRunner?: PlanRunner;
22
37
  }
23
38
  /**
24
39
  * Estimate the token footprint of a message list with a cheap chars/4 heuristic
@@ -46,7 +61,16 @@ export declare class Session {
46
61
  private readonly args;
47
62
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
48
63
  private projectInstructions;
64
+ /** Mutable so `/plan` can toggle plan mode mid-session. */
65
+ private planMode;
49
66
  constructor(args: SessionArgs);
67
+ /** Whether plan mode is currently on. */
68
+ getPlanMode(): boolean;
69
+ /**
70
+ * Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
71
+ * the session factory); without one, plan mode stays off.
72
+ */
73
+ setPlanMode(enabled: boolean): void;
50
74
  /**
51
75
  * Run one user turn: append the prompt, compact if the history has grown past
52
76
  * the threshold, drive the agent loop over the full history, adopt the
@@ -49,9 +49,25 @@ export class Session {
49
49
  args;
50
50
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
51
51
  projectInstructions;
52
+ /** Mutable so `/plan` can toggle plan mode mid-session. */
53
+ planMode;
52
54
  constructor(args) {
53
55
  this.args = args;
54
56
  this.projectInstructions = args.projectInstructions ?? null;
57
+ // Plan mode requires a wired runner; without one it stays off (no half-on
58
+ // state where the plan directive is injected but nothing orchestrates it).
59
+ this.planMode = (args.planMode ?? false) && args.planRunner !== undefined;
60
+ }
61
+ /** Whether plan mode is currently on. */
62
+ getPlanMode() {
63
+ return this.planMode;
64
+ }
65
+ /**
66
+ * Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
67
+ * the session factory); without one, plan mode stays off.
68
+ */
69
+ setPlanMode(enabled) {
70
+ this.planMode = enabled && this.args.planRunner !== undefined;
55
71
  }
56
72
  /**
57
73
  * Run one user turn: append the prompt, compact if the history has grown past
@@ -65,13 +81,23 @@ export class Session {
65
81
  this.messages.push({ role: "user", content: userPrompt });
66
82
  // Compact *before* the agent call so the turn runs against a bounded history.
67
83
  await this.maybeCompact();
68
- const result = await runAgent({
69
- messages: this.messages,
70
- ...this.args,
71
- // After the spread so a mid-session `/reload` wins over the initial value.
72
- projectInstructions: this.projectInstructions,
73
- onText,
74
- });
84
+ // Plan mode (C.31) delegates the whole turn to the injected runner: propose a
85
+ // plan, approve/revise, then execute step-by-step. Falls back to the normal
86
+ // single-shot loop when off or unwired, so existing behavior is untouched.
87
+ const result = this.planMode && this.args.planRunner
88
+ ? await this.args.planRunner({
89
+ messages: this.messages,
90
+ projectInstructions: this.projectInstructions,
91
+ onText,
92
+ })
93
+ : await runAgent({
94
+ messages: this.messages,
95
+ ...this.args,
96
+ // After the spread so a mid-session `/reload` wins over the initial value.
97
+ projectInstructions: this.projectInstructions,
98
+ planMode: false, // the plan directive belongs only to the runner's propose phase
99
+ onText,
100
+ });
75
101
  this.messages = result.messages;
76
102
  this.usage.input_tokens += result.usage.input_tokens;
77
103
  this.usage.output_tokens += result.usage.output_tokens;
@@ -0,0 +1,7 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy init` — set up a project (U.6): ensure a key (acquired only if missing),
4
+ * offer to scaffold a project `CRUXY.md`, and offer a first-win demo run. Like
5
+ * `login`, it's interactive-only.
6
+ */
7
+ export declare function initCommand(): Command;
@@ -0,0 +1,40 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { loadConfig } from "../../config/index.js";
5
+ import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
6
+ import { runFirstWinTask } from "../onboard.js";
7
+ /**
8
+ * `cruxy init` — set up a project (U.6): ensure a key (acquired only if missing),
9
+ * offer to scaffold a project `CRUXY.md`, and offer a first-win demo run. Like
10
+ * `login`, it's interactive-only.
11
+ */
12
+ export function initCommand() {
13
+ return new Command("init")
14
+ .description("set up cruxy in this project (key + CRUXY.md + a first run)")
15
+ .action(async () => {
16
+ if (!process.stdin.isTTY) {
17
+ logger.print(pc.dim("cruxy init is interactive — run it in a terminal, or export your key as an environment variable."));
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const cwd = process.cwd();
22
+ const { config } = loadConfig();
23
+ const result = await runOnboarding({
24
+ provider: config.model.provider,
25
+ mode: "init",
26
+ offerScaffold: true,
27
+ offerFirstWin: true,
28
+ io: defaultOnboardingIO(),
29
+ deps: createDefaultDeps({
30
+ config,
31
+ cwd,
32
+ runTask: (prompt) => runFirstWinTask(config, cwd, prompt),
33
+ }),
34
+ });
35
+ if (!result.completed) {
36
+ logger.print(pc.dim("setup not completed — run `cruxy init` again to resume."));
37
+ process.exitCode = 1;
38
+ }
39
+ });
40
+ }
@@ -0,0 +1,8 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `cruxy login` — set or replace the API key on demand (U.6). Runs the key step
4
+ * of onboarding (always, even if a key already resolves — this is how you
5
+ * re-key / switch), validates it live, and persists it to the credentials store.
6
+ * Non-interactive invocations fail loud rather than hang.
7
+ */
8
+ export declare function loginCommand(): Command;
@@ -0,0 +1,36 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { loadConfig } from "../../config/index.js";
5
+ import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
6
+ /**
7
+ * `cruxy login` — set or replace the API key on demand (U.6). Runs the key step
8
+ * of onboarding (always, even if a key already resolves — this is how you
9
+ * re-key / switch), validates it live, and persists it to the credentials store.
10
+ * Non-interactive invocations fail loud rather than hang.
11
+ */
12
+ export function loginCommand() {
13
+ return new Command("login")
14
+ .description("set or replace your API key (validated, saved to ~/.cruxy)")
15
+ .action(async () => {
16
+ if (!process.stdin.isTTY) {
17
+ logger.print(pc.dim("cruxy login is interactive — run it in a terminal, or export your key as an environment variable."));
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const { config } = loadConfig();
22
+ const result = await runOnboarding({
23
+ provider: config.model.provider,
24
+ mode: "login",
25
+ forceKey: true,
26
+ offerFirstWin: false,
27
+ offerScaffold: false,
28
+ io: defaultOnboardingIO(),
29
+ deps: createDefaultDeps({ config, cwd: process.cwd() }),
30
+ });
31
+ if (!result.completed) {
32
+ logger.print(pc.dim("login not completed."));
33
+ process.exitCode = 1;
34
+ }
35
+ });
36
+ }
@@ -1,20 +1,18 @@
1
1
  import { Command } from "commander";
2
2
  import pc from "picocolors";
3
- import { createProvider } from "@cruxy/sdk";
4
3
  import { logger } from "../../utils/logger.js";
5
- import { loadConfig, resolveApiKey, loadProjectInstructions, } from "../../config/index.js";
4
+ import { loadConfig, resolveApiKey } from "../../config/index.js";
6
5
  import { authMissingKey, usageError } from "../../errors/index.js";
7
- import { ApprovalService } from "../../approval/index.js";
8
- import { buildDefaultRegistry } from "../../tools/index.js";
9
- import { Session } from "../../agent/index.js";
10
6
  import { runInteractive } from "../repl.js";
11
7
  import { createStreamPrinter } from "../stream-print.js";
12
- import { getGitInfo } from "../../utils/git.js";
8
+ import { buildAgentSession } from "../session-factory.js";
9
+ import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
13
10
  export function runCommand() {
14
11
  return new Command("run")
15
12
  .description("run cruxy on a prompt (one-shot), or with no prompt for an interactive session")
16
13
  .argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
17
- .action(async (promptParts) => {
14
+ .option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
15
+ .action(async (promptParts, opts) => {
18
16
  const prompt = promptParts.join(" ").trim();
19
17
  const interactive = prompt === "";
20
18
  // No prompt and stdin isn't a terminal: there's no way to read input and
@@ -23,45 +21,36 @@ export function runCommand() {
23
21
  throw usageError("cruxy run needs a prompt when stdin is not a terminal", ['provide a task, e.g. cruxy run "fix the failing test"']);
24
22
  }
25
23
  const { config, sources } = loadConfig();
26
- const apiKey = resolveApiKey(config.model.provider);
24
+ let apiKey = resolveApiKey(config.model.provider);
27
25
  logger.info(pc.dim(`model: ${config.model.provider}/${config.model.model}`));
28
26
  logger.info(pc.dim(`config: ${sources.project ?? sources.global ?? "defaults"}`));
27
+ // First-run with no key (and a TTY) → guided onboarding instead of the
28
+ // dead-end auth error. The first-win demo is offered only in the no-prompt
29
+ // (REPL) path; with a real prompt, that prompt IS the first win.
29
30
  if (!apiKey) {
30
- throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
31
+ const onboarding = maybeRunOnboarding(config, {
32
+ ttyInteractive: Boolean(process.stdin.isTTY),
33
+ offerFirstWin: interactive,
34
+ cwd: process.cwd(),
35
+ });
36
+ if (onboarding === null) {
37
+ // Not a first run (non-TTY, or onboarded then key removed) → fail loud.
38
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
39
+ }
40
+ const result = await onboarding;
41
+ if (!result.completed) {
42
+ // Aborted/failed mid-setup — guidance already shown; exit cleanly.
43
+ logger.print(pc.dim("run `cruxy login` to finish setup."));
44
+ return;
45
+ }
46
+ apiKey = result.apiKey ?? resolveApiKey(config.model.provider);
47
+ if (!apiKey) {
48
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
49
+ }
31
50
  }
32
- const provider = createProvider({
33
- provider: config.model.provider,
34
- apiKey,
35
- model: config.model.model,
36
- maxTokens: config.model.maxTokens,
37
- temperature: config.model.temperature,
38
- gatewayUrl: config.cruxy.gatewayUrl,
39
- });
40
- const registry = buildDefaultRegistry();
41
- // The approval gate. Default-deny, risk-tiered, scoped session allowlist;
42
- // no global skip flag (the policy seam lives in src/approval). When stdin
43
- // isn't a TTY, a side-effecting action fails with CRUXY_E_APPROVAL_REQUIRED
44
- // rather than being auto-approved.
45
- const approval = new ApprovalService({
46
- cwd: process.cwd(),
47
- interactive: Boolean(process.stdin.isTTY),
48
- });
49
- const ctx = {
50
- cwd: process.cwd(),
51
- config,
52
- logger,
53
- requestApproval: (action) => approval.requestApproval(action),
54
- };
55
- const projectInstructions = loadProjectInstructions(process.cwd());
56
- const git = getGitInfo(process.cwd());
57
- const session = new Session({
58
- provider,
59
- registry,
60
- config,
61
- ctx,
62
- git,
63
- projectInstructions,
64
- });
51
+ // Plan mode is opt-in: --plan flag overrides the config default.
52
+ const planMode = opts.plan ?? config.agent.planMode;
53
+ const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode);
65
54
  if (interactive) {
66
55
  await runInteractive(session);
67
56
  return;
@@ -80,14 +69,3 @@ export function runCommand() {
80
69
  `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
81
70
  });
82
71
  }
83
- /** Environment variable that holds the API key for a provider. */
84
- function apiKeyEnvVar(provider) {
85
- switch (provider) {
86
- case "anthropic":
87
- return "ANTHROPIC_API_KEY";
88
- case "openai":
89
- return "OPENAI_API_KEY";
90
- default:
91
- return "CRUXY_API_KEY";
92
- }
93
- }
@@ -0,0 +1,25 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import { type OnboardingResult } from "../onboarding/index.js";
3
+ /**
4
+ * CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
5
+ * `run`/`program`/`login`/`init` thin: they decide *when* to onboard; this builds
6
+ * the production wiring (live validation, credential persistence, first-win run).
7
+ */
8
+ /** The env var that holds the key for a provider (for the fail-loud message). */
9
+ export declare function apiKeyEnvVar(provider: string): string;
10
+ /** Run one first-win task end-to-end against the just-saved key. */
11
+ export declare function runFirstWinTask(config: CruxyConfig, cwd: string, prompt: string): Promise<void>;
12
+ export interface MaybeOnboardOptions {
13
+ /** Whether stdin is a TTY (the gate). */
14
+ ttyInteractive: boolean;
15
+ /** Offer the first-win demo run (no real task queued). */
16
+ offerFirstWin: boolean;
17
+ cwd: string;
18
+ }
19
+ /**
20
+ * Run the guided first-run flow **iff** this is a first run; otherwise return
21
+ * `null` so the caller can fall back (fail loud, or show a banner). Never throws
22
+ * for an aborted setup — `result.completed` is `false` and the caller exits
23
+ * cleanly.
24
+ */
25
+ export declare function maybeRunOnboarding(config: CruxyConfig, opts: MaybeOnboardOptions): Promise<OnboardingResult> | null;
@@ -0,0 +1,54 @@
1
+ import { resolveApiKey } from "../config/index.js";
2
+ import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
3
+ import { createStreamPrinter } from "./stream-print.js";
4
+ import { buildAgentSession } from "./session-factory.js";
5
+ /**
6
+ * CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
7
+ * `run`/`program`/`login`/`init` thin: they decide *when* to onboard; this builds
8
+ * the production wiring (live validation, credential persistence, first-win run).
9
+ */
10
+ /** The env var that holds the key for a provider (for the fail-loud message). */
11
+ export function apiKeyEnvVar(provider) {
12
+ switch (provider) {
13
+ case "anthropic":
14
+ return "ANTHROPIC_API_KEY";
15
+ case "openai":
16
+ return "OPENAI_API_KEY";
17
+ default:
18
+ return "CRUXY_API_KEY";
19
+ }
20
+ }
21
+ /** Run one first-win task end-to-end against the just-saved key. */
22
+ export async function runFirstWinTask(config, cwd, prompt) {
23
+ const apiKey = resolveApiKey(config.model.provider);
24
+ if (!apiKey)
25
+ return; // defensive — the key was just persisted
26
+ const session = buildAgentSession(config, apiKey, cwd, true);
27
+ const print = createStreamPrinter((text) => process.stdout.write(text));
28
+ await session.send(prompt, print);
29
+ }
30
+ /**
31
+ * Run the guided first-run flow **iff** this is a first run; otherwise return
32
+ * `null` so the caller can fall back (fail loud, or show a banner). Never throws
33
+ * for an aborted setup — `result.completed` is `false` and the caller exits
34
+ * cleanly.
35
+ */
36
+ export function maybeRunOnboarding(config, opts) {
37
+ const provider = config.model.provider;
38
+ if (!isFirstRun({ provider, interactive: opts.ttyInteractive }))
39
+ return null;
40
+ const deps = createDefaultDeps({
41
+ config,
42
+ cwd: opts.cwd,
43
+ runTask: opts.offerFirstWin
44
+ ? (prompt) => runFirstWinTask(config, opts.cwd, prompt)
45
+ : undefined,
46
+ });
47
+ return runOnboarding({
48
+ provider,
49
+ mode: "first-run",
50
+ offerFirstWin: opts.offerFirstWin,
51
+ io: defaultOnboardingIO(),
52
+ deps,
53
+ });
54
+ }
@@ -8,6 +8,10 @@ import { configCommand } from "./commands/config.js";
8
8
  import { indexCommand } from "./commands/index.js";
9
9
  import { skillsCommand } from "./commands/skills.js";
10
10
  import { prCommand } from "./commands/pr.js";
11
+ import { loginCommand } from "./commands/login.js";
12
+ import { initCommand } from "./commands/init.js";
13
+ import { loadConfig } from "../config/index.js";
14
+ import { maybeRunOnboarding } from "./onboard.js";
11
15
  export function buildProgram() {
12
16
  const program = new Command();
13
17
  program
@@ -30,15 +34,29 @@ export function buildProgram() {
30
34
  program.addCommand(indexCommand());
31
35
  program.addCommand(skillsCommand());
32
36
  program.addCommand(prCommand());
37
+ program.addCommand(loginCommand());
38
+ program.addCommand(initCommand());
33
39
  // Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
34
40
  // means an unknown command (Commander runs the default action with it as an
35
41
  // operand rather than erroring), so reject it as a usage error.
36
- program.action((_opts, command) => {
42
+ program.action(async (_opts, command) => {
37
43
  if (command.args.length > 0) {
38
44
  throw usageError(`unknown command: ${command.args[0]}`, [
39
45
  "run `cruxy --help` to see available commands",
40
46
  ]);
41
47
  }
48
+ // First-run with no key (and a TTY) → guided setup, with the first-win demo.
49
+ // Otherwise fall through to the banner (an already-set-up user, or non-TTY).
50
+ const { config } = loadConfig();
51
+ const onboarding = maybeRunOnboarding(config, {
52
+ ttyInteractive: Boolean(process.stdin.isTTY),
53
+ offerFirstWin: true,
54
+ cwd: process.cwd(),
55
+ });
56
+ if (onboarding) {
57
+ await onboarding;
58
+ return;
59
+ }
42
60
  logger.print(pc.cyan(`${APP_NAME} v${APP_VERSION}`));
43
61
  logger.print(pc.dim("an agentic coding CLI\n"));
44
62
  logger.print("The interactive REPL lands in C.3 (terminal UI).");
package/dist/cli/repl.js CHANGED
@@ -9,6 +9,7 @@ const HELP = `Commands:
9
9
  /clear clear the conversation history (keep the session)
10
10
  /compact summarize older history to free up context now
11
11
  /reload re-read project instructions (CRUXY.md)
12
+ /plan toggle plan mode (propose a plan before executing)
12
13
  /exit, /quit leave cruxy
13
14
  Ctrl+D leave cruxy`;
14
15
  const defaultIO = () => ({
@@ -107,6 +108,14 @@ export async function runInteractive(session, io = defaultIO()) {
107
108
  : "no project instructions found"));
108
109
  continue;
109
110
  }
111
+ if (trimmed === "/plan") {
112
+ session.setPlanMode(!session.getPlanMode());
113
+ const on = session.getPlanMode();
114
+ logger.print(pc.dim(on
115
+ ? "plan mode on — the next prompt proposes a plan for approval"
116
+ : "plan mode off"));
117
+ continue;
118
+ }
110
119
  if (trimmed === "/help") {
111
120
  logger.print(HELP);
112
121
  continue;
@@ -0,0 +1,12 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import { Session } from "../agent/index.js";
3
+ /**
4
+ * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
5
+ * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
6
+ * The approval gate's interactivity tracks the TTY, exactly as in `run`.
7
+ *
8
+ * When `planMode` is on (C.31), it additionally wires a {@link PlanExecutionPolicy}
9
+ * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
10
+ * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
11
+ */
12
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean): Session;