@cruxy/cli 0.3.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 (76) hide show
  1. package/README.md +46 -1
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +0 -1
  4. package/dist/agent/loop.js +2 -2
  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/config.js +2 -3
  20. package/dist/cli/commands/index.js +5 -2
  21. package/dist/cli/commands/pr.d.ts +8 -0
  22. package/dist/cli/commands/pr.js +87 -0
  23. package/dist/cli/commands/run.js +31 -23
  24. package/dist/cli/program.js +26 -4
  25. package/dist/cli/repl.js +15 -2
  26. package/dist/config/manager.js +5 -4
  27. package/dist/config/schema.d.ts +38 -9
  28. package/dist/config/schema.js +13 -4
  29. package/dist/errors/boundary.d.ts +43 -0
  30. package/dist/errors/boundary.js +73 -0
  31. package/dist/errors/constructors.d.ts +52 -0
  32. package/dist/errors/constructors.js +329 -0
  33. package/dist/errors/format.d.ts +31 -0
  34. package/dist/errors/format.js +60 -0
  35. package/dist/errors/index.d.ts +4 -0
  36. package/dist/errors/index.js +4 -0
  37. package/dist/errors/types.d.ts +79 -0
  38. package/dist/errors/types.js +118 -0
  39. package/dist/index.js +8 -5
  40. package/dist/indexing/embedder.js +3 -3
  41. package/dist/indexing/service.js +4 -1
  42. package/dist/skills/loader.d.ts +2 -1
  43. package/dist/skills/loader.js +0 -0
  44. package/dist/skills/parser.d.ts +6 -4
  45. package/dist/skills/parser.js +13 -5
  46. package/dist/tools/create-pull-request.d.ts +24 -0
  47. package/dist/tools/create-pull-request.js +83 -0
  48. package/dist/tools/file/apply-patch.js +3 -3
  49. package/dist/tools/file/edit-file.js +6 -3
  50. package/dist/tools/file/paths.d.ts +4 -2
  51. package/dist/tools/file/paths.js +5 -3
  52. package/dist/tools/file/write-file.js +6 -3
  53. package/dist/tools/index.d.ts +1 -0
  54. package/dist/tools/index.js +1 -0
  55. package/dist/tools/registry.js +2 -0
  56. package/dist/tools/shell/run-command.js +11 -3
  57. package/dist/tools/types.d.ts +25 -6
  58. package/dist/vcs/auth.d.ts +22 -0
  59. package/dist/vcs/auth.js +29 -0
  60. package/dist/vcs/generate.d.ts +72 -0
  61. package/dist/vcs/generate.js +265 -0
  62. package/dist/vcs/git.d.ts +52 -0
  63. package/dist/vcs/git.js +152 -0
  64. package/dist/vcs/github.d.ts +44 -0
  65. package/dist/vcs/github.js +145 -0
  66. package/dist/vcs/guidance.d.ts +20 -0
  67. package/dist/vcs/guidance.js +76 -0
  68. package/dist/vcs/index.d.ts +7 -0
  69. package/dist/vcs/index.js +7 -0
  70. package/dist/vcs/service.d.ts +53 -0
  71. package/dist/vcs/service.js +79 -0
  72. package/dist/vcs/types.d.ts +57 -0
  73. package/dist/vcs/types.js +6 -0
  74. package/package.json +1 -1
  75. package/dist/agent/approval.d.ts +0 -41
  76. package/dist/agent/approval.js +0 -179
@@ -1,6 +1,7 @@
1
1
  import { promises as fsp } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globalDir } from "../config/index.js";
4
+ import { indexStoreUnavailable } from "../errors/index.js";
4
5
  import { GLOBAL_DIR_NAME } from "../constants.js";
5
6
  import { createEmbedder } from "./embedder.js";
6
7
  import { Indexer } from "./indexer.js";
@@ -124,8 +125,10 @@ async function openStore(root, kind, logger) {
124
125
  return { store, storePath: dbPath };
125
126
  }
126
127
  catch (err) {
128
+ // store=sqlite is explicit: fail loud with a code. store=auto degrades to
129
+ // an in-memory index with a warning (no quality loss, just no persistence).
127
130
  if (kind === "sqlite")
128
- throw err;
131
+ throw indexStoreUnavailable(err);
129
132
  logger.warn(`sqlite index unavailable (${err.message}); using an in-memory index`);
130
133
  return { store: new InMemoryVectorStore(), storePath: null };
131
134
  }
@@ -1,3 +1,4 @@
1
+ import { CruxyError } from "../errors/index.js";
1
2
  import { type Skill, type SkillCatalog, type SkillError, type SkillSource } from "./types.js";
2
3
  /** The three source directories the loader scans. */
