@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
@@ -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";
6
+ import { createRenderer } from "../../render/index.js";
10
7
  import { runInteractive } from "../repl.js";
11
- 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,71 +21,57 @@ 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
+ // One renderer for the whole run (U.2): the streaming path and the
54
+ // approval prompt's status-suspend hook must share the same live region.
55
+ const renderer = createRenderer();
56
+ const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer);
65
57
  if (interactive) {
66
- await runInteractive(session);
58
+ await runInteractive(session, undefined, renderer);
67
59
  return;
68
60
  }
69
61
  // One-shot: a single turn, then exit. Preserves scripting/pipe use.
70
- // Assistant text streams to stdout delta by delta (same as the REPL),
71
- // through a printer that trims the model's leading blank lines; the agent
72
- // loop terminates the line.
62
+ // Assistant text streams to stdout delta by delta (same as the REPL);
63
+ // piped output degrades to the plain renderer (no ANSI, chrome on stderr).
73
64
  logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
74
65
  // Provider/network/auth failures propagate to the top-level boundary,
75
66
  // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
76
67
  // the matching code — a one-shot run must fail non-zero on error.
77
- const print = createStreamPrinter((text) => process.stdout.write(text));
78
- const result = await session.send(prompt, print);
79
- logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
80
- `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
68
+ try {
69
+ const result = await session.send(prompt, renderer);
70
+ logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
71
+ `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
72
+ }
73
+ finally {
74
+ renderer.close();
75
+ }
81
76
  });
82
77
  }
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,59 @@
1
+ import { resolveApiKey } from "../config/index.js";
2
+ import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
3
+ import { createRenderer } from "../render/index.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 renderer = createRenderer();
27
+ const session = buildAgentSession(config, apiKey, cwd, true, false, renderer);
28
+ try {
29
+ await session.send(prompt, renderer);
30
+ }
31
+ finally {
32
+ renderer.close();
33
+ }
34
+ }
35
+ /**
36
+ * Run the guided first-run flow **iff** this is a first run; otherwise return
37
+ * `null` so the caller can fall back (fail loud, or show a banner). Never throws
38
+ * for an aborted setup — `result.completed` is `false` and the caller exits
39
+ * cleanly.
40
+ */
41
+ export function maybeRunOnboarding(config, opts) {
42
+ const provider = config.model.provider;
43
+ if (!isFirstRun({ provider, interactive: opts.ttyInteractive }))
44
+ return null;
45
+ const deps = createDefaultDeps({
46
+ config,
47
+ cwd: opts.cwd,
48
+ runTask: opts.offerFirstWin
49
+ ? (prompt) => runFirstWinTask(config, opts.cwd, prompt)
50
+ : undefined,
51
+ });
52
+ return runOnboarding({
53
+ provider,
54
+ mode: "first-run",
55
+ offerFirstWin: opts.offerFirstWin,
56
+ io: defaultOnboardingIO(),
57
+ deps,
58
+ });
59
+ }
@@ -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).");
@@ -1,5 +1,6 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
2
  import type { Session } from "../agent/index.js";
3
+ import { type StreamRenderer } from "../render/index.js";
3
4
  /** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
4
5
  export interface ReplIO {
5
6
  input: Readable;
@@ -7,9 +8,13 @@ export interface ReplIO {
7
8
  }
8
9
  /**
9
10
  * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
10
- * commands or run a turn, repeat. Assistant text streams to stdout from within
11
- * `session.send` (via the logger); this loop only owns input and control.
11
+ * commands or run a turn, repeat. Assistant text and tool-call progress stream
12
+ * through the `renderer` from within `session.send`; this loop only owns input
13
+ * and control.
12
14
  *
13
- * `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
15
+ * `io` defaults to real stdin/stdout; tests inject a scripted stream pair. The
16
+ * renderer defaults to whatever `io.output` supports (a TTY gets the managed
17
+ * live region, anything else the plain append-only renderer); `cruxy run`
18
+ * passes its own so the approval prompt's status-suspend hook shares it.
14
19
  */
15
- export declare function runInteractive(session: Session, io?: ReplIO): Promise<void>;
20
+ export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer): Promise<void>;
package/dist/cli/repl.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import readline from "node:readline";
2
2
  import pc from "picocolors";
3
3
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
4
+ import { createRenderer } from "../render/index.js";
4
5
  import { logger } from "../utils/logger.js";
5
- import { createStreamPrinter } from "./stream-print.js";
6
6
  const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
7
7
  const HELP = `Commands:
8
8
  /help show this help
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 = () => ({
@@ -64,13 +65,25 @@ function printReplError(err) {
64
65
  }
65
66
  /**
66
67
  * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
67
- * commands or run a turn, repeat. Assistant text streams to stdout from within
68
- * `session.send` (via the logger); this loop only owns input and control.
68
+ * commands or run a turn, repeat. Assistant text and tool-call progress stream
69
+ * through the `renderer` from within `session.send`; this loop only owns input
70
+ * and control.
69
71
  *
70
- * `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
72
+ * `io` defaults to real stdin/stdout; tests inject a scripted stream pair. The
73
+ * renderer defaults to whatever `io.output` supports (a TTY gets the managed
74
+ * live region, anything else the plain append-only renderer); `cruxy run`
75
+ * passes its own so the approval prompt's status-suspend hook shares it.
71
76
  */
