@synmux/claude-commit 0.1.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 +177 -0
- package/bin/cco.ts +18 -0
- package/index.ts +40 -0
- package/package.json +59 -0
- package/src/agent.ts +253 -0
- package/src/cli.ts +315 -0
- package/src/config.ts +247 -0
- package/src/diff.ts +154 -0
- package/src/errors.ts +4 -0
- package/src/generate.ts +180 -0
- package/src/git.ts +111 -0
- package/src/prompts.ts +243 -0
- package/src/tokens.ts +20 -0
- package/src/types.ts +85 -0
- package/src/ui/colors.ts +12 -0
- package/src/ui/editor.ts +89 -0
- package/src/ui/interactive.ts +313 -0
- package/src/ui/spinner.ts +71 -0
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# `claude-commit`
|
|
2
|
+
|
|
3
|
+
Generate high-quality git commit messages with Claude — using your **Claude Code
|
|
4
|
+
subscription**, not an API key.
|
|
5
|
+
|
|
6
|
+
`claude-commit` reads your staged diff, has a strong model summarize it, and a fast
|
|
7
|
+
model turn that summary into a well-formed commit message. It handles diffs of any
|
|
8
|
+
size (including ones too large to fit in a single context window), and supports
|
|
9
|
+
Conventional Commits, gitmoji, first-line templates, custom instructions, and an
|
|
10
|
+
interactive mode for choosing between several options.
|
|
11
|
+
|
|
12
|
+
```console
|
|
13
|
+
$ cco -c
|
|
14
|
+
✔ Committed
|
|
15
|
+
feat(auth): add error handling and refresh token rotation to login
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
Want more details? See [WALKTHROUGH.md](WALKTHROUGH.md).
|
|
21
|
+
|
|
22
|
+
```plaintext
|
|
23
|
+
staged diff ──split──▶ [chunk, …] ──sonnet[1m]──▶ summaries ──haiku──▶ commit message
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
1. **Summarize** — the diff is split into chunks that fit the context window and
|
|
27
|
+
each chunk is summarized by a strong model (`sonnet[1m]`, Sonnet with a 1M-token
|
|
28
|
+
context). Diffs larger than 1M tokens simply produce more chunks.
|
|
29
|
+
2. **Write** — the summaries are handed to a fast model (`haiku`) that writes the
|
|
30
|
+
final commit message according to your formatting rules.
|
|
31
|
+
|
|
32
|
+
Both stages run through the [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Requires [Bun](https://bun.sh).
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bun install
|
|
40
|
+
bun link # makes `cco` and `claude-commit` available on your PATH
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or run it directly without linking:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
bun run bin/cco.ts --help
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Authentication
|
|
50
|
+
|
|
51
|
+
`claude-commit` uses the Claude Agent SDK and, by default, always authenticates
|
|
52
|
+
with your Claude Code subscription session (run `claude login` once). Usage is
|
|
53
|
+
bundled with your Claude Code usage — no separate API bill.
|
|
54
|
+
|
|
55
|
+
To protect you from surprise pay-as-you-go charges, API credentials in your
|
|
56
|
+
environment (`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`) are **ignored by
|
|
57
|
+
default**: they are stripped from the environment passed to the model
|
|
58
|
+
subprocess, and a one-line notice is printed to stderr. To bill an API key
|
|
59
|
+
instead (pay-as-you-go), opt in explicitly in your configuration:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "allowApiKey": true }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
cco [options]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
By default `cco` summarizes your **staged** changes, generates a message, shows it,
|
|
72
|
+
and asks for confirmation before committing. Pass `-y` to skip the prompt, or
|
|
73
|
+
`--dry-run` to print the message without committing.
|
|
74
|
+
|
|
75
|
+
### Options
|
|
76
|
+
|
|
77
|
+
| Flag | Description |
|
|
78
|
+
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
79
|
+
| `-i, --interactive` / `--no-interactive` | Choose between several options in an interactive TUI, or skip it when enabled in config |
|
|
80
|
+
| `-n, --count <n>` | Number of options to generate in interactive mode (default 3) |
|
|
81
|
+
| `-a, --all` | Stage all changes (`git add -A`) before committing |
|
|
82
|
+
| `-c, --conventional` | Format as a [Conventional Commit](https://www.conventionalcommits.org) |
|
|
83
|
+
| `-g, --gitmoji` | Prefix the subject with a [gitmoji](https://gitmoji.dev) |
|
|
84
|
+
| `-m, --multiline` / `--no-multiline` | Write a multi-line commit (subject + body), or force a single line |
|
|
85
|
+
| `-t, --template <tpl>` | Template for the first line, e.g. `"[PROJ-1] {message}"` |
|
|
86
|
+
| `-p, --prompt <text>` | Extra instructions appended to the prompt |
|
|
87
|
+
| `--model-summary <model>` | Model used to summarize the diff (default `sonnet[1m]`) |
|
|
88
|
+
| `--model-final <model>` | Model used to write the message (default `haiku`) |
|
|
89
|
+
| `-d, --dry-run` | Print the message to stdout without committing |
|
|
90
|
+
| `-y, --yes` | Commit without asking for confirmation |
|
|
91
|
+
| `--no-spinner` | Disable the progress spinner |
|
|
92
|
+
| `--config <path>` | Path to a config file |
|
|
93
|
+
| `-v, --verbose` | Print summaries, cost and debug output |
|
|
94
|
+
|
|
95
|
+
### Examples
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
cco # generate, confirm, and commit staged changes
|
|
99
|
+
cco -a -c # stage everything and write a Conventional Commit
|
|
100
|
+
cco -c -g -m # conventional + gitmoji + a body
|
|
101
|
+
cco -i -n 5 # pick from 5 options interactively
|
|
102
|
+
cco --dry-run | cat # print a message without committing (TUI-free, pipe-safe)
|
|
103
|
+
git commit -F <(cco -d) # use the message with your own git invocation
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
In a pipe (no TTY) there is no spinner and no confirmation prompt — `cco` just
|
|
107
|
+
generates and commits (or prints, with `--dry-run`).
|
|
108
|
+
|
|
109
|
+
## Interactive mode
|
|
110
|
+
|
|
111
|
+
`cco -i` opens a TUI listing several candidate messages to choose from. The
|
|
112
|
+
options are generated with a higher temperature (`interactiveTemperature`) for
|
|
113
|
+
more variety. Use the arrow keys to move between options, `Enter` to commit the
|
|
114
|
+
highlighted option, `e` to edit it in your `$EDITOR` first, and `q`/`Esc` to
|
|
115
|
+
cancel.
|
|
116
|
+
|
|
117
|
+
To make interactive mode the default without typing `-i` every time, set
|
|
118
|
+
`"interactive": true` in your config (see below); opt out of a single run with
|
|
119
|
+
`--no-interactive`. When there is no interactive terminal — in a pipe, a CI job,
|
|
120
|
+
or with `--dry-run` — `cco` ignores the setting and falls back to the
|
|
121
|
+
non-interactive flow rather than failing.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
Configuration is layered, from lowest to highest precedence:
|
|
126
|
+
|
|
127
|
+
1. Built-in defaults.
|
|
128
|
+
2. **A global user config** at `~/.config/claude-commit/config.json` (or
|
|
129
|
+
`$XDG_CONFIG_HOME/claude-commit/config.json`) — your personal defaults across
|
|
130
|
+
every project. The `.claude-commit.json` / `.claude-commitrc.json` /
|
|
131
|
+
`.claude-commitrc` names are also accepted in that directory.
|
|
132
|
+
3. A `claude-commit` key in the repo's `package.json`.
|
|
133
|
+
4. The nearest `.claude-commit.json` / `.claude-commitrc.json` / `.claude-commitrc`,
|
|
134
|
+
searched from the current directory up to the repo root.
|
|
135
|
+
5. CLI flags.
|
|
136
|
+
|
|
137
|
+
So a global config sets your personal defaults and any project further down the
|
|
138
|
+
tree can override them. Note that the `config.json` name is recognised **only** in
|
|
139
|
+
the global directory; inside a project, use one of the dotted filenames. The same
|
|
140
|
+
keys are valid at every level:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"conventionalCommits": true,
|
|
145
|
+
"gitmoji": true,
|
|
146
|
+
"multiline": true,
|
|
147
|
+
"template": null,
|
|
148
|
+
"customPrompt": "Reference the ticket id from the branch name when present.",
|
|
149
|
+
"interactive": true,
|
|
150
|
+
"interactiveCount": 3,
|
|
151
|
+
"interactiveTemperature": 1,
|
|
152
|
+
"models": {
|
|
153
|
+
"summary": "sonnet[1m]",
|
|
154
|
+
"final": "haiku"
|
|
155
|
+
},
|
|
156
|
+
"maxChunkTokens": 600000,
|
|
157
|
+
"charsPerToken": 3.5,
|
|
158
|
+
"allowApiKey": false
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
bun test # run the test suite
|
|
166
|
+
bun run typecheck # tsc --noEmit
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Did you vibe this?
|
|
170
|
+
|
|
171
|
+
I distinguish vibe coding and AI-assisted development by
|
|
172
|
+
_where you live as the developer_. If you live in the code, it's AI-assisted dev.
|
|
173
|
+
If you just shout at a chat and hope for the best, that's vibe coding.
|
|
174
|
+
|
|
175
|
+
This was AI-assisted development.
|
|
176
|
+
|
|
177
|
+
Thanks for coming to my TED talk.
|
package/bin/cco.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Executable entry point for `cco` / `claude-commit`.
|
|
4
|
+
*/
|
|
5
|
+
import { run } from "../src/cli";
|
|
6
|
+
import { color } from "../src/ui/colors";
|
|
7
|
+
|
|
8
|
+
run(process.argv.slice(2))
|
|
9
|
+
.then((code) => {
|
|
10
|
+
process.exitCode = code;
|
|
11
|
+
})
|
|
12
|
+
.catch((err) => {
|
|
13
|
+
// Unexpected (non-ClaudeCommitError) failures: print a stack for debugging.
|
|
14
|
+
process.stderr.write(
|
|
15
|
+
`${color("31", "unexpected error:")} ${err?.stack ?? err}\n`,
|
|
16
|
+
);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
});
|
package/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library entry point for `claude-commit`.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the building blocks so the commit-message pipeline can be used
|
|
5
|
+
* programmatically. The CLI lives in `bin/cco.ts` (`src/cli.ts`).
|
|
6
|
+
*/
|
|
7
|
+
export { generateCommit } from "./src/generate";
|
|
8
|
+
export type {
|
|
9
|
+
GenerateOptions,
|
|
10
|
+
GenerateProgress,
|
|
11
|
+
GenerateResult,
|
|
12
|
+
} from "./src/generate";
|
|
13
|
+
export { runPrompt } from "./src/agent";
|
|
14
|
+
export type { RunPromptOptions } from "./src/agent";
|
|
15
|
+
export { splitDiff } from "./src/diff";
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_CONFIG,
|
|
18
|
+
loadFileConfig,
|
|
19
|
+
resolveConfig,
|
|
20
|
+
mergeConfig,
|
|
21
|
+
mergePartial,
|
|
22
|
+
sanitizePartial,
|
|
23
|
+
} from "./src/config";
|
|
24
|
+
export {
|
|
25
|
+
buildSummarySystem,
|
|
26
|
+
buildSummaryUser,
|
|
27
|
+
buildFinalSystem,
|
|
28
|
+
buildFinalUser,
|
|
29
|
+
parseOptions,
|
|
30
|
+
cleanMessage,
|
|
31
|
+
} from "./src/prompts";
|
|
32
|
+
export * as git from "./src/git";
|
|
33
|
+
export { ClaudeCommitError } from "./src/errors";
|
|
34
|
+
export type {
|
|
35
|
+
Config,
|
|
36
|
+
ModelConfig,
|
|
37
|
+
PartialConfig,
|
|
38
|
+
ModelResult,
|
|
39
|
+
FileChange,
|
|
40
|
+
} from "./src/types";
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@synmux/claude-commit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate git commit messages with Claude, using your Claude Code subscription.",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"module": "index.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"private": false,
|
|
9
|
+
"bin": {
|
|
10
|
+
"cco": "bin/cco.ts",
|
|
11
|
+
"claude-commit": "bin/cco.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"index.ts"
|
|
17
|
+
],
|
|
18
|
+
"claude-commit": {
|
|
19
|
+
"conventionalCommits": true,
|
|
20
|
+
"gitmoji": true,
|
|
21
|
+
"multiline": true,
|
|
22
|
+
"template": null,
|
|
23
|
+
"customPrompt": null,
|
|
24
|
+
"interactive": false,
|
|
25
|
+
"interactiveCount": 3,
|
|
26
|
+
"interactiveTemperature": 1,
|
|
27
|
+
"models": {
|
|
28
|
+
"summary": "sonnet[1m]",
|
|
29
|
+
"final": "haiku"
|
|
30
|
+
},
|
|
31
|
+
"maxChunkTokens": 600000,
|
|
32
|
+
"charsPerToken": 3.5,
|
|
33
|
+
"allowApiKey": false
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@anthropic-ai/claude-code": "^2.1.198",
|
|
37
|
+
"@trunkio/launcher": "^1.3.4",
|
|
38
|
+
"@types/bun": "^1.3.14",
|
|
39
|
+
"prettier": "3.9.4",
|
|
40
|
+
"skilld": "^2.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"typescript": "^6.0.3"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.198",
|
|
47
|
+
"@opentui/core": "^0.4.2",
|
|
48
|
+
"commander": "^15.0.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"start": "bun run bin/cco.ts",
|
|
52
|
+
"test": "bun test",
|
|
53
|
+
"format": "bun run prettier --write . && bun run trunk fmt -a",
|
|
54
|
+
"lint": "bun run trunk check -a",
|
|
55
|
+
"lint:fix": "bun run trunk check -a --fix",
|
|
56
|
+
"typecheck": "bun run tsc --noEmit",
|
|
57
|
+
"prepare": "bun run skilld prepare || true"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around the Claude Agent SDK that turns a single prompt into a
|
|
3
|
+
* single text completion.
|
|
4
|
+
*
|
|
5
|
+
* The Agent SDK spawns a bundled `claude` binary, so authentication follows
|
|
6
|
+
* Claude Code's own resolution order over the environment we hand it. By
|
|
7
|
+
* default the API credential variables (`ANTHROPIC_API_KEY` /
|
|
8
|
+
* `ANTHROPIC_AUTH_TOKEN`) are stripped from that environment, forcing the
|
|
9
|
+
* `claude login` subscription session so the cost is bundled with Claude Code
|
|
10
|
+
* usage; the `allowApiKey` config option passes them through for explicit
|
|
11
|
+
* pay-as-you-go billing. We deliberately disable every tool (`tools: []`) and
|
|
12
|
+
* load no settings (`settingSources: []`) so the run is a clean, isolated,
|
|
13
|
+
* prompt-in/text-out request.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
query,
|
|
17
|
+
type Options,
|
|
18
|
+
type SDKMessage,
|
|
19
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
20
|
+
import { ClaudeCommitError } from "./errors";
|
|
21
|
+
import type { ModelResult } from "./types";
|
|
22
|
+
|
|
23
|
+
export interface RunPromptOptions {
|
|
24
|
+
/** Model string (alias like `haiku`, `sonnet[1m]`, or a full model id). */
|
|
25
|
+
model: string;
|
|
26
|
+
/** Full custom system prompt. */
|
|
27
|
+
system: string;
|
|
28
|
+
/** Receives assistant text as it streams in (enables partial messages). */
|
|
29
|
+
onText?: (delta: string) => void;
|
|
30
|
+
/** Abort the in-flight request. */
|
|
31
|
+
abortController?: AbortController;
|
|
32
|
+
/** Receives the underlying CLI's stderr (for `--verbose`). */
|
|
33
|
+
onStderr?: (data: string) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Sampling temperature. Passed to the model via `CLAUDE_CODE_EXTRA_BODY`.
|
|
36
|
+
* Used to add variety when generating several interactive options. Models
|
|
37
|
+
* that don't accept a temperature override will reject the request, so the
|
|
38
|
+
* caller should be prepared to retry without it.
|
|
39
|
+
*/
|
|
40
|
+
temperature?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Request a structured JSON response matching this schema. The parsed object
|
|
43
|
+
* is returned on {@link ModelResult.structured}. Models that don't support
|
|
44
|
+
* structured outputs will reject the request, so the caller should be
|
|
45
|
+
* prepared to retry without it.
|
|
46
|
+
*/
|
|
47
|
+
outputFormat?: { type: "json_schema"; schema: Record<string, unknown> };
|
|
48
|
+
/**
|
|
49
|
+
* Allow API credentials from the environment to reach the SDK subprocess.
|
|
50
|
+
* Defaults to false: `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` are
|
|
51
|
+
* stripped so the run is billed to the Claude subscription.
|
|
52
|
+
*/
|
|
53
|
+
allowApiKey?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Environment variables that carry Claude API credentials. Their presence
|
|
58
|
+
* switches the spawned `claude` binary from subscription auth to
|
|
59
|
+
* pay-as-you-go API billing, so they are stripped from the subprocess
|
|
60
|
+
* environment unless the user opts in via the `allowApiKey` config option.
|
|
61
|
+
*/
|
|
62
|
+
export const GATED_CREDENTIAL_VARS = [
|
|
63
|
+
"ANTHROPIC_API_KEY",
|
|
64
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Names of the gated credential variables present in `env`. An empty string
|
|
69
|
+
* counts as present, since presence alone perturbs credential resolution.
|
|
70
|
+
*/
|
|
71
|
+
export function presentCredentialVars(
|
|
72
|
+
env: Record<string, string | undefined>,
|
|
73
|
+
): string[] {
|
|
74
|
+
return GATED_CREDENTIAL_VARS.filter((name) => env[name] !== undefined);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SubprocessEnvOptions {
|
|
78
|
+
/** Environment to derive the subprocess environment from (usually `process.env`). */
|
|
79
|
+
baseEnv: Record<string, string | undefined>;
|
|
80
|
+
/**
|
|
81
|
+
* Pass API credential variables through instead of stripping them.
|
|
82
|
+
* Defaults to false so the gate fails safe when a caller omits it.
|
|
83
|
+
*/
|
|
84
|
+
allowApiKey?: boolean;
|
|
85
|
+
/** Sampling temperature to inject via `CLAUDE_CODE_EXTRA_BODY`, preserving any existing extra body. */
|
|
86
|
+
temperature?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the environment for the Claude Agent SDK subprocess, or return
|
|
91
|
+
* `undefined` when the parent environment can be inherited unchanged.
|
|
92
|
+
*
|
|
93
|
+
* Unless `allowApiKey` is set, API credential variables are removed so the
|
|
94
|
+
* spawned `claude` binary always authenticates with the user's subscription
|
|
95
|
+
* session — an exported `ANTHROPIC_API_KEY` must never silently switch
|
|
96
|
+
* billing to pay-as-you-go.
|
|
97
|
+
*/
|
|
98
|
+
export function buildSubprocessEnv(
|
|
99
|
+
opts: SubprocessEnvOptions,
|
|
100
|
+
): Record<string, string | undefined> | undefined {
|
|
101
|
+
const { baseEnv, allowApiKey = false, temperature } = opts;
|
|
102
|
+
const stripped = allowApiKey ? [] : presentCredentialVars(baseEnv);
|
|
103
|
+
if (stripped.length === 0 && temperature == null) return undefined;
|
|
104
|
+
|
|
105
|
+
const env = { ...baseEnv };
|
|
106
|
+
for (const name of stripped) delete env[name];
|
|
107
|
+
|
|
108
|
+
if (temperature != null) {
|
|
109
|
+
let extra: Record<string, unknown> = {};
|
|
110
|
+
const existing = baseEnv.CLAUDE_CODE_EXTRA_BODY;
|
|
111
|
+
if (existing) {
|
|
112
|
+
try {
|
|
113
|
+
const parsed: unknown = JSON.parse(existing);
|
|
114
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
115
|
+
extra = parsed as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* ignore a malformed existing value */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
env.CLAUDE_CODE_EXTRA_BODY = JSON.stringify({ ...extra, temperature });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return env;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Map a known SDK assistant error code to a friendlier, actionable message. */
|
|
128
|
+
function describeAssistantError(code: string): string {
|
|
129
|
+
switch (code) {
|
|
130
|
+
case "authentication_failed":
|
|
131
|
+
case "oauth_org_not_allowed":
|
|
132
|
+
return (
|
|
133
|
+
"Authentication failed. Run `claude login` to sign in with your Claude " +
|
|
134
|
+
"subscription, or set ANTHROPIC_API_KEY and enable `allowApiKey` in " +
|
|
135
|
+
"your claude-commit config."
|
|
136
|
+
);
|
|
137
|
+
case "billing_error":
|
|
138
|
+
return "Billing error from the Claude API. Check your plan or API credits.";
|
|
139
|
+
case "rate_limit":
|
|
140
|
+
return "Rate limited by the Claude API. Try again shortly.";
|
|
141
|
+
case "overloaded":
|
|
142
|
+
return "The Claude API is overloaded. Try again shortly.";
|
|
143
|
+
case "model_not_found":
|
|
144
|
+
return "The requested model was not found. Check the configured model name.";
|
|
145
|
+
case "max_output_tokens":
|
|
146
|
+
return "The model hit its output limit before finishing.";
|
|
147
|
+
default:
|
|
148
|
+
return `Model request failed (${code}).`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run a single prompt and return the model's text response.
|
|
154
|
+
*
|
|
155
|
+
* Throws {@link ClaudeCommitError} on any model/authentication/quota failure.
|
|
156
|
+
*/
|
|
157
|
+
export async function runPrompt(
|
|
158
|
+
prompt: string,
|
|
159
|
+
opts: RunPromptOptions,
|
|
160
|
+
): Promise<ModelResult> {
|
|
161
|
+
const subprocessEnv = buildSubprocessEnv({
|
|
162
|
+
baseEnv: process.env,
|
|
163
|
+
allowApiKey: opts.allowApiKey ?? false,
|
|
164
|
+
...(opts.temperature != null ? { temperature: opts.temperature } : {}),
|
|
165
|
+
});
|
|
166
|
+
const options: Options = {
|
|
167
|
+
model: opts.model,
|
|
168
|
+
systemPrompt: opts.system,
|
|
169
|
+
tools: [], // pure text completion: no Bash/Read/Edit/etc.
|
|
170
|
+
maxTurns: 1,
|
|
171
|
+
settingSources: [], // ignore user/project/local settings, CLAUDE.md, MCP, plugins
|
|
172
|
+
includePartialMessages: Boolean(opts.onText),
|
|
173
|
+
...(opts.abortController ? { abortController: opts.abortController } : {}),
|
|
174
|
+
...(opts.onStderr ? { stderr: opts.onStderr } : {}),
|
|
175
|
+
...(subprocessEnv ? { env: subprocessEnv } : {}),
|
|
176
|
+
...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
let resultText: string | null = null;
|
|
180
|
+
let costUsd = 0;
|
|
181
|
+
let model: string | undefined;
|
|
182
|
+
let structured: unknown;
|
|
183
|
+
let assistantError: string | undefined;
|
|
184
|
+
|
|
185
|
+
let response;
|
|
186
|
+
try {
|
|
187
|
+
response = query({ prompt, options });
|
|
188
|
+
for await (const message of response as AsyncIterable<SDKMessage>) {
|
|
189
|
+
switch (message.type) {
|
|
190
|
+
case "stream_event": {
|
|
191
|
+
if (opts.onText) {
|
|
192
|
+
const event = message.event as {
|
|
193
|
+
type?: string;
|
|
194
|
+
delta?: { type?: string; text?: string };
|
|
195
|
+
};
|
|
196
|
+
if (
|
|
197
|
+
event.type === "content_block_delta" &&
|
|
198
|
+
event.delta?.type === "text_delta"
|
|
199
|
+
) {
|
|
200
|
+
opts.onText(event.delta.text ?? "");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "assistant": {
|
|
206
|
+
if (message.error) assistantError = message.error;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "result": {
|
|
210
|
+
costUsd = message.total_cost_usd ?? 0;
|
|
211
|
+
// The served model is the (only) key of modelUsage, when present.
|
|
212
|
+
const usedModels = Object.keys(message.modelUsage ?? {});
|
|
213
|
+
if (usedModels.length > 0) model = usedModels[0];
|
|
214
|
+
if (message.subtype === "success") {
|
|
215
|
+
resultText = message.result;
|
|
216
|
+
structured = message.structured_output;
|
|
217
|
+
} else {
|
|
218
|
+
const detail =
|
|
219
|
+
"errors" in message && message.errors.length
|
|
220
|
+
? message.errors.join("; ")
|
|
221
|
+
: message.subtype;
|
|
222
|
+
throw new ClaudeCommitError(`Model run failed: ${detail}`);
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
default:
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (err instanceof ClaudeCommitError) throw err;
|
|
232
|
+
if (opts.abortController?.signal.aborted) {
|
|
233
|
+
throw new ClaudeCommitError("Generation was cancelled.");
|
|
234
|
+
}
|
|
235
|
+
throw new ClaudeCommitError(
|
|
236
|
+
`Failed to call the Claude Agent SDK: ${(err as Error).message}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (assistantError) {
|
|
241
|
+
throw new ClaudeCommitError(describeAssistantError(assistantError));
|
|
242
|
+
}
|
|
243
|
+
if (resultText === null) {
|
|
244
|
+
throw new ClaudeCommitError("The model returned no result.");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
text: resultText.trim(),
|
|
249
|
+
costUsd,
|
|
250
|
+
...(model ? { model } : {}),
|
|
251
|
+
...(structured !== undefined ? { structured } : {}),
|
|
252
|
+
};
|
|
253
|
+
}
|