@sabinm677/ccommit 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sabin Maharjan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # ccommit
2
+
3
+ Generate Conventional Commit messages from staged Git changes using Claude Code CLI, OpenCode CLI, or Codex CLI.
4
+
5
+ ## Requirements
6
+ - Bun 1.0+
7
+ - Git 2.0+
8
+ - Claude Code CLI, OpenCode CLI, or Codex CLI configured and available on `PATH`
9
+
10
+ ## Usage
11
+ ```bash
12
+ ccommit
13
+ # or from repo root
14
+ bin/ccommit
15
+ ```
16
+
17
+ ## Installation
18
+
19
+ ### Via npm (recommended)
20
+ ```bash
21
+ npm install -g @sabinm677/ccommit
22
+ ```
23
+
24
+ ### From source
25
+ ```bash
26
+ git clone https://github.com/sabinm677/ccommit.git
27
+ cd ccommit
28
+ chmod +x bin/ccommit
29
+ ln -sf "$(pwd)/bin/ccommit" ~/.local/bin/ccommit
30
+ ```
31
+
32
+ If needed, add `~/.local/bin` to your `PATH`.
33
+
34
+ Default mode:
35
+ - Generates a commit message from staged changes
36
+ - Shows the message
37
+ - Prompts `Create commit? [y/N]`
38
+ - Creates commit only when you answer `y` or `yes`
39
+ - In non-interactive mode, commit is auto-cancelled unless `--yes` is used
40
+
41
+ Show-only mode (no commit created):
42
+ ```bash
43
+ ccommit --show
44
+ ccommit -s
45
+ ```
46
+
47
+ Choose provider explicitly:
48
+ ```bash
49
+ bin/ccommit -p opencode
50
+ ccommit --provider opencode
51
+ ccommit -p opencode
52
+ ccommit -p codex
53
+ ccommit --yes
54
+ ```
55
+
56
+ ## Options
57
+ - `-s`, `--show`: Generate and print message only. Do not prompt or create commit.
58
+ - `-y`, `--yes`: Create commit without interactive confirmation.
59
+ - `-p`, `--provider <claude|opencode|codex>`: Select AI provider for this run.
60
+ - `-h`, `--help`: Show usage help.
61
+ - `-v`, `--version`: Show CLI version.
62
+ - `--verbose`: Print debug details to stderr, including provider failure diagnostics.
63
+
64
+ ccommit prints the selected provider in the header (including `--show`/`-s` mode):
65
+ ```text
66
+ Generated commit message (provider):
67
+ Generated commit message (claude):
68
+ Generated commit message (opencode):
69
+ Generated commit message (codex):
70
+ ```
71
+
72
+ ## Config
73
+ Optional config file: `~/.config/ccommit/config.json`
74
+
75
+ ```json
76
+ {
77
+ "provider": "codex",
78
+ "providers": {
79
+ "claude": { "command": "claude", "args": ["-p"] },
80
+ "opencode": { "command": "opencode", "args": ["run"] },
81
+ "codex": { "command": "codex", "args": ["exec"] }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Precedence for provider/command settings: `CLI > env > config > defaults`.
87
+ Default provider: `claude`.
88
+
89
+ ## Exit Codes
90
+ - `0`: success
91
+ - `1`: expected error (for example not a git repo, no staged changes, provider CLI/auth/network failure)
92
+
93
+ ## Development
94
+ Run tests:
95
+ ```bash
96
+ bun test
97
+ ```
98
+
99
+ Node compatibility:
100
+ - `ccommit` is Bun-first, but basic CLI invocation with Node remains supported (for example `node bin/ccommit --version`).
101
+
102
+ ## Test Helpers
103
+ For local testing without provider API calls:
104
+ - `CCOMMIT_MOCK_MESSAGE="feat: add feature"` to bypass AI provider call.
105
+ - `CCOMMIT_MOCK_ERROR=AUTH` to force auth error.
106
+ - `CCOMMIT_MOCK_ERROR=NETWORK` to force network error.
107
+
108
+ Provider env overrides:
109
+ - `CCOMMIT_PROVIDER=claude|opencode|codex`
110
+ - `CCOMMIT_CLAUDE_CMD`, `CCOMMIT_CLAUDE_ARGS`
111
+ - `CCOMMIT_OPENCODE_CMD`, `CCOMMIT_OPENCODE_ARGS`
112
+ - `CCOMMIT_CODEX_CMD`, `CCOMMIT_CODEX_ARGS`
113
+ - `CCOMMIT_PROVIDER_TIMEOUT_MS` (optional, default `60000`)
package/bin/ccommit ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ import { run } from "../src/main.js";
3
+
4
+ await run(process.argv.slice(2));
5
+ process.exit(process.exitCode ?? 0);
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@sabinm677/ccommit",
3
+ "version": "0.1.0",
4
+ "description": "AI-powered Git commit message generator with Conventional Commits output.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Sabin Maharjan",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sabinm677/ccommit.git"
11
+ },
12
+ "homepage": "https://github.com/sabinm677/ccommit",
13
+ "bugs": {
14
+ "url": "https://github.com/sabinm677/ccommit/issues"
15
+ },
16
+ "keywords": [
17
+ "git",
18
+ "commit",
19
+ "ai",
20
+ "conventional-commits",
21
+ "cli",
22
+ "claude",
23
+ "opencode",
24
+ "codex"
25
+ ],
26
+ "files": [
27
+ "bin",
28
+ "src"
29
+ ],
30
+ "bin": {
31
+ "ccommit": "./bin/ccommit"
32
+ },
33
+ "scripts": {
34
+ "test": "bun test"
35
+ },
36
+ "engines": {
37
+ "bun": ">=1.0.0",
38
+ "node": ">=18"
39
+ }
40
+ }
@@ -0,0 +1,185 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { AppError } from "../errors/app-error.js";
3
+ import { ERROR_MESSAGES } from "../errors/messages.js";
4
+ import { resolveCommand, resolveProvider } from "./provider-resolution.js";
5
+
6
+ const DEFAULT_PROVIDER_TIMEOUT_MS = 60_000;
7
+
8
+ function getProviderErrors() {
9
+ return {
10
+ notFound: ERROR_MESSAGES.PROVIDER_NOT_FOUND,
11
+ auth: ERROR_MESSAGES.PROVIDER_AUTH_FAILURE,
12
+ network: ERROR_MESSAGES.PROVIDER_NETWORK_FAILURE,
13
+ timeout: ERROR_MESSAGES.PROVIDER_TIMEOUT,
14
+ };
15
+ }
16
+
17
+ function resolveProviderTimeoutMs() {
18
+ const raw = process.env.CCOMMIT_PROVIDER_TIMEOUT_MS;
19
+ if (!raw) {
20
+ return DEFAULT_PROVIDER_TIMEOUT_MS;
21
+ }
22
+ const parsed = Number.parseInt(raw, 10);
23
+ if (!Number.isFinite(parsed) || parsed <= 0) {
24
+ return DEFAULT_PROVIDER_TIMEOUT_MS;
25
+ }
26
+ return parsed;
27
+ }
28
+
29
+ function classifyCliFailure(stderr = "", stdout = "") {
30
+ const errors = getProviderErrors();
31
+ const output = `${stderr}\n${stdout}`.toLowerCase();
32
+ if (
33
+ output.includes("unauthorized") ||
34
+ output.includes("authentication") ||
35
+ output.includes("api key")
36
+ ) {
37
+ return new AppError(errors.auth);
38
+ }
39
+ if (
40
+ output.includes("network") ||
41
+ output.includes("unable to connect") ||
42
+ output.includes("failed to fetch") ||
43
+ output.includes("connect") ||
44
+ output.includes("timed out") ||
45
+ output.includes("connection") ||
46
+ output.includes("econn")
47
+ ) {
48
+ return new AppError(errors.network);
49
+ }
50
+ return new AppError(ERROR_MESSAGES.UNKNOWN_ERROR);
51
+ }
52
+
53
+ function summarizeOutput(text = "") {
54
+ if (typeof text !== "string") {
55
+ text = text == null ? "" : String(text);
56
+ }
57
+ const trimmed = text.trim();
58
+ if (!trimmed) {
59
+ return "<empty>";
60
+ }
61
+ const max = 600;
62
+ return trimmed.length > max ? `${trimmed.slice(0, max)}...` : trimmed;
63
+ }
64
+
65
+ function writeVerboseFailure(options, details) {
66
+ if (!options.verbose) {
67
+ return;
68
+ }
69
+ process.stderr.write(
70
+ [
71
+ "Provider execution failed:",
72
+ `provider=${details.provider}`,
73
+ `command=${details.command} ${details.args.join(" ")}`.trim(),
74
+ `timeout=${details.timeout}ms`,
75
+ `status=${details.status ?? "null"}`,
76
+ `error_code=${details.errorCode || "<none>"}`,
77
+ `error_message=${details.errorMessage || "<none>"}`,
78
+ `stdout=${summarizeOutput(details.stdout)}`,
79
+ `stderr=${summarizeOutput(details.stderr)}`,
80
+ "",
81
+ ].join("\n"),
82
+ );
83
+ }
84
+
85
+ export function generateCommitMessage(prompt, options = {}, config = {}) {
86
+ const provider = resolveProvider(options, config);
87
+ const errors = getProviderErrors();
88
+
89
+ const mockMessage = process.env.CCOMMIT_MOCK_MESSAGE;
90
+ if (mockMessage) {
91
+ return mockMessage;
92
+ }
93
+
94
+ const mockError = process.env.CCOMMIT_MOCK_ERROR;
95
+ if (mockError === "AUTH") {
96
+ throw new AppError(errors.auth);
97
+ }
98
+ if (mockError === "NETWORK") {
99
+ throw new AppError(errors.network);
100
+ }
101
+
102
+ const { command, args } = resolveCommand(provider, config);
103
+ const timeout = resolveProviderTimeoutMs();
104
+ let result;
105
+ try {
106
+ result = spawnSync(command, [...args, prompt], {
107
+ encoding: "utf8",
108
+ maxBuffer: 10 * 1024 * 1024,
109
+ timeout,
110
+ });
111
+ } catch (error) {
112
+ writeVerboseFailure(options, {
113
+ provider,
114
+ command,
115
+ args,
116
+ timeout,
117
+ status: null,
118
+ errorCode: error?.code,
119
+ errorMessage: error?.message,
120
+ stdout: "",
121
+ stderr: "",
122
+ });
123
+ if (error?.code === "ENOENT") {
124
+ throw new AppError(errors.notFound);
125
+ }
126
+ if (error?.code === "ETIMEDOUT") {
127
+ throw new AppError(errors.timeout);
128
+ }
129
+ throw new AppError(ERROR_MESSAGES.UNKNOWN_ERROR);
130
+ }
131
+
132
+ if (result.error && result.error.code === "ETIMEDOUT") {
133
+ writeVerboseFailure(options, {
134
+ provider,
135
+ command,
136
+ args,
137
+ timeout,
138
+ status: result.status,
139
+ errorCode: result.error.code,
140
+ errorMessage: result.error.message,
141
+ stdout: result.stdout,
142
+ stderr: result.stderr,
143
+ });
144
+ throw new AppError(errors.timeout);
145
+ }
146
+
147
+ if (result.error && result.error.code === "ENOENT") {
148
+ writeVerboseFailure(options, {
149
+ provider,
150
+ command,
151
+ args,
152
+ timeout,
153
+ status: result.status,
154
+ errorCode: result.error.code,
155
+ errorMessage: result.error.message,
156
+ stdout: result.stdout,
157
+ stderr: result.stderr,
158
+ });
159
+ throw new AppError(errors.notFound);
160
+ }
161
+
162
+ if (result.status !== 0) {
163
+ const classified = classifyCliFailure(result.stderr, result.stdout);
164
+ writeVerboseFailure(options, {
165
+ provider,
166
+ command,
167
+ args,
168
+ timeout,
169
+ status: result.status,
170
+ errorCode: result.error?.code,
171
+ errorMessage: classified.message,
172
+ stdout: result.stdout,
173
+ stderr: result.stderr,
174
+ });
175
+ throw classified;
176
+ }
177
+
178
+ if (options.verbose) {
179
+ process.stderr.write(
180
+ `Provider: ${provider}\nCommand: ${command} ${args.join(" ")}\nTimeout: ${timeout}ms\n`,
181
+ );
182
+ }
183
+
184
+ return (result.stdout || "").trim();
185
+ }
@@ -0,0 +1,63 @@
1
+ import { COMMIT_TYPES } from "../commit/types.js";
2
+
3
+ const MAX_DIFF_CHARS = 120_000;
4
+
5
+ function extractStagedPaths(diff) {
6
+ const paths = [];
7
+ for (const line of diff.split("\n")) {
8
+ if (!line.startsWith("diff --git ")) {
9
+ continue;
10
+ }
11
+ const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
12
+ if (!match) {
13
+ continue;
14
+ }
15
+ paths.push(match[2]);
16
+ }
17
+ return paths;
18
+ }
19
+
20
+ function isDocsOnlyPath(path) {
21
+ const lower = path.toLowerCase();
22
+ if (lower.startsWith("docs/")) {
23
+ return true;
24
+ }
25
+ return (
26
+ lower.endsWith(".md") ||
27
+ lower.endsWith(".mdx") ||
28
+ lower.endsWith(".rst") ||
29
+ lower.endsWith(".txt") ||
30
+ lower.endsWith(".adoc")
31
+ );
32
+ }
33
+
34
+ export function buildPrompt(diff) {
35
+ const clippedDiff =
36
+ diff.length > MAX_DIFF_CHARS
37
+ ? `${diff.slice(0, MAX_DIFF_CHARS)}\n\n[diff truncated for length]`
38
+ : diff;
39
+ const stagedPaths = extractStagedPaths(diff);
40
+ const docsOnly =
41
+ stagedPaths.length > 0 && stagedPaths.every((path) => isDocsOnlyPath(path));
42
+
43
+ const typeGuidance = docsOnly
44
+ ? "- This change is documentation-only. Use type docs."
45
+ : "- Choose the type strictly from actual staged changes.";
46
+
47
+ return `You are generating a git commit message from a staged diff.
48
+
49
+ Rules:
50
+ - Use Conventional Commits v1.0.0.
51
+ - Allowed types: ${COMMIT_TYPES.join(", ")}.
52
+ - Format header exactly: <type>[optional scope][optional !]: <description>
53
+ - Description must be concise and imperative.
54
+ - Prefer a single-line commit message. Add body/footer only if essential.
55
+ - Do not claim changes that are not present in the staged diff.
56
+ - Do not use markdown bullets in the commit body.
57
+ - ${typeGuidance}
58
+ - Use BREAKING CHANGE: footer when required.
59
+ - Return only the commit message text with no explanation.
60
+
61
+ Staged diff:
62
+ ${clippedDiff}`;
63
+ }
@@ -0,0 +1,77 @@
1
+ import { AppError } from "../errors/app-error.js";
2
+ import { ERROR_MESSAGES } from "../errors/messages.js";
3
+ import { isValidProvider } from "./providers.js";
4
+
5
+ const DEFAULT_PROVIDER = "claude";
6
+
7
+ function splitArgs(raw) {
8
+ if (!raw) {
9
+ return [];
10
+ }
11
+ return raw
12
+ .split(" ")
13
+ .map((value) => value.trim())
14
+ .filter(Boolean);
15
+ }
16
+
17
+ export function resolveProvider(options = {}, config = {}) {
18
+ const provider =
19
+ options.provider ||
20
+ process.env.CCOMMIT_PROVIDER ||
21
+ config.provider ||
22
+ DEFAULT_PROVIDER;
23
+
24
+ if (!isValidProvider(provider)) {
25
+ throw new AppError(ERROR_MESSAGES.INVALID_PROVIDER);
26
+ }
27
+ return provider;
28
+ }
29
+
30
+ export function resolveCommand(provider, config = {}) {
31
+ const defaults =
32
+ provider === "claude"
33
+ ? { command: "claude", args: ["-p"] }
34
+ : provider === "opencode"
35
+ ? { command: "opencode", args: ["run"] }
36
+ : { command: "codex", args: ["exec"] };
37
+
38
+ if (provider === "claude") {
39
+ if (process.env.CCOMMIT_CLAUDE_CMD || process.env.CCOMMIT_CLAUDE_ARGS) {
40
+ return {
41
+ command: process.env.CCOMMIT_CLAUDE_CMD || defaults.command,
42
+ args: splitArgs(process.env.CCOMMIT_CLAUDE_ARGS || defaults.args.join(" ")),
43
+ };
44
+ }
45
+ } else if (provider === "opencode") {
46
+ if (process.env.CCOMMIT_OPENCODE_CMD || process.env.CCOMMIT_OPENCODE_ARGS) {
47
+ return {
48
+ command: process.env.CCOMMIT_OPENCODE_CMD || defaults.command,
49
+ args: splitArgs(
50
+ process.env.CCOMMIT_OPENCODE_ARGS || defaults.args.join(" "),
51
+ ),
52
+ };
53
+ }
54
+ } else if (provider === "codex") {
55
+ if (process.env.CCOMMIT_CODEX_CMD || process.env.CCOMMIT_CODEX_ARGS) {
56
+ return {
57
+ command: process.env.CCOMMIT_CODEX_CMD || defaults.command,
58
+ args: splitArgs(process.env.CCOMMIT_CODEX_ARGS || defaults.args.join(" ")),
59
+ };
60
+ }
61
+ }
62
+
63
+ const providerConfig = config.providers?.[provider];
64
+ if (providerConfig?.command !== undefined || providerConfig?.args !== undefined) {
65
+ const command = providerConfig.command || defaults.command;
66
+ const args = providerConfig.args || defaults.args;
67
+ if (!command || typeof command !== "string") {
68
+ throw new AppError(ERROR_MESSAGES.INVALID_CONFIG);
69
+ }
70
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) {
71
+ throw new AppError(ERROR_MESSAGES.INVALID_CONFIG);
72
+ }
73
+ return { command, args };
74
+ }
75
+
76
+ return defaults;
77
+ }
@@ -0,0 +1,5 @@
1
+ export const AI_PROVIDERS = ["claude", "opencode", "codex"];
2
+
3
+ export function isValidProvider(provider) {
4
+ return AI_PROVIDERS.includes(provider);
5
+ }
@@ -0,0 +1,14 @@
1
+ function stripCodeFence(text) {
2
+ const trimmed = text.trim();
3
+ if (trimmed.startsWith("```") && trimmed.endsWith("```")) {
4
+ return trimmed.replace(/^```[a-zA-Z]*\n?/, "").replace(/\n?```$/, "");
5
+ }
6
+ return text;
7
+ }
8
+
9
+ export function parseCommitMessage(rawOutput) {
10
+ let text = stripCodeFence(rawOutput).trim();
11
+ text = text.replace(/^commit message:\s*/i, "").trim();
12
+ text = text.replace(/^message:\s*/i, "").trim();
13
+ return text;
14
+ }
@@ -0,0 +1,82 @@
1
+ import { AppError } from "../errors/app-error.js";
2
+ import { ERROR_MESSAGES } from "../errors/messages.js";
3
+ import { isValidProvider } from "../ai/providers.js";
4
+
5
+ export function parseArgs(args) {
6
+ const options = {
7
+ help: false,
8
+ show: false,
9
+ yes: false,
10
+ version: false,
11
+ verbose: false,
12
+ provider: undefined,
13
+ };
14
+
15
+ for (let index = 0; index < args.length; index += 1) {
16
+ const arg = args[index];
17
+ if (arg === "--help" || arg === "-h") {
18
+ options.help = true;
19
+ continue;
20
+ }
21
+ if (arg === "--version" || arg === "-v") {
22
+ options.version = true;
23
+ continue;
24
+ }
25
+ if (arg === "--show" || arg === "-s") {
26
+ options.show = true;
27
+ continue;
28
+ }
29
+ if (arg === "--yes" || arg === "-y") {
30
+ options.yes = true;
31
+ continue;
32
+ }
33
+ if (arg === "--verbose") {
34
+ options.verbose = true;
35
+ continue;
36
+ }
37
+ if (arg === "--provider" || arg === "-p") {
38
+ const value = args[index + 1];
39
+ if (!value || value.startsWith("-")) {
40
+ throw new AppError("Missing value for --provider/-p.");
41
+ }
42
+ if (!isValidProvider(value)) {
43
+ throw new AppError(ERROR_MESSAGES.INVALID_PROVIDER);
44
+ }
45
+ options.provider = value;
46
+ index += 1;
47
+ continue;
48
+ }
49
+ if (arg.startsWith("--provider=") || arg.startsWith("-p=")) {
50
+ const value = arg.includes("--provider=")
51
+ ? arg.slice("--provider=".length)
52
+ : arg.slice("-p=".length);
53
+ if (!isValidProvider(value)) {
54
+ throw new AppError(ERROR_MESSAGES.INVALID_PROVIDER);
55
+ }
56
+ options.provider = value;
57
+ continue;
58
+ }
59
+ throw new AppError(`Unknown option: ${arg}`);
60
+ }
61
+
62
+ return options;
63
+ }
64
+
65
+ export function getHelpText() {
66
+ return `Usage: ccommit [options]
67
+
68
+ Generate a Conventional Commit message from staged Git changes.
69
+
70
+ Options:
71
+ -s, --show Show generated message only (do not commit)
72
+ -y, --yes Create commit without interactive confirmation
73
+ -p, --provider AI provider: claude | opencode | codex
74
+ -h, --help Show help
75
+ -v, --version Show version
76
+ --verbose Print debug information to stderr
77
+
78
+ Config:
79
+ ~/.config/ccommit/config.json
80
+ Precedence: CLI > env > config > defaults
81
+ `;
82
+ }
@@ -0,0 +1,24 @@
1
+ import { createInterface } from "node:readline";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+
4
+ export function isInteractiveTerminal() {
5
+ return Boolean(input.isTTY && output.isTTY);
6
+ }
7
+
8
+ export async function askForConfirmation(promptText) {
9
+ const rl = createInterface({ input, output });
10
+ try {
11
+ const answer = await new Promise((resolve) => {
12
+ rl.question(promptText, (value) => {
13
+ resolve(value ?? "");
14
+ });
15
+ });
16
+ return /^y(es)?$/i.test(answer.trim());
17
+ } catch {
18
+ return false;
19
+ } finally {
20
+ rl.close();
21
+ // Bun can keep stdin alive after readline close; pause to allow clean exit.
22
+ input.pause();
23
+ }
24
+ }
@@ -0,0 +1,4 @@
1
+ export const EXIT_CODES = {
2
+ SUCCESS: 0,
3
+ ERROR: 1,
4
+ };
@@ -0,0 +1,36 @@
1
+ import { COMMIT_TYPES } from "./types.js";
2
+
3
+ function compactWhitespace(text) {
4
+ return text.replace(/\s+/g, " ").trim();
5
+ }
6
+
7
+ function sanitizeDescription(raw) {
8
+ const cleaned = compactWhitespace(raw).replace(/[.]+$/, "");
9
+ if (!cleaned) {
10
+ return "update project files";
11
+ }
12
+ return cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
13
+ }
14
+
15
+ export function normalizeCommitMessage(message) {
16
+ const normalized = message.trim();
17
+ if (!normalized) {
18
+ return "chore: update project files";
19
+ }
20
+
21
+ const [header, ...rest] = normalized.split("\n");
22
+ const cleanedHeader = compactWhitespace(header);
23
+ const matchedType = COMMIT_TYPES.find((type) =>
24
+ cleanedHeader.toLowerCase().startsWith(`${type}:`),
25
+ );
26
+
27
+ if (matchedType) {
28
+ return [cleanedHeader, ...rest].join("\n").trim();
29
+ }
30
+
31
+ if (/^[a-z]+(\([^)]+\))?!?:/i.test(cleanedHeader)) {
32
+ return `chore: ${sanitizeDescription(cleanedHeader.split(":").slice(1).join(":"))}`;
33
+ }
34
+
35
+ return `chore: ${sanitizeDescription(cleanedHeader)}`;
36
+ }
@@ -0,0 +1,12 @@
1
+ export const COMMIT_TYPES = [
2
+ "feat",
3
+ "fix",
4
+ "docs",
5
+ "style",
6
+ "refactor",
7
+ "perf",
8
+ "test",
9
+ "build",
10
+ "ci",
11
+ "chore",
12
+ ];
@@ -0,0 +1,30 @@
1
+ import { COMMIT_TYPES } from "./types.js";
2
+
3
+ const typeGroup = COMMIT_TYPES.join("|");
4
+ const HEADER_REGEX = new RegExp(
5
+ `^(${typeGroup})(\\([a-z0-9._-]+\\))?(!)?: .+`,
6
+ "i",
7
+ );
8
+
9
+ export function isValidCommitMessage(message) {
10
+ const normalized = message.trim();
11
+ if (!normalized) {
12
+ return false;
13
+ }
14
+
15
+ const [header] = normalized.split("\n");
16
+ if (!HEADER_REGEX.test(header.trim())) {
17
+ return false;
18
+ }
19
+
20
+ return true;
21
+ }
22
+
23
+ export function isBreakingChange(message) {
24
+ const normalized = message.trim();
25
+ const [header] = normalized.split("\n");
26
+ if (header.includes("!:")) {
27
+ return true;
28
+ }
29
+ return /\nBREAKING CHANGE:/i.test(`\n${normalized}`);
30
+ }
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { AppError } from "../errors/app-error.js";
5
+ import { ERROR_MESSAGES } from "../errors/messages.js";
6
+ import { isValidProvider } from "../ai/providers.js";
7
+
8
+ const defaultConfig = Object.freeze({
9
+ provider: undefined,
10
+ providers: {
11
+ claude: { command: undefined, args: undefined },
12
+ opencode: { command: undefined, args: undefined },
13
+ codex: { command: undefined, args: undefined },
14
+ },
15
+ });
16
+
17
+ export function getConfigPath() {
18
+ return join(homedir(), ".config", "ccommit", "config.json");
19
+ }
20
+
21
+ export function loadConfig() {
22
+ const configPath = getConfigPath();
23
+ if (!existsSync(configPath)) {
24
+ return structuredClone(defaultConfig);
25
+ }
26
+
27
+ let raw;
28
+ try {
29
+ raw = JSON.parse(readFileSync(configPath, "utf8"));
30
+ } catch {
31
+ throw new AppError(ERROR_MESSAGES.INVALID_CONFIG_JSON);
32
+ }
33
+
34
+ return normalizeConfig(raw);
35
+ }
36
+
37
+ function normalizeConfig(raw) {
38
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
39
+ throw new AppError(ERROR_MESSAGES.INVALID_CONFIG);
40
+ }
41
+
42
+ const provider = raw.provider;
43
+ if (provider !== undefined) {
44
+ if (typeof provider !== "string" || !isValidProvider(provider.trim())) {
45
+ throw new AppError(ERROR_MESSAGES.INVALID_PROVIDER);
46
+ }
47
+ }
48
+
49
+ const providers = raw.providers;
50
+ if (
51
+ providers !== undefined &&
52
+ (!providers || typeof providers !== "object" || Array.isArray(providers))
53
+ ) {
54
+ throw new AppError(ERROR_MESSAGES.INVALID_CONFIG);
55
+ }
56
+
57
+ const normalized = structuredClone(defaultConfig);
58
+ if (provider !== undefined) {
59
+ normalized.provider = provider.trim();
60
+ }
61
+
62
+ for (const providerName of ["claude", "opencode", "codex"]) {
63
+ const value = providers?.[providerName];
64
+ if (value === undefined) {
65
+ continue;
66
+ }
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
68
+ throw new AppError(ERROR_MESSAGES.INVALID_CONFIG);
69
+ }
70
+ if (value.command !== undefined) {
71
+ if (typeof value.command !== "string" || !value.command.trim()) {
72
+ throw new AppError(ERROR_MESSAGES.INVALID_CONFIG);
73
+ }
74
+ normalized.providers[providerName].command = value.command.trim();
75
+ }
76
+ if (value.args !== undefined) {
77
+ if (
78
+ !Array.isArray(value.args) ||
79
+ value.args.some((arg) => typeof arg !== "string" || !arg.trim())
80
+ ) {
81
+ throw new AppError(ERROR_MESSAGES.INVALID_CONFIG);
82
+ }
83
+ normalized.providers[providerName].args = value.args.map((arg) =>
84
+ arg.trim(),
85
+ );
86
+ }
87
+ }
88
+
89
+ return normalized;
90
+ }
@@ -0,0 +1,9 @@
1
+ export class AppError extends Error {
2
+ /**
3
+ * @param {string} message
4
+ */
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "AppError";
8
+ }
9
+ }
@@ -0,0 +1,27 @@
1
+ export const ERROR_MESSAGES = {
2
+ NO_STAGED_CHANGES:
3
+ "No staged changes found. Use 'git add' to stage changes first.",
4
+ NOT_GIT_REPO:
5
+ "Not a git repository. Please run this command inside a git repository.",
6
+ PROVIDER_NOT_FOUND:
7
+ "AI provider CLI not found. Please install and configure the selected provider CLI.",
8
+ PROVIDER_AUTH_FAILURE:
9
+ "Failed to authenticate with AI provider API. Please check your API key and provider login.",
10
+ PROVIDER_NETWORK_FAILURE:
11
+ "Unable to connect to AI provider API. Please check your internet connection.",
12
+ PROVIDER_TIMEOUT:
13
+ "AI provider request timed out. Please retry or increase CCOMMIT_PROVIDER_TIMEOUT_MS.",
14
+ COMMIT_CANCELLED: "Commit cancelled.",
15
+ COMMIT_CANCELLED_NON_INTERACTIVE:
16
+ "Commit cancelled in non-interactive mode. Use --yes to create commit automatically or --show to print message only.",
17
+ COMMIT_FAILED: "Failed to create git commit with generated message.",
18
+ INVALID_GENERATED_MESSAGE:
19
+ "Generated commit message is invalid. Please retry or commit manually.",
20
+ INVALID_PROVIDER:
21
+ "Invalid provider. Supported providers: claude, opencode, codex.",
22
+ INVALID_CONFIG:
23
+ "Invalid config file at ~/.config/ccommit/config.json.",
24
+ INVALID_CONFIG_JSON:
25
+ "Failed to parse config file at ~/.config/ccommit/config.json.",
26
+ UNKNOWN_ERROR: "An unexpected error occurred while generating commit message.",
27
+ };
@@ -0,0 +1,17 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { AppError } from "../errors/app-error.js";
3
+ import { ERROR_MESSAGES } from "../errors/messages.js";
4
+
5
+ export function createCommit(message, cwd = process.cwd()) {
6
+ try {
7
+ execFileSync("git", ["commit", "-F", "-"], {
8
+ cwd,
9
+ input: `${message.trim()}\n`,
10
+ encoding: "utf8",
11
+ stdio: ["pipe", "pipe", "pipe"],
12
+ maxBuffer: 10 * 1024 * 1024,
13
+ });
14
+ } catch {
15
+ throw new AppError(ERROR_MESSAGES.COMMIT_FAILED);
16
+ }
17
+ }
@@ -0,0 +1,14 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export function isGitRepository(cwd = process.cwd()) {
4
+ try {
5
+ const output = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
6
+ cwd,
7
+ encoding: "utf8",
8
+ stdio: ["ignore", "pipe", "ignore"],
9
+ }).trim();
10
+ return output === "true";
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
@@ -0,0 +1,23 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { AppError } from "../errors/app-error.js";
3
+ import { ERROR_MESSAGES } from "../errors/messages.js";
4
+
5
+ export function getStagedDiff(cwd = process.cwd()) {
6
+ let diff = "";
7
+ try {
8
+ diff = execFileSync("git", ["diff", "--cached", "--no-ext-diff"], {
9
+ cwd,
10
+ encoding: "utf8",
11
+ maxBuffer: 10 * 1024 * 1024,
12
+ stdio: ["ignore", "pipe", "pipe"],
13
+ });
14
+ } catch {
15
+ throw new AppError(ERROR_MESSAGES.NOT_GIT_REPO);
16
+ }
17
+
18
+ if (!diff.trim()) {
19
+ throw new AppError(ERROR_MESSAGES.NO_STAGED_CHANGES);
20
+ }
21
+
22
+ return diff;
23
+ }
package/src/main.js ADDED
@@ -0,0 +1,102 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+ import { parseArgs, getHelpText } from "./cli/args.js";
5
+ import { EXIT_CODES } from "./cli/exit-codes.js";
6
+ import { isGitRepository } from "./git/repo-check.js";
7
+ import { getStagedDiff } from "./git/staged-diff.js";
8
+ import { createCommit } from "./git/create-commit.js";
9
+ import { ERROR_MESSAGES } from "./errors/messages.js";
10
+ import { AppError } from "./errors/app-error.js";
11
+ import { writeStdout, writeStderr } from "./output/writer.js";
12
+ import { askForConfirmation, isInteractiveTerminal } from "./cli/confirm.js";
13
+ import { buildPrompt } from "./ai/prompt-builder.js";
14
+ import { generateCommitMessage } from "./ai/generate.js";
15
+ import { resolveProvider } from "./ai/provider-resolution.js";
16
+ import { parseCommitMessage } from "./ai/response-parser.js";
17
+ import { isValidCommitMessage } from "./commit/validator.js";
18
+ import { normalizeCommitMessage } from "./commit/normalizer.js";
19
+ import { loadConfig } from "./config/loader.js";
20
+
21
+ function getVersion() {
22
+ const currentFile = fileURLToPath(import.meta.url);
23
+ const root = join(dirname(currentFile), "..");
24
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
25
+ return pkg.version;
26
+ }
27
+
28
+ export async function run(args = []) {
29
+ try {
30
+ const options = parseArgs(args);
31
+
32
+ if (options.help) {
33
+ writeStdout(getHelpText());
34
+ process.exitCode = EXIT_CODES.SUCCESS;
35
+ return;
36
+ }
37
+
38
+ if (options.version) {
39
+ writeStdout(`${getVersion()}\n`);
40
+ process.exitCode = EXIT_CODES.SUCCESS;
41
+ return;
42
+ }
43
+
44
+ if (!isGitRepository()) {
45
+ throw new AppError(ERROR_MESSAGES.NOT_GIT_REPO);
46
+ }
47
+
48
+ const config = loadConfig();
49
+ const provider = resolveProvider(options, config);
50
+ const diff = getStagedDiff();
51
+ const prompt = buildPrompt(diff);
52
+ const rawMessage = generateCommitMessage(prompt, options, config);
53
+ let commitMessage = parseCommitMessage(rawMessage);
54
+
55
+ if (!isValidCommitMessage(commitMessage)) {
56
+ commitMessage = normalizeCommitMessage(commitMessage);
57
+ }
58
+
59
+ if (!isValidCommitMessage(commitMessage)) {
60
+ throw new AppError(ERROR_MESSAGES.INVALID_GENERATED_MESSAGE);
61
+ }
62
+
63
+ if (options.show) {
64
+ writeStdout(
65
+ `Generated commit message (${provider}):\n\n${commitMessage.trim()}\n`,
66
+ );
67
+ process.exitCode = EXIT_CODES.SUCCESS;
68
+ return;
69
+ }
70
+
71
+ writeStdout(
72
+ `Generated commit message (${provider}):\n\n${commitMessage.trim()}\n\n`,
73
+ );
74
+ let confirmed = false;
75
+
76
+ if (options.yes) {
77
+ confirmed = true;
78
+ } else if (!isInteractiveTerminal()) {
79
+ writeStderr(`${ERROR_MESSAGES.COMMIT_CANCELLED_NON_INTERACTIVE}\n`);
80
+ process.exitCode = EXIT_CODES.SUCCESS;
81
+ return;
82
+ } else {
83
+ confirmed = await askForConfirmation("Create commit? [y/N] ");
84
+ writeStdout("\n");
85
+ }
86
+
87
+ if (!confirmed) {
88
+ writeStderr(`${ERROR_MESSAGES.COMMIT_CANCELLED}\n`);
89
+ process.exitCode = EXIT_CODES.SUCCESS;
90
+ return;
91
+ }
92
+
93
+ createCommit(commitMessage);
94
+ writeStdout("Commit created.\n");
95
+ process.exitCode = EXIT_CODES.SUCCESS;
96
+ } catch (error) {
97
+ const message =
98
+ error instanceof AppError ? error.message : ERROR_MESSAGES.UNKNOWN_ERROR;
99
+ writeStderr(`${message}\n`);
100
+ process.exitCode = EXIT_CODES.ERROR;
101
+ }
102
+ }
@@ -0,0 +1,7 @@
1
+ export function writeStdout(message) {
2
+ process.stdout.write(message);
3
+ }
4
+
5
+ export function writeStderr(message) {
6
+ process.stderr.write(message);
7
+ }