3
4
  export interface LoaderSources {
@@ -16,7 +17,7 @@ interface SkillCandidate {
16
17
  dir: string;
17
18
  }
18
19
  /** Thrown by `getSkill` when no catalog entry matches the requested name. */
19
- export declare class SkillNotFoundError extends Error {
20
+ export declare class SkillNotFoundError extends CruxyError {
20
21
  constructor(message: string);
21
22
  }
22
23
  /**
Binary file
@@ -1,10 +1,12 @@
1
+ import { CruxyError } from "../errors/index.js";
1
2
  import { type SkillFrontmatter } from "./types.js";
2
3
  /**
3
- * Thrown when a SKILL.md is malformed or fails validation. The message is
4
- * actionable (it names the problem) and is caught by the loader, which records
5
- * it as a `SkillError` and excludes the skill — loud, never silently skipped.
4
+ * Thrown when a SKILL.md is malformed or fails validation. A {@link CruxyError}
5
+ * (code CRUXY_E_SKILL_INVALID) so it carries a stable code if it reaches the
6
+ * boundary; the loader still catches it to record a `SkillError` and exclude the
7
+ * skill — loud, never silently skipped.
6
8
  */
7
- export declare class SkillValidationError extends Error {
9
+ export declare class SkillValidationError extends CruxyError {
8
10
  constructor(message: string);
9
11
  }
10
12
  /** The validated frontmatter plus the markdown body that followed it. */
@@ -1,12 +1,20 @@
1
+ import { CruxyError, ErrorCode } from "../errors/index.js";
1
2
  import { SkillFrontmatterSchema } from "./types.js";
2
3
  /**
3
- * Thrown when a SKILL.md is malformed or fails validation. The message is
4
- * actionable (it names the problem) and is caught by the loader, which records
5
- * it as a `SkillError` and excludes the skill — loud, never silently skipped.
4
+ * Thrown when a SKILL.md is malformed or fails validation. A {@link CruxyError}
5
+ * (code CRUXY_E_SKILL_INVALID) so it carries a stable code if it reaches the
6
+ * boundary; the loader still catches it to record a `SkillError` and exclude the
7
+ * skill — loud, never silently skipped.
6
8
  */
7
- export class SkillValidationError extends Error {
9
+ export class SkillValidationError extends CruxyError {
8
10
  constructor(message) {
9
- super(message);
11
+ super({
12
+ code: ErrorCode.SkillInvalid,
13
+ title: message,
14
+ nextSteps: [
15
+ "see the built-in `using-skills` skill for the SKILL.md rules",
16
+ ],
17
+ });
10
18
  this.name = "SkillValidationError";
11
19
  }
12
20
  }
@@ -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 approved = await ctx.approve({
83
+ const decision = await ctx.requestApproval({
84
84
  kind: "patch",
85
85
  preview: { type: "patch", files: planned.map(toPreview) },
86
86
  });
87
- if (!approved) {
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 approved = await ctx.approve({
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 (!approved) {
66
- return { ok: false, error: `edit to ${input.path} denied` };
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);
@@ -1,10 +1,12 @@
1
+ import { CruxyError } from "../../errors/index.js";
1
2
  import type { ToolContext } from "../types.js";
2
3
  /**
3
4
  * Thrown when a tool argument resolves to a path outside the project root —
4
5
  * whether via `../` traversal, an absolute path, or a symlink pointing outward.
5
- * Tools catch this and surface it as `{ ok:false }` rather than letting it throw.
6
+ * A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code if it
7
+ * reaches the boundary; tools still catch it and surface `{ ok:false }`.
6
8
  */
7
- export declare class PathEscapeError extends Error {
9
+ export declare class PathEscapeError extends CruxyError {
8
10
  constructor(message: string);
9
11
  }
10
12
  /**
@@ -1,13 +1,15 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ import { CruxyError, ErrorCode } from "../../errors/index.js";
3
4
  /**
4
5
  * Thrown when a tool argument resolves to a path outside the project root —
5
6
  * whether via `../` traversal, an absolute path, or a symlink pointing outward.
6
- * Tools catch this and surface it as `{ ok:false }` rather than letting it throw.
7
+ * A {@link CruxyError} (code CRUXY_E_PATH_ESCAPE) so it carries a code if it
8
+ * reaches the boundary; tools still catch it and surface `{ ok:false }`.
7
9
  */
8
- export class PathEscapeError extends Error {
10
+ export class PathEscapeError extends CruxyError {
9
11
  constructor(message) {
10
- super(message);
12
+ super({ code: ErrorCode.PathEscape, title: message });
11
13
  this.name = "PathEscapeError";
12
14
  }
13
15
  }
@@ -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 approved = await ctx.approve({
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 (!approved) {
44
- return { ok: false, error: `write to ${input.path} denied` };
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 });
@@ -5,4 +5,5 @@ export * from "./git-status.js";
5
5
  export * from "./search-codebase.js";
6
6
  export * from "./list-skills.js";
7
7
  export * from "./load-skill.js";
8
+ export * from "./create-pull-request.js";
8
9
  export * from "./file/index.js";
@@ -5,4 +5,5 @@ export * from "./git-status.js";
5
5
  export * from "./search-codebase.js";
6
6
  export * from "./list-skills.js";
7
7
  export * from "./load-skill.js";
8
+ export * from "./create-pull-request.js";
8
9
  export * from "./file/index.js";
@@ -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
- if (!(await ctx.approve({ kind: "shell", command: input.command }))) {
19
- return { ok: false, error: "denied by user" };
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
  },
@@ -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
- * Permission gate for side-effecting actions. Returns `true` when the action
93
- * is allowed. Backed by the interactive `Approver` (see agent/approval.ts);
94
- * tests may inject their own.
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
- approve(action: ApproveAction): Promise<boolean>;
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;
@@ -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 {};