@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.
- package/README.md +39 -16
- package/dist/agent/loop.d.ts +9 -5
- package/dist/agent/loop.js +53 -10
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +6 -0
- package/dist/agent/session.d.ts +29 -3
- package/dist/agent/session.js +37 -10
- package/dist/approval/prompt.d.ts +9 -0
- package/dist/approval/prompt.js +2 -77
- package/dist/cli/commands/init.d.ts +7 -0
- package/dist/cli/commands/init.js +40 -0
- package/dist/cli/commands/login.d.ts +8 -0
- package/dist/cli/commands/login.js +36 -0
- package/dist/cli/commands/run.js +46 -62
- package/dist/cli/onboard.d.ts +25 -0
- package/dist/cli/onboard.js +59 -0
- package/dist/cli/program.js +19 -1
- package/dist/cli/repl.d.ts +9 -4
- package/dist/cli/repl.js +32 -12
- package/dist/cli/session-factory.d.ts +13 -0
- package/dist/cli/session-factory.js +109 -0
- package/dist/config/credentials.d.ts +10 -0
- package/dist/config/credentials.js +69 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.d.ts +6 -1
- package/dist/config/manager.js +11 -1
- package/dist/config/schema.d.ts +10 -0
- package/dist/config/schema.js +2 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/errors/constructors.d.ts +10 -0
- package/dist/errors/constructors.js +46 -2
- package/dist/errors/types.d.ts +3 -0
- package/dist/errors/types.js +6 -0
- package/dist/onboarding/detect.d.ts +26 -0
- package/dist/onboarding/detect.js +56 -0
- package/dist/onboarding/flow.d.ts +28 -0
- package/dist/onboarding/flow.js +100 -0
- package/dist/onboarding/index.d.ts +5 -0
- package/dist/onboarding/index.js +5 -0
- package/dist/onboarding/io.d.ts +8 -0
- package/dist/onboarding/io.js +133 -0
- package/dist/onboarding/steps.d.ts +17 -0
- package/dist/onboarding/steps.js +100 -0
- package/dist/onboarding/types.d.ts +81 -0
- package/dist/onboarding/types.js +6 -0
- package/dist/plan/approve.d.ts +16 -0
- package/dist/plan/approve.js +46 -0
- package/dist/plan/execute.d.ts +20 -0
- package/dist/plan/execute.js +31 -0
- package/dist/plan/index.d.ts +7 -0
- package/dist/plan/index.js +7 -0
- package/dist/plan/policy.d.ts +26 -0
- package/dist/plan/policy.js +45 -0
- package/dist/plan/render.d.ts +5 -0
- package/dist/plan/render.js +47 -0
- package/dist/plan/service.d.ts +40 -0
- package/dist/plan/service.js +118 -0
- package/dist/plan/submit-plan.d.ts +33 -0
- package/dist/plan/submit-plan.js +57 -0
- package/dist/plan/types.d.ts +60 -0
- package/dist/plan/types.js +6 -0
- package/dist/render/capabilities.d.ts +12 -0
- package/dist/render/capabilities.js +27 -0
- package/dist/render/diff.d.ts +19 -0
- package/dist/render/diff.js +80 -0
- package/dist/render/highlight.d.ts +47 -0
- package/dist/render/highlight.js +265 -0
- package/dist/render/index.d.ts +14 -0
- package/dist/render/index.js +20 -0
- package/dist/render/plain-renderer.d.ts +32 -0
- package/dist/render/plain-renderer.js +61 -0
- package/dist/render/tty-renderer.d.ts +47 -0
- package/dist/render/tty-renderer.js +149 -0
- package/dist/render/types.d.ts +76 -0
- package/dist/render/types.js +1 -0
- package/package.json +1 -1
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
|
4
|
+
import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
6
5
|
import { authMissingKey, usageError } from "../../errors/index.js";
|
|
7
|
-
import {
|
|
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 {
|
|
12
|
-
import {
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -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.d.ts
CHANGED
|
@@ -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
|
|
11
|
-
* `
|
|
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
|
|
68
|
-
* `
|
|
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
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/config/index.d.ts
CHANGED
package/dist/config/index.js
CHANGED
package/dist/config/manager.d.ts
CHANGED
|
@@ -30,5 +30,10 @@ export declare function initConfig(file: string): {
|
|
|
30
30
|
path: string;
|
|
31
31
|
created: boolean;
|
|
32
32
|
};
|
|
33
|
-
/**
|
|
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;
|
package/dist/config/manager.js
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|