depbot-policy 0.1.0 → 0.2.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 CHANGED
@@ -39,6 +39,7 @@ npx depbot-policy generate
39
39
  - [Why](#why)
40
40
  - [Quick start](#quick-start)
41
41
  - [CLI](#cli)
42
+ - [Writing a policy with AI](#writing-a-policy-with-ai)
42
43
  - [Policy reference](#policy-reference)
43
44
  - [What gets generated](#what-gets-generated)
44
45
  - [Repository setup](#repository-setup)
@@ -86,6 +87,9 @@ git commit -m "Manage Dependabot with depbot-policy"
86
87
 
87
88
  Then do the one-time [repository setup](#repository-setup), so that auto-merge waits for CI.
88
89
 
90
+ Prefer to describe what you want? `npx depbot-policy init --describe "…"` has an AI model (Claude
91
+ or Gemini) write the policy for you. See [Writing a policy with AI](#writing-a-policy-with-ai).
92
+
89
93
  To change anything later, edit `depbot.policy.yml` and run `npx depbot-policy generate` again.
90
94
  Don't edit the generated files by hand. [`check`](#keeping-files-in-sync-in-ci) catches that.
91
95
 
@@ -95,7 +99,7 @@ Don't edit the generated files by hand. [`check`](#keeping-files-in-sync-in-ci)
95
99
  depbot-policy <command> [options]
96
100
 
97
101
  Commands:
98
- init Create a starter depbot.policy.yml
102
+ init Create a starter depbot.policy.yml, or one written by AI with --describe
99
103
  generate Write .github/dependabot.yml and the auto-merge workflow
100
104
  check Fail if the policy is invalid or the generated files are out of date
101
105
  reviewer Print this week's reviewer from review.rotation
@@ -104,6 +108,17 @@ Options:
104
108
  --policy <file> Policy file (default: depbot.policy.yml)
105
109
  --out <dir> Repository root to write to or check (default: .)
106
110
  --dry-run generate: print the files instead of writing them
111
+ --describe <text> init: have an AI model write the policy from a description
112
+ (needs ANTHROPIC_API_KEY or GEMINI_API_KEY)
113
+ --model <id> init --describe: which model to use (default: claude-opus-5-5)
114
+ Claude models:
115
+ claude-opus-5-5 Recommended: best balance of quality and cost, about 3–5¢ per policy
116
+ claude-sonnet-5-5 Faster and cheaper, about 2¢ per policy
117
+ claude-haiku-5-5 Fastest and cheapest, well under 1¢ per policy
118
+ claude-fable-5-1 Most capable and most expensive, about 8–13¢ per policy
119
+ Gemini models:
120
+ gemini-3-flash-preview Fast and cheap, under 1¢ per policy (free tier available)
121
+ gemini-3.1-pro-preview More capable, about 2–3¢ per policy
107
122
  --force init: overwrite an existing policy file
108
123
  --date <date> reviewer: use this date instead of today (e.g. 2026-10-12)
109
124
  -h, --help Show this help
@@ -113,7 +128,7 @@ Options:
113
128
  | Command | Exit code |
114
129
  |---|---|
115
130
  | Success | `0` |
116
- | Invalid policy, or `check` found missing or outdated files | `1` |
131
+ | Invalid policy, `check` found missing or outdated files, or `--describe` failed | `1` |
117
132
  | Wrong usage (unknown command or option, bad `--date`) | `2` |
118
133
 
119
134
  Examples:
@@ -128,6 +143,73 @@ npx depbot-policy reviewer --date 2026-12-28 # ...and in the last week o
128
143
  You can also install it as a dev dependency (`npm install --save-dev depbot-policy`) and call
129
144
  `depbot-policy` from npm scripts.
130
145
 
146
+ ## Writing a policy with AI
147
+
148
+ Describe your setup in plain words and let an AI model (Claude or Gemini) write the policy:
149
+
150
+ ```sh
151
+ export ANTHROPIC_API_KEY=sk-ant-... # or: export GEMINI_API_KEY=AIza... with a gemini model
152
+ npx depbot-policy init --describe "pnpm monorepo with apps in /apps/web and /apps/api, \
153
+ plus Dockerfiles. Auto-merge patch updates to dev dependencies. Never update react \
154
+ until we migrate to 19. alice and bob-smith take turns reviewing."
155
+
156
+ npx depbot-policy init --describe "..." --model gemini-3-flash-preview # use Gemini instead
157
+ ```
158
+
159
+ Example output (the model's notes vary):
160
+
161
+ ```text
162
+ Asking Claude Opus 5.5 to write the policy...
163
+ Note: Assumed a weekly schedule, since none was given.
164
+ Created depbot.policy.yml. Review it, then run: depbot-policy generate
165
+ ```
166
+
167
+ The [playground](#web-playground) has the same feature under **Describe with AI**.
168
+
169
+ **How it works.** The model only writes the *policy*. It never writes the workflow or
170
+ `dependabot.yml`.
171
+
172
+ 1. The model fills in a simplified version of the policy schema and adds short notes about any
173
+ assumptions it made. Claude uses
174
+ [structured outputs](https://platform.claude.com/docs/en/build-with-claude/structured-outputs);
175
+ Gemini uses JSON output with a response schema. Both are held to the same schema.
176
+ 2. depbot-policy turns that into YAML and validates it with the same rules as a hand-written
177
+ policy.
178
+ 3. If anything fails validation, the errors go back to the model once to fix. Anything still wrong
179
+ is reported with its line and column, like any other policy error.
180
+ 4. You review the policy. `generate` then produces the files with the same deterministic code as
181
+ always.
182
+
183
+ So the safety properties of the generated workflow (pinned actions, minimal permissions, the
184
+ Dependabot-only checks, no major auto-merges) don't depend on the model.
185
+
186
+ **Details:**
187
+
188
+ - **Models:** pick one with `--model <id>` (or the Model menu in the playground). Claude models run
189
+ at low effort. Costs are rough list-price estimates per policy:
190
+
191
+ | Model | `--model` | About |
192
+ |---|---|---|
193
+ | Claude Opus 5.5 (default) | `claude-opus-5-5` | 3–5¢, best balance of quality and cost |
194
+ | Claude Sonnet 5.5 | `claude-sonnet-5-5` | 2¢, faster and cheaper |
195
+ | Claude Haiku 5.5 | `claude-haiku-5-5` | well under 1¢, fastest and cheapest |
196
+ | Claude Fable 5.1 | `claude-fable-5-1` | 8–13¢, most capable |
197
+ | Gemini 3 Flash | `gemini-3-flash-preview` | under 1¢, and Google lists it as free-tier eligible |
198
+ | Gemini 3.1 Pro | `gemini-3.1-pro-preview` | 2–3¢, more capable |
199
+
200
+ Try a cheaper model first. This is a short, well-specified task, and every result is validated
201
+ and shown to you before it's used. The Gemini models are previews, so Google may change or
202
+ retire them.
203
+ - **Credentials:** for Claude, the CLI reads `ANTHROPIC_API_KEY`, or a login from Anthropic's
204
+ `ant` CLI. For Gemini, it reads `GEMINI_API_KEY` (or `GOOGLE_API_KEY`). In the playground, paste
205
+ a key for the provider of the model you picked.
206
+ - **Privacy:** only your description is sent, to the provider whose model you choose (Anthropic or
207
+ Google). The playground sends it straight from your browser, keeps your key in memory only, and
208
+ forgets it when you leave the page.
209
+ - **Exit codes:** if the model declines, or the result still has errors after the repair attempt,
210
+ `init --describe` exits with `1`. If there are remaining errors, it still writes the file so you
211
+ can fix them by hand.
212
+
131
213
  ## Policy reference
132
214
 
133
215
  A complete policy, with every option:
@@ -387,6 +469,10 @@ Rules worth knowing:
387
469
  **[heyhadi.github.io/depbot-policy](https://heyhadi.github.io/depbot-policy/)** lets you write a
388
470
  policy and see the generated files as you type.
389
471
 
472
+ - **Describe with AI.** Describe your setup in plain words, paste your own Anthropic API key, and
473
+ the model writes the policy into the editor, with notes on any assumptions it made. You can choose
474
+ between four Claude models and two Gemini models. See
475
+ [Writing a policy with AI](#writing-a-policy-with-ai).
390
476
  - **Live validation.** Errors are underlined in the editor and listed below it in line order.
391
477
  Click one to jump to the exact text.
392
478
  - **Generated files in tabs**, with Copy and Download buttons. While the policy has errors, the
@@ -440,6 +526,30 @@ if (!result.ok) {
440
526
  Everything is pure: no file system, network or global state. That's what lets the same code run in
441
527
  the CLI, in tests and in the browser.
442
528
 
529
+ To write a policy from a description, import from `depbot-policy/describe`. It's a separate entry
530
+ point, so the core library never loads the Anthropic or Google SDKs:
531
+
532
+ ```ts
533
+ import Anthropic from "@anthropic-ai/sdk";
534
+ import { describePolicy } from "depbot-policy/describe";
535
+
536
+ const { source, errors, notes } = await describePolicy(
537
+ "npm project at the root; auto-merge patch updates",
538
+ new Anthropic(), // reads ANTHROPIC_API_KEY
539
+ { model: "claude-sonnet-5-5" }, // optional; defaults to claude-opus-5-5
540
+ );
541
+ // source: policy YAML, errors: [] when valid, notes: the model's assumptions
542
+ ```
543
+
544
+ For a Gemini model, pass a Google client instead. The client has to match the model's provider, or
545
+ `describePolicy` throws:
546
+
547
+ ```ts
548
+ import { GoogleGenAI } from "@google/genai";
549
+
550
+ await describePolicy("npm project", new GoogleGenAI({ apiKey }), { model: "gemini-3-flash-preview" });
551
+ ```
552
+
443
553
  ## Development
444
554
 
445
555
  Requires Node 22 or newer (see [`.nvmrc`](.nvmrc)). Node runs the TypeScript source directly
@@ -483,6 +593,8 @@ src/
483
593
  dependabot.ts dependabot.yml generator
484
594
  workflow.ts Auto-merge workflow generator
485
595
  rotation.ts Weekly reviewer: TypeScript formula and the workflow's shell version
596
+ describe.ts Write a policy from a description with Claude or Gemini (depbot-policy/describe)
597
+ describe-models.ts The models it can use, kept free of the SDK so help text and UI can list them
486
598
  files.ts generateFiles: every generated file and its path
487
599
  cli.ts, bin.ts The depbot-policy command (bin.ts is the executable entry point)
488
600
  starter.ts The policy written by `init`
@@ -508,6 +620,10 @@ depbot.policy.yml This repository's own policy; .github/dependabot.yml and th
508
620
  - **Rotation:** the workflow's shell script runs in bash, with stand-in `date` and `gh` commands,
509
621
  and must pick the same person as `reviewerFor` on every test date.
510
622
  - **CLI:** every command runs against a real temporary directory.
623
+ - **AI:** tests use real client objects with the request method stubbed, so they never call an API.
624
+ They cover the request (model,
625
+ structured output format, refusal fallback for each model), the repair loop, replies the model can't use, and
626
+ error messages.
511
627
  - **Playground:** unit tests for its logic, plus tests of the whole page with React Testing Library.
512
628
  CodeMirror can't run in jsdom, so the tests replace the two small editor components with plain
513
629
  elements.
@@ -570,6 +686,9 @@ Short versions of the main decisions. The pull requests have the full reasoning.
570
686
  browser.
571
687
  - **Generated YAML via the `yaml` document API**, not string templates. The library handles quoting
572
688
  (`"@types/*"` must be quoted) and comments.
689
+ - **AI writes the policy, never the workflow.** The model handles what needs judgment (understanding a
690
+ description). Validation and generation stay deterministic, so a bad model reply can't weaken
691
+ the security properties.
573
692
  - **Stateless rotation.** The reviewer is a function of the week, so there's nothing to store, sync
574
693
  or get out of date.
575
694
  - **Pinned actions.** Actions are referenced by commit SHA with a `# vX.Y.Z` comment, both in the
package/dist/bin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "./cli.js";
3
- process.exitCode = run(process.argv.slice(2), {
3
+ process.exitCode = await run(process.argv.slice(2), {
4
4
  cwd: process.cwd(),
5
5
  stdout: (text) => process.stdout.write(text),
6
6
  stderr: (text) => process.stderr.write(text),
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,12 @@
1
+ import { type DescribeModelId } from "./describe-models.ts";
2
+ import type { DescribeResult } from "./describe.ts";
1
3
  import { type PolicyError } from "./parse.ts";
2
4
  export interface CliIo {
3
5
  cwd: string;
4
6
  stdout: (text: string) => void;
5
7
  stderr: (text: string) => void;
8
+ /** Writes a policy from a description. Defaults to calling the chosen provider; tests pass a stand-in. */
9
+ describe?: (description: string, model: DescribeModelId) => Promise<DescribeResult>;
6
10
  }
7
11
  /** Exit codes: 0 success, 1 invalid policy or outdated files, 2 wrong usage. */
8
12
  export declare const exitCodes: {
@@ -11,6 +15,6 @@ export declare const exitCodes: {
11
15
  readonly usage: 2;
12
16
  };
13
17
  /** Runs the CLI and returns its exit code. Side effects go through `io` and the file system. */
14
- export declare function run(argv: readonly string[], io: CliIo): number;
18
+ export declare function run(argv: readonly string[], io: CliIo): Promise<number>;
15
19
  /** `file:line:column: path: message`, the format editors and CI annotations understand. */
16
20
  export declare function formatError(file: string, error: PolicyError): string;
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseArgs } from "node:util";
4
+ import { defaultDescribeModel, describeModel, describeModels, isDescribeModelId, providerLabel, } from "./describe-models.js";
4
5
  import { generateFiles } from "./files.js";
5
6
  import { parsePolicy } from "./parse.js";
6
7
  import { reviewerFor } from "./rotation.js";
@@ -8,10 +9,23 @@ import { starterPolicy } from "./starter.js";
8
9
  /** Exit codes: 0 success, 1 invalid policy or outdated files, 2 wrong usage. */
9
10
  export const exitCodes = { ok: 0, failed: 1, usage: 2 };
10
11
  const defaultPolicyPath = "depbot.policy.yml";
12
+ // The model list, grouped by provider, for the --model help. Each provider gets its own heading
13
+ // so the two sets of models don't blur together.
14
+ const modelHelp = ["anthropic", "google"]
15
+ .map((provider) => {
16
+ const heading = provider === "anthropic" ? "Claude models:" : "Gemini models:";
17
+ return [
18
+ ` ${heading}`,
19
+ ...describeModels
20
+ .filter((model) => model.provider === provider)
21
+ .map((model) => ` ${model.id.padEnd(23)} ${model.summary}`),
22
+ ].join("\n");
23
+ })
24
+ .join("\n");
11
25
  const usage = `Usage: depbot-policy <command> [options]
12
26
 
13
27
  Commands:
14
- init Create a starter ${defaultPolicyPath}
28
+ init Create a starter ${defaultPolicyPath}, or one written by AI with --describe
15
29
  generate Write .github/dependabot.yml and the auto-merge workflow
16
30
  check Fail if the policy is invalid or the generated files are out of date
17
31
  reviewer Print this week's reviewer from review.rotation
@@ -20,13 +34,17 @@ Options:
20
34
  --policy <file> Policy file (default: ${defaultPolicyPath})
21
35
  --out <dir> Repository root to write to or check (default: .)
22
36
  --dry-run generate: print the files instead of writing them
37
+ --describe <text> init: have an AI model write the policy from a description
38
+ (needs ANTHROPIC_API_KEY or GEMINI_API_KEY)
39
+ --model <id> init --describe: which model to use (default: ${defaultDescribeModel})
40
+ ${modelHelp}
23
41
  --force init: overwrite an existing policy file
24
42
  --date <date> reviewer: use this date instead of today (e.g. 2026-10-12)
25
43
  -h, --help Show this help
26
44
  -v, --version Show the version
27
45
  `;
28
46
  /** Runs the CLI and returns its exit code. Side effects go through `io` and the file system. */
29
- export function run(argv, io) {
47
+ export async function run(argv, io) {
30
48
  let parsed;
31
49
  try {
32
50
  parsed = parseArgs({
@@ -37,6 +55,8 @@ export function run(argv, io) {
37
55
  out: { type: "string", default: "." },
38
56
  "dry-run": { type: "boolean", default: false },
39
57
  force: { type: "boolean", default: false },
58
+ describe: { type: "string" },
59
+ model: { type: "string" },
40
60
  date: { type: "string" },
41
61
  help: { type: "boolean", short: "h", default: false },
42
62
  version: { type: "boolean", short: "v", default: false },
@@ -61,6 +81,10 @@ export function run(argv, io) {
61
81
  io.stderr(`Unexpected argument: ${extra[0]}\n\n${usage}`);
62
82
  return exitCodes.usage;
63
83
  }
84
+ if (values.model !== undefined && values.describe === undefined) {
85
+ io.stderr("--model only applies to init --describe.\n");
86
+ return exitCodes.usage;
87
+ }
64
88
  const policyPath = values.policy;
65
89
  const resolve = (file) => path.resolve(io.cwd, file);
66
90
  switch (command) {
@@ -69,9 +93,12 @@ export function run(argv, io) {
69
93
  io.stderr(`${policyPath} already exists. Use --force to overwrite it.\n`);
70
94
  return exitCodes.failed;
71
95
  }
72
- writeFileSync(resolve(policyPath), starterPolicy);
73
- io.stdout(`Created ${policyPath}. Edit it, then run: depbot-policy generate\n`);
74
- return exitCodes.ok;
96
+ if (values.describe === undefined) {
97
+ writeFileSync(resolve(policyPath), starterPolicy);
98
+ io.stdout(`Created ${policyPath}. Edit it, then run: depbot-policy generate\n`);
99
+ return exitCodes.ok;
100
+ }
101
+ return describeInto(policyPath, values.describe, values.model, resolve, io);
75
102
  }
76
103
  case "generate": {
77
104
  const policy = loadPolicy(policyPath, resolve, io);
@@ -129,6 +156,51 @@ export function run(argv, io) {
129
156
  return exitCodes.usage;
130
157
  }
131
158
  }
159
+ async function describeInto(policyPath, description, modelOption, resolve, io) {
160
+ if (description.trim() === "") {
161
+ io.stderr("--describe needs a description, e.g. --describe \"pnpm monorepo, auto-merge patches\"\n");
162
+ return exitCodes.usage;
163
+ }
164
+ // Loaded on demand, so the other commands never load the provider SDKs.
165
+ const describeModule = await import("./describe.js");
166
+ const model = modelOption ?? defaultDescribeModel;
167
+ if (!isDescribeModelId(model)) {
168
+ const ids = describeModels.map((candidate) => candidate.id).join(", ");
169
+ io.stderr(`Unknown model: ${model}. Use one of: ${ids}\n`);
170
+ return exitCodes.usage;
171
+ }
172
+ const describe = io.describe ??
173
+ ((text, chosen) => describeModule.describePolicy(text, describeModule.environmentClient(describeModel(chosen).provider), {
174
+ model: chosen,
175
+ }));
176
+ const { label } = describeModels.find((candidate) => candidate.id === model);
177
+ io.stdout(`Asking ${label} to write the policy...\n`);
178
+ let result;
179
+ try {
180
+ result = await describe(description, model);
181
+ }
182
+ catch (error) {
183
+ io.stderr(`${describeModule.describeFailureMessage(error)}\n`);
184
+ // A missing Gemini key already says so. For Claude, the SDK's own error doesn't.
185
+ if (describeModel(model).provider === "anthropic" &&
186
+ !process.env.ANTHROPIC_API_KEY &&
187
+ !process.env.ANTHROPIC_AUTH_TOKEN) {
188
+ io.stderr("Set ANTHROPIC_API_KEY (or log in with `ant auth login`) to use --describe.\n");
189
+ }
190
+ return exitCodes.failed;
191
+ }
192
+ writeFileSync(resolve(policyPath), result.source);
193
+ for (const note of result.notes)
194
+ io.stdout(`Note: ${note}\n`);
195
+ if (result.errors.length > 0) {
196
+ io.stderr(`Created ${policyPath}, but it still has problems to fix by hand:\n`);
197
+ for (const error of result.errors)
198
+ io.stderr(`${formatError(policyPath, error)}\n`);
199
+ return exitCodes.failed;
200
+ }
201
+ io.stdout(`Created ${policyPath}. Review it, then run: depbot-policy generate\n`);
202
+ return exitCodes.ok;
203
+ }
132
204
  function loadPolicy(policyPath, resolve, io) {
133
205
  let source;
134
206
  try {
@@ -0,0 +1,61 @@
1
+ /** Which API a model runs on. Drives the SDK client, the key it reads, and the failure copy. */
2
+ export type DescribeProvider = "anthropic" | "google";
3
+ export interface DescribeModel {
4
+ id: string;
5
+ label: string;
6
+ /** One line for pickers: what it's good for and roughly what one policy costs. */
7
+ summary: string;
8
+ /** Which provider runs the model. */
9
+ provider: DescribeProvider;
10
+ /**
11
+ * Whether the API may retry a refusal on another model (`fallbacks: "default"`).
12
+ * Claude Haiku 5.5 has no server-side fallback, so it must not send the parameter.
13
+ */
14
+ serverSideFallback: boolean;
15
+ }
16
+ export declare const describeModels: readonly [{
17
+ readonly id: "claude-opus-5-5";
18
+ readonly label: "Claude Opus 5.5";
19
+ readonly summary: "Recommended: best balance of quality and cost, about 3–5¢ per policy";
20
+ readonly provider: "anthropic";
21
+ readonly serverSideFallback: true;
22
+ }, {
23
+ readonly id: "claude-sonnet-5-5";
24
+ readonly label: "Claude Sonnet 5.5";
25
+ readonly summary: "Faster and cheaper, about 2¢ per policy";
26
+ readonly provider: "anthropic";
27
+ readonly serverSideFallback: true;
28
+ }, {
29
+ readonly id: "claude-haiku-5-5";
30
+ readonly label: "Claude Haiku 5.5";
31
+ readonly summary: "Fastest and cheapest, well under 1¢ per policy";
32
+ readonly provider: "anthropic";
33
+ readonly serverSideFallback: false;
34
+ }, {
35
+ readonly id: "claude-fable-5-1";
36
+ readonly label: "Claude Fable 5.1";
37
+ readonly summary: "Most capable and most expensive, about 8–13¢ per policy";
38
+ readonly provider: "anthropic";
39
+ readonly serverSideFallback: true;
40
+ }, {
41
+ readonly id: "gemini-3-flash-preview";
42
+ readonly label: "Gemini 3 Flash";
43
+ readonly summary: "Fast and cheap, under 1¢ per policy (free tier available)";
44
+ readonly provider: "google";
45
+ readonly serverSideFallback: false;
46
+ }, {
47
+ readonly id: "gemini-3.1-pro-preview";
48
+ readonly label: "Gemini 3.1 Pro";
49
+ readonly summary: "More capable, about 2–3¢ per policy";
50
+ readonly provider: "google";
51
+ readonly serverSideFallback: false;
52
+ }];
53
+ export type DescribeModelId = (typeof describeModels)[number]["id"];
54
+ export declare const defaultDescribeModel: DescribeModelId;
55
+ /** The model with the given id, or undefined if it isn't supported. */
56
+ export declare function describeModel(model: DescribeModelId): DescribeModel;
57
+ /** A short, friendly name for a provider, used in CLI and playground copy. */
58
+ export declare function providerLabel(provider: DescribeProvider): string;
59
+ /** The provider a given model runs on. */
60
+ export declare function providerFor(model: DescribeModelId): DescribeProvider;
61
+ export declare function isDescribeModelId(value: string): value is DescribeModelId;
@@ -0,0 +1,66 @@
1
+ // The models `describePolicy` can use. Kept apart from describe.ts, which loads the provider SDKs,
2
+ // so the CLI's help text and the playground's picker can list them without loading any SDK.
3
+ // Each model names its provider; everything else (clients, schemas) follows from that.
4
+ // Costs assume ~2.5k input and 1–2k output tokens per policy, at each model's list price.
5
+ // Gemini ids are exactly as listed on ai.google.dev/gemini-api/docs/models. Both are preview
6
+ // models, so Google may change or retire them.
7
+ export const describeModels = [
8
+ {
9
+ id: "claude-opus-5-5",
10
+ label: "Claude Opus 5.5",
11
+ summary: "Recommended: best balance of quality and cost, about 3–5¢ per policy",
12
+ provider: "anthropic",
13
+ serverSideFallback: true,
14
+ },
15
+ {
16
+ id: "claude-sonnet-5-5",
17
+ label: "Claude Sonnet 5.5",
18
+ summary: "Faster and cheaper, about 2¢ per policy",
19
+ provider: "anthropic",
20
+ serverSideFallback: true,
21
+ },
22
+ {
23
+ id: "claude-haiku-5-5",
24
+ label: "Claude Haiku 5.5",
25
+ summary: "Fastest and cheapest, well under 1¢ per policy",
26
+ provider: "anthropic",
27
+ serverSideFallback: false,
28
+ },
29
+ {
30
+ id: "claude-fable-5-1",
31
+ label: "Claude Fable 5.1",
32
+ summary: "Most capable and most expensive, about 8–13¢ per policy",
33
+ provider: "anthropic",
34
+ serverSideFallback: true,
35
+ },
36
+ {
37
+ id: "gemini-3-flash-preview",
38
+ label: "Gemini 3 Flash",
39
+ summary: "Fast and cheap, under 1¢ per policy (free tier available)",
40
+ provider: "google",
41
+ serverSideFallback: false,
42
+ },
43
+ {
44
+ id: "gemini-3.1-pro-preview",
45
+ label: "Gemini 3.1 Pro",
46
+ summary: "More capable, about 2–3¢ per policy",
47
+ provider: "google",
48
+ serverSideFallback: false,
49
+ },
50
+ ];
51
+ export const defaultDescribeModel = "claude-opus-5-5";
52
+ /** The model with the given id, or undefined if it isn't supported. */
53
+ export function describeModel(model) {
54
+ return describeModels.find((candidate) => candidate.id === model);
55
+ }
56
+ /** A short, friendly name for a provider, used in CLI and playground copy. */
57
+ export function providerLabel(provider) {
58
+ return provider === "anthropic" ? "Claude" : "Gemini";
59
+ }
60
+ /** The provider a given model runs on. */
61
+ export function providerFor(model) {
62
+ return describeModel(model).provider;
63
+ }
64
+ export function isDescribeModelId(value) {
65
+ return describeModels.some((model) => model.id === value);
66
+ }
@@ -0,0 +1,116 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { GoogleGenAI } from "@google/genai";
3
+ import { z } from "zod";
4
+ import { type DescribeModelId, type DescribeProvider } from "./describe-models.ts";
5
+ import { type PolicyError } from "./parse.ts";
6
+ export { defaultDescribeModel, describeModel, describeModels, isDescribeModelId, providerFor, providerLabel, type DescribeModel, type DescribeModelId, type DescribeProvider, } from "./describe-models.ts";
7
+ /**
8
+ * What Claude fills in. It's a simplified, refinement-free version of the policy schema, because
9
+ * structured outputs can't express cross-field rules or defaults. Our own code turns the draft into
10
+ * YAML, and `parsePolicy` still applies every rule, so the model can never bypass validation.
11
+ * A reply outside this schema (say, `major` as an update type) is rejected when the SDK parses it.
12
+ */
13
+ export declare const policyDraftSchema: z.ZodObject<{
14
+ ecosystems: z.ZodArray<z.ZodObject<{
15
+ type: z.ZodEnum<{
16
+ bundler: "bundler";
17
+ cargo: "cargo";
18
+ composer: "composer";
19
+ docker: "docker";
20
+ "github-actions": "github-actions";
21
+ gomod: "gomod";
22
+ gradle: "gradle";
23
+ maven: "maven";
24
+ mix: "mix";
25
+ npm: "npm";
26
+ nuget: "nuget";
27
+ pip: "pip";
28
+ pub: "pub";
29
+ swift: "swift";
30
+ terraform: "terraform";
31
+ }>;
32
+ directory: z.ZodString;
33
+ schedule: z.ZodEnum<{
34
+ daily: "daily";
35
+ monthly: "monthly";
36
+ weekly: "weekly";
37
+ }>;
38
+ }, z.core.$strip>>;
39
+ autoMerge: z.ZodObject<{
40
+ updateTypes: z.ZodArray<z.ZodEnum<{
41
+ minor: "minor";
42
+ patch: "patch";
43
+ }>>;
44
+ dependencyTypes: z.ZodArray<z.ZodEnum<{
45
+ development: "development";
46
+ production: "production";
47
+ }>>;
48
+ mergeMethod: z.ZodEnum<{
49
+ merge: "merge";
50
+ rebase: "rebase";
51
+ squash: "squash";
52
+ }>;
53
+ }, z.core.$strip>;
54
+ block: z.ZodArray<z.ZodObject<{
55
+ name: z.ZodString;
56
+ reason: z.ZodString;
57
+ ecosystems: z.ZodArray<z.ZodEnum<{
58
+ bundler: "bundler";
59
+ cargo: "cargo";
60
+ composer: "composer";
61
+ docker: "docker";
62
+ "github-actions": "github-actions";
63
+ gomod: "gomod";
64
+ gradle: "gradle";
65
+ maven: "maven";
66
+ mix: "mix";
67
+ npm: "npm";
68
+ nuget: "nuget";
69
+ pip: "pip";
70
+ pub: "pub";
71
+ swift: "swift";
72
+ terraform: "terraform";
73
+ }>>;
74
+ }, z.core.$strip>>;
75
+ reviewRotation: z.ZodArray<z.ZodString>;
76
+ notes: z.ZodArray<z.ZodString>;
77
+ }, z.core.$strip>;
78
+ export type PolicyDraft = z.infer<typeof policyDraftSchema>;
79
+ export interface DescribeResult {
80
+ /** The policy as YAML, ready to save as depbot.policy.yml. */
81
+ source: string;
82
+ /** Validation errors still present after one repair attempt; empty when the policy is valid. */
83
+ errors: PolicyError[];
84
+ /** Claude's notes on assumptions it made. */
85
+ notes: string[];
86
+ }
87
+ export declare class DescribeError extends Error {
88
+ name: string;
89
+ }
90
+ export interface DescribeOptions {
91
+ /** Which model writes the policy. Defaults to `defaultDescribeModel`. */
92
+ model?: DescribeModelId;
93
+ }
94
+ /**
95
+ * A provider-agnostic way to ask a model for a policy draft. Each provider (Claude, Gemini) has an
96
+ * adapter that knows how to call its SDK with structured output and how to turn its replies into
97
+ * a `PolicyDraft`. `describePolicy` stays the same regardless of which one it's given.
98
+ */
99
+ export interface DescribeBackend {
100
+ draft(content: string): Promise<PolicyDraft>;
101
+ }
102
+ /** Either SDK's client. The adapters narrow this with `instanceof`. */
103
+ export type DescribeBackendClient = Anthropic | GoogleGenAI;
104
+ /**
105
+ * Asks a model to write a policy from a plain-language description, validates it with
106
+ * `parsePolicy`, and gives the model one chance to fix any errors.
107
+ */
108
+ export declare function describePolicy(description: string, client: DescribeBackendClient, { model }?: DescribeOptions): Promise<DescribeResult>;
109
+ /** Turns a draft into policy YAML, leaving out empty optional sections. */
110
+ export declare function draftToYaml(draft: PolicyDraft, provider?: DescribeProvider): string;
111
+ /** A short, user-facing explanation for an error thrown while describing a policy. */
112
+ export declare function describeFailureMessage(error: unknown): string;
113
+ /** A client for use in a browser, with a key the user typed in. */
114
+ export declare function browserClient(provider: DescribeProvider, apiKey: string): DescribeBackendClient;
115
+ /** A backend that reads credentials from the environment for the given provider. */
116
+ export declare function environmentClient(provider: DescribeProvider): DescribeBackendClient;
@@ -0,0 +1,262 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { betaZodOutputFormat } from "@anthropic-ai/sdk/helpers/beta/zod";
3
+ import { ApiError, GoogleGenAI } from "@google/genai";
4
+ import { Document, isScalar, visit } from "yaml";
5
+ import { z } from "zod";
6
+ import { defaultDescribeModel, describeModel, describeModels, providerLabel, } from "./describe-models.js";
7
+ import { parsePolicy } from "./parse.js";
8
+ import { ecosystemTypes } from "./schema.js";
9
+ export { defaultDescribeModel, describeModel, describeModels, isDescribeModelId, providerFor, providerLabel, } from "./describe-models.js";
10
+ /**
11
+ * What Claude fills in. It's a simplified, refinement-free version of the policy schema, because
12
+ * structured outputs can't express cross-field rules or defaults. Our own code turns the draft into
13
+ * YAML, and `parsePolicy` still applies every rule, so the model can never bypass validation.
14
+ * A reply outside this schema (say, `major` as an update type) is rejected when the SDK parses it.
15
+ */
16
+ export const policyDraftSchema = z.object({
17
+ ecosystems: z
18
+ .array(z.object({
19
+ type: z.enum(ecosystemTypes),
20
+ directory: z.string().describe('Path from the repository root, starting with "/"'),
21
+ schedule: z.enum(["daily", "weekly", "monthly"]),
22
+ }))
23
+ .describe("One entry per package manager and directory"),
24
+ autoMerge: z.object({
25
+ updateTypes: z.array(z.enum(["patch", "minor"])),
26
+ dependencyTypes: z.array(z.enum(["development", "production"])),
27
+ mergeMethod: z.enum(["squash", "merge", "rebase"]),
28
+ }),
29
+ block: z
30
+ .array(z.object({
31
+ name: z.string().describe('Package name or glob, e.g. "@types/*"'),
32
+ reason: z.string().describe("Why it's blocked, in the user's words where possible"),
33
+ ecosystems: z
34
+ .array(z.enum(ecosystemTypes))
35
+ .describe("Ecosystems the block applies to; empty means all of them"),
36
+ }))
37
+ .describe("Packages Dependabot must never update; empty if none were mentioned"),
38
+ reviewRotation: z
39
+ .array(z.string())
40
+ .describe("GitHub usernames without @, in the order given; empty if none were named"),
41
+ notes: z
42
+ .array(z.string())
43
+ .describe("Short notes on assumptions made or requests that couldn't be honoured"),
44
+ });
45
+ const systemPrompt = `You turn a team's plain-language description of how they want Dependabot to behave into a depbot-policy draft.
46
+
47
+ How to fill each field:
48
+ - ecosystems: one entry per package manager and directory. Yarn, pnpm and Bun projects use "npm"; Poetry and Pipenv use "pip"; Go modules use "gomod"; Rust uses "cargo"; Ruby uses "bundler"; Dockerfiles use "docker"; GitHub Actions workflows use "github-actions" with directory "/". Directories start with "/", and "/" is the repository root. In a monorepo, list each package directory the user mentions. Use the schedule they ask for, otherwise "weekly" ("monthly" for github-actions).
49
+ - autoMerge: unless the user says otherwise, use updateTypes ["patch"], both dependency types, and "squash". Include "minor" only if the user explicitly allows it. Major updates can never be auto-merged; if the user asks for that, leave majors out and say so in notes.
50
+ - block: only packages the user says to pin, freeze or never update. Keep their reason. Limit the block to an ecosystem when the package clearly belongs to one.
51
+ - reviewRotation: only GitHub usernames the user actually gives, without "@". Never invent people.
52
+ - notes: brief, one sentence each, for any assumption you made or anything you couldn't do. Leave it empty when there's nothing worth saying.`;
53
+ export class DescribeError extends Error {
54
+ name = "DescribeError";
55
+ }
56
+ /**
57
+ * Asks a model to write a policy from a plain-language description, validates it with
58
+ * `parsePolicy`, and gives the model one chance to fix any errors.
59
+ */
60
+ export async function describePolicy(description, client, { model = defaultDescribeModel } = {}) {
61
+ const backend = backendFor(client, model);
62
+ const provider = providerOf(model);
63
+ const first = await backend.draft(description);
64
+ const firstSource = draftToYaml(first, provider);
65
+ const firstResult = parsePolicy(firstSource);
66
+ if (firstResult.ok)
67
+ return { source: firstSource, errors: [], notes: first.notes };
68
+ const repair = await backend.draft(`${description}
69
+
70
+ A previous attempt produced this policy, which fails validation:
71
+
72
+ ${firstSource}
73
+ Errors:
74
+ ${firstResult.errors.map((error) => `- ${error.path || "(file)"}: ${error.message}`).join("\n")}
75
+
76
+ Return a corrected draft.`);
77
+ const source = draftToYaml(repair, provider);
78
+ const result = parsePolicy(source);
79
+ return { source, errors: result.ok ? [] : result.errors, notes: repair.notes };
80
+ }
81
+ /**
82
+ * Picks the adapter for the client the caller passed. Claude and Gemini use different SDKs and
83
+ * request shapes, so each gets its own adapter; everything above it is shared.
84
+ */
85
+ function backendFor(client, model) {
86
+ const provider = providerOf(model);
87
+ if (provider === "google" && client instanceof GoogleGenAI)
88
+ return geminiBackend(client, model);
89
+ if (provider === "anthropic" && client instanceof Anthropic)
90
+ return anthropicBackend(client, model);
91
+ throw new DescribeError(`${describeModel(model).label} needs a ${providerLabel(provider)} client, but a different one was passed.`);
92
+ }
93
+ /** Claude adapter: structured outputs via the Anthropic SDK's `messages.parse`. */
94
+ function anthropicBackend(client, model) {
95
+ return {
96
+ async draft(content) {
97
+ let response;
98
+ try {
99
+ response = await sendAnthropicRequest(client, model, content);
100
+ }
101
+ catch (error) {
102
+ // The SDK reports a reply that fails policyDraftSchema as a plain AnthropicError. API errors
103
+ // (a subclass) and anything else, such as missing credentials, go to the caller unchanged.
104
+ if (error instanceof Anthropic.AnthropicError && !(error instanceof Anthropic.APIError)) {
105
+ throw new DescribeError("The model's reply didn't match the policy format. Try again.");
106
+ }
107
+ throw error;
108
+ }
109
+ if (response.stop_reason === "refusal") {
110
+ throw new DescribeError("The model declined to write a policy for this description.");
111
+ }
112
+ if (response.stop_reason === "max_tokens") {
113
+ throw new DescribeError("The answer was cut off. Try a shorter description.");
114
+ }
115
+ if (response.parsed_output == null) {
116
+ throw new DescribeError("The model didn't return a policy. Try rephrasing the description.");
117
+ }
118
+ return response.parsed_output;
119
+ },
120
+ };
121
+ }
122
+ function sendAnthropicRequest(client, model, content) {
123
+ const { serverSideFallback } = describeModel(model);
124
+ return client.beta.messages.parse({
125
+ model,
126
+ max_tokens: 16000,
127
+ // On a refusal, the API retries on a fallback model it picks for the refusal category.
128
+ ...(serverSideFallback && {
129
+ betas: ["server-side-fallback-2026-07-01"],
130
+ fallbacks: "default",
131
+ }),
132
+ system: systemPrompt,
133
+ messages: [{ role: "user", content }],
134
+ output_config: {
135
+ // A short, well-specified extraction; low effort keeps it fast.
136
+ effort: "low",
137
+ format: betaZodOutputFormat(policyDraftSchema),
138
+ },
139
+ });
140
+ }
141
+ /** Gemini adapter: JSON output via `generateContent` + `responseJsonSchema`. */
142
+ function geminiBackend(client, model) {
143
+ return {
144
+ async draft(content) {
145
+ // API failures, including a 400 for a bad key, are explained by describeFailureMessage.
146
+ // Only a reply we can't parse (below) means the model didn't follow the format.
147
+ const response = await client.models.generateContent({
148
+ model,
149
+ contents: content,
150
+ config: {
151
+ systemInstruction: systemPrompt,
152
+ responseMimeType: "application/json",
153
+ // The same draft schema the Claude path uses, so both providers are held to one
154
+ // contract. Gemini returns plain JSON, which we validate with Zod below.
155
+ responseJsonSchema: geminiResponseSchema,
156
+ },
157
+ });
158
+ const text = response.text;
159
+ if (!text) {
160
+ throw new DescribeError("The model didn't return a policy. Try rephrasing the description.");
161
+ }
162
+ try {
163
+ return policyDraftSchema.parse(JSON.parse(text));
164
+ }
165
+ catch {
166
+ throw new DescribeError("The model's reply didn't match the policy format. Try again.");
167
+ }
168
+ },
169
+ };
170
+ }
171
+ // Gemini's `responseJsonSchema` accepts only a subset of JSON Schema properties, and `$schema`
172
+ // isn't one of them.
173
+ const { $schema: _dialect, ...geminiResponseSchema } = z.toJSONSchema(policyDraftSchema);
174
+ /** Turns a draft into policy YAML, leaving out empty optional sections. */
175
+ export function draftToYaml(draft, provider = "anthropic") {
176
+ const doc = new Document({
177
+ version: 1,
178
+ ecosystems: draft.ecosystems,
179
+ autoMerge: draft.autoMerge,
180
+ ...(draft.block.length > 0 && {
181
+ block: draft.block.map(({ ecosystems, ...entry }) => ({
182
+ ...entry,
183
+ ...(ecosystems.length > 0 && { ecosystems }),
184
+ })),
185
+ }),
186
+ ...(draft.reviewRotation.length > 0 && { review: { rotation: draft.reviewRotation } }),
187
+ });
188
+ doc.commentBefore = ` Written by ${providerLabel(provider)} from a description. Review it before committing.`;
189
+ // Short lists of plain values read better inline: updateTypes: [patch, minor]
190
+ visit(doc, {
191
+ Seq(_, node) {
192
+ if (node.items.every(isScalar))
193
+ node.flow = true;
194
+ },
195
+ });
196
+ return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
197
+ }
198
+ /** The provider a given model runs on. */
199
+ function providerOf(model) {
200
+ return describeModel(model).provider;
201
+ }
202
+ /** A short, user-facing explanation for an error thrown while describing a policy. */
203
+ export function describeFailureMessage(error) {
204
+ if (error instanceof DescribeError)
205
+ return error.message;
206
+ if (error instanceof Anthropic.AuthenticationError)
207
+ return "The API key was rejected. Check it and try again.";
208
+ if (error instanceof Anthropic.PermissionDeniedError)
209
+ return "This API key isn't allowed to use the model.";
210
+ if (error instanceof Anthropic.RateLimitError)
211
+ return "Rate limited by the Anthropic API. Wait a moment and try again.";
212
+ if (error instanceof Anthropic.APIConnectionError)
213
+ return "Couldn't reach the Anthropic API. Check your connection.";
214
+ if (error instanceof Anthropic.APIError)
215
+ return `The Anthropic API returned an error (${error.status ?? "unknown"}).`;
216
+ if (error instanceof ApiError) {
217
+ // Google reports a bad key as HTTP 400 with the reason API_KEY_INVALID, not as 401.
218
+ if (googleErrorReason(error) === "API_KEY_INVALID" || error.status === 401 || error.status === 403) {
219
+ return "The API key was rejected. Check it and try again.";
220
+ }
221
+ if (error.status === 404)
222
+ return "The Gemini API doesn't know that model, or this key can't use it.";
223
+ if (error.status === 429)
224
+ return "Rate limited by the Gemini API. Wait a moment and try again.";
225
+ if (error.status === 400)
226
+ return "The Gemini API rejected the request (400).";
227
+ return `The Gemini API returned an error (${error.status}).`;
228
+ }
229
+ return "Something went wrong while generating the policy.";
230
+ }
231
+ /** The `reason` in a Gemini API error's details, such as `API_KEY_INVALID`, if there is one. */
232
+ function googleErrorReason(error) {
233
+ try {
234
+ const body = JSON.parse(error.message);
235
+ return body.error?.details?.find((detail) => detail.reason !== undefined)?.reason;
236
+ }
237
+ catch {
238
+ // The message isn't JSON (for example a network failure), so there's no reason to read.
239
+ return undefined;
240
+ }
241
+ }
242
+ /** A client for use in a browser, with a key the user typed in. */
243
+ export function browserClient(provider, apiKey) {
244
+ if (provider === "google")
245
+ return new GoogleGenAI({ apiKey });
246
+ return new Anthropic({ apiKey, dangerouslyAllowBrowser: true });
247
+ }
248
+ /** A backend that reads credentials from the environment for the given provider. */
249
+ export function environmentClient(provider) {
250
+ return providerClient(provider);
251
+ }
252
+ /** The provider's SDK client, reading its key from the environment. */
253
+ function providerClient(provider) {
254
+ if (provider === "anthropic")
255
+ return new Anthropic();
256
+ // Passing the key explicitly keeps the Gemini SDK from printing its own messages, or from
257
+ // probing for Google Cloud credentials, when none is set.
258
+ const apiKey = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY;
259
+ if (!apiKey)
260
+ throw new DescribeError("No Gemini API key found. Set GEMINI_API_KEY.");
261
+ return new GoogleGenAI({ apiKey });
262
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "depbot-policy",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "One policy file → dependabot.yml, a safe auto-merge workflow, a block list, and a weekly review rotation",
5
5
  "keywords": [
6
6
  "dependabot",
@@ -22,6 +22,10 @@
22
22
  ".": {
23
23
  "types": "./dist/index.d.ts",
24
24
  "default": "./dist/index.js"
25
+ },
26
+ "./describe": {
27
+ "types": "./dist/describe.d.ts",
28
+ "default": "./dist/describe.js"
25
29
  }
26
30
  },
27
31
  "bin": {
@@ -41,6 +45,8 @@
41
45
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
42
46
  },
43
47
  "dependencies": {
48
+ "@anthropic-ai/sdk": "^0.131.0",
49
+ "@google/genai": "^2.28.0",
44
50
  "yaml": "^2.9.1",
45
51
  "zod": "^4.6.5"
46
52
  },