@cruxy/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +37 -13
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +0 -1
  4. package/dist/agent/loop.js +0 -1
  5. package/dist/agent/prompts.d.ts +0 -2
  6. package/dist/agent/prompts.js +3 -3
  7. package/dist/approval/classify.d.ts +18 -0
  8. package/dist/approval/classify.js +162 -0
  9. package/dist/approval/index.d.ts +5 -0
  10. package/dist/approval/index.js +5 -0
  11. package/dist/approval/policy.d.ts +37 -0
  12. package/dist/approval/policy.js +81 -0
  13. package/dist/approval/prompt.d.ts +33 -0
  14. package/dist/approval/prompt.js +212 -0
  15. package/dist/approval/service.d.ts +36 -0
  16. package/dist/approval/service.js +37 -0
  17. package/dist/approval/types.d.ts +64 -0
  18. package/dist/approval/types.js +1 -0
  19. package/dist/cli/commands/pr.d.ts +8 -0
  20. package/dist/cli/commands/pr.js +87 -0
  21. package/dist/cli/commands/run.js +10 -10
  22. package/dist/cli/program.js +2 -0
  23. package/dist/cli/repl.js +1 -1
  24. package/dist/config/schema.d.ts +38 -9
  25. package/dist/config/schema.js +13 -4
  26. package/dist/errors/constructors.d.ts +25 -0
  27. package/dist/errors/constructors.js +83 -0
  28. package/dist/errors/types.d.ts +5 -0
  29. package/dist/errors/types.js +11 -0
  30. package/dist/tools/create-pull-request.d.ts +24 -0
  31. package/dist/tools/create-pull-request.js +83 -0
  32. package/dist/tools/file/apply-patch.js +3 -3
  33. package/dist/tools/file/edit-file.js +6 -3
  34. package/dist/tools/file/write-file.js +6 -3
  35. package/dist/tools/index.d.ts +1 -0
  36. package/dist/tools/index.js +1 -0
  37. package/dist/tools/registry.js +2 -0
  38. package/dist/tools/shell/run-command.js +11 -3
  39. package/dist/tools/types.d.ts +25 -6
  40. package/dist/vcs/auth.d.ts +22 -0
  41. package/dist/vcs/auth.js +29 -0
  42. package/dist/vcs/generate.d.ts +72 -0
  43. package/dist/vcs/generate.js +265 -0
  44. package/dist/vcs/git.d.ts +52 -0
  45. package/dist/vcs/git.js +152 -0
  46. package/dist/vcs/github.d.ts +44 -0
  47. package/dist/vcs/github.js +145 -0
  48. package/dist/vcs/guidance.d.ts +20 -0
  49. package/dist/vcs/guidance.js +76 -0
  50. package/dist/vcs/index.d.ts +7 -0
  51. package/dist/vcs/index.js +7 -0
  52. package/dist/vcs/service.d.ts +53 -0
  53. package/dist/vcs/service.js +79 -0
  54. package/dist/vcs/types.d.ts +57 -0
  55. package/dist/vcs/types.js +6 -0
  56. package/package.json +1 -1
  57. package/dist/agent/approval.d.ts +0 -41
  58. package/dist/agent/approval.js +0 -179
package/README.md CHANGED
@@ -26,7 +26,14 @@ cruxy run # interactive session
26
26
 
27
27
  - **Tools** — `read_file`, `write_file`, `edit_file`, `glob`, `list_files`,
28
28
  `grep_files`, `run_command`, `git_status`, `apply_patch`, `search_codebase`,
29
- `list_skills`, `load_skill`.
29
+ `list_skills`, `load_skill`, `create_pull_request`.
30
+ - **Pull requests** — `cruxy pr` (and the `create_pull_request` tool) turn the
31
+ current changes into a PR: feature branch → conventional commit → push → open
32
+ the PR, with a generated title/body, all behind the approval gate. GitHub ships
33
+ first behind a swappable `ForgeProvider`; the token is read from
34
+ `GITHUB_TOKEN` / `GH_TOKEN` / `gh auth token` (never stored). It never commits
35
+ or pushes on a protected branch, never force-pushes, and never bypasses the
36
+ commit/push hooks.
30
37
  - **Codebase index** — a local, incremental semantic index (`cruxy index`)
31
38
  behind the `search_codebase` tool. Embeddings run on-device via fastembed
32
39
  (ONNX, bge-small-en-v1.5) with no network calls; the store is SQLite at
@@ -70,6 +77,22 @@ Add a skill by creating `<name>/SKILL.md` under `.cruxy/skills/` (project) or
70
77
  `~/.cruxy/skills/` (user); shipped builtins are the lowest layer. See the
71
78
  built-in `using-skills` skill for the authoring rules.
72
79
 
80
+ ### Pull requests
81
+
82
+ ```bash
83
+ export GITHUB_TOKEN=ghp_... # or GH_TOKEN, or be logged in with `gh`
84
+
85
+ cruxy pr # generate + open a PR for the current changes
86
+ cruxy pr --base develop # target a specific base branch
87
+ cruxy pr --title "fix(cli): …" # override the generated title
88
+ cruxy pr --draft # open as a draft
89
+ ```
90
+
91
+ The whole plan — branch, commit, and PR title/body — is shown for approval
92
+ before anything runs. On a protected branch (`main`/`master`/configured via
93
+ `git.protectedBranches`) cruxy branches off first; the base defaults to
94
+ `git.defaultBase`, then the repo's default branch, then `main`.
95
+
73
96
  ## Errors & exit codes
74
97
 
75
98
  Every user-facing error prints a title, the cause (when known), concrete next
@@ -78,18 +101,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
78
101
  `NO_COLOR` disables color. Exit codes are stable per category, so scripts can
79
102
  branch on them:
80
103
 
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` |
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` |
93
117
 
94
118
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
95
119
  provider-agnostic, built over `fetch`, with no vendor SDKs.
@@ -1,4 +1,3 @@
1
1
  export * from "./loop.js";
2
2
  export * from "./session.js";
3
3
  export * from "./prompts.js";
4
- export * from "./approval.js";
@@ -1,4 +1,3 @@
1
1
  export * from "./loop.js";
2
2
  export * from "./session.js";
3
3
  export * from "./prompts.js";
4
- export * from "./approval.js";
@@ -30,7 +30,6 @@ 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,
36
35
  });
@@ -26,8 +26,6 @@ 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;
33
31
  }
@@ -69,9 +69,9 @@ function renderTools(tools) {
69
69
  }
70
70
  /** Assemble the full system prompt for a session. */
71
71
  export function buildSystemPrompt(ctx) {
72
- const approval = ctx.autoApprove
73
- ? "Auto-approve is on for this session, so you may proceed without asking — but still pause and confirm before genuinely destructive or irreversible actions."
74
- : "Confirm with the user before taking any destructive or irreversible action.";
72
+ // Every side-effecting action goes through the approval gate (U.3); the user
73
+ // always gets the final say before a mutation or a destructive action.
74
+ const approval = "Side-effecting actions (file writes, shell commands) require the user's approval; destructive or irreversible actions are flagged distinctly.";
75
75
  const core = CORE.replace("${APPROVAL_CLAUSE}", approval);
76
76
  const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
77
77
  if (ctx.projectInstructions?.trim()) {
@@ -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,5 @@
1
+ export * from "./types.js";
2
+ export * from "./classify.js";
3
+ export * from "./policy.js";
4
+ export * from "./prompt.js";
5
+ export * from "./service.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./classify.js";
3
+ export * from "./policy.js";
4
+ export * from "./prompt.js";
5
+ export * from "./service.js";
@@ -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;