@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.
- package/README.md +37 -13
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +0 -1
- package/dist/agent/loop.js +0 -1
- package/dist/agent/prompts.d.ts +0 -2
- package/dist/agent/prompts.js +3 -3
- 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/pr.d.ts +8 -0
- package/dist/cli/commands/pr.js +87 -0
- package/dist/cli/commands/run.js +10 -10
- package/dist/cli/program.js +2 -0
- package/dist/cli/repl.js +1 -1
- package/dist/config/schema.d.ts +38 -9
- package/dist/config/schema.js +13 -4
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +83 -0
- package/dist/errors/types.d.ts +5 -0
- package/dist/errors/types.js +11 -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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { getSkill, loadCatalog } from "../skills/loader.js";
|
|
5
|
+
import { defaultSources } from "../skills/service.js";
|
|
6
|
+
/** Load the git-commit skill body + commitlint scopes for `cwd`. Never throws. */
|
|
7
|
+
export async function loadCommitGuidance(cwd) {
|
|
8
|
+
const [skillBody, scopes] = await Promise.all([
|
|
9
|
+
loadGitCommitSkill(cwd),
|
|
10
|
+
loadCommitlintScopes(cwd),
|
|
11
|
+
]);
|
|
12
|
+
return { skillBody, scopes };
|
|
13
|
+
}
|
|
14
|
+
/** Read the git-commit skill body via the skills catalog, or `undefined`. */
|
|
15
|
+
async function loadGitCommitSkill(cwd) {
|
|
16
|
+
try {
|
|
17
|
+
const catalog = await loadCatalog(defaultSources(cwd));
|
|
18
|
+
const skill = await getSkill(catalog, "git-commit");
|
|
19
|
+
return skill.body;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const COMMITLINT_FILES = [
|
|
26
|
+
"commitlint.config.cjs",
|
|
27
|
+
"commitlint.config.js",
|
|
28
|
+
"commitlint.config.mjs",
|
|
29
|
+
".commitlintrc.cjs",
|
|
30
|
+
".commitlintrc.js",
|
|
31
|
+
".commitlintrc.json",
|
|
32
|
+
".commitlintrc",
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Extract the `scope-enum` allow-list from a repo's commitlint config. Supports
|
|
36
|
+
* the JS/CJS module forms (imported) and JSON. Any failure ⇒ `[]` (no
|
|
37
|
+
* constraint), so a malformed or absent config never blocks a PR.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadCommitlintScopes(cwd) {
|
|
40
|
+
for (const name of COMMITLINT_FILES) {
|
|
41
|
+
const file = path.join(cwd, name);
|
|
42
|
+
try {
|
|
43
|
+
await fs.access(file);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const config = name.endsWith(".json") || name === ".commitlintrc"
|
|
50
|
+
? JSON.parse(await fs.readFile(file, "utf8"))
|
|
51
|
+
: await importConfig(file);
|
|
52
|
+
const scopes = extractScopeEnum(config);
|
|
53
|
+
if (scopes.length > 0)
|
|
54
|
+
return scopes;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Try the next candidate; a parse failure isn't fatal.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
async function importConfig(file) {
|
|
63
|
+
const mod = (await import(pathToFileURL(file).href));
|
|
64
|
+
return mod.default ?? mod;
|
|
65
|
+
}
|
|
66
|
+
/** Pull `rules['scope-enum'][2]` (the allowed values array) out of a config. */
|
|
67
|
+
function extractScopeEnum(config) {
|
|
68
|
+
const rules = config?.rules;
|
|
69
|
+
const rule = rules?.["scope-enum"];
|
|
70
|
+
if (!Array.isArray(rule))
|
|
71
|
+
return [];
|
|
72
|
+
const values = rule[2];
|
|
73
|
+
return Array.isArray(values)
|
|
74
|
+
? values.filter((v) => typeof v === "string")
|
|
75
|
+
: [];
|
|
76
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ApprovalDecision } from "../approval/types.js";
|
|
2
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { ApproveAction } from "../tools/types.js";
|
|
4
|
+
import type { ForgeProvider, GeneratedContent } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* The PR-publish orchestrator (C.15): ensure a feature branch → commit → push →
|
|
7
|
+
* open PR, with the **entire** plan gated by one U.3 approval. Nothing mutates
|
|
8
|
+
* before the user approves; a rejection aborts cleanly with no commit or push.
|
|
9
|
+
*
|
|
10
|
+
* The forge and the content generator are injected, so both call sites — the
|
|
11
|
+
* `create_pull_request` tool (deterministic fill) and the `cruxy pr` command
|
|
12
|
+
* (LLM generation) — share this exact flow.
|
|
13
|
+
*/
|
|
14
|
+
/** Produce the publish content for a change set (LLM or deterministic). */
|
|
15
|
+
export type GenerateContent = (input: {
|
|
16
|
+
diff: string;
|
|
17
|
+
base: string;
|
|
18
|
+
currentBranch?: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
body?: string;
|
|
21
|
+
}) => Promise<GeneratedContent>;
|
|
22
|
+
export interface PrServiceDeps {
|
|
23
|
+
cwd: string;
|
|
24
|
+
config: CruxyConfig;
|
|
25
|
+
forge: ForgeProvider;
|
|
26
|
+
generate: GenerateContent;
|
|
27
|
+
/** The U.3 gate (the same capability tools receive as `ctx.requestApproval`). */
|
|
28
|
+
requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
29
|
+
}
|
|
30
|
+
export interface OpenPrOptions {
|
|
31
|
+
/** Override the base branch (else config.git.defaultBase → repo default → main). */
|
|
32
|
+
base?: string;
|
|
33
|
+
/** Caller-supplied PR title (normalized, not generated). */
|
|
34
|
+
title?: string;
|
|
35
|
+
/** Caller-supplied PR body. */
|
|
36
|
+
body?: string;
|
|
37
|
+
draft?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export type OpenPrOutcome = {
|
|
40
|
+
approved: false;
|
|
41
|
+
feedback?: string;
|
|
42
|
+
} | {
|
|
43
|
+
approved: true;
|
|
44
|
+
url: string;
|
|
45
|
+
number: number;
|
|
46
|
+
branch: string;
|
|
47
|
+
base: string;
|
|
48
|
+
alreadyExists: boolean;
|
|
49
|
+
};
|
|
50
|
+
/** Build a PR service from its dependencies. */
|
|
51
|
+
export declare function createPrService(deps: PrServiceDeps): {
|
|
52
|
+
openPullRequest: (opts?: OpenPrOptions) => Promise<OpenPrOutcome>;
|
|
53
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { usageError } from "../errors/index.js";
|
|
2
|
+
import { commit, currentBranch, diffAgainst, ensureFeatureBranch, hasChanges, isProtectedBranch, push, stageAll, } from "./git.js";
|
|
3
|
+
/** Build a PR service from its dependencies. */
|
|
4
|
+
export function createPrService(deps) {
|
|
5
|
+
return {
|
|
6
|
+
openPullRequest: (opts = {}) => openPullRequest(deps, opts),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
async function openPullRequest(deps, opts) {
|
|
10
|
+
const { cwd, config, forge, generate, requestApproval } = deps;
|
|
11
|
+
const protectedExtra = config.git.protectedBranches;
|
|
12
|
+
const branchNow = currentBranch(cwd);
|
|
13
|
+
if (branchNow === null) {
|
|
14
|
+
throw usageError("not on a branch (detached HEAD or not a git repository)", ["checkout a branch, or run cruxy inside a git repository"]);
|
|
15
|
+
}
|
|
16
|
+
// Resolve the repo + base before generating, so the diff is computed correctly.
|
|
17
|
+
const repo = await forge.getRepoInfo(cwd);
|
|
18
|
+
const base = opts.base?.trim() ||
|
|
19
|
+
config.git.defaultBase?.trim() ||
|
|
20
|
+
repo.defaultBranch ||
|
|
21
|
+
"main";
|
|
22
|
+
const diff = diffAgainst(cwd, base);
|
|
23
|
+
if (diff.trim() === "" && !hasChanges(cwd)) {
|
|
24
|
+
throw usageError(`no changes to open a pull request for (vs ${base})`, [
|
|
25
|
+
"make and save your changes, then try again",
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
// Keep the current branch only if it's already a feature branch; otherwise let
|
|
29
|
+
// generation propose a fresh branch name (we're on a protected branch).
|
|
30
|
+
const onProtected = isProtectedBranch(branchNow, protectedExtra);
|
|
31
|
+
const content = await generate({
|
|
32
|
+
diff,
|
|
33
|
+
base,
|
|
34
|
+
currentBranch: onProtected ? undefined : branchNow,
|
|
35
|
+
title: opts.title,
|
|
36
|
+
body: opts.body,
|
|
37
|
+
});
|
|
38
|
+
// ── the one gate: show the whole publish plan before anything mutates ──────────
|
|
39
|
+
const decision = await requestApproval({
|
|
40
|
+
kind: "vcs",
|
|
41
|
+
preview: {
|
|
42
|
+
type: "pr",
|
|
43
|
+
branch: content.branchName,
|
|
44
|
+
base,
|
|
45
|
+
commitSubject: content.commitSubject,
|
|
46
|
+
commitBody: content.commitBody,
|
|
47
|
+
prTitle: content.prTitle,
|
|
48
|
+
prBody: content.prBody,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (!decision.allow) {
|
|
52
|
+
return { approved: false, feedback: decision.feedback };
|
|
53
|
+
}
|
|
54
|
+
// ── approved: execute in order; each git op fails loud with a coded error ──────
|
|
55
|
+
const ensured = ensureFeatureBranch(cwd, content.branchName, protectedExtra);
|
|
56
|
+
const head = ensured.branch;
|
|
57
|
+
if (hasChanges(cwd)) {
|
|
58
|
+
// stageAll/commit throw on a protected branch (defense-in-depth) and never
|
|
59
|
+
// pass --no-verify, so the commitlint hook runs.
|
|
60
|
+
stageAll(cwd);
|
|
61
|
+
commit(cwd, content.commitSubject, content.commitBody, protectedExtra);
|
|
62
|
+
}
|
|
63
|
+
push(cwd, head, protectedExtra);
|
|
64
|
+
const pr = await forge.createPullRequest(repo, {
|
|
65
|
+
title: content.prTitle,
|
|
66
|
+
body: content.prBody,
|
|
67
|
+
head,
|
|
68
|
+
base,
|
|
69
|
+
draft: opts.draft,
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
approved: true,
|
|
73
|
+
url: pr.url,
|
|
74
|
+
number: pr.number,
|
|
75
|
+
branch: head,
|
|
76
|
+
base,
|
|
77
|
+
alreadyExists: pr.alreadyExists,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version-control / forge types for PR generation (C.15). The {@link ForgeProvider}
|
|
3
|
+
* interface is the swap seam — GitHub ships first, GitLab/Bitbucket slot in later
|
|
4
|
+
* without touching call sites (same discipline as `VectorStore`/`Embedder`).
|
|
5
|
+
*/
|
|
6
|
+
/** Where a repository lives and who owns it, parsed from the `origin` remote. */
|
|
7
|
+
export interface RepoInfo {
|
|
8
|
+
/** Forge host, e.g. `github.com`. */
|
|
9
|
+
readonly host: string;
|
|
10
|
+
/** Repository owner (user or org). */
|
|
11
|
+
readonly owner: string;
|
|
12
|
+
/** Repository name (no `.git` suffix). */
|
|
13
|
+
readonly repo: string;
|
|
14
|
+
/** Default branch, when the provider can resolve it (PR base fallback). */
|
|
15
|
+
readonly defaultBranch?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Everything needed to open one pull request. */
|
|
18
|
+
export interface PullRequestSpec {
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly body: string;
|
|
21
|
+
/** The branch carrying the change. */
|
|
22
|
+
readonly head: string;
|
|
23
|
+
/** The branch to merge into. */
|
|
24
|
+
readonly base: string;
|
|
25
|
+
readonly draft?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** The outcome of opening (or finding an already-open) pull request. */
|
|
28
|
+
export interface PullRequestResult {
|
|
29
|
+
readonly url: string;
|
|
30
|
+
readonly number: number;
|
|
31
|
+
/** True when a PR for this head already existed and we returned it. */
|
|
32
|
+
readonly alreadyExists: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* An abstract forge. The orchestrator depends only on this; the concrete
|
|
36
|
+
* GitHub/GitLab implementation is chosen by {@link createForgeProvider}.
|
|
37
|
+
*/
|
|
38
|
+
export interface ForgeProvider {
|
|
39
|
+
/** Stable id, e.g. `"github"`. */
|
|
40
|
+
readonly id: string;
|
|
41
|
+
/** Parse the repository's `origin` remote into structured {@link RepoInfo}. */
|
|
42
|
+
getRepoInfo(cwd: string): Promise<RepoInfo>;
|
|
43
|
+
/** Open a pull request and return its URL (idempotent on already-exists). */
|
|
44
|
+
createPullRequest(repo: RepoInfo, spec: PullRequestSpec): Promise<PullRequestResult>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The full set of fields a PR publish needs, produced by `generate.ts`. The
|
|
48
|
+
* commit subject and PR title are the same conventional-commit line; the bodies
|
|
49
|
+
* may differ (the PR body is the richer, structured summary).
|
|
50
|
+
*/
|
|
51
|
+
export interface GeneratedContent {
|
|
52
|
+
readonly branchName: string;
|
|
53
|
+
readonly commitSubject: string;
|
|
54
|
+
readonly commitBody: string;
|
|
55
|
+
readonly prTitle: string;
|
|
56
|
+
readonly prBody: string;
|
|
57
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version-control / forge types for PR generation (C.15). The {@link ForgeProvider}
|
|
3
|
+
* interface is the swap seam — GitHub ships first, GitLab/Bitbucket slot in later
|
|
4
|
+
* without touching call sites (same discipline as `VectorStore`/`Embedder`).
|
|
5
|
+
*/
|
|
6
|
+
export {};
|
package/package.json
CHANGED
package/dist/agent/approval.d.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { ApproveAction } from "../tools/index.js";
|
|
2
|
-
import { logger as defaultLogger } from "../utils/logger.js";
|
|
3
|
-
type Logger = typeof defaultLogger;
|
|
4
|
-
/** Reads a single keypress from the user. Returns "" on EOF. */
|
|
5
|
-
export type KeypressReader = () => Promise<string>;
|
|
6
|
-
export interface ApproverConfig {
|
|
7
|
-
/** "prompt" asks interactively; "auto" approves everything unattended. */
|
|
8
|
-
mode: "prompt" | "auto";
|
|
9
|
-
/** Project root, used to render action paths relative for the prompt. */
|
|
10
|
-
cwd: string;
|
|
11
|
-
/** Whether stdin is an interactive TTY. */
|
|
12
|
-
isInteractive: boolean;
|
|
13
|
-
/** Explicit unattended opt-in (e.g. --yes / --dangerously-approve). */
|
|
14
|
-
autoApprove?: boolean;
|
|
15
|
-
/** Keypress source (injectable for tests). Defaults to a raw-mode stdin read. */
|
|
16
|
-
readKey?: KeypressReader;
|
|
17
|
-
/** Where the prompt text is written (injectable). Defaults to stderr. */
|
|
18
|
-
write?: (text: string) => void;
|
|
19
|
-
/** Logger for unattended / denial diagnostics. */
|
|
20
|
-
logger?: Logger;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Decides whether a side-effecting tool action may proceed. The single gate every
|
|
24
|
-
* mutating tool funnels through (write/edit today, run_command later).
|
|
25
|
-
*
|
|
26
|
-
* Interactive: prints the action (plus a change preview when the tool supplied
|
|
27
|
-
* one) and reads one key — `y` allow once, `a` allow-all for this run, anything
|
|
28
|
-
* else (including EOF) denies. **Fails closed.**
|
|
29
|
-
* Non-interactive: denies by default unless an explicit opt-in (`mode:"auto"` or
|
|
30
|
-
* `autoApprove`) is set, in which case it auto-approves and warns it's unattended.
|
|
31
|
-
*/
|
|
32
|
-
export declare class Approver {
|
|
33
|
-
/** Session allow-all — scoped to this instance, never persisted. */
|
|
34
|
-
private allowAll;
|
|
35
|
-
private warnedUnattended;
|
|
36
|
-
private readonly cfg;
|
|
37
|
-
constructor(cfg: ApproverConfig);
|
|
38
|
-
approve(action: ApproveAction): Promise<boolean>;
|
|
39
|
-
}
|
|
40
|
-
export declare function createApprover(cfg: ApproverConfig): Approver;
|
|
41
|
-
export {};
|
package/dist/agent/approval.js
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import pc from "picocolors";
|
|
3
|
-
import { logger as defaultLogger } from "../utils/logger.js";
|
|
4
|
-
/** Cap on rendered preview lines before collapsing the rest into "...N more". */
|
|
5
|
-
const PREVIEW_MAX_LINES = 40;
|
|
6
|
-
/** Human-readable verb + detail for a pending action. */
|
|
7
|
-
function renderAction(cwd, action) {
|
|
8
|
-
switch (action.kind) {
|
|
9
|
-
case "write":
|
|
10
|
-
return `write ${path.relative(cwd, action.path ?? "") || action.path}`;
|
|
11
|
-
case "edit":
|
|
12
|
-
return `edit ${path.relative(cwd, action.path ?? "") || action.path}`;
|
|
13
|
-
case "shell":
|
|
14
|
-
return `run ${action.command ?? ""}`;
|
|
15
|
-
case "patch": {
|
|
16
|
-
const n = action.preview?.type === "patch" ? action.preview.files.length : 0;
|
|
17
|
-
return `apply patch (${n} file${n === 1 ? "" : "s"})`;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* A minimal unified-style diff for one hunk: the removed block (red `-`) then the
|
|
23
|
-
* added block (green `+`). The `-`/`+` prefixes keep it readable without color.
|
|
24
|
-
* Shared by the `edit` and `patch` previews (the C.6 renderer).
|
|
25
|
-
*/
|
|
26
|
-
function diffLines(oldStr, newStr) {
|
|
27
|
-
const removed = oldStr.split("\n").map((l) => pc.red(`- ${l}`));
|
|
28
|
-
const added = newStr.split("\n").map((l) => pc.green(`+ ${l}`));
|
|
29
|
-
return [...removed, ...added];
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Render every file in an `apply_patch` preview as a combined diff: a per-file
|
|
33
|
-
* header (op + path) followed by its hunk diffs (update), created content
|
|
34
|
-
* (create), or nothing (delete).
|
|
35
|
-
*/
|
|
36
|
-
function renderPatchFiles(files) {
|
|
37
|
-
const out = [];
|
|
38
|
-
for (const file of files) {
|
|
39
|
-
if (file.op === "delete") {
|
|
40
|
-
out.push(pc.red(`delete ${file.path}`));
|
|
41
|
-
}
|
|
42
|
-
else if (file.op === "create") {
|
|
43
|
-
out.push(pc.green(`create ${file.path}`));
|
|
44
|
-
out.push(...file.lines.map((l) => pc.green(`+ ${l}`)));
|
|
45
|
-
if (file.omittedLines > 0) {
|
|
46
|
-
out.push(pc.dim(` ...${file.omittedLines} more lines`));
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
out.push(pc.yellow(`update ${file.path}`));
|
|
51
|
-
for (const hunk of file.hunks) {
|
|
52
|
-
out.push(...diffLines(hunk.oldStr, hunk.newStr));
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Render the exact-change preview for a write/edit action as indented lines, so
|
|
60
|
-
* the user sees what they're approving. Caps the output at `PREVIEW_MAX_LINES`
|
|
61
|
-
* and collapses the overflow into a "...N more" notice. Returns "" when there's
|
|
62
|
-
* nothing to preview. Colors are decorative — the `-`/`+`/header prefixes keep
|
|
63
|
-
* it readable without them.
|
|
64
|
-
*/
|
|
65
|
-
function renderPreview(preview) {
|
|
66
|
-
if (!preview)
|
|
67
|
-
return "";
|
|
68
|
-
let lines;
|
|
69
|
-
if (preview.type === "edit") {
|
|
70
|
-
lines = diffLines(preview.oldStr, preview.newStr);
|
|
71
|
-
}
|
|
72
|
-
else if (preview.type === "patch") {
|
|
73
|
-
lines = renderPatchFiles(preview.files);
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
const header = preview.exists
|
|
77
|
-
? pc.yellow("OVERWRITE existing")
|
|
78
|
-
: pc.green("create");
|
|
79
|
-
const body = preview.lines.map((l) => ` ${l}`);
|
|
80
|
-
if (preview.omittedLines > 0) {
|
|
81
|
-
body.push(pc.dim(` ...${preview.omittedLines} more lines`));
|
|
82
|
-
}
|
|
83
|
-
lines = [header, ...body];
|
|
84
|
-
}
|
|
85
|
-
// Cap the rendered block; collapse the rest into a count.
|
|
86
|
-
if (lines.length > PREVIEW_MAX_LINES) {
|
|
87
|
-
const hidden = lines.length - PREVIEW_MAX_LINES;
|
|
88
|
-
lines = [...lines.slice(0, PREVIEW_MAX_LINES), pc.dim(`...${hidden} more`)];
|
|
89
|
-
}
|
|
90
|
-
return lines.map((l) => ` ${l}\n`).join("");
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Decides whether a side-effecting tool action may proceed. The single gate every
|
|
94
|
-
* mutating tool funnels through (write/edit today, run_command later).
|
|
95
|
-
*
|
|
96
|
-
* Interactive: prints the action (plus a change preview when the tool supplied
|
|
97
|
-
* one) and reads one key — `y` allow once, `a` allow-all for this run, anything
|
|
98
|
-
* else (including EOF) denies. **Fails closed.**
|
|
99
|
-
* Non-interactive: denies by default unless an explicit opt-in (`mode:"auto"` or
|
|
100
|
-
* `autoApprove`) is set, in which case it auto-approves and warns it's unattended.
|
|
101
|
-
*/
|
|
102
|
-
export class Approver {
|
|
103
|
-
/** Session allow-all — scoped to this instance, never persisted. */
|
|
104
|
-
allowAll = false;
|
|
105
|
-
warnedUnattended = false;
|
|
106
|
-
cfg;
|
|
107
|
-
constructor(cfg) {
|
|
108
|
-
this.cfg = {
|
|
109
|
-
...cfg,
|
|
110
|
-
readKey: cfg.readKey ?? readKeyFromStdin,
|
|
111
|
-
write: cfg.write ?? ((t) => process.stderr.write(t)),
|
|
112
|
-
logger: cfg.logger ?? defaultLogger,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
async approve(action) {
|
|
116
|
-
const log = this.cfg.logger ?? defaultLogger;
|
|
117
|
-
if (this.allowAll)
|
|
118
|
-
return true;
|
|
119
|
-
// Explicit unattended opt-in — approve everywhere, warn once.
|
|
120
|
-
if (this.cfg.mode === "auto" || this.cfg.autoApprove) {
|
|
121
|
-
if (!this.warnedUnattended) {
|
|
122
|
-
log.warn("running unattended — auto-approving all tool actions");
|
|
123
|
-
this.warnedUnattended = true;
|
|
124
|
-
}
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
// No way to ask → fail closed.
|
|
128
|
-
if (!this.cfg.isInteractive) {
|
|
129
|
-
log.warn(`denied (${renderAction(this.cfg.cwd, action)}): non-interactive — pass --yes or set approval.mode=auto`);
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
const write = this.cfg.write ?? ((t) => process.stderr.write(t));
|
|
133
|
-
write(`${pc.yellow("?")} cruxy wants to ${renderAction(this.cfg.cwd, action)}\n` +
|
|
134
|
-
renderPreview(action.preview) +
|
|
135
|
-
` ${pc.dim("[y] allow once · [n] deny · [a] allow all:")} `);
|
|
136
|
-
const key = (await (this.cfg.readKey ?? readKeyFromStdin)()).toLowerCase();
|
|
137
|
-
write("\n");
|
|
138
|
-
if (key === "y")
|
|
139
|
-
return true;
|
|
140
|
-
if (key === "a") {
|
|
141
|
-
this.allowAll = true;
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
// n, EOF (""), Ctrl-C, or any other key → deny.
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
export function createApprover(cfg) {
|
|
149
|
-
return new Approver(cfg);
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Read a single keypress from stdin in raw mode. Resolves to the first character
|
|
153
|
-
* of the chunk, or "" on EOF. Always restores cooked mode and pauses stdin.
|
|
154
|
-
*/
|
|
155
|
-
function readKeyFromStdin() {
|
|
156
|
-
const stdin = process.stdin;
|
|
157
|
-
return new Promise((resolve) => {
|
|
158
|
-
const cleanup = () => {
|
|
159
|
-
stdin.removeListener("data", onData);
|
|
160
|
-
stdin.removeListener("end", onEnd);
|
|
161
|
-
if (stdin.isTTY)
|
|
162
|
-
stdin.setRawMode(false);
|
|
163
|
-
stdin.pause();
|
|
164
|
-
};
|
|
165
|
-
const onData = (buf) => {
|
|
166
|
-
cleanup();
|
|
167
|
-
resolve(buf.toString("utf8").slice(0, 1));
|
|
168
|
-
};
|
|
169
|
-
const onEnd = () => {
|
|
170
|
-
cleanup();
|
|
171
|
-
resolve("");
|
|
172
|
-
};
|
|
173
|
-
if (stdin.isTTY)
|
|
174
|
-
stdin.setRawMode(true);
|
|
175
|
-
stdin.resume();
|
|
176
|
-
stdin.once("data", onData);
|
|
177
|
-
stdin.once("end", onEnd);
|
|
178
|
-
});
|
|
179
|
-
}
|