@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.
- package/README.md +33 -16
- package/dist/agent/loop.d.ts +2 -0
- package/dist/agent/loop.js +1 -0
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +6 -0
- package/dist/agent/session.d.ts +24 -0
- package/dist/agent/session.js +33 -7
- 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 +31 -53
- package/dist/cli/onboard.d.ts +25 -0
- package/dist/cli/onboard.js +54 -0
- package/dist/cli/program.js +19 -1
- package/dist/cli/repl.js +9 -0
- package/dist/cli/session-factory.d.ts +12 -0
- package/dist/cli/session-factory.js +88 -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 +39 -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/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
|
-
|
|
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
|
-
|
|
22
|
-
cruxy
|
|
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.
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -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. */
|
package/dist/agent/loop.js
CHANGED
|
@@ -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++) {
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent/prompts.js
CHANGED
|
@@ -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
|
}
|
package/dist/agent/session.d.ts
CHANGED
|
@@ -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
|
package/dist/agent/session.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
}
|
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 { 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 {
|
|
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,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
|
-
|
|
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
|
-
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
|
+
}
|
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.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;
|