@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/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,17 @@ 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
|
+
- **Streaming render** — flicker-free live output: a single in-place status line
|
|
62
|
+
(spinner while the model thinks / tools run), append-only committed text,
|
|
63
|
+
syntax-highlighted code fences, tool-call notes, and diffs drawn by the same
|
|
64
|
+
renderer as the approval prompt. Degrades cleanly: piped/CI output is plain
|
|
65
|
+
append-only text with zero ANSI (chrome on stderr), `NO_COLOR` drops color,
|
|
66
|
+
and `CRUXY_NO_SPINNER=1` stills the animation.
|
|
67
|
+
- **Plan mode** (opt-in: `cruxy run --plan`, `/plan`, or `agent.planMode`) — the
|
|
68
|
+
agent proposes a structured, step-by-step plan; you approve it once, then it
|
|
69
|
+
executes with live per-step status. Approving consents to the _shape_ of the
|
|
70
|
+
work — every action still passes the approval gate, and destructive/ungrantable
|
|
71
|
+
actions always re-confirm even after approval.
|
|
49
72
|
- **Safety** — a single approval gate with diff previews that fails closed;
|
|
50
73
|
read-only tools never prompt; file access is confined to the project root;
|
|
51
74
|
shell commands run bounded.
|
|
@@ -101,19 +124,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
|
|
|
101
124
|
`NO_COLOR` disables color. Exit codes are stable per category, so scripts can
|
|
102
125
|
branch on them:
|
|
103
126
|
|
|
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`
|
|
127
|
+
| Exit | Category | Example codes |
|
|
128
|
+
| ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
129
|
+
| `0` | success | — |
|
|
130
|
+
| `1` | internal | `CRUXY_E_INTERNAL` |
|
|
131
|
+
| `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` |
|
|
132
|
+
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
|
|
133
|
+
| `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
|
|
134
|
+
| `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
|
|
135
|
+
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
|
|
136
|
+
| `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
|
|
137
|
+
| `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
|
|
138
|
+
| `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
|
|
139
|
+
| `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED` |
|
|
117
140
|
|
|
118
141
|
The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
|
|
119
142
|
provider-agnostic, built over `fetch`, with no vendor SDKs.
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
3
4
|
import type { ToolContext } from "../tools/index.js";
|
|
4
5
|
import { ToolRegistry } from "../tools/index.js";
|
|
5
6
|
export interface RunAgentArgs {
|
|
@@ -17,12 +18,13 @@ export interface RunAgentArgs {
|
|
|
17
18
|
/** Ambient capabilities handed to each tool. */
|
|
18
19
|
ctx: ToolContext;
|
|
19
20
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* The render seam (U.2): assistant text streams through `renderer.write`
|
|
22
|
+
* delta by delta (each non-empty segment closed via `endSegment`), and
|
|
23
|
+
* tool-call progress is surfaced as transient `status` + committed `note`
|
|
24
|
+
* lines. When omitted, behavior is unchanged (one buffered print per turn,
|
|
25
|
+
* no tool-call chrome). The loop never touches stdout directly.
|
|
24
26
|
*/
|
|
25
|
-
|
|
27
|
+
renderer?: StreamRenderer;
|
|
26
28
|
/** Git context (branch + dirty) for the system prompt's Environment section. */
|
|
27
29
|
git?: {
|
|
28
30
|
branch: string;
|
|
@@ -30,6 +32,8 @@ export interface RunAgentArgs {
|
|
|
30
32
|
} | null;
|
|
31
33
|
/** Project instructions (e.g. from CRUXY.md) folded into the system prompt. */
|
|
32
34
|
projectInstructions?: string | null;
|
|
35
|
+
/** Plan mode's propose phase (C.31): inject the plan-first system directive. */
|
|
36
|
+
planMode?: boolean;
|
|
33
37
|
}
|
|
34
38
|
export interface AgentResult {
|
|
35
39
|
/** The full conversation, including assistant tool calls and tool results. */
|
package/dist/agent/loop.js
CHANGED
|
@@ -10,10 +10,23 @@ import { buildSystemPrompt } from "./prompts.js";
|
|
|
10
10
|
* silently running tool-less.
|
|
11
11
|
*/
|
|
12
12
|
export async function runAgent(args) {
|
|
13
|
-
const { provider,
|
|
13
|
+
const { provider, config, renderer } = args;
|
|
14
14
|
if (!provider.supportsTools) {
|
|
15
15
|
throw providerUnsupported(config.model.provider);
|
|
16
16
|
}
|
|
17
|
+
renderer?.beginTurn();
|
|
18
|
+
try {
|
|
19
|
+
return await driveLoop(args, renderer);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
// Always leave the terminal clean: no orphaned status line, no held text —
|
|
23
|
+
// even when a provider error aborts the turn mid-stream.
|
|
24
|
+
renderer?.endTurn();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
|
|
28
|
+
async function driveLoop(args, renderer) {
|
|
29
|
+
const { provider, registry, config, ctx } = args;
|
|
17
30
|
const { logger } = ctx;
|
|
18
31
|
// Work on a copy so we never mutate the caller's array as a side effect; the
|
|
19
32
|
// extended history is returned for the caller to adopt.
|
|
@@ -32,6 +45,7 @@ export async function runAgent(args) {
|
|
|
32
45
|
.map((tool) => ({ name: tool.name, description: tool.description })),
|
|
33
46
|
git: args.git ?? null,
|
|
34
47
|
projectInstructions: args.projectInstructions ?? null,
|
|
48
|
+
planMode: args.planMode ?? false,
|
|
35
49
|
});
|
|
36
50
|
let iterations = 0;
|
|
37
51
|
for (let i = 0; i < maxIterations; i++) {
|
|
@@ -41,6 +55,8 @@ export async function runAgent(args) {
|
|
|
41
55
|
let turnText = "";
|
|
42
56
|
const pending = new Map();
|
|
43
57
|
const toolUses = [];
|
|
58
|
+
// Live progress while waiting on the model; dismissed by the first delta.
|
|
59
|
+
renderer?.status("thinking…");
|
|
44
60
|
for await (const ev of provider.stream({
|
|
45
61
|
system,
|
|
46
62
|
messages,
|
|
@@ -49,7 +65,7 @@ export async function runAgent(args) {
|
|
|
49
65
|
switch (ev.type) {
|
|
50
66
|
case "text_delta":
|
|
51
67
|
turnText += ev.text;
|
|
52
|
-
|
|
68
|
+
renderer?.write(ev.text);
|
|
53
69
|
break;
|
|
54
70
|
case "tool_use_start":
|
|
55
71
|
pending.set(ev.index, { id: ev.id, name: ev.name });
|
|
@@ -82,13 +98,13 @@ export async function runAgent(args) {
|
|
|
82
98
|
}
|
|
83
99
|
// ── Record the assistant turn ───────────────────────────────────────────
|
|
84
100
|
if (turnText) {
|
|
85
|
-
// Streaming (
|
|
86
|
-
// so close the segment
|
|
87
|
-
//
|
|
88
|
-
// turn, or an approval prompt starts on its own line. Otherwise
|
|
89
|
-
// whole buffered block (no-
|
|
90
|
-
if (
|
|
91
|
-
|
|
101
|
+
// Streaming (renderer set): the text already reached the user delta by
|
|
102
|
+
// delta, so close the segment through the *same* renderer — it flushes any
|
|
103
|
+
// held partial line and terminates with one newline, so tool output, the
|
|
104
|
+
// next turn, or an approval prompt starts on its own line. Otherwise
|
|
105
|
+
// render the whole buffered block (no-renderer path, unchanged).
|
|
106
|
+
if (renderer)
|
|
107
|
+
renderer.endSegment();
|
|
92
108
|
else
|
|
93
109
|
logger.print(turnText);
|
|
94
110
|
}
|
|
@@ -104,13 +120,40 @@ export async function runAgent(args) {
|
|
|
104
120
|
// ── Execute each tool call, collecting one tool_result per call ──────────
|
|
105
121
|
const toolResults = [];
|
|
106
122
|
for (const call of toolUses) {
|
|
107
|
-
|
|
123
|
+
const label = describeToolCall(call);
|
|
124
|
+
renderer?.status(`${label}…`);
|
|
125
|
+
const result = await runToolCall(call, registry, ctx);
|
|
126
|
+
renderer?.note(`${result.is_error ? "✗" : "✓"} ${label}`);
|
|
127
|
+
toolResults.push(result);
|
|
108
128
|
}
|
|
109
129
|
messages.push({ role: "user", content: toolResults });
|
|
110
130
|
}
|
|
111
131
|
logger.warn(`reached maxIterations (${maxIterations}) without completing`);
|
|
112
132
|
return { messages, iterations, stop: "max_iterations", usage };
|
|
113
133
|
}
|
|
134
|
+
/** Input keys worth surfacing in tool-call chrome, in preference order. */
|
|
135
|
+
const HINT_KEYS = ["path", "file_path", "command", "pattern", "query", "url"];
|
|
136
|
+
/** Longest hint shown before truncation — chrome, not information of record. */
|
|
137
|
+
const HINT_MAX = 60;
|
|
138
|
+
/**
|
|
139
|
+
* A short human label for a tool call ("read_file src/x.ts"): the tool name
|
|
140
|
+
* plus the first recognizable scalar argument, if any. Best-effort — unknown
|
|
141
|
+
* shapes fall back to the bare name.
|
|
142
|
+
*/
|
|
143
|
+
function describeToolCall(call) {
|
|
144
|
+
const input = call.input;
|
|
145
|
+
if (typeof input === "object" && input !== null) {
|
|
146
|
+
for (const key of HINT_KEYS) {
|
|
147
|
+
const value = input[key];
|
|
148
|
+
if (typeof value === "string" && value !== "") {
|
|
149
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
150
|
+
const hint = flat.length > HINT_MAX ? flat.slice(0, HINT_MAX - 1) + "…" : flat;
|
|
151
|
+
return `${call.name} ${hint}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return call.name;
|
|
156
|
+
}
|
|
114
157
|
/**
|
|
115
158
|
* Dispatch a single reassembled tool call to its tool and shape the outcome as
|
|
116
159
|
* a `tool_result` block. Unknown tools and invalid arguments become `is_error`
|
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
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
3
4
|
import type { ToolContext } from "../tools/index.js";
|
|
4
5
|
import type { ToolRegistry } from "../tools/index.js";
|
|
5
6
|
import { type AgentResult } from "./loop.js";
|
|
7
|
+
/**
|
|
8
|
+
* Plan-mode turn runner (C.31), injected so the agent package doesn't depend on
|
|
9
|
+
* the plan package. When plan mode is on, `send` delegates the whole turn to
|
|
10
|
+
* this instead of a single `runAgent` call. Receives the running history (with
|
|
11
|
+
* the user turn already appended) and returns the extended history to adopt.
|
|
12
|
+
*/
|
|
13
|
+
export type PlanRunner = (args: {
|
|
14
|
+
messages: Message[];
|
|
15
|
+
projectInstructions: string | null;
|
|
16
|
+
renderer?: StreamRenderer;
|
|
17
|
+
}) => Promise<AgentResult>;
|
|
6
18
|
export interface SessionArgs {
|
|
7
19
|
/** A constructed provider to stream from. */
|
|
8
20
|
provider: Provider;
|
|
@@ -19,6 +31,10 @@ export interface SessionArgs {
|
|
|
19
31
|
} | null;
|
|
20
32
|
/** Project instructions (e.g. CRUXY.md) folded into every turn's system prompt. */
|
|
21
33
|
projectInstructions?: string | null;
|
|
34
|
+
/** Start in plan mode (C.31). Toggleable at runtime via `setPlanMode`. */
|
|
35
|
+
planMode?: boolean;
|
|
36
|
+
/** The plan-mode turn runner; required for plan mode to actually engage. */
|
|
37
|
+
planRunner?: PlanRunner;
|
|
22
38
|
}
|
|
23
39
|
/**
|
|
24
40
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -46,16 +62,26 @@ export declare class Session {
|
|
|
46
62
|
private readonly args;
|
|
47
63
|
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
48
64
|
private projectInstructions;
|
|
65
|
+
/** Mutable so `/plan` can toggle plan mode mid-session. */
|
|
66
|
+
private planMode;
|
|
49
67
|
constructor(args: SessionArgs);
|
|
68
|
+
/** Whether plan mode is currently on. */
|
|
69
|
+
getPlanMode(): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
|
|
72
|
+
* the session factory); without one, plan mode stays off.
|
|
73
|
+
*/
|
|
74
|
+
setPlanMode(enabled: boolean): void;
|
|
50
75
|
/**
|
|
51
76
|
* Run one user turn: append the prompt, compact if the history has grown past
|
|
52
77
|
* the threshold, drive the agent loop over the full history, adopt the
|
|
53
78
|
* extended history, and accumulate usage. Returns the turn's `AgentResult`.
|
|
54
79
|
*
|
|
55
|
-
* `
|
|
56
|
-
* caller
|
|
80
|
+
* `renderer`, when supplied, receives assistant text deltas and tool-call
|
|
81
|
+
* progress as they stream so the caller sees the turn live (see the REPL);
|
|
82
|
+
* history is unaffected.
|
|
57
83
|
*/
|
|
58
|
-
send(userPrompt: string,
|
|
84
|
+
send(userPrompt: string, renderer?: StreamRenderer): Promise<AgentResult>;
|
|
59
85
|
/**
|
|
60
86
|
* Re-read project instructions (CRUXY.md / AGENTS.md) from the working
|
|
61
87
|
* directory so edits take effect without restarting. Returns the new text, or
|
package/dist/agent/session.js
CHANGED
|
@@ -49,29 +49,56 @@ 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
|
|
58
74
|
* the threshold, drive the agent loop over the full history, adopt the
|
|
59
75
|
* extended history, and accumulate usage. Returns the turn's `AgentResult`.
|
|
60
76
|
*
|
|
61
|
-
* `
|
|
62
|
-
* caller
|
|
77
|
+
* `renderer`, when supplied, receives assistant text deltas and tool-call
|
|
78
|
+
* progress as they stream so the caller sees the turn live (see the REPL);
|
|
79
|
+
* history is unaffected.
|
|
63
80
|
*/
|
|
64
|
-
async send(userPrompt,
|
|
81
|
+
async send(userPrompt, renderer) {
|
|
65
82
|
this.messages.push({ role: "user", content: userPrompt });
|
|
66
83
|
// Compact *before* the agent call so the turn runs against a bounded history.
|
|
67
84
|
await this.maybeCompact();
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
// Plan mode (C.31) delegates the whole turn to the injected runner: propose a
|
|
86
|
+
// plan, approve/revise, then execute step-by-step. Falls back to the normal
|
|
87
|
+
// single-shot loop when off or unwired, so existing behavior is untouched.
|
|
88
|
+
const result = this.planMode && this.args.planRunner
|
|
89
|
+
? await this.args.planRunner({
|
|
90
|
+
messages: this.messages,
|
|
91
|
+
projectInstructions: this.projectInstructions,
|
|
92
|
+
renderer,
|
|
93
|
+
})
|
|
94
|
+
: await runAgent({
|
|
95
|
+
messages: this.messages,
|
|
96
|
+
...this.args,
|
|
97
|
+
// After the spread so a mid-session `/reload` wins over the initial value.
|
|
98
|
+
projectInstructions: this.projectInstructions,
|
|
99
|
+
planMode: false, // the plan directive belongs only to the runner's propose phase
|
|
100
|
+
renderer,
|
|
101
|
+
});
|
|
75
102
|
this.messages = result.messages;
|
|
76
103
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
77
104
|
this.usage.output_tokens += result.usage.output_tokens;
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { ApprovalRequest } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The interactive prompt: render a pending action (a real diff for file edits,
|
|
4
|
+
* the exact command + cwd for shell) and read a 4-way choice. Rendering is data
|
|
5
|
+
* → string so it's testable; color is gated on `io.color` (NO_COLOR / non-TTY
|
|
6
|
+
* aware). **Default-deny**: EOF / Ctrl-C / any unrecognized key → reject.
|
|
7
|
+
*
|
|
8
|
+
* Diff/preview rendering is the shared implementation in `render/diff.ts` —
|
|
9
|
+
* the streaming path and this prompt draw the same bytes for the same change.
|
|
10
|
+
*/
|
|
2
11
|
/** The four user choices (plus the implicit default-deny). */
|
|
3
12
|
export type PromptChoice = {
|
|
4
13
|
kind: "once";
|
package/dist/approval/prompt.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
-
|
|
4
|
-
const PREVIEW_MAX_LINES = 40;
|
|
3
|
+
import { renderActionPreview } from "../render/diff.js";
|
|
5
4
|
/**
|
|
6
5
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
7
6
|
* read a follow-up line (reason / instruction). Anything else — including EOF —
|
|
@@ -53,7 +52,7 @@ function detail(request, c) {
|
|
|
53
52
|
` ${c.dim(`in ${request.cwd}`)}`,
|
|
54
53
|
].join("\n");
|
|
55
54
|
}
|
|
56
|
-
return
|
|
55
|
+
return renderActionPreview(request.action.preview, c);
|
|
57
56
|
}
|
|
58
57
|
/** The choices line, including a short label of what an `a` grant would cover. */
|
|
59
58
|
function choices(scope, c) {
|
|
@@ -71,80 +70,6 @@ function scopeLabel(scope) {
|
|
|
71
70
|
return `changes under ${path.basename(scope.root)}/`;
|
|
72
71
|
return null;
|
|
73
72
|
}
|
|
74
|
-
// ── diff rendering (shared with the old C.6 renderer) ──────────────────────────
|
|
75
|
-
function diffLines(oldStr, newStr, c) {
|
|
76
|
-
const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
|
|
77
|
-
const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
|
|
78
|
-
return [...removed, ...added];
|
|
79
|
-
}
|
|
80
|
-
function renderPatchFiles(files, c) {
|
|
81
|
-
const out = [];
|
|
82
|
-
for (const file of files) {
|
|
83
|
-
if (file.op === "delete") {
|
|
84
|
-
out.push(c.red(`delete ${file.path}`));
|
|
85
|
-
}
|
|
86
|
-
else if (file.op === "create") {
|
|
87
|
-
out.push(c.green(`create ${file.path}`));
|
|
88
|
-
out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
|
|
89
|
-
if (file.omittedLines > 0)
|
|
90
|
-
out.push(c.dim(` ...${file.omittedLines} more lines`));
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
out.push(c.yellow(`update ${file.path}`));
|
|
94
|
-
for (const hunk of file.hunks)
|
|
95
|
-
out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return out;
|
|
99
|
-
}
|
|
100
|
-
/** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
|
|
101
|
-
function renderPrPreview(preview, c) {
|
|
102
|
-
const out = [];
|
|
103
|
-
out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
|
|
104
|
-
out.push("");
|
|
105
|
-
out.push(c.bold("commit"));
|
|
106
|
-
out.push(` ${preview.commitSubject}`);
|
|
107
|
-
for (const line of bodyLines(preview.commitBody))
|
|
108
|
-
out.push(c.dim(` ${line}`));
|
|
109
|
-
out.push("");
|
|
110
|
-
out.push(`${c.bold("pull request")} ${preview.prTitle}`);
|
|
111
|
-
for (const line of bodyLines(preview.prBody))
|
|
112
|
-
out.push(c.dim(` ${line}`));
|
|
113
|
-
return out;
|
|
114
|
-
}
|
|
115
|
-
/** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
|
|
116
|
-
function bodyLines(body) {
|
|
117
|
-
const lines = body.replace(/\s+$/, "").split("\n");
|
|
118
|
-
return lines.length === 1 && lines[0] === "" ? [] : lines;
|
|
119
|
-
}
|
|
120
|
-
function renderPreview(preview, c) {
|
|
121
|
-
if (!preview)
|
|
122
|
-
return "";
|
|
123
|
-
let lines;
|
|
124
|
-
if (preview.type === "edit") {
|
|
125
|
-
lines = diffLines(preview.oldStr, preview.newStr, c);
|
|
126
|
-
}
|
|
127
|
-
else if (preview.type === "patch") {
|
|
128
|
-
lines = renderPatchFiles(preview.files, c);
|
|
129
|
-
}
|
|
130
|
-
else if (preview.type === "pr") {
|
|
131
|
-
lines = renderPrPreview(preview, c);
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
const header = preview.exists
|
|
135
|
-
? c.yellow("OVERWRITE existing")
|
|
136
|
-
: c.green("create");
|
|
137
|
-
const body = preview.lines.map((l) => ` ${l}`);
|
|
138
|
-
if (preview.omittedLines > 0)
|
|
139
|
-
body.push(c.dim(` ...${preview.omittedLines} more lines`));
|
|
140
|
-
lines = [header, ...body];
|
|
141
|
-
}
|
|
142
|
-
if (lines.length > PREVIEW_MAX_LINES) {
|
|
143
|
-
const hidden = lines.length - PREVIEW_MAX_LINES;
|
|
144
|
-
lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
|
|
145
|
-
}
|
|
146
|
-
return lines.map((l) => ` ${l}`).join("\n");
|
|
147
|
-
}
|
|
148
73
|
// ── default stdin-backed PromptIO ──────────────────────────────────────────────
|
|
149
74
|
/** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
|
|
150
75
|
export function defaultPromptIO(color) {
|
|
@@ -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
|
+
}
|