@sabinm677/ccommit 0.1.0 → 0.2.1
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 +68 -15
- package/bin/ccommit +1 -1
- package/package.json +4 -2
- package/src/ai/generate.js +19 -5
- package/src/ai/prompt-builder.js +1 -1
- package/src/ai/provider-resolution.js +30 -1
- package/src/ai/providers.js +8 -1
- package/src/ai/response-parser.js +9 -0
- package/src/cli/args.js +52 -1
- package/src/cli/compiled-entry.mjs +6 -0
- package/src/commit/normalizer.js +21 -4
- package/src/commit/validator.js +5 -1
- package/src/config/loader.js +90 -5
- package/src/errors/messages.js +1 -1
- package/src/main.js +33 -4
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# ccommit
|
|
2
2
|
|
|
3
|
-
Generate Conventional Commit messages from staged Git changes using Claude Code CLI, OpenCode CLI, or
|
|
3
|
+
Generate Conventional Commit messages from staged Git changes using Claude Code CLI, OpenCode CLI, Codex CLI, Kilo Code CLI, Qwen Code CLI, or Gemini CLI.
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
|
-
- Bun 1.0+
|
|
6
|
+
- Node 18+ or Bun 1.0+
|
|
7
7
|
- Git 2.0+
|
|
8
|
-
-
|
|
8
|
+
- One supported provider CLI configured and available on `PATH` (`claude`, `opencode`, `codex`, `kilo`, `qwen`, or `gemini`)
|
|
9
|
+
- Native support for Windows, macOS, and Linux
|
|
9
10
|
|
|
10
11
|
## Usage
|
|
11
12
|
```bash
|
|
@@ -31,6 +32,8 @@ ln -sf "$(pwd)/bin/ccommit" ~/.local/bin/ccommit
|
|
|
31
32
|
|
|
32
33
|
If needed, add `~/.local/bin` to your `PATH`.
|
|
33
34
|
|
|
35
|
+
## Behavior
|
|
36
|
+
|
|
34
37
|
Default mode:
|
|
35
38
|
- Generates a commit message from staged changes
|
|
36
39
|
- Shows the message
|
|
@@ -44,33 +47,61 @@ ccommit --show
|
|
|
44
47
|
ccommit -s
|
|
45
48
|
```
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
Override generated first line:
|
|
51
|
+
```bash
|
|
52
|
+
ccommit -m "ABC-1: Some message"
|
|
53
|
+
ccommit --message "ABC-1: Some message"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Choose provider for one run:
|
|
48
57
|
```bash
|
|
49
|
-
bin/ccommit -p opencode
|
|
50
58
|
ccommit --provider opencode
|
|
51
|
-
ccommit -p opencode
|
|
52
59
|
ccommit -p codex
|
|
53
|
-
ccommit
|
|
60
|
+
ccommit -p kilo
|
|
61
|
+
ccommit -p qwen
|
|
62
|
+
ccommit -p gemini
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Persist a default provider:
|
|
66
|
+
```bash
|
|
67
|
+
ccommit --set-provider codex
|
|
68
|
+
ccommit -sp opencode
|
|
69
|
+
ccommit --set-provider=opencode
|
|
54
70
|
```
|
|
55
71
|
|
|
56
72
|
## Options
|
|
57
73
|
- `-s`, `--show`: Generate and print message only. Do not prompt or create commit.
|
|
58
74
|
- `-y`, `--yes`: Create commit without interactive confirmation.
|
|
59
|
-
- `-
|
|
75
|
+
- `-m`, `--message <text>`: Replace the first line of the generated commit message. Remaining lines are preserved.
|
|
76
|
+
- `-p`, `--provider <claude|opencode|codex|kilo|qwen|gemini>`: Select AI provider for this run.
|
|
77
|
+
- `-sp`, `--set-provider <claude|opencode|codex|kilo|qwen|gemini>`: Persist default provider to config and exit.
|
|
60
78
|
- `-h`, `--help`: Show usage help.
|
|
61
79
|
- `-v`, `--version`: Show CLI version.
|
|
62
80
|
- `--verbose`: Print debug details to stderr, including provider failure diagnostics.
|
|
63
81
|
|
|
64
|
-
|
|
82
|
+
## Commit Format
|
|
83
|
+
ccommit enforces Conventional Commits with a required multi-line message:
|
|
84
|
+
- Header format: `<type>[optional scope][optional !]: <description>`
|
|
85
|
+
- At least one non-empty body line is required
|
|
86
|
+
- `BREAKING CHANGE:` footer is supported
|
|
87
|
+
|
|
88
|
+
Example output:
|
|
89
|
+
```text
|
|
90
|
+
feat(parser): improve prompt normalization
|
|
91
|
+
|
|
92
|
+
Improve normalization to keep body content stable across providers.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
ccommit prints the selected provider in the header (including `--show` mode):
|
|
65
96
|
```text
|
|
66
|
-
Generated commit message (provider):
|
|
67
97
|
Generated commit message (claude):
|
|
68
|
-
Generated commit message (opencode):
|
|
69
|
-
Generated commit message (codex):
|
|
70
98
|
```
|
|
71
99
|
|
|
72
100
|
## Config
|
|
73
|
-
Optional config file:
|
|
101
|
+
Optional config file locations:
|
|
102
|
+
- Windows: `%APPDATA%\\ccommit\\config.json`
|
|
103
|
+
- macOS/Linux: `$XDG_CONFIG_HOME/ccommit/config.json` or `~/.config/ccommit/config.json`
|
|
104
|
+
- Override on any OS: `CCOMMIT_CONFIG_PATH=/custom/path/config.json`
|
|
74
105
|
|
|
75
106
|
```json
|
|
76
107
|
{
|
|
@@ -78,13 +109,17 @@ Optional config file: `~/.config/ccommit/config.json`
|
|
|
78
109
|
"providers": {
|
|
79
110
|
"claude": { "command": "claude", "args": ["-p"] },
|
|
80
111
|
"opencode": { "command": "opencode", "args": ["run"] },
|
|
81
|
-
"codex": { "command": "codex", "args": ["exec"] }
|
|
112
|
+
"codex": { "command": "codex", "args": ["exec"] },
|
|
113
|
+
"kilo": { "command": "kilo", "args": ["run"] },
|
|
114
|
+
"qwen": { "command": "qwen", "args": ["run"] },
|
|
115
|
+
"gemini": { "command": "gemini", "args": ["-p"] }
|
|
82
116
|
}
|
|
83
117
|
}
|
|
84
118
|
```
|
|
85
119
|
|
|
86
120
|
Precedence for provider/command settings: `CLI > env > config > defaults`.
|
|
87
121
|
Default provider: `claude`.
|
|
122
|
+
`--set-provider` updates the config file and can run outside a Git repository.
|
|
88
123
|
|
|
89
124
|
## Exit Codes
|
|
90
125
|
- `0`: success
|
|
@@ -96,6 +131,21 @@ Run tests:
|
|
|
96
131
|
bun test
|
|
97
132
|
```
|
|
98
133
|
|
|
134
|
+
Build standalone executables:
|
|
135
|
+
```bash
|
|
136
|
+
bun run build:bin
|
|
137
|
+
# or only current platform/arch
|
|
138
|
+
bun run build:bin:current
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Build outputs are written to `dist/`:
|
|
142
|
+
- `ccommit-darwin-x64`
|
|
143
|
+
- `ccommit-darwin-arm64`
|
|
144
|
+
- `ccommit-linux-x64`
|
|
145
|
+
- `ccommit-linux-arm64`
|
|
146
|
+
- `ccommit-windows-x64.exe`
|
|
147
|
+
- `ccommit-windows-arm64.exe`
|
|
148
|
+
|
|
99
149
|
Node compatibility:
|
|
100
150
|
- `ccommit` is Bun-first, but basic CLI invocation with Node remains supported (for example `node bin/ccommit --version`).
|
|
101
151
|
|
|
@@ -106,8 +156,11 @@ For local testing without provider API calls:
|
|
|
106
156
|
- `CCOMMIT_MOCK_ERROR=NETWORK` to force network error.
|
|
107
157
|
|
|
108
158
|
Provider env overrides:
|
|
109
|
-
- `CCOMMIT_PROVIDER=claude|opencode|codex`
|
|
159
|
+
- `CCOMMIT_PROVIDER=claude|opencode|codex|kilo|qwen|gemini`
|
|
110
160
|
- `CCOMMIT_CLAUDE_CMD`, `CCOMMIT_CLAUDE_ARGS`
|
|
111
161
|
- `CCOMMIT_OPENCODE_CMD`, `CCOMMIT_OPENCODE_ARGS`
|
|
112
162
|
- `CCOMMIT_CODEX_CMD`, `CCOMMIT_CODEX_ARGS`
|
|
163
|
+
- `CCOMMIT_KILO_CMD`, `CCOMMIT_KILO_ARGS`
|
|
164
|
+
- `CCOMMIT_QWEN_CMD`, `CCOMMIT_QWEN_ARGS`
|
|
165
|
+
- `CCOMMIT_GEMINI_CMD`, `CCOMMIT_GEMINI_ARGS`
|
|
113
166
|
- `CCOMMIT_PROVIDER_TIMEOUT_MS` (optional, default `60000`)
|
package/bin/ccommit
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabinm677/ccommit",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "AI-powered Git commit message generator with Conventional Commits output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"ccommit": "./bin/ccommit"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"test": "bun test"
|
|
34
|
+
"test": "bun test",
|
|
35
|
+
"build:bin": "node scripts/build-binaries.mjs",
|
|
36
|
+
"build:bin:current": "node scripts/build-binaries.mjs --current"
|
|
35
37
|
},
|
|
36
38
|
"engines": {
|
|
37
39
|
"bun": ">=1.0.0",
|
package/src/ai/generate.js
CHANGED
|
@@ -82,6 +82,20 @@ function writeVerboseFailure(options, details) {
|
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
export function getProviderSpawnOptions(
|
|
86
|
+
timeout,
|
|
87
|
+
{ platform = process.platform } = {},
|
|
88
|
+
) {
|
|
89
|
+
const isWindows = platform === "win32";
|
|
90
|
+
return {
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
93
|
+
timeout,
|
|
94
|
+
shell: isWindows,
|
|
95
|
+
windowsHide: isWindows,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
export function generateCommitMessage(prompt, options = {}, config = {}) {
|
|
86
100
|
const provider = resolveProvider(options, config);
|
|
87
101
|
const errors = getProviderErrors();
|
|
@@ -103,11 +117,11 @@ export function generateCommitMessage(prompt, options = {}, config = {}) {
|
|
|
103
117
|
const timeout = resolveProviderTimeoutMs();
|
|
104
118
|
let result;
|
|
105
119
|
try {
|
|
106
|
-
result = spawnSync(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
timeout,
|
|
110
|
-
|
|
120
|
+
result = spawnSync(
|
|
121
|
+
command,
|
|
122
|
+
[...args, prompt],
|
|
123
|
+
getProviderSpawnOptions(timeout),
|
|
124
|
+
);
|
|
111
125
|
} catch (error) {
|
|
112
126
|
writeVerboseFailure(options, {
|
|
113
127
|
provider,
|
package/src/ai/prompt-builder.js
CHANGED
|
@@ -51,7 +51,7 @@ Rules:
|
|
|
51
51
|
- Allowed types: ${COMMIT_TYPES.join(", ")}.
|
|
52
52
|
- Format header exactly: <type>[optional scope][optional !]: <description>
|
|
53
53
|
- Description must be concise and imperative.
|
|
54
|
-
-
|
|
54
|
+
- Use a multi-line commit message with a meaningful body.
|
|
55
55
|
- Do not claim changes that are not present in the staged diff.
|
|
56
56
|
- Do not use markdown bullets in the commit body.
|
|
57
57
|
- ${typeGuidance}
|
|
@@ -33,7 +33,13 @@ export function resolveCommand(provider, config = {}) {
|
|
|
33
33
|
? { command: "claude", args: ["-p"] }
|
|
34
34
|
: provider === "opencode"
|
|
35
35
|
? { command: "opencode", args: ["run"] }
|
|
36
|
-
:
|
|
36
|
+
: provider === "codex"
|
|
37
|
+
? { command: "codex", args: ["exec"] }
|
|
38
|
+
: provider === "kilo"
|
|
39
|
+
? { command: "kilo", args: ["run"] }
|
|
40
|
+
: provider === "qwen"
|
|
41
|
+
? { command: "qwen", args: ["run"] }
|
|
42
|
+
: { command: "gemini", args: ["-p"] };
|
|
37
43
|
|
|
38
44
|
if (provider === "claude") {
|
|
39
45
|
if (process.env.CCOMMIT_CLAUDE_CMD || process.env.CCOMMIT_CLAUDE_ARGS) {
|
|
@@ -58,6 +64,29 @@ export function resolveCommand(provider, config = {}) {
|
|
|
58
64
|
args: splitArgs(process.env.CCOMMIT_CODEX_ARGS || defaults.args.join(" ")),
|
|
59
65
|
};
|
|
60
66
|
}
|
|
67
|
+
} else if (provider === "kilo") {
|
|
68
|
+
if (process.env.CCOMMIT_KILO_CMD || process.env.CCOMMIT_KILO_ARGS) {
|
|
69
|
+
return {
|
|
70
|
+
command: process.env.CCOMMIT_KILO_CMD || defaults.command,
|
|
71
|
+
args: splitArgs(process.env.CCOMMIT_KILO_ARGS || defaults.args.join(" ")),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
} else if (provider === "qwen") {
|
|
75
|
+
if (process.env.CCOMMIT_QWEN_CMD || process.env.CCOMMIT_QWEN_ARGS) {
|
|
76
|
+
return {
|
|
77
|
+
command: process.env.CCOMMIT_QWEN_CMD || defaults.command,
|
|
78
|
+
args: splitArgs(process.env.CCOMMIT_QWEN_ARGS || defaults.args.join(" ")),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
} else if (provider === "gemini") {
|
|
82
|
+
if (process.env.CCOMMIT_GEMINI_CMD || process.env.CCOMMIT_GEMINI_ARGS) {
|
|
83
|
+
return {
|
|
84
|
+
command: process.env.CCOMMIT_GEMINI_CMD || defaults.command,
|
|
85
|
+
args: splitArgs(
|
|
86
|
+
process.env.CCOMMIT_GEMINI_ARGS || defaults.args.join(" "),
|
|
87
|
+
),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
61
90
|
}
|
|
62
91
|
|
|
63
92
|
const providerConfig = config.providers?.[provider];
|
package/src/ai/providers.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
export const AI_PROVIDERS = [
|
|
1
|
+
export const AI_PROVIDERS = [
|
|
2
|
+
"claude",
|
|
3
|
+
"opencode",
|
|
4
|
+
"codex",
|
|
5
|
+
"kilo",
|
|
6
|
+
"qwen",
|
|
7
|
+
"gemini",
|
|
8
|
+
];
|
|
2
9
|
|
|
3
10
|
export function isValidProvider(provider) {
|
|
4
11
|
return AI_PROVIDERS.includes(provider);
|
|
@@ -6,9 +6,18 @@ function stripCodeFence(text) {
|
|
|
6
6
|
return text;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
function stripCommitTrailers(text) {
|
|
10
|
+
return text
|
|
11
|
+
.split("\n")
|
|
12
|
+
.filter((line) => !/^co-authored-by:\s*/i.test(line.trim()))
|
|
13
|
+
.join("\n")
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
export function parseCommitMessage(rawOutput) {
|
|
10
18
|
let text = stripCodeFence(rawOutput).trim();
|
|
11
19
|
text = text.replace(/^commit message:\s*/i, "").trim();
|
|
12
20
|
text = text.replace(/^message:\s*/i, "").trim();
|
|
21
|
+
text = stripCommitTrailers(text);
|
|
13
22
|
return text;
|
|
14
23
|
}
|
package/src/cli/args.js
CHANGED
|
@@ -10,6 +10,8 @@ export function parseArgs(args) {
|
|
|
10
10
|
version: false,
|
|
11
11
|
verbose: false,
|
|
12
12
|
provider: undefined,
|
|
13
|
+
setDefaultProvider: undefined,
|
|
14
|
+
message: undefined,
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -34,6 +36,25 @@ export function parseArgs(args) {
|
|
|
34
36
|
options.verbose = true;
|
|
35
37
|
continue;
|
|
36
38
|
}
|
|
39
|
+
if (arg === "--message" || arg === "-m") {
|
|
40
|
+
const value = args[index + 1];
|
|
41
|
+
if (!value || value.startsWith("-") || !value.trim()) {
|
|
42
|
+
throw new AppError("Missing value for --message/-m.");
|
|
43
|
+
}
|
|
44
|
+
options.message = value.trim();
|
|
45
|
+
index += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (arg.startsWith("--message=") || arg.startsWith("-m=")) {
|
|
49
|
+
const value = arg.startsWith("--message=")
|
|
50
|
+
? arg.slice("--message=".length)
|
|
51
|
+
: arg.slice("-m=".length);
|
|
52
|
+
if (!value.trim()) {
|
|
53
|
+
throw new AppError("Missing value for --message/-m.");
|
|
54
|
+
}
|
|
55
|
+
options.message = value.trim();
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
37
58
|
if (arg === "--provider" || arg === "-p") {
|
|
38
59
|
const value = args[index + 1];
|
|
39
60
|
if (!value || value.startsWith("-")) {
|
|
@@ -56,9 +77,37 @@ export function parseArgs(args) {
|
|
|
56
77
|
options.provider = value;
|
|
57
78
|
continue;
|
|
58
79
|
}
|
|
80
|
+
if (arg === "--set-provider" || arg === "-sp") {
|
|
81
|
+
const value = args[index + 1];
|
|
82
|
+
if (!value || value.startsWith("-")) {
|
|
83
|
+
throw new AppError("Missing value for --set-provider/-sp.");
|
|
84
|
+
}
|
|
85
|
+
if (!isValidProvider(value)) {
|
|
86
|
+
throw new AppError(ERROR_MESSAGES.INVALID_PROVIDER);
|
|
87
|
+
}
|
|
88
|
+
options.setDefaultProvider = value;
|
|
89
|
+
index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (arg.startsWith("--set-provider=") || arg.startsWith("-sp=")) {
|
|
93
|
+
const value = arg.startsWith("--set-provider=")
|
|
94
|
+
? arg.slice("--set-provider=".length)
|
|
95
|
+
: arg.slice("-sp=".length);
|
|
96
|
+
if (!isValidProvider(value)) {
|
|
97
|
+
throw new AppError(ERROR_MESSAGES.INVALID_PROVIDER);
|
|
98
|
+
}
|
|
99
|
+
options.setDefaultProvider = value;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
59
102
|
throw new AppError(`Unknown option: ${arg}`);
|
|
60
103
|
}
|
|
61
104
|
|
|
105
|
+
if (options.provider && options.setDefaultProvider) {
|
|
106
|
+
throw new AppError(
|
|
107
|
+
"Cannot use --provider/-p together with --set-provider/-sp.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
62
111
|
return options;
|
|
63
112
|
}
|
|
64
113
|
|
|
@@ -70,7 +119,9 @@ Generate a Conventional Commit message from staged Git changes.
|
|
|
70
119
|
Options:
|
|
71
120
|
-s, --show Show generated message only (do not commit)
|
|
72
121
|
-y, --yes Create commit without interactive confirmation
|
|
73
|
-
-
|
|
122
|
+
-m, --message Override first line of generated message
|
|
123
|
+
-p, --provider AI provider: claude | opencode | codex | kilo | qwen | gemini
|
|
124
|
+
-sp, --set-provider Persist default provider: claude | opencode | codex | kilo | qwen | gemini
|
|
74
125
|
-h, --help Show help
|
|
75
126
|
-v, --version Show version
|
|
76
127
|
--verbose Print debug information to stderr
|
package/src/commit/normalizer.js
CHANGED
|
@@ -12,10 +12,23 @@ function sanitizeDescription(raw) {
|
|
|
12
12
|
return cleaned.charAt(0).toLowerCase() + cleaned.slice(1);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function toBodyLine(description) {
|
|
16
|
+
const sentence = sanitizeDescription(description);
|
|
17
|
+
return `${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}.`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function withBody(header, rest) {
|
|
21
|
+
if (rest.some((line) => line.trim().length > 0)) {
|
|
22
|
+
return [header, ...rest].join("\n").trim();
|
|
23
|
+
}
|
|
24
|
+
const description = header.split(":").slice(1).join(":").trim();
|
|
25
|
+
return `${header}\n\n${toBodyLine(description)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
export function normalizeCommitMessage(message) {
|
|
16
29
|
const normalized = message.trim();
|
|
17
30
|
if (!normalized) {
|
|
18
|
-
return "chore: update project files";
|
|
31
|
+
return "chore: update project files\n\nUpdate project files.";
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
const [header, ...rest] = normalized.split("\n");
|
|
@@ -25,12 +38,16 @@ export function normalizeCommitMessage(message) {
|
|
|
25
38
|
);
|
|
26
39
|
|
|
27
40
|
if (matchedType) {
|
|
28
|
-
return
|
|
41
|
+
return withBody(cleanedHeader, rest);
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
if (/^[a-z]+(\([^)]+\))?!?:/i.test(cleanedHeader)) {
|
|
32
|
-
|
|
45
|
+
const normalizedHeader = `chore: ${sanitizeDescription(
|
|
46
|
+
cleanedHeader.split(":").slice(1).join(":"),
|
|
47
|
+
)}`;
|
|
48
|
+
return withBody(normalizedHeader, rest);
|
|
33
49
|
}
|
|
34
50
|
|
|
35
|
-
|
|
51
|
+
const normalizedHeader = `chore: ${sanitizeDescription(cleanedHeader)}`;
|
|
52
|
+
return withBody(normalizedHeader, rest);
|
|
36
53
|
}
|
package/src/commit/validator.js
CHANGED
|
@@ -12,11 +12,15 @@ export function isValidCommitMessage(message) {
|
|
|
12
12
|
return false;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const [header] = normalized.split("\n");
|
|
15
|
+
const [header, ...rest] = normalized.split("\n");
|
|
16
16
|
if (!HEADER_REGEX.test(header.trim())) {
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
if (!rest.some((line) => line.trim().length > 0)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
return true;
|
|
21
25
|
}
|
|
22
26
|
|
package/src/config/loader.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { AppError } from "../errors/app-error.js";
|
|
5
5
|
import { ERROR_MESSAGES } from "../errors/messages.js";
|
|
6
6
|
import { isValidProvider } from "../ai/providers.js";
|
|
@@ -11,15 +11,69 @@ const defaultConfig = Object.freeze({
|
|
|
11
11
|
claude: { command: undefined, args: undefined },
|
|
12
12
|
opencode: { command: undefined, args: undefined },
|
|
13
13
|
codex: { command: undefined, args: undefined },
|
|
14
|
+
kilo: { command: undefined, args: undefined },
|
|
15
|
+
qwen: { command: undefined, args: undefined },
|
|
16
|
+
gemini: { command: undefined, args: undefined },
|
|
14
17
|
},
|
|
15
18
|
});
|
|
16
19
|
|
|
20
|
+
export function getLegacyConfigPath(homeDir = homedir()) {
|
|
21
|
+
return join(homeDir, ".config", "ccommit", "config.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getPrimaryConfigPath({
|
|
25
|
+
env = process.env,
|
|
26
|
+
platform = process.platform,
|
|
27
|
+
homeDir = homedir(),
|
|
28
|
+
cwd = process.cwd(),
|
|
29
|
+
} = {}) {
|
|
30
|
+
const overridePath = env.CCOMMIT_CONFIG_PATH?.trim();
|
|
31
|
+
if (overridePath) {
|
|
32
|
+
return resolve(cwd, overridePath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (platform === "win32") {
|
|
36
|
+
const appData = env.APPDATA?.trim();
|
|
37
|
+
if (appData) {
|
|
38
|
+
return join(appData, "ccommit", "config.json");
|
|
39
|
+
}
|
|
40
|
+
return getLegacyConfigPath(homeDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const xdgConfigHome = env.XDG_CONFIG_HOME?.trim();
|
|
44
|
+
if (xdgConfigHome) {
|
|
45
|
+
return join(xdgConfigHome, "ccommit", "config.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return getLegacyConfigPath(homeDir);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getReadableConfigPath({
|
|
52
|
+
exists = existsSync,
|
|
53
|
+
env = process.env,
|
|
54
|
+
platform = process.platform,
|
|
55
|
+
homeDir = homedir(),
|
|
56
|
+
cwd = process.cwd(),
|
|
57
|
+
} = {}) {
|
|
58
|
+
const primaryPath = getPrimaryConfigPath({ env, platform, homeDir, cwd });
|
|
59
|
+
if (exists(primaryPath)) {
|
|
60
|
+
return primaryPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const legacyPath = getLegacyConfigPath(homeDir);
|
|
64
|
+
if (legacyPath !== primaryPath && exists(legacyPath)) {
|
|
65
|
+
return legacyPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return primaryPath;
|
|
69
|
+
}
|
|
70
|
+
|
|
17
71
|
export function getConfigPath() {
|
|
18
|
-
return
|
|
72
|
+
return getPrimaryConfigPath();
|
|
19
73
|
}
|
|
20
74
|
|
|
21
75
|
export function loadConfig() {
|
|
22
|
-
const configPath =
|
|
76
|
+
const configPath = getReadableConfigPath();
|
|
23
77
|
if (!existsSync(configPath)) {
|
|
24
78
|
return structuredClone(defaultConfig);
|
|
25
79
|
}
|
|
@@ -34,6 +88,30 @@ export function loadConfig() {
|
|
|
34
88
|
return normalizeConfig(raw);
|
|
35
89
|
}
|
|
36
90
|
|
|
91
|
+
export function setDefaultProvider(provider) {
|
|
92
|
+
const normalizedProvider = provider?.trim();
|
|
93
|
+
if (!normalizedProvider || !isValidProvider(normalizedProvider)) {
|
|
94
|
+
throw new AppError(ERROR_MESSAGES.INVALID_PROVIDER);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const configPath = getConfigPath();
|
|
98
|
+
const readablePath = getReadableConfigPath();
|
|
99
|
+
let raw = {};
|
|
100
|
+
|
|
101
|
+
if (existsSync(readablePath)) {
|
|
102
|
+
try {
|
|
103
|
+
raw = JSON.parse(readFileSync(readablePath, "utf8"));
|
|
104
|
+
} catch {
|
|
105
|
+
throw new AppError(ERROR_MESSAGES.INVALID_CONFIG_JSON);
|
|
106
|
+
}
|
|
107
|
+
normalizeConfig(raw);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
raw.provider = normalizedProvider;
|
|
111
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
112
|
+
writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
|
|
113
|
+
}
|
|
114
|
+
|
|
37
115
|
function normalizeConfig(raw) {
|
|
38
116
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
39
117
|
throw new AppError(ERROR_MESSAGES.INVALID_CONFIG);
|
|
@@ -59,7 +137,14 @@ function normalizeConfig(raw) {
|
|
|
59
137
|
normalized.provider = provider.trim();
|
|
60
138
|
}
|
|
61
139
|
|
|
62
|
-
for (const providerName of [
|
|
140
|
+
for (const providerName of [
|
|
141
|
+
"claude",
|
|
142
|
+
"opencode",
|
|
143
|
+
"codex",
|
|
144
|
+
"kilo",
|
|
145
|
+
"qwen",
|
|
146
|
+
"gemini",
|
|
147
|
+
]) {
|
|
63
148
|
const value = providers?.[providerName];
|
|
64
149
|
if (value === undefined) {
|
|
65
150
|
continue;
|
package/src/errors/messages.js
CHANGED
|
@@ -18,7 +18,7 @@ export const ERROR_MESSAGES = {
|
|
|
18
18
|
INVALID_GENERATED_MESSAGE:
|
|
19
19
|
"Generated commit message is invalid. Please retry or commit manually.",
|
|
20
20
|
INVALID_PROVIDER:
|
|
21
|
-
"Invalid provider. Supported providers: claude, opencode, codex.",
|
|
21
|
+
"Invalid provider. Supported providers: claude, opencode, codex, kilo, qwen, gemini.",
|
|
22
22
|
INVALID_CONFIG:
|
|
23
23
|
"Invalid config file at ~/.config/ccommit/config.json.",
|
|
24
24
|
INVALID_CONFIG_JSON:
|
package/src/main.js
CHANGED
|
@@ -16,16 +16,32 @@ import { resolveProvider } from "./ai/provider-resolution.js";
|
|
|
16
16
|
import { parseCommitMessage } from "./ai/response-parser.js";
|
|
17
17
|
import { isValidCommitMessage } from "./commit/validator.js";
|
|
18
18
|
import { normalizeCommitMessage } from "./commit/normalizer.js";
|
|
19
|
-
import { loadConfig } from "./config/loader.js";
|
|
19
|
+
import { getConfigPath, loadConfig, setDefaultProvider } from "./config/loader.js";
|
|
20
|
+
|
|
21
|
+
export function resolveVersion(runtime = {}) {
|
|
22
|
+
if (runtime.version) {
|
|
23
|
+
return runtime.version;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (process.env.CCOMMIT_VERSION) {
|
|
27
|
+
return process.env.CCOMMIT_VERSION;
|
|
28
|
+
}
|
|
20
29
|
|
|
21
|
-
function getVersion() {
|
|
22
30
|
const currentFile = fileURLToPath(import.meta.url);
|
|
23
31
|
const root = join(dirname(currentFile), "..");
|
|
24
32
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
25
33
|
return pkg.version;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
|
|
36
|
+
function replaceHeader(message, header) {
|
|
37
|
+
const [_, ...rest] = message.split("\n");
|
|
38
|
+
if (rest.length === 0) {
|
|
39
|
+
return header;
|
|
40
|
+
}
|
|
41
|
+
return [header, ...rest].join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function run(args = [], runtime = {}) {
|
|
29
45
|
try {
|
|
30
46
|
const options = parseArgs(args);
|
|
31
47
|
|
|
@@ -36,7 +52,16 @@ export async function run(args = []) {
|
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
if (options.version) {
|
|
39
|
-
writeStdout(`${
|
|
55
|
+
writeStdout(`${resolveVersion(runtime)}\n`);
|
|
56
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (options.setDefaultProvider) {
|
|
61
|
+
setDefaultProvider(options.setDefaultProvider);
|
|
62
|
+
writeStdout(
|
|
63
|
+
`Default provider saved to ${getConfigPath()} as ${options.setDefaultProvider}.\n`,
|
|
64
|
+
);
|
|
40
65
|
process.exitCode = EXIT_CODES.SUCCESS;
|
|
41
66
|
return;
|
|
42
67
|
}
|
|
@@ -60,6 +85,10 @@ export async function run(args = []) {
|
|
|
60
85
|
throw new AppError(ERROR_MESSAGES.INVALID_GENERATED_MESSAGE);
|
|
61
86
|
}
|
|
62
87
|
|
|
88
|
+
if (options.message) {
|
|
89
|
+
commitMessage = replaceHeader(commitMessage, options.message);
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
if (options.show) {
|
|
64
93
|
writeStdout(
|
|
65
94
|
`Generated commit message (${provider}):\n\n${commitMessage.trim()}\n`,
|