@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
|
@@ -18,6 +18,31 @@ export declare function permissionDenied(path: string, underlying?: unknown): Cr
|
|
|
18
18
|
export declare function indexEmbedderUnavailable(underlying?: unknown): CruxyError;
|
|
19
19
|
export declare function indexStoreUnavailable(underlying?: unknown): CruxyError;
|
|
20
20
|
export declare function indexFailed(underlying?: unknown): CruxyError;
|
|
21
|
+
/**
|
|
22
|
+
* A side-effecting action needs approval but cruxy can't ask (non-interactive,
|
|
23
|
+
* no policy). Default-deny — never auto-approve. A distinct exit code (10) so CI
|
|
24
|
+
* can tell "needed approval" apart from a usage error.
|
|
25
|
+
*/
|
|
26
|
+
export declare function approvalRequired(summary: string): CruxyError;
|
|
27
|
+
/**
|
|
28
|
+
* No forge token could be resolved (PR generation, C.15). The chain is env →
|
|
29
|
+
* `gh auth token` → fail. We never prompt for, store, or persist a token, so the
|
|
30
|
+
* fix is always to provide one in the environment.
|
|
31
|
+
*/
|
|
32
|
+
export declare function forgeAuth(host?: string): CruxyError;
|
|
33
|
+
/**
|
|
34
|
+
* A commit/push was attempted on a protected branch (`main`/`master`/configured).
|
|
35
|
+
* The PR flow must branch off first; this is the last-line guard.
|
|
36
|
+
*/
|
|
37
|
+
export declare function gitProtectedBranch(branch: string): CruxyError;
|
|
38
|
+
/** The forge REST API returned an error (non-auth) while opening a PR. */
|
|
39
|
+
export declare function forgeApi(title: string, underlying?: unknown, meta?: Record<string, unknown>): CruxyError;
|
|
40
|
+
/**
|
|
41
|
+
* `git push` failed — most often the husky `pre-push` verify hook (build ·
|
|
42
|
+
* typecheck · lint · test) or a rejected non-fast-forward. We never `--force` or
|
|
43
|
+
* `--no-verify`, so the underlying reason is surfaced verbatim.
|
|
44
|
+
*/
|
|
45
|
+
export declare function gitPushFailed(branch: string, stderr?: string): CruxyError;
|
|
21
46
|
export declare function internal(underlying?: unknown): CruxyError;
|
|
22
47
|
/**
|
|
23
48
|
* Map a known provider/transport error (from `@cruxy/sdk`) to a typed
|
|
@@ -208,6 +208,89 @@ export function indexFailed(underlying) {
|
|
|
208
208
|
underlying,
|
|
209
209
|
});
|
|
210
210
|
}
|
|
211
|
+
// ── approval (exit 10) ────────────────────────────────────────────────────────
|
|
212
|
+
/**
|
|
213
|
+
* A side-effecting action needs approval but cruxy can't ask (non-interactive,
|
|
214
|
+
* no policy). Default-deny — never auto-approve. A distinct exit code (10) so CI
|
|
215
|
+
* can tell "needed approval" apart from a usage error.
|
|
216
|
+
*/
|
|
217
|
+
export function approvalRequired(summary) {
|
|
218
|
+
return new CruxyError({
|
|
219
|
+
code: ErrorCode.ApprovalRequired,
|
|
220
|
+
title: "this action needs your approval, but cruxy is running non-interactively",
|
|
221
|
+
cause: `pending action — ${summary}`,
|
|
222
|
+
nextSteps: [
|
|
223
|
+
"run cruxy in an interactive terminal so you can approve actions",
|
|
224
|
+
"or scope the task to read-only actions (no file writes or shell commands)",
|
|
225
|
+
],
|
|
226
|
+
meta: { summary },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// ── vcs: forge + git (exit 4 / 2 / 6 / 5) ─────────────────────────────────────
|
|
230
|
+
/**
|
|
231
|
+
* No forge token could be resolved (PR generation, C.15). The chain is env →
|
|
232
|
+
* `gh auth token` → fail. We never prompt for, store, or persist a token, so the
|
|
233
|
+
* fix is always to provide one in the environment.
|
|
234
|
+
*/
|
|
235
|
+
export function forgeAuth(host = "github.com") {
|
|
236
|
+
return new CruxyError({
|
|
237
|
+
code: ErrorCode.ForgeAuth,
|
|
238
|
+
title: `no ${host} token available to open a pull request`,
|
|
239
|
+
cause: "checked GITHUB_TOKEN, GH_TOKEN, and `gh auth token` — none provided a token",
|
|
240
|
+
nextSteps: [
|
|
241
|
+
"export GITHUB_TOKEN=… (a personal access token with `repo` scope)",
|
|
242
|
+
"or install the GitHub CLI and run `gh auth login`",
|
|
243
|
+
],
|
|
244
|
+
meta: { host },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* A commit/push was attempted on a protected branch (`main`/`master`/configured).
|
|
249
|
+
* The PR flow must branch off first; this is the last-line guard.
|
|
250
|
+
*/
|
|
251
|
+
export function gitProtectedBranch(branch) {
|
|
252
|
+
return new CruxyError({
|
|
253
|
+
code: ErrorCode.GitProtectedBranch,
|
|
254
|
+
title: `refusing to commit or push on the protected branch "${branch}"`,
|
|
255
|
+
cause: "cruxy never writes directly to a protected branch",
|
|
256
|
+
nextSteps: [
|
|
257
|
+
"switch to a feature branch first, e.g. `git switch -c feat/my-change`",
|
|
258
|
+
"adjust the protected list with `git.protectedBranches` in your config",
|
|
259
|
+
],
|
|
260
|
+
meta: { branch },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** The forge REST API returned an error (non-auth) while opening a PR. */
|
|
264
|
+
export function forgeApi(title, underlying, meta) {
|
|
265
|
+
return new CruxyError({
|
|
266
|
+
code: ErrorCode.ForgeApi,
|
|
267
|
+
title,
|
|
268
|
+
cause: messageOf(underlying),
|
|
269
|
+
nextSteps: [
|
|
270
|
+
"retry in a moment; if it persists, check the forge's status",
|
|
271
|
+
"re-run with --verbose to see the API response",
|
|
272
|
+
],
|
|
273
|
+
underlying,
|
|
274
|
+
meta,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* `git push` failed — most often the husky `pre-push` verify hook (build ·
|
|
279
|
+
* typecheck · lint · test) or a rejected non-fast-forward. We never `--force` or
|
|
280
|
+
* `--no-verify`, so the underlying reason is surfaced verbatim.
|
|
281
|
+
*/
|
|
282
|
+
export function gitPushFailed(branch, stderr) {
|
|
283
|
+
return new CruxyError({
|
|
284
|
+
code: ErrorCode.GitPushFailed,
|
|
285
|
+
title: `failed to push branch "${branch}"`,
|
|
286
|
+
cause: stderr?.trim() || undefined,
|
|
287
|
+
nextSteps: [
|
|
288
|
+
"check the error above (the pre-push hook runs build · typecheck · lint · test)",
|
|
289
|
+
"fix the reported issue and try again — cruxy never force-pushes or skips hooks",
|
|
290
|
+
],
|
|
291
|
+
meta: { branch },
|
|
292
|
+
});
|
|
293
|
+
}
|
|
211
294
|
// ── internal (exit 1) ─────────────────────────────────────────────────────────
|
|
212
295
|
export function internal(underlying) {
|
|
213
296
|
return new CruxyError({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -16,15 +16,19 @@ export declare const ErrorCode: {
|
|
|
16
16
|
readonly Usage: "CRUXY_E_USAGE";
|
|
17
17
|
readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
|
|
18
18
|
readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
|
|
19
|
+
readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
|
|
19
20
|
readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
|
|
20
21
|
readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
|
|
21
22
|
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
|
22
23
|
readonly AuthInvalid: "CRUXY_E_AUTH_INVALID";
|
|
24
|
+
readonly ForgeAuth: "CRUXY_E_FORGE_AUTH";
|
|
23
25
|
readonly GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE";
|
|
26
|
+
readonly GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED";
|
|
24
27
|
readonly Api: "CRUXY_E_API";
|
|
25
28
|
readonly ApiRateLimit: "CRUXY_E_API_RATE_LIMIT";
|
|
26
29
|
readonly ApiOverloaded: "CRUXY_E_API_OVERLOADED";
|
|
27
30
|
readonly BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED";
|
|
31
|
+
readonly ForgeApi: "CRUXY_E_FORGE_API";
|
|
28
32
|
readonly FileNotFound: "CRUXY_E_FILE_NOT_FOUND";
|
|
29
33
|
readonly PermissionDenied: "CRUXY_E_PERMISSION_DENIED";
|
|
30
34
|
readonly PathEscape: "CRUXY_E_PATH_ESCAPE";
|
|
@@ -33,6 +37,7 @@ export declare const ErrorCode: {
|
|
|
33
37
|
readonly IndexFailed: "CRUXY_E_INDEX_FAILED";
|
|
34
38
|
readonly SkillInvalid: "CRUXY_E_SKILL_INVALID";
|
|
35
39
|
readonly SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND";
|
|
40
|
+
readonly ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED";
|
|
36
41
|
};
|
|
37
42
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
38
43
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -18,19 +18,23 @@ export const ErrorCode = {
|
|
|
18
18
|
Usage: "CRUXY_E_USAGE",
|
|
19
19
|
ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
|
|
20
20
|
ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
|
|
21
|
+
GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
|
|
21
22
|
// config (exit 3)
|
|
22
23
|
ConfigParse: "CRUXY_E_CONFIG_PARSE",
|
|
23
24
|
ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
|
|
24
25
|
// auth (exit 4)
|
|
25
26
|
AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
|
|
26
27
|
AuthInvalid: "CRUXY_E_AUTH_INVALID",
|
|
28
|
+
ForgeAuth: "CRUXY_E_FORGE_AUTH",
|
|
27
29
|
// network (exit 5)
|
|
28
30
|
GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE",
|
|
31
|
+
GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED",
|
|
29
32
|
// api (exit 6)
|
|
30
33
|
Api: "CRUXY_E_API",
|
|
31
34
|
ApiRateLimit: "CRUXY_E_API_RATE_LIMIT",
|
|
32
35
|
ApiOverloaded: "CRUXY_E_API_OVERLOADED",
|
|
33
36
|
BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED",
|
|
37
|
+
ForgeApi: "CRUXY_E_FORGE_API",
|
|
34
38
|
// filesystem (exit 7)
|
|
35
39
|
FileNotFound: "CRUXY_E_FILE_NOT_FOUND",
|
|
36
40
|
PermissionDenied: "CRUXY_E_PERMISSION_DENIED",
|
|
@@ -42,6 +46,8 @@ export const ErrorCode = {
|
|
|
42
46
|
// skill (exit 9)
|
|
43
47
|
SkillInvalid: "CRUXY_E_SKILL_INVALID",
|
|
44
48
|
SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND",
|
|
49
|
+
// approval (exit 10)
|
|
50
|
+
ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED",
|
|
45
51
|
};
|
|
46
52
|
/**
|
|
47
53
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -52,15 +58,19 @@ const EXIT_CODES = {
|
|
|
52
58
|
[ErrorCode.Usage]: 2,
|
|
53
59
|
[ErrorCode.ConfigKeyUnknown]: 2,
|
|
54
60
|
[ErrorCode.ProviderUnsupported]: 2,
|
|
61
|
+
[ErrorCode.GitProtectedBranch]: 2,
|
|
55
62
|
[ErrorCode.ConfigParse]: 3,
|
|
56
63
|
[ErrorCode.ConfigInvalid]: 3,
|
|
57
64
|
[ErrorCode.AuthMissingKey]: 4,
|
|
58
65
|
[ErrorCode.AuthInvalid]: 4,
|
|
66
|
+
[ErrorCode.ForgeAuth]: 4,
|
|
59
67
|
[ErrorCode.GatewayUnreachable]: 5,
|
|
68
|
+
[ErrorCode.GitPushFailed]: 5,
|
|
60
69
|
[ErrorCode.Api]: 6,
|
|
61
70
|
[ErrorCode.ApiRateLimit]: 6,
|
|
62
71
|
[ErrorCode.ApiOverloaded]: 6,
|
|
63
72
|
[ErrorCode.BudgetExhausted]: 6,
|
|
73
|
+
[ErrorCode.ForgeApi]: 6,
|
|
64
74
|
[ErrorCode.FileNotFound]: 7,
|
|
65
75
|
[ErrorCode.PermissionDenied]: 7,
|
|
66
76
|
[ErrorCode.PathEscape]: 7,
|
|
@@ -69,6 +79,7 @@ const EXIT_CODES = {
|
|
|
69
79
|
[ErrorCode.IndexFailed]: 8,
|
|
70
80
|
[ErrorCode.SkillInvalid]: 9,
|
|
71
81
|
[ErrorCode.SkillNotFound]: 9,
|
|
82
|
+
[ErrorCode.ApprovalRequired]: 10,
|
|
72
83
|
};
|
|
73
84
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
74
85
|
export function exitCodeFor(code) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Tool } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Open a pull request from the agent's work (C.15): branch → conventional commit
|
|
5
|
+
* → push → open PR, all behind the one U.3 approval. The model authors `title`
|
|
6
|
+
* and `body`; anything omitted is filled deterministically (no second LLM call —
|
|
7
|
+
* the agent IS the LLM). The token is resolved from the environment / `gh`; it is
|
|
8
|
+
* never prompted for or stored.
|
|
9
|
+
*/
|
|
10
|
+
declare const parameters: z.ZodObject<{
|
|
11
|
+
title: z.ZodOptional<z.ZodString>;
|
|
12
|
+
body: z.ZodOptional<z.ZodString>;
|
|
13
|
+
base: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, "strip", z.ZodTypeAny, {
|
|
15
|
+
body?: string | undefined;
|
|
16
|
+
base?: string | undefined;
|
|
17
|
+
title?: string | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
body?: string | undefined;
|
|
20
|
+
base?: string | undefined;
|
|
21
|
+
title?: string | undefined;
|
|
22
|
+
}>;
|
|
23
|
+
export declare const createPullRequestTool: Tool<typeof parameters>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CruxyError, ErrorCode } from "../errors/index.js";
|
|
3
|
+
import { createForgeProvider, createPrService, fillContent, loadCommitGuidance, resolveForgeToken, } from "../vcs/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Open a pull request from the agent's work (C.15): branch → conventional commit
|
|
6
|
+
* → push → open PR, all behind the one U.3 approval. The model authors `title`
|
|
7
|
+
* and `body`; anything omitted is filled deterministically (no second LLM call —
|
|
8
|
+
* the agent IS the LLM). The token is resolved from the environment / `gh`; it is
|
|
9
|
+
* never prompted for or stored.
|
|
10
|
+
*/
|
|
11
|
+
const parameters = z.object({
|
|
12
|
+
title: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("PR title as a conventional-commit subject (e.g. `feat(cli): add X`). " +
|
|
16
|
+
"Lowercase subject; omit to auto-generate."),
|
|
17
|
+
body: z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe("PR body in markdown (what changed · why · verification). Omit to auto-generate."),
|
|
21
|
+
base: z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Base branch to merge into (defaults to the repo's default branch)."),
|
|
25
|
+
});
|
|
26
|
+
export const createPullRequestTool = {
|
|
27
|
+
name: "create_pull_request",
|
|
28
|
+
description: "Open a pull request for the current changes: create a feature branch, make a " +
|
|
29
|
+
"conventional commit, push, and open the PR. Branch/commit/PR are shown for your " +
|
|
30
|
+
"approval before anything runs. Never acts on a protected branch and never force-pushes.",
|
|
31
|
+
parameters,
|
|
32
|
+
async execute(input, ctx) {
|
|
33
|
+
try {
|
|
34
|
+
const token = resolveForgeToken();
|
|
35
|
+
const forge = createForgeProvider(token);
|
|
36
|
+
const guidance = await loadCommitGuidance(ctx.cwd);
|
|
37
|
+
const service = createPrService({
|
|
38
|
+
cwd: ctx.cwd,
|
|
39
|
+
config: ctx.config,
|
|
40
|
+
forge,
|
|
41
|
+
requestApproval: ctx.requestApproval,
|
|
42
|
+
generate: async (i) => fillContent({
|
|
43
|
+
...i,
|
|
44
|
+
scopes: guidance.scopes,
|
|
45
|
+
skillBody: guidance.skillBody,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
const outcome = await service.openPullRequest({
|
|
49
|
+
title: input.title,
|
|
50
|
+
body: input.body,
|
|
51
|
+
base: input.base,
|
|
52
|
+
});
|
|
53
|
+
if (!outcome.approved) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: outcome.feedback ?? "you declined to open the pull request",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const verb = outcome.alreadyExists
|
|
60
|
+
? "a pull request already exists"
|
|
61
|
+
: "opened pull request";
|
|
62
|
+
return { ok: true, output: `${verb}: ${outcome.url}` };
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
// The non-interactive approval error must reach the boundary (exit 10);
|
|
66
|
+
// every other coded failure is surfaced to the model so it can adapt.
|
|
67
|
+
if (err instanceof CruxyError &&
|
|
68
|
+
err.code === ErrorCode.ApprovalRequired) {
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
if (err instanceof CruxyError) {
|
|
72
|
+
return { ok: false, error: formatCoded(err) };
|
|
73
|
+
}
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
/** One-line, model-readable rendering of a coded error + its next steps. */
|
|
79
|
+
function formatCoded(err) {
|
|
80
|
+
const head = err.cause ? `${err.title} — ${err.cause}` : err.title;
|
|
81
|
+
const steps = err.nextSteps.length > 0 ? ` next steps: ${err.nextSteps.join("; ")}` : "";
|
|
82
|
+
return `${head} (${err.code}).${steps}`;
|
|
83
|
+
}
|
|
@@ -80,12 +80,12 @@ export const applyPatchTool = {
|
|
|
80
80
|
planned.push(planResult.planned);
|
|
81
81
|
}
|
|
82
82
|
// One approval for the whole patch — denial writes nothing.
|
|
83
|
-
const
|
|
83
|
+
const decision = await ctx.requestApproval({
|
|
84
84
|
kind: "patch",
|
|
85
85
|
preview: { type: "patch", files: planned.map(toPreview) },
|
|
86
86
|
});
|
|
87
|
-
if (!
|
|
88
|
-
return { ok: false, error: "patch denied" };
|
|
87
|
+
if (!decision.allow) {
|
|
88
|
+
return { ok: false, error: decision.feedback ?? "patch denied" };
|
|
89
89
|
}
|
|
90
90
|
// Validation passed and the user approved; apply everything. A mid-apply I/O
|
|
91
91
|
// failure is rare but reported with what already landed.
|
|
@@ -57,13 +57,16 @@ export const editFileTool = {
|
|
|
57
57
|
error: `old_str not unique (${matches} matches); add surrounding context to disambiguate`,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
-
const
|
|
60
|
+
const decision = await ctx.requestApproval({
|
|
61
61
|
kind: "edit",
|
|
62
62
|
path: abs,
|
|
63
63
|
preview: { type: "edit", oldStr: input.old_str, newStr: input.new_str },
|
|
64
64
|
});
|
|
65
|
-
if (!
|
|
66
|
-
return {
|
|
65
|
+
if (!decision.allow) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: decision.feedback ?? `edit to ${input.path} denied`,
|
|
69
|
+
};
|
|
67
70
|
}
|
|
68
71
|
// Replace the single occurrence by index to avoid `$`-pattern interpretation.
|
|
69
72
|
const idx = content.indexOf(input.old_str);
|
|
@@ -35,13 +35,16 @@ export const writeFileTool = {
|
|
|
35
35
|
const lines = allLines.slice(0, PREVIEW_LINES);
|
|
36
36
|
const omittedLines = Math.max(0, allLines.length - PREVIEW_LINES);
|
|
37
37
|
// Approve BEFORE any mutation; a denial writes nothing.
|
|
38
|
-
const
|
|
38
|
+
const decision = await ctx.requestApproval({
|
|
39
39
|
kind: "write",
|
|
40
40
|
path: abs,
|
|
41
41
|
preview: { type: "write", exists, lines, omittedLines },
|
|
42
42
|
});
|
|
43
|
-
if (!
|
|
44
|
-
return {
|
|
43
|
+
if (!decision.allow) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
error: decision.feedback ?? `write to ${input.path} denied`,
|
|
47
|
+
};
|
|
45
48
|
}
|
|
46
49
|
try {
|
|
47
50
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
package/dist/tools/index.d.ts
CHANGED
package/dist/tools/index.js
CHANGED
package/dist/tools/registry.js
CHANGED
|
@@ -6,6 +6,7 @@ import { runCommandTool } from "./shell/index.js";
|
|
|
6
6
|
import { searchCodebaseTool } from "./search-codebase.js";
|
|
7
7
|
import { listSkillsTool } from "./list-skills.js";
|
|
8
8
|
import { loadSkillTool } from "./load-skill.js";
|
|
9
|
+
import { createPullRequestTool } from "./create-pull-request.js";
|
|
9
10
|
/**
|
|
10
11
|
* In-memory catalogue of the tools available to the agent. Names are unique;
|
|
11
12
|
* `toToolSpecs()` projects the catalogue into the `@cruxy/sdk` wire format.
|
|
@@ -65,5 +66,6 @@ export function buildDefaultRegistry() {
|
|
|
65
66
|
registry.register(searchCodebaseTool);
|
|
66
67
|
registry.register(listSkillsTool);
|
|
67
68
|
registry.register(loadSkillTool);
|
|
69
|
+
registry.register(createPullRequestTool);
|
|
68
70
|
return registry;
|
|
69
71
|
}
|
|
@@ -14,9 +14,17 @@ export const runCommandTool = {
|
|
|
14
14
|
.describe("The shell command to run (executed via the system shell)."),
|
|
15
15
|
}),
|
|
16
16
|
async execute(input, ctx) {
|
|
17
|
-
// Approve BEFORE anything runs; a denial executes nothing.
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// Approve BEFORE anything runs; a denial executes nothing. A thrown
|
|
18
|
+
// CRUXY_E_APPROVAL_REQUIRED (non-interactive) propagates — do not catch.
|
|
19
|
+
const decision = await ctx.requestApproval({
|
|
20
|
+
kind: "shell",
|
|
21
|
+
command: input.command,
|
|
22
|
+
});
|
|
23
|
+
if (!decision.allow) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: decision.feedback ?? "command denied by the user",
|
|
27
|
+
};
|
|
20
28
|
}
|
|
21
29
|
return runBounded(input.command, ctx);
|
|
22
30
|
},
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { z, ZodTypeAny } from "zod";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { ApprovalDecision } from "../approval/types.js";
|
|
3
4
|
import type { logger } from "../utils/logger.js";
|
|
4
5
|
/** The leveled logger instance shared across the CLI. */
|
|
5
6
|
type Logger = typeof logger;
|
|
@@ -61,6 +62,20 @@ export type ActionPreview =
|
|
|
61
62
|
| {
|
|
62
63
|
type: "patch";
|
|
63
64
|
files: PatchFilePreview[];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The whole publish plan for a `vcs` action (C.15): the feature branch, the
|
|
68
|
+
* conventional commit, and the PR title/body — shown as one block so the user
|
|
69
|
+
* approves the entire branch → commit → push → open-PR sequence at once.
|
|
70
|
+
*/
|
|
71
|
+
| {
|
|
72
|
+
type: "pr";
|
|
73
|
+
branch: string;
|
|
74
|
+
base: string;
|
|
75
|
+
commitSubject: string;
|
|
76
|
+
commitBody: string;
|
|
77
|
+
prTitle: string;
|
|
78
|
+
prBody: string;
|
|
64
79
|
};
|
|
65
80
|
/**
|
|
66
81
|
* A side-effecting action a tool wants to take, passed to `ctx.approve`. The
|
|
@@ -68,12 +83,12 @@ export type ActionPreview =
|
|
|
68
83
|
*/
|
|
69
84
|
export interface ApproveAction {
|
|
70
85
|
/** The category of side effect being requested. */
|
|
71
|
-
kind: "write" | "edit" | "shell" | "patch";
|
|
86
|
+
kind: "write" | "edit" | "shell" | "patch" | "vcs";
|
|
72
87
|
/** Absolute resolved path the action targets (write/edit). */
|
|
73
88
|
path?: string;
|
|
74
89
|
/** The command to run (shell). */
|
|
75
90
|
command?: string;
|
|
76
|
-
/** Exact-change preview rendered above the prompt (write/edit/patch). */
|
|
91
|
+
/** Exact-change preview rendered above the prompt (write/edit/patch/vcs). */
|
|
77
92
|
preview?: ActionPreview;
|
|
78
93
|
}
|
|
79
94
|
/**
|
|
@@ -89,11 +104,15 @@ export interface ToolContext {
|
|
|
89
104
|
/** Shared leveled logger (diagnostics to stderr, `print` to stdout). */
|
|
90
105
|
logger: Logger;
|
|
91
106
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
107
|
+
* The approval gate every side-effecting tool funnels through, **before** any
|
|
108
|
+
* mutation. Returns a decision: `{allow:true}` to proceed, or `{allow:false,
|
|
109
|
+
* feedback?}` (a clean rejection whose feedback is surfaced to the agent).
|
|
110
|
+
* Backed by the risk-tiered `ApprovalService` (see src/approval); it may
|
|
111
|
+
* *throw* `CRUXY_E_APPROVAL_REQUIRED` when it can't ask (non-interactive),
|
|
112
|
+
* which propagates to the boundary rather than being swallowed. Read-only
|
|
113
|
+
* tools never call this.
|
|
95
114
|
*/
|
|
96
|
-
|
|
115
|
+
requestApproval(action: ApproveAction): Promise<ApprovalDecision>;
|
|
97
116
|
}
|
|
98
117
|
/**
|
|
99
118
|
* The one interface every tool implements. `parameters` is a zod schema; it both
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a forge token (C.15). The chain is, in order:
|
|
3
|
+
* 1. `GITHUB_TOKEN`
|
|
4
|
+
* 2. `GH_TOKEN`
|
|
5
|
+
* 3. `gh auth token` (if the GitHub CLI is installed and logged in)
|
|
6
|
+
* 4. fail loud — {@link forgeAuth} (`CRUXY_E_FORGE_AUTH`)
|
|
7
|
+
*
|
|
8
|
+
* We **never** prompt for, store, or persist a token: it's read from the
|
|
9
|
+
* environment / `gh` on every run, exactly like provider API keys.
|
|
10
|
+
*/
|
|
11
|
+
/** Injection seams so the chain is testable without real env / a real `gh`. */
|
|
12
|
+
export interface ForgeAuthDeps {
|
|
13
|
+
/** Environment to read tokens from (defaults to `process.env`). */
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
15
|
+
/** Run `gh auth token`, returning the token or `null`. Defaults to the CLI. */
|
|
16
|
+
ghToken?: () => string | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve a forge token or throw {@link forgeAuth}. `host` is only used to make
|
|
20
|
+
* the error message specific.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveForgeToken(deps?: ForgeAuthDeps, host?: string): string;
|
package/dist/vcs/auth.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { forgeAuth } from "../errors/index.js";
|
|
3
|
+
/** Invoke `gh auth token`; returns the trimmed token, or `null` on any failure. */
|
|
4
|
+
function ghAuthToken() {
|
|
5
|
+
const res = spawnSync("gh", ["auth", "token"], {
|
|
6
|
+
encoding: "utf8",
|
|
7
|
+
timeout: 5000,
|
|
8
|
+
windowsHide: true,
|
|
9
|
+
});
|
|
10
|
+
if (res.error || res.status !== 0 || typeof res.stdout !== "string") {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const token = res.stdout.trim();
|
|
14
|
+
return token === "" ? null : token;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a forge token or throw {@link forgeAuth}. `host` is only used to make
|
|
18
|
+
* the error message specific.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveForgeToken(deps = {}, host = "github.com") {
|
|
21
|
+
const env = deps.env ?? process.env;
|
|
22
|
+
const fromEnv = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim();
|
|
23
|
+
if (fromEnv)
|
|
24
|
+
return fromEnv;
|
|
25
|
+
const fromGh = (deps.ghToken ?? ghAuthToken)();
|
|
26
|
+
if (fromGh && fromGh.trim() !== "")
|
|
27
|
+
return fromGh.trim();
|
|
28
|
+
throw forgeAuth(host);
|
|
29
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Provider } from "@cruxy/sdk";
|
|
2
|
+
import type { GeneratedContent } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Turn a diff + session context into the PR publish content (C.15): a
|
|
5
|
+
* conventional-commit subject, a structured body, a branch name, and the PR
|
|
6
|
+
* title/body. The repo's commit rules (the C.18 git-commit skill + the
|
|
7
|
+
* commitlint scope list) are honored two ways: they're handed to the LLM in the
|
|
8
|
+
* prompt, **and** every field is run through deterministic normalizers so the
|
|
9
|
+
* lowercase-subject rule (and scope allow-list) hold even if the model slips.
|
|
10
|
+
*
|
|
11
|
+
* Secrets never leave: the diff is redacted before the LLM sees it, and the
|
|
12
|
+
* generated bodies are redacted again (defense-in-depth).
|
|
13
|
+
*/
|
|
14
|
+
/** Conventional-commit types accepted by `@commitlint/config-conventional`. */
|
|
15
|
+
export declare const CONVENTIONAL_TYPES: readonly ["feat", "fix", "chore", "docs", "refactor", "test", "perf", "build", "ci", "style", "revert"];
|
|
16
|
+
export interface GenerateInput {
|
|
17
|
+
/** The change set to summarize (already from `diffAgainst`). */
|
|
18
|
+
diff: string;
|
|
19
|
+
/** The PR base branch. */
|
|
20
|
+
base: string;
|
|
21
|
+
/** The branch the change is on, if already a feature branch. */
|
|
22
|
+
currentBranch?: string;
|
|
23
|
+
/** A short summary of what the agent did this session, if available. */
|
|
24
|
+
sessionSummary?: string;
|
|
25
|
+
/** commitlint scope allow-list; empty ⇒ no scope constraint. */
|
|
26
|
+
scopes: string[];
|
|
27
|
+
/** The git-commit SKILL.md body, embedded into the LLM prompt verbatim. */
|
|
28
|
+
skillBody?: string;
|
|
29
|
+
/** Caller-supplied title (e.g. the agent's), normalized rather than generated. */
|
|
30
|
+
title?: string;
|
|
31
|
+
/** Caller-supplied body. */
|
|
32
|
+
body?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Strip anything that looks like a credential from `text`. */
|
|
35
|
+
export declare function redactSecrets(text: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Force `raw` into a valid conventional-commit subject: a known `type`, an
|
|
38
|
+
* allow-listed scope (dropped if it isn't), a **lowercase** first word, no
|
|
39
|
+
* trailing period, clamped to 72 chars. This is the deterministic guarantee that
|
|
40
|
+
* the commitlint `subject-case` + `scope-enum` rules pass.
|
|
41
|
+
*/
|
|
42
|
+
export declare function normalizeSubject(raw: string, scopes?: string[]): string;
|
|
43
|
+
/** A safe branch name `type/slug` derived from a conventional subject. */
|
|
44
|
+
export declare function slugifyBranch(subject: string): string;
|
|
45
|
+
/** Assemble a structured PR body: what changed · why · verification. */
|
|
46
|
+
export declare function assembleBody(parts: {
|
|
47
|
+
what: string;
|
|
48
|
+
why?: string;
|
|
49
|
+
verification?: string;
|
|
50
|
+
}): string;
|
|
51
|
+
/**
|
|
52
|
+
* Build the publish content deterministically (used by the `create_pull_request`
|
|
53
|
+
* tool, where the agent itself authored the title/body). Missing fields are
|
|
54
|
+
* derived; every field is still normalized + redacted.
|
|
55
|
+
*/
|
|
56
|
+
export declare function fillContent(input: GenerateInput): GeneratedContent;
|
|
57
|
+
/**
|
|
58
|
+
* Generate publish content from the diff with the model, honoring the commit
|
|
59
|
+
* rules, then normalize + redact. Resilient: if the model's reply isn't the
|
|
60
|
+
* expected JSON, the first line becomes the subject and the rest the body.
|
|
61
|
+
*/
|
|
62
|
+
export declare function generateWithLlm(provider: Provider, input: GenerateInput): Promise<GeneratedContent>;
|
|
63
|
+
interface ParsedGenerated {
|
|
64
|
+
branchName?: string;
|
|
65
|
+
commitSubject?: string;
|
|
66
|
+
commitBody?: string;
|
|
67
|
+
prTitle?: string;
|
|
68
|
+
prBody?: string;
|
|
69
|
+
}
|
|
70
|
+
/** Parse the model reply: a JSON object if present, else first-line/rest. */
|
|
71
|
+
export declare function parseGenerated(text: string): ParsedGenerated;
|
|
72
|
+
export {};
|