@cruxy/cli 0.4.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 +57 -16
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +0 -1
- package/dist/agent/loop.d.ts +2 -0
- package/dist/agent/loop.js +1 -1
- package/dist/agent/prompts.d.ts +2 -2
- package/dist/agent/prompts.js +9 -3
- package/dist/agent/session.d.ts +24 -0
- package/dist/agent/session.js +33 -7
- package/dist/approval/classify.d.ts +18 -0
- package/dist/approval/classify.js +162 -0
- package/dist/approval/index.d.ts +5 -0
- package/dist/approval/index.js +5 -0
- package/dist/approval/policy.d.ts +37 -0
- package/dist/approval/policy.js +81 -0
- package/dist/approval/prompt.d.ts +33 -0
- package/dist/approval/prompt.js +212 -0
- package/dist/approval/service.d.ts +36 -0
- package/dist/approval/service.js +37 -0
- package/dist/approval/types.d.ts +64 -0
- package/dist/approval/types.js +1 -0
- 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/pr.d.ts +8 -0
- package/dist/cli/commands/pr.js +87 -0
- package/dist/cli/commands/run.js +30 -52
- package/dist/cli/onboard.d.ts +25 -0
- package/dist/cli/onboard.js +54 -0
- package/dist/cli/program.js +21 -1
- package/dist/cli/repl.js +10 -1
- 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 +48 -9
- package/dist/config/schema.js +15 -4
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/errors/constructors.d.ts +35 -0
- package/dist/errors/constructors.js +129 -2
- package/dist/errors/types.d.ts +8 -0
- package/dist/errors/types.js +17 -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/dist/tools/create-pull-request.d.ts +24 -0
- package/dist/tools/create-pull-request.js +83 -0
- package/dist/tools/file/apply-patch.js +3 -3
- package/dist/tools/file/edit-file.js +6 -3
- package/dist/tools/file/write-file.js +6 -3
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/registry.js +2 -0
- package/dist/tools/shell/run-command.js +11 -3
- package/dist/tools/types.d.ts +25 -6
- package/dist/vcs/auth.d.ts +22 -0
- package/dist/vcs/auth.js +29 -0
- package/dist/vcs/generate.d.ts +72 -0
- package/dist/vcs/generate.js +265 -0
- package/dist/vcs/git.d.ts +52 -0
- package/dist/vcs/git.js +152 -0
- package/dist/vcs/github.d.ts +44 -0
- package/dist/vcs/github.js +145 -0
- package/dist/vcs/guidance.d.ts +20 -0
- package/dist/vcs/guidance.js +76 -0
- package/dist/vcs/index.d.ts +7 -0
- package/dist/vcs/index.js +7 -0
- package/dist/vcs/service.d.ts +53 -0
- package/dist/vcs/service.js +79 -0
- package/dist/vcs/types.d.ts +57 -0
- package/dist/vcs/types.js +6 -0
- package/package.json +1 -1
- package/dist/agent/approval.d.ts +0 -41
- package/dist/agent/approval.js +0 -179
package/README.md
CHANGED
|
@@ -16,17 +16,36 @@ 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
|
-
`list_skills`, `load_skill`.
|
|
41
|
+
`list_skills`, `load_skill`, `create_pull_request`.
|
|
42
|
+
- **Pull requests** — `cruxy pr` (and the `create_pull_request` tool) turn the
|
|
43
|
+
current changes into a PR: feature branch → conventional commit → push → open
|
|
44
|
+
the PR, with a generated title/body, all behind the approval gate. GitHub ships
|
|
45
|
+
first behind a swappable `ForgeProvider`; the token is read from
|
|
46
|
+
`GITHUB_TOKEN` / `GH_TOKEN` / `gh auth token` (never stored). It never commits
|
|
47
|
+
or pushes on a protected branch, never force-pushes, and never bypasses the
|
|
48
|
+
commit/push hooks.
|
|
30
49
|
- **Codebase index** — a local, incremental semantic index (`cruxy index`)
|
|
31
50
|
behind the `search_codebase` tool. Embeddings run on-device via fastembed
|
|
32
51
|
(ONNX, bge-small-en-v1.5) with no network calls; the store is SQLite at
|
|
@@ -39,6 +58,11 @@ cruxy run # interactive session
|
|
|
39
58
|
validated, and never auto-executed.
|
|
40
59
|
- **Agent** — streaming output, multi-turn interactive sessions, context
|
|
41
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.
|
|
42
66
|
- **Safety** — a single approval gate with diff previews that fails closed;
|
|
43
67
|
read-only tools never prompt; file access is confined to the project root;
|
|
44
68
|
shell commands run bounded.
|
|
@@ -70,6 +94,22 @@ Add a skill by creating `<name>/SKILL.md` under `.cruxy/skills/` (project) or
|
|
|
70
94
|
`~/.cruxy/skills/` (user); shipped builtins are the lowest layer. See the
|
|
71
95
|
built-in `using-skills` skill for the authoring rules.
|
|
72
96
|
|
|
97
|
+
### Pull requests
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
export GITHUB_TOKEN=ghp_... # or GH_TOKEN, or be logged in with `gh`
|
|
101
|
+
|
|
102
|
+
cruxy pr # generate + open a PR for the current changes
|
|
103
|
+
cruxy pr --base develop # target a specific base branch
|
|
104
|
+
cruxy pr --title "fix(cli): …" # override the generated title
|
|
105
|
+
cruxy pr --draft # open as a draft
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The whole plan — branch, commit, and PR title/body — is shown for approval
|
|
109
|
+
before anything runs. On a protected branch (`main`/`master`/configured via
|
|
110
|
+
`git.protectedBranches`) cruxy branches off first; the base defaults to
|
|
111
|
+
`git.defaultBase`, then the repo's default branch, then `main`.
|
|
112
|
+
|
|
73
113
|
## Errors & exit codes
|
|
74
114
|
|
|
75
115
|
Every user-facing error prints a title, the cause (when known), concrete next
|
|
@@ -78,18 +118,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
|
|
|
78
118
|
`NO_COLOR` disables color. Exit codes are stable per category, so scripts can
|
|
79
119
|
branch on them:
|
|
80
120
|
|
|
81
|
-
| Exit | Category | Example codes
|
|
82
|
-
| ---- | ---------- |
|
|
83
|
-
| `0` | success | —
|
|
84
|
-
| `1` | internal | `CRUXY_E_INTERNAL`
|
|
85
|
-
| `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`
|
|
86
|
-
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID`
|
|
87
|
-
| `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`
|
|
88
|
-
| `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`
|
|
89
|
-
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`
|
|
90
|
-
| `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE`
|
|
91
|
-
| `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED`
|
|
92
|
-
| `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND`
|
|
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` |
|
|
93
134
|
|
|
94
135
|
The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
|
|
95
136
|
provider-agnostic, built over `fetch`, with no vendor SDKs.
|
package/dist/agent/index.d.ts
CHANGED
package/dist/agent/index.js
CHANGED
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
|
@@ -30,9 +30,9 @@ export async function runAgent(args) {
|
|
|
30
30
|
tools: registry
|
|
31
31
|
.list()
|
|
32
32
|
.map((tool) => ({ name: tool.name, description: tool.description })),
|
|
33
|
-
autoApprove: config.approval.mode === "auto",
|
|
34
33
|
git: args.git ?? null,
|
|
35
34
|
projectInstructions: args.projectInstructions ?? null,
|
|
35
|
+
planMode: args.planMode ?? false,
|
|
36
36
|
});
|
|
37
37
|
let iterations = 0;
|
|
38
38
|
for (let i = 0; i < maxIterations; i++) {
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -26,10 +26,10 @@ export interface PromptContext {
|
|
|
26
26
|
branch: string;
|
|
27
27
|
dirty: boolean;
|
|
28
28
|
} | null;
|
|
29
|
-
/** When true, the agent may act without per-step confirmation. */
|
|
30
|
-
autoApprove: boolean;
|
|
31
29
|
/** Optional extra instructions (e.g. from a project CRUXY.md). */
|
|
32
30
|
projectInstructions?: string | null;
|
|
31
|
+
/** Plan mode's propose phase (C.31): inject the plan-first directive. */
|
|
32
|
+
planMode?: boolean;
|
|
33
33
|
}
|
|
34
34
|
/** Assemble the full system prompt for a session. */
|
|
35
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
|
|
@@ -69,11 +73,13 @@ function renderTools(tools) {
|
|
|
69
73
|
}
|
|
70
74
|
/** Assemble the full system prompt for a session. */
|
|
71
75
|
export function buildSystemPrompt(ctx) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
// Every side-effecting action goes through the approval gate (U.3); the user
|
|
77
|
+
// always gets the final say before a mutation or a destructive action.
|
|
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,18 @@
|
|
|
1
|
+
import type { ApproveAction } from "../tools/types.js";
|
|
2
|
+
import type { ApprovalRequest } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Classify a pending tool action into a {@link RiskTier} + a tight session
|
|
5
|
+
* {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
|
|
6
|
+
* (most-restrictive) so a future/unknown action can never slip through as
|
|
7
|
+
* read-only or low-risk.
|
|
8
|
+
*/
|
|
9
|
+
export declare function classify(action: ApproveAction, cwd: string): ApprovalRequest;
|
|
10
|
+
/**
|
|
11
|
+
* Tokenize a command **only if** we can positively prove it is a single, simple
|
|
12
|
+
* invocation (the granted program plus plain args, no shell features). Returns
|
|
13
|
+
* the tokens, or `null` for anything we can't prove safe — deny-by-default. This
|
|
14
|
+
* is what makes a `git` session grant refuse `git push && rm -rf /`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function commandTokens(command: string): string[] | null;
|
|
17
|
+
/** Is `target` the root itself or a descendant of it? (Also true if root is a file == target.) */
|
|
18
|
+
export declare function isInside(root: string, target: string): boolean;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Classify a pending tool action into a {@link RiskTier} + a tight session
|
|
4
|
+
* {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
|
|
5
|
+
* (most-restrictive) so a future/unknown action can never slip through as
|
|
6
|
+
* read-only or low-risk.
|
|
7
|
+
*/
|
|
8
|
+
export function classify(action, cwd) {
|
|
9
|
+
const root = path.resolve(cwd);
|
|
10
|
+
switch (action.kind) {
|
|
11
|
+
case "write":
|
|
12
|
+
case "edit":
|
|
13
|
+
return fileRequest(action, "mutate", root);
|
|
14
|
+
case "patch":
|
|
15
|
+
// A patch that deletes files is destructive; create/update-only is mutate.
|
|
16
|
+
return fileRequest(action, patchHasDelete(action) ? "destructive" : "mutate", root);
|
|
17
|
+
case "shell":
|
|
18
|
+
return shellRequest(action, root);
|
|
19
|
+
case "vcs":
|
|
20
|
+
return vcsRequest(action, root);
|
|
21
|
+
default:
|
|
22
|
+
return {
|
|
23
|
+
action,
|
|
24
|
+
tier: "destructive",
|
|
25
|
+
scope: { kind: "none" },
|
|
26
|
+
summary: `perform an unrecognized action (${describeKind(action)})`,
|
|
27
|
+
targets: [],
|
|
28
|
+
cwd: root,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ── shell ─────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Any shell control/expansion character means we cannot prove the command is a
|
|
34
|
+
// single simple invocation. Covers && || ; | ` $ ( ) { } * ? ~ ! # ' " \ < > and
|
|
35
|
+
// newlines (so >> and & background are caught by > and & too).
|
|
36
|
+
const SHELL_META = /[&|;<>`$(){}*?~!#'"\\\n\r]/;
|
|
37
|
+
// A token we positively accept as a simple argument — letters, digits, and a
|
|
38
|
+
// small set of punctuation that has no shell meaning in an unquoted word.
|
|
39
|
+
const SAFE_ARG = /^[A-Za-z0-9_./=,:+@%-]+$/;
|
|
40
|
+
/**
|
|
41
|
+
* Tokenize a command **only if** we can positively prove it is a single, simple
|
|
42
|
+
* invocation (the granted program plus plain args, no shell features). Returns
|
|
43
|
+
* the tokens, or `null` for anything we can't prove safe — deny-by-default. This
|
|
44
|
+
* is what makes a `git` session grant refuse `git push && rm -rf /`.
|
|
45
|
+
*/
|
|
46
|
+
export function commandTokens(command) {
|
|
47
|
+
const trimmed = command.trim();
|
|
48
|
+
if (trimmed === "" || SHELL_META.test(trimmed))
|
|
49
|
+
return null;
|
|
50
|
+
const tokens = trimmed.split(/\s+/);
|
|
51
|
+
return tokens.every((t) => SAFE_ARG.test(t)) ? tokens : null;
|
|
52
|
+
}
|
|
53
|
+
function shellRequest(action, root) {
|
|
54
|
+
const command = action.command ?? "";
|
|
55
|
+
// A session grant is only offered when the command is provably simple; its
|
|
56
|
+
// scope is the program token. Complex commands get `none` (approve-once only).
|
|
57
|
+
const tokens = commandTokens(command);
|
|
58
|
+
const scope = tokens
|
|
59
|
+
? { kind: "shell-prefix", token: tokens[0] }
|
|
60
|
+
: { kind: "none" };
|
|
61
|
+
return {
|
|
62
|
+
action,
|
|
63
|
+
tier: "destructive",
|
|
64
|
+
scope,
|
|
65
|
+
summary: `run: ${command}`,
|
|
66
|
+
targets: [],
|
|
67
|
+
cwd: root,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ── vcs (open pull request) ─────────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* A pull-request publish (C.15): branch → commit → push → open PR. Always
|
|
73
|
+
* `destructive` (it pushes under the user's identity) and never session-grantable
|
|
74
|
+
* — scope `none`, so every PR is approved on its own. The preview carries the
|
|
75
|
+
* full plan; the summary names the PR.
|
|
76
|
+
*/
|
|
77
|
+
function vcsRequest(action, root) {
|
|
78
|
+
const title = action.preview?.type === "pr" ? action.preview.prTitle : "a pull request";
|
|
79
|
+
return {
|
|
80
|
+
action,
|
|
81
|
+
tier: "destructive",
|
|
82
|
+
scope: { kind: "none" },
|
|
83
|
+
summary: `open PR: ${title}`,
|
|
84
|
+
targets: [],
|
|
85
|
+
cwd: root,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ── file (write / edit / patch) ────────────────────────────────────────────────
|
|
89
|
+
function fileRequest(action, tier, root) {
|
|
90
|
+
const targets = fileTargets(action, root);
|
|
91
|
+
return {
|
|
92
|
+
action,
|
|
93
|
+
tier,
|
|
94
|
+
scope: fileScope(targets, root),
|
|
95
|
+
summary: fileSummary(action, targets, root),
|
|
96
|
+
targets,
|
|
97
|
+
cwd: root,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Absolute target paths for a file action (write/edit: one; patch: many). */
|
|
101
|
+
function fileTargets(action, root) {
|
|
102
|
+
if (action.kind === "write" || action.kind === "edit") {
|
|
103
|
+
return action.path ? [path.resolve(root, action.path)] : [];
|
|
104
|
+
}
|
|
105
|
+
if (action.kind === "patch" && action.preview?.type === "patch") {
|
|
106
|
+
return action.preview.files.map((f) => path.resolve(root, f.path));
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The tight subtree a file grant covers: the common parent directory of the
|
|
112
|
+
* targets. **Root-cap:** if that parent is the project root, never grant the
|
|
113
|
+
* whole project — narrow to the exact single file, or refuse (scope `none`) for
|
|
114
|
+
* a multi-file action spanning the root.
|
|
115
|
+
*/
|
|
116
|
+
function fileScope(targets, root) {
|
|
117
|
+
if (targets.length === 0)
|
|
118
|
+
return { kind: "none" };
|
|
119
|
+
const dir = commonDir(targets);
|
|
120
|
+
if (dir === root || !isInside(root, dir)) {
|
|
121
|
+
return targets.length === 1
|
|
122
|
+
? { kind: "file-subtree", root: targets[0] } // exact-file grant
|
|
123
|
+
: { kind: "none" };
|
|
124
|
+
}
|
|
125
|
+
return { kind: "file-subtree", root: dir };
|
|
126
|
+
}
|
|
127
|
+
function fileSummary(action, targets, root) {
|
|
128
|
+
const verb = action.kind === "write"
|
|
129
|
+
? "write"
|
|
130
|
+
: action.kind === "edit"
|
|
131
|
+
? "edit"
|
|
132
|
+
: "patch";
|
|
133
|
+
if (targets.length === 1)
|
|
134
|
+
return `${verb}: ${path.relative(root, targets[0]) || targets[0]}`;
|
|
135
|
+
return `${verb}: ${targets.length} files`;
|
|
136
|
+
}
|
|
137
|
+
function patchHasDelete(action) {
|
|
138
|
+
return (action.preview?.type === "patch" &&
|
|
139
|
+
action.preview.files.some((f) => f.op === "delete"));
|
|
140
|
+
}
|
|
141
|
+
// ── shared path helpers ────────────────────────────────────────────────────────
|
|
142
|
+
/** Is `target` the root itself or a descendant of it? (Also true if root is a file == target.) */
|
|
143
|
+
export function isInside(root, target) {
|
|
144
|
+
return target === root || target.startsWith(root + path.sep);
|
|
145
|
+
}
|
|
146
|
+
/** The deepest directory that contains every path. */
|
|
147
|
+
function commonDir(paths) {
|
|
148
|
+
let common = path.dirname(paths[0]);
|
|
149
|
+
for (const p of paths.slice(1)) {
|
|
150
|
+
const dir = path.dirname(p);
|
|
151
|
+
while (!isInside(common, dir)) {
|
|
152
|
+
const parent = path.dirname(common);
|
|
153
|
+
if (parent === common)
|
|
154
|
+
return common; // filesystem root
|
|
155
|
+
common = parent;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return common;
|
|
159
|
+
}
|
|
160
|
+
function describeKind(action) {
|
|
161
|
+
return String(action.kind ?? "unknown");
|
|
162
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type PromptIO } from "./prompt.js";
|
|
2
|
+
import type { ApprovalDecision, ApprovalPolicy, ApprovalRequest, Scope } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The in-memory, per-session allowlist. Grants are **scoped** (a command prefix
|
|
5
|
+
* or a path subtree) and **tier-keyed** (a `mutate` grant can never cover a
|
|
6
|
+
* `destructive` action), **never blanket**, and **never persisted**. A new
|
|
7
|
+
* session ⇒ a new allowlist (it is owned by the per-session ApprovalService).
|
|
8
|
+
*/
|
|
9
|
+
export declare class SessionAllowlist {
|
|
10
|
+
private grants;
|
|
11
|
+
/** Record a session grant for `request`'s scope. No-op when nothing is safe to grant. */
|
|
12
|
+
grant(request: ApprovalRequest): void;
|
|
13
|
+
/** Does an existing grant cover `request`? Requires same tier **and** scope match. */
|
|
14
|
+
allows(request: ApprovalRequest): boolean;
|
|
15
|
+
clear(): void;
|
|
16
|
+
size(): number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether `scope` covers `request`. Shell: the command must be *provably simple*
|
|
20
|
+
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
21
|
+
* so a `git` grant never matches `git push && rm -rf /`. File: every target must
|
|
22
|
+
* resolve inside the granted subtree.
|
|
23
|
+
*/
|
|
24
|
+
export declare function scopeCovers(scope: Exclude<Scope, {
|
|
25
|
+
kind: "none";
|
|
26
|
+
}>, request: ApprovalRequest): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The default policy: consult the allowlist, otherwise prompt. On "allow this
|
|
29
|
+
* session" it records the scoped grant; a rejection carries optional feedback
|
|
30
|
+
* back to the agent.
|
|
31
|
+
*/
|
|
32
|
+
export declare class InteractivePolicy implements ApprovalPolicy {
|
|
33
|
+
private readonly allowlist;
|
|
34
|
+
private readonly io;
|
|
35
|
+
constructor(allowlist: SessionAllowlist, io: PromptIO);
|
|
36
|
+
decide(request: ApprovalRequest): Promise<ApprovalDecision>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { commandTokens, isInside } from "./classify.js";
|
|
2
|
+
import { promptForApproval } from "./prompt.js";
|
|
3
|
+
/**
|
|
4
|
+
* The in-memory, per-session allowlist. Grants are **scoped** (a command prefix
|
|
5
|
+
* or a path subtree) and **tier-keyed** (a `mutate` grant can never cover a
|
|
6
|
+
* `destructive` action), **never blanket**, and **never persisted**. A new
|
|
7
|
+
* session ⇒ a new allowlist (it is owned by the per-session ApprovalService).
|
|
8
|
+
*/
|
|
9
|
+
export class SessionAllowlist {
|
|
10
|
+
grants = [];
|
|
11
|
+
/** Record a session grant for `request`'s scope. No-op when nothing is safe to grant. */
|
|
12
|
+
grant(request) {
|
|
13
|
+
if (request.scope.kind === "none")
|
|
14
|
+
return;
|
|
15
|
+
this.grants.push({ tier: request.tier, scope: request.scope });
|
|
16
|
+
}
|
|
17
|
+
/** Does an existing grant cover `request`? Requires same tier **and** scope match. */
|
|
18
|
+
allows(request) {
|
|
19
|
+
return this.grants.some((g) => g.tier === request.tier && scopeCovers(g.scope, request));
|
|
20
|
+
}
|
|
21
|
+
clear() {
|
|
22
|
+
this.grants = [];
|
|
23
|
+
}
|
|
24
|
+
size() {
|
|
25
|
+
return this.grants.length;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether `scope` covers `request`. Shell: the command must be *provably simple*
|
|
30
|
+
* ({@link commandTokens}) and its program token must equal the granted token —
|
|
31
|
+
* so a `git` grant never matches `git push && rm -rf /`. File: every target must
|
|
32
|
+
* resolve inside the granted subtree.
|
|
33
|
+
*/
|
|
34
|
+
export function scopeCovers(scope, request) {
|
|
35
|
+
if (scope.kind === "shell-prefix") {
|
|
36
|
+
if (request.action.kind !== "shell")
|
|
37
|
+
return false;
|
|
38
|
+
const tokens = commandTokens(request.action.command ?? "");
|
|
39
|
+
return tokens !== null && tokens[0] === scope.token;
|
|
40
|
+
}
|
|
41
|
+
// file-subtree
|
|
42
|
+
return (request.targets.length > 0 &&
|
|
43
|
+
request.targets.every((t) => isInside(scope.root, t)));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The default policy: consult the allowlist, otherwise prompt. On "allow this
|
|
47
|
+
* session" it records the scoped grant; a rejection carries optional feedback
|
|
48
|
+
* back to the agent.
|
|
49
|
+
*/
|
|
50
|
+
export class InteractivePolicy {
|
|
51
|
+
allowlist;
|
|
52
|
+
io;
|
|
53
|
+
constructor(allowlist, io) {
|
|
54
|
+
this.allowlist = allowlist;
|
|
55
|
+
this.io = io;
|
|
56
|
+
}
|
|
57
|
+
async decide(request) {
|
|
58
|
+
if (this.allowlist.allows(request))
|
|
59
|
+
return { allow: true };
|
|
60
|
+
const choice = await promptForApproval(request, this.io);
|
|
61
|
+
switch (choice.kind) {
|
|
62
|
+
case "once":
|
|
63
|
+
return { allow: true };
|
|
64
|
+
case "session":
|
|
65
|
+
this.allowlist.grant(request);
|
|
66
|
+
return { allow: true };
|
|
67
|
+
case "reject":
|
|
68
|
+
return choice.reason
|
|
69
|
+
? {
|
|
70
|
+
allow: false,
|
|
71
|
+
feedback: `The user rejected this action. Reason: ${choice.reason}`,
|
|
72
|
+
}
|
|
73
|
+
: { allow: false };
|
|
74
|
+
case "instruct":
|
|
75
|
+
return {
|
|
76
|
+
allow: false,
|
|
77
|
+
feedback: `The user rejected this action and asked you to do this instead: ${choice.instruction}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ApprovalRequest } from "./types.js";
|
|
2
|
+
/** The four user choices (plus the implicit default-deny). */
|
|
3
|
+
export type PromptChoice = {
|
|
4
|
+
kind: "once";
|
|
5
|
+
} | {
|
|
6
|
+
kind: "session";
|
|
7
|
+
} | {
|
|
8
|
+
kind: "reject";
|
|
9
|
+
reason?: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "instruct";
|
|
12
|
+
instruction: string;
|
|
13
|
+
};
|
|
14
|
+
/** I/O surface for the prompt — injectable so tests script the keys/lines. */
|
|
15
|
+
export interface PromptIO {
|
|
16
|
+
write(text: string): void;
|
|
17
|
+
/** Read a single keypress; resolves "" on EOF / Ctrl-C. */
|
|
18
|
+
readKey(): Promise<string>;
|
|
19
|
+
/** Read one line (no trailing newline); resolves "" on EOF. */
|
|
20
|
+
readLine(): Promise<string>;
|
|
21
|
+
/** Whether to emit ANSI color. */
|
|
22
|
+
color: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
26
|
+
* read a follow-up line (reason / instruction). Anything else — including EOF —
|
|
27
|
+
* is a reject.
|
|
28
|
+
*/
|
|
29
|
+
export declare function promptForApproval(request: ApprovalRequest, io: PromptIO): Promise<PromptChoice>;
|
|
30
|
+
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
31
|
+
export declare function render(request: ApprovalRequest, color: boolean): string;
|
|
32
|
+
/** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
|
|
33
|
+
export declare function defaultPromptIO(color: boolean): PromptIO;
|