72
- export async function runInteractive(session, io = defaultIO()) {
77
+ export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr)) {
73
78
  logger.print(pc.dim("interactive session — /help for commands, /exit or Ctrl+D to quit"));
79
+ try {
80
+ await replLoop(session, io, renderer);
81
+ }
82
+ finally {
83
+ renderer.close();
84
+ }
85
+ }
86
+ async function replLoop(session, io, renderer) {
74
87
  for (;;) {
75
88
  const line = await readLine(io, PROMPT);
76
89
  // EOF / Ctrl+D.
@@ -107,18 +120,25 @@ export async function runInteractive(session, io = defaultIO()) {
107
120
  : "no project instructions found"));
108
121
  continue;
109
122
  }
123
+ if (trimmed === "/plan") {
124
+ session.setPlanMode(!session.getPlanMode());
125
+ const on = session.getPlanMode();
126
+ logger.print(pc.dim(on
127
+ ? "plan mode on — the next prompt proposes a plan for approval"
128
+ : "plan mode off"));
129
+ continue;
130
+ }
110
131
  if (trimmed === "/help") {
111
132
  logger.print(HELP);
112
133
  continue;
113
134
  }
114
- // A real turn. Assistant text streams to the output delta by delta through a
115
- // single printer (which trims the model's leading blank lines); the agent
116
- // loop closes the segment with one newline, so the next prompt lands on its
117
- // own line. Errors (provider/API failures) log and return to the prompt
118
- // rather than killing the REPL.
135
+ // A real turn. Assistant text streams through the renderer delta by delta
136
+ // (leading blank lines trimmed, code fences highlighted); the agent loop
137
+ // closes each segment with one newline, so the next prompt lands on its own
138
+ // line. Errors (provider/API failures) log and return to the prompt rather
139
+ // than killing the REPL.
119
140
  try {
120
- const print = createStreamPrinter((text) => io.output.write(text));
121
- await session.send(line, print);
141
+ await session.send(line, renderer);
122
142
  }
123
143
  catch (err) {
124
144
  logger.error(err.message);
@@ -0,0 +1,13 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { StreamRenderer } from "../render/index.js";
3
+ import { Session } from "../agent/index.js";
4
+ /**
5
+ * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
6
+ * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
7
+ * The approval gate's interactivity tracks the TTY, exactly as in `run`.
8
+ *
9
+ * When `planMode` is on (C.31), it additionally wires a {@link PlanExecutionPolicy}
10
+ * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
11
+ * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
12
+ */
13
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer): Session;
@@ -0,0 +1,109 @@
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
+ * Wrap a PromptIO so the renderer's transient status line is erased before any
12
+ * prompt text lands (U.2): the approval prompt writes to stderr while the
13
+ * spinner owns the last stdout row of the same terminal — clearing first keeps
14
+ * the prompt from tearing through the live region.
15
+ */
16
+ function suspendStatusOnPrompt(io, renderer) {
17
+ if (!renderer)
18
+ return io;
19
+ return {
20
+ ...io,
21
+ write: (text) => {
22
+ renderer.status(null);
23
+ io.write(text);
24
+ },
25
+ };
26
+ }
27
+ /**
28
+ * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
29
+ * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
30
+ * The approval gate's interactivity tracks the TTY, exactly as in `run`.
31
+ *
32
+ * When `planMode` is on (C.31), it additionally wires a {@link PlanExecutionPolicy}
33
+ * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
34
+ * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
35
+ */
36
+ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer) {
37
+ const provider = createProvider({
38
+ provider: config.model.provider,
39
+ apiKey,
40
+ model: config.model.model,
41
+ maxTokens: config.model.maxTokens,
42
+ temperature: config.model.temperature,
43
+ gatewayUrl: config.cruxy.gatewayUrl,
44
+ });
45
+ const execRegistry = buildDefaultRegistry();
46
+ const git = getGitInfo(cwd);
47
+ const projectInstructions = loadProjectInstructions(cwd);
48
+ if (planMode) {
49
+ // One io + allowlist shared by the plan-approval prompt and the per-action
50
+ // gate, so a grant recorded during execution is honored by U.3's own check.
51
+ const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
52
+ const allowlist = new SessionAllowlist();
53
+ const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io));
54
+ const approval = new ApprovalService({
55
+ cwd,
56
+ interactive: ttyInteractive,
57
+ policy: planPolicy,
58
+ io,
59
+ });
60
+ const ctx = {
61
+ cwd,
62
+ config,
63
+ logger,
64
+ requestApproval: (action) => approval.requestApproval(action),
65
+ };
66
+ const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
67
+ provider,
68
+ config,
69
+ ctx,
70
+ execRegistry,
71
+ planPolicy,
72
+ io,
73
+ interactive: ttyInteractive,
74
+ messages,
75
+ git,
76
+ projectInstructions,
77
+ renderer: turnRenderer,
78
+ });
79
+ return new Session({
80
+ provider,
81
+ registry: execRegistry,
82
+ config,
83
+ ctx,
84
+ git,
85
+ projectInstructions,
86
+ planMode: true,
87
+ planRunner,
88
+ });
89
+ }
90
+ const approval = new ApprovalService({
91
+ cwd,
92
+ interactive: ttyInteractive,
93
+ io: suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer),
94
+ });
95
+ const ctx = {
96
+ cwd,
97
+ config,
98
+ logger,
99
+ requestApproval: (action) => approval.requestApproval(action),
100
+ };
101
+ return new Session({
102
+ provider,
103
+ registry: execRegistry,
104
+ config,
105
+ ctx,
106
+ git,
107
+ projectInstructions,
108
+ });
109
+ }
@@ -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;