@yourfam/yf-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/LICENSE +21 -0
- package/README.md +99 -0
- package/bin/yf-commit.js +5 -0
- package/package.json +44 -0
- package/src/args.js +107 -0
- package/src/config.js +100 -0
- package/src/errors.js +11 -0
- package/src/git.js +80 -0
- package/src/index.js +131 -0
- package/src/init.js +126 -0
- package/src/llm.js +77 -0
- package/src/prompt.js +61 -0
- package/src/providers.js +81 -0
- package/src/types.js +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YourFam
|
|
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,99 @@
|
|
|
1
|
+
# yf-commit
|
|
2
|
+
|
|
3
|
+
AI commit message from your staged git diff.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx @yourfam/yf-commit
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Writes a conventional first line (`emoji type(scope): …`) plus WHY, WHAT CHANGED, and FILES IMPACTED. It does **not** run your test suite and does **not** append worktree / branch / machine trailers.
|
|
10
|
+
|
|
11
|
+
YourFam does not give you an API key. Use your own OpenAI-compatible key.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Node 20+. Friend one-liner:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @yourfam/yf-commit
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install once:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g @yourfam/yf-commit
|
|
25
|
+
yf-commit
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The command is `yf-commit` (not `ai-commit` — that name is already used by other packages).
|
|
29
|
+
|
|
30
|
+
## First run / `yf-commit init`
|
|
31
|
+
|
|
32
|
+
The first `yf-commit` with no key **is** setup (same idea as `gh auth login`).
|
|
33
|
+
|
|
34
|
+
1. Pick a provider (numbered list): OpenAI, DeepSeek, xAI (Grok), Groq, Ollama (local).
|
|
35
|
+
2. Pick a model. First item is always **Auto (recommended)** — a cheap, fast Chat Completions model for summarizing a diff. Named picks stay pinned; Auto tracks our lightweight default.
|
|
36
|
+
3. Paste an API key (hidden). Skipped for Ollama.
|
|
37
|
+
|
|
38
|
+
Saved to `~/.yf-commit/config.json` (Windows: `%USERPROFILE%\.yf-commit\config.json`). Mode `0600` where the OS allows. This file lives in your home directory; never commit it.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
yf-commit init # wizard
|
|
42
|
+
yf-commit init --show # provider, model, base URL, masked key
|
|
43
|
+
yf-commit init --reset # delete saved config
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Primary env var: **`YF_COMMIT_API_KEY`**. You can also:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export YF_COMMIT_API_KEY=...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`OPENAI_API_KEY` is also accepted if the others are unset.
|
|
53
|
+
|
|
54
|
+
Need a host we don't list? Set `YF_COMMIT_BASE_URL` and `YF_COMMIT_MODEL`.
|
|
55
|
+
|
|
56
|
+
| Env | Config key | Default |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `YF_COMMIT_API_KEY` | `apiKey` | — |
|
|
59
|
+
| `YF_COMMIT_BASE_URL` | `baseUrl` | `https://api.openai.com/v1` |
|
|
60
|
+
| `YF_COMMIT_MODEL` | `model` | `auto` → lightweight model for that provider |
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
Stage files, then:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
yf-commit # print draft in the terminal → confirm → git commit
|
|
68
|
+
yf-commit -y # no confirm (also --yes, --auto)
|
|
69
|
+
yf-commit --print # print only; do not commit
|
|
70
|
+
yf-commit --all # git add -A, then same as default
|
|
71
|
+
yf-commit --type fix # force type
|
|
72
|
+
yf-commit --type 4 # same (1–6: chore, docs, feat, fix, refactor, test)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Default: the draft is printed in the terminal. Press Enter or `y` to commit, `n` or Ctrl+C to cancel. It does not open an editor.
|
|
76
|
+
|
|
77
|
+
If nothing is staged: one-line error, exit non-zero. It will not `git add -A` unless you pass `--all`.
|
|
78
|
+
|
|
79
|
+
Non-interactive (CI / scripts): pass `--print` or `-y`. It will not hang waiting for a paste or a confirm.
|
|
80
|
+
|
|
81
|
+
## Message format
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
✨ feat(cli): add --print to skip git commit
|
|
85
|
+
|
|
86
|
+
💡 WHY:
|
|
87
|
+
- Friends can preview a message without committing
|
|
88
|
+
|
|
89
|
+
🔧 WHAT CHANGED:
|
|
90
|
+
- Added a --print flag that writes the draft to stdout and exits
|
|
91
|
+
|
|
92
|
+
📁 FILES IMPACTED:
|
|
93
|
+
- src/index.js
|
|
94
|
+
- README.md
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
package/bin/yf-commit.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yourfam/yf-commit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI commit message from your staged git diff",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"yf-commit": "./bin/yf-commit.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/*.test.js",
|
|
20
|
+
"yf-commit": "node ./bin/yf-commit.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"git",
|
|
24
|
+
"commit",
|
|
25
|
+
"conventional-commits",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"author": "YourFam",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/YourFam/yf-commit.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/YourFam/yf-commit/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/YourFam/yf-commit#readme",
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@clack/prompts": "^0.11.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { parseType } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} ParsedArgs
|
|
6
|
+
* @property {"commit" | "init"} command
|
|
7
|
+
* @property {boolean} auto
|
|
8
|
+
* @property {boolean} all
|
|
9
|
+
* @property {boolean} print
|
|
10
|
+
* @property {string | null} type
|
|
11
|
+
* @property {boolean} show
|
|
12
|
+
* @property {boolean} reset
|
|
13
|
+
* @property {boolean} help
|
|
14
|
+
* @property {boolean} version
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string[]} argv process.argv
|
|
19
|
+
* @returns {ParsedArgs}
|
|
20
|
+
*/
|
|
21
|
+
export function parseArgs(argv) {
|
|
22
|
+
const args = argv.slice(2);
|
|
23
|
+
/** @type {ParsedArgs} */
|
|
24
|
+
const result = {
|
|
25
|
+
command: "commit",
|
|
26
|
+
auto: false,
|
|
27
|
+
all: false,
|
|
28
|
+
print: false,
|
|
29
|
+
type: null,
|
|
30
|
+
show: false,
|
|
31
|
+
reset: false,
|
|
32
|
+
help: false,
|
|
33
|
+
version: false,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let i = 0;
|
|
37
|
+
if (args[0] === "init") {
|
|
38
|
+
result.command = "init";
|
|
39
|
+
i = 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
while (i < args.length) {
|
|
43
|
+
const a = args[i];
|
|
44
|
+
if (a === "--auto" || a === "--yes" || a === "-y") {
|
|
45
|
+
result.auto = true;
|
|
46
|
+
} else if (a === "--all") {
|
|
47
|
+
result.all = true;
|
|
48
|
+
} else if (a === "--print") {
|
|
49
|
+
result.print = true;
|
|
50
|
+
} else if (a === "--show") {
|
|
51
|
+
result.show = true;
|
|
52
|
+
} else if (a === "--reset") {
|
|
53
|
+
result.reset = true;
|
|
54
|
+
} else if (a === "--help" || a === "-h") {
|
|
55
|
+
result.help = true;
|
|
56
|
+
} else if (a === "--version" || a === "-V") {
|
|
57
|
+
result.version = true;
|
|
58
|
+
} else if (a === "--type") {
|
|
59
|
+
const value = args[i + 1];
|
|
60
|
+
if (value == null || value.startsWith("-")) {
|
|
61
|
+
throw new CliError("Flag --type requires a value: feat|fix|… or 1–6.");
|
|
62
|
+
}
|
|
63
|
+
result.type = parseType(value);
|
|
64
|
+
i += 1;
|
|
65
|
+
} else if (a.startsWith("--type=")) {
|
|
66
|
+
result.type = parseType(a.slice("--type=".length));
|
|
67
|
+
} else if (a.startsWith("-")) {
|
|
68
|
+
throw new CliError(
|
|
69
|
+
`Unknown flag: ${a}\nSee yf-commit --help`,
|
|
70
|
+
);
|
|
71
|
+
} else {
|
|
72
|
+
throw new CliError(
|
|
73
|
+
`Unknown argument: ${a}\nNot a command. Use yf-commit, yf-commit init, or flags like -y / --auto.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (result.command === "commit") {
|
|
80
|
+
if (result.show) {
|
|
81
|
+
throw new CliError("Use yf-commit init --show");
|
|
82
|
+
}
|
|
83
|
+
if (result.reset) {
|
|
84
|
+
throw new CliError("Use yf-commit init --reset");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function helpText() {
|
|
92
|
+
return `yf-commit — AI commit message from your staged git diff
|
|
93
|
+
|
|
94
|
+
Usage:
|
|
95
|
+
yf-commit Generate, print draft, confirm, then git commit
|
|
96
|
+
yf-commit -y Commit immediately (also --yes, --auto)
|
|
97
|
+
yf-commit --print Print message only; do not commit
|
|
98
|
+
yf-commit --all git add -A, then same as default
|
|
99
|
+
yf-commit --type fix Force type (name or 1–6)
|
|
100
|
+
yf-commit init Provider, model, API key → ~/.yf-commit/config.json
|
|
101
|
+
yf-commit init --show Masked status
|
|
102
|
+
yf-commit init --reset Delete saved config
|
|
103
|
+
|
|
104
|
+
This does not run your test suite. YourFam does not give you an API key.
|
|
105
|
+
|
|
106
|
+
Need a host we don't list? Set YF_COMMIT_BASE_URL and YF_COMMIT_MODEL.`;
|
|
107
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_PROVIDER_ID,
|
|
6
|
+
isOllama,
|
|
7
|
+
providerById,
|
|
8
|
+
resolveAutoModel,
|
|
9
|
+
} from "./providers.js";
|
|
10
|
+
|
|
11
|
+
export function configDir() {
|
|
12
|
+
return path.join(homedir(), ".yf-commit");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function configPath() {
|
|
16
|
+
return path.join(configDir(), "config.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @returns {Record<string, string> | null}
|
|
21
|
+
*/
|
|
22
|
+
export function loadConfig() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = readFileSync(configPath(), "utf8");
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
27
|
+
return parsed;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} data
|
|
35
|
+
*/
|
|
36
|
+
export function saveConfig(data) {
|
|
37
|
+
const dir = configDir();
|
|
38
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
39
|
+
const payload = { ...data };
|
|
40
|
+
if (!payload.apiKey) delete payload.apiKey;
|
|
41
|
+
writeFileSync(configPath(), `${JSON.stringify(payload, null, 2)}\n`, {
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
mode: 0o600,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function deleteConfig() {
|
|
48
|
+
try {
|
|
49
|
+
unlinkSync(configPath());
|
|
50
|
+
return true;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err && err.code === "ENOENT") return false;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} key
|
|
59
|
+
*/
|
|
60
|
+
export function maskKey(key) {
|
|
61
|
+
if (!key) return "(none)";
|
|
62
|
+
const last = key.slice(-4);
|
|
63
|
+
if (key.startsWith("sk-")) return `sk-…${last}`;
|
|
64
|
+
if (key.length <= 4) return "…";
|
|
65
|
+
return `…${last}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {Record<string, string> | null} fileConfig
|
|
70
|
+
*/
|
|
71
|
+
export function resolveSettings(fileConfig = null) {
|
|
72
|
+
const file = fileConfig && typeof fileConfig === "object" ? fileConfig : {};
|
|
73
|
+
const provider = file.provider || DEFAULT_PROVIDER_ID;
|
|
74
|
+
const fallback = providerById(provider) || providerById(DEFAULT_PROVIDER_ID);
|
|
75
|
+
const baseUrl =
|
|
76
|
+
process.env.YF_COMMIT_BASE_URL || file.baseUrl || fallback.baseUrl;
|
|
77
|
+
const model = process.env.YF_COMMIT_MODEL || file.model || "auto";
|
|
78
|
+
const resolvedModel =
|
|
79
|
+
model === "auto" ? resolveAutoModel(provider, baseUrl) : model;
|
|
80
|
+
const apiKey =
|
|
81
|
+
process.env.YF_COMMIT_API_KEY ||
|
|
82
|
+
file.apiKey ||
|
|
83
|
+
process.env.OPENAI_API_KEY ||
|
|
84
|
+
"";
|
|
85
|
+
return {
|
|
86
|
+
provider,
|
|
87
|
+
baseUrl,
|
|
88
|
+
model,
|
|
89
|
+
resolvedModel,
|
|
90
|
+
apiKey,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {ReturnType<typeof resolveSettings>} settings
|
|
96
|
+
*/
|
|
97
|
+
export function hasUsableKey(settings) {
|
|
98
|
+
if (isOllama(settings.provider, settings.baseUrl)) return true;
|
|
99
|
+
return Boolean(settings.apiKey);
|
|
100
|
+
}
|
package/src/errors.js
ADDED
package/src/git.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} cwd
|
|
6
|
+
* @param {string[]} args
|
|
7
|
+
* @param {{ input?: string }} [opts]
|
|
8
|
+
*/
|
|
9
|
+
function git(cwd, args, opts = {}) {
|
|
10
|
+
const result = spawnSync("git", args, {
|
|
11
|
+
cwd,
|
|
12
|
+
encoding: "utf8",
|
|
13
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
14
|
+
input: opts.input,
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
});
|
|
17
|
+
if (result.error) {
|
|
18
|
+
if (result.error.code === "ENOENT") {
|
|
19
|
+
throw new CliError("git was not found on PATH.");
|
|
20
|
+
}
|
|
21
|
+
throw new CliError(result.error.message);
|
|
22
|
+
}
|
|
23
|
+
if (result.status !== 0) {
|
|
24
|
+
const err = (result.stderr || result.stdout || "").trim();
|
|
25
|
+
throw new CliError(err || `git ${args.join(" ")} failed`);
|
|
26
|
+
}
|
|
27
|
+
return result.stdout ?? "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} cwd
|
|
32
|
+
*/
|
|
33
|
+
export function ensureGitRepo(cwd) {
|
|
34
|
+
try {
|
|
35
|
+
const out = git(cwd, ["rev-parse", "--is-inside-work-tree"]).trim();
|
|
36
|
+
if (out !== "true") {
|
|
37
|
+
throw new CliError("Not a git work tree. Run this from a git repository.");
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err instanceof CliError) {
|
|
41
|
+
if (/not a git repository/i.test(err.message)) {
|
|
42
|
+
throw new CliError("Not a git work tree. Run this from a git repository.");
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
throw new CliError("Not a git work tree. Run this from a git repository.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} cwd
|
|
52
|
+
*/
|
|
53
|
+
export function gitAddAll(cwd) {
|
|
54
|
+
git(cwd, ["add", "-A"]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} cwd
|
|
59
|
+
*/
|
|
60
|
+
export function getStagedDiff(cwd) {
|
|
61
|
+
return git(cwd, ["diff", "--cached"]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} diff
|
|
66
|
+
*/
|
|
67
|
+
export function requireStagedDiff(diff) {
|
|
68
|
+
if (!diff || !String(diff).trim()) {
|
|
69
|
+
throw new CliError("Nothing staged. Stage files first, or pass --all.");
|
|
70
|
+
}
|
|
71
|
+
return diff;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} cwd
|
|
76
|
+
* @param {string} message
|
|
77
|
+
*/
|
|
78
|
+
export function commitWithMessage(cwd, message) {
|
|
79
|
+
git(cwd, ["commit", "-F", "-"], { input: message });
|
|
80
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { helpText, parseArgs } from "./args.js";
|
|
4
|
+
import {
|
|
5
|
+
hasUsableKey,
|
|
6
|
+
loadConfig,
|
|
7
|
+
resolveSettings,
|
|
8
|
+
} from "./config.js";
|
|
9
|
+
import { CliError } from "./errors.js";
|
|
10
|
+
import {
|
|
11
|
+
commitWithMessage,
|
|
12
|
+
ensureGitRepo,
|
|
13
|
+
getStagedDiff,
|
|
14
|
+
gitAddAll,
|
|
15
|
+
requireStagedDiff,
|
|
16
|
+
} from "./git.js";
|
|
17
|
+
import {
|
|
18
|
+
isTTY,
|
|
19
|
+
offerInitNow,
|
|
20
|
+
printSetupBlock,
|
|
21
|
+
runInitCommand,
|
|
22
|
+
} from "./init.js";
|
|
23
|
+
import { completeChat } from "./llm.js";
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
|
|
28
|
+
|
|
29
|
+
const pkg = JSON.parse(
|
|
30
|
+
readFileSync(
|
|
31
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
|
|
32
|
+
"utf8",
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export async function main(argv) {
|
|
37
|
+
const args = parseArgs(argv);
|
|
38
|
+
|
|
39
|
+
if (args.help) {
|
|
40
|
+
console.log(helpText());
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
if (args.version) {
|
|
44
|
+
console.log(pkg.version);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (args.command === "init") {
|
|
49
|
+
return runInitCommand(args);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cwd = process.cwd();
|
|
53
|
+
ensureGitRepo(cwd);
|
|
54
|
+
|
|
55
|
+
if (args.all) gitAddAll(cwd);
|
|
56
|
+
|
|
57
|
+
const diff = requireStagedDiff(getStagedDiff(cwd));
|
|
58
|
+
|
|
59
|
+
let settings = resolveSettings(loadConfig());
|
|
60
|
+
if (!hasUsableKey(settings)) {
|
|
61
|
+
printSetupBlock();
|
|
62
|
+
if (!isTTY()) {
|
|
63
|
+
throw new CliError(
|
|
64
|
+
"Non-interactive stdin: set YF_COMMIT_API_KEY or run yf-commit init in a terminal.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const ok = await offerInitNow();
|
|
68
|
+
if (!ok) {
|
|
69
|
+
throw new CliError("No API key. Run yf-commit init or export YF_COMMIT_API_KEY.");
|
|
70
|
+
}
|
|
71
|
+
settings = resolveSettings(loadConfig());
|
|
72
|
+
if (!hasUsableKey(settings)) {
|
|
73
|
+
throw new CliError("No API key. Run yf-commit init or export YF_COMMIT_API_KEY.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const message = await completeChat({
|
|
78
|
+
baseUrl: settings.baseUrl,
|
|
79
|
+
model: settings.resolvedModel,
|
|
80
|
+
apiKey: settings.apiKey || "ollama",
|
|
81
|
+
systemPrompt: buildSystemPrompt({ forcedType: args.type }),
|
|
82
|
+
userPrompt: buildUserPrompt(diff),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (args.print) {
|
|
86
|
+
console.log(message);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(message);
|
|
91
|
+
|
|
92
|
+
if (!args.auto) {
|
|
93
|
+
if (!isTTY()) {
|
|
94
|
+
throw new CliError(
|
|
95
|
+
"Non-interactive stdin: pass --auto / -y to commit, or --print to only print.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const yes = await confirmCommit();
|
|
99
|
+
if (!yes) {
|
|
100
|
+
throw new CliError("Cancelled.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
commitWithMessage(cwd, message);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function confirmCommit() {
|
|
109
|
+
const rl = createInterface({ input, output });
|
|
110
|
+
try {
|
|
111
|
+
const answer = await rl.question(
|
|
112
|
+
"\nCommit this message? Press Enter or y to commit, n to cancel: ",
|
|
113
|
+
);
|
|
114
|
+
const t = answer.trim().toLowerCase();
|
|
115
|
+
return t === "" || t === "y" || t === "yes";
|
|
116
|
+
} finally {
|
|
117
|
+
rl.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function run(argv) {
|
|
122
|
+
main(argv)
|
|
123
|
+
.then((code) => {
|
|
124
|
+
process.exit(typeof code === "number" ? code : 0);
|
|
125
|
+
})
|
|
126
|
+
.catch((err) => {
|
|
127
|
+
const message = err instanceof CliError ? err.message : err.message || String(err);
|
|
128
|
+
console.error(message);
|
|
129
|
+
process.exit(err instanceof CliError ? err.exitCode : 1);
|
|
130
|
+
});
|
|
131
|
+
}
|
package/src/init.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import {
|
|
4
|
+
configPath,
|
|
5
|
+
deleteConfig,
|
|
6
|
+
loadConfig,
|
|
7
|
+
maskKey,
|
|
8
|
+
saveConfig,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import { PROVIDERS, providerById } from "./providers.js";
|
|
11
|
+
|
|
12
|
+
export function printSetupBlock() {
|
|
13
|
+
console.error(`This tool needs an OpenAI-compatible API key (OpenAI, DeepSeek, xAI, Groq, Ollama).
|
|
14
|
+
Create a key at the provider; YourFam does not issue keys.
|
|
15
|
+
|
|
16
|
+
Then either:
|
|
17
|
+
yf-commit init
|
|
18
|
+
export YF_COMMIT_API_KEY=...
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isTTY() {
|
|
23
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runInitCommand({ show, reset }) {
|
|
27
|
+
if (reset) {
|
|
28
|
+
const removed = deleteConfig();
|
|
29
|
+
if (removed) console.error(`Deleted ${configPath()}`);
|
|
30
|
+
else console.error("No config file to delete.");
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
if (show) {
|
|
34
|
+
showStatus();
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
if (!isTTY()) {
|
|
38
|
+
printSetupBlock();
|
|
39
|
+
throw new CliError("Non-interactive stdin: set YF_COMMIT_API_KEY or run yf-commit init in a terminal.");
|
|
40
|
+
}
|
|
41
|
+
await runWizard();
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function showStatus() {
|
|
46
|
+
const file = loadConfig();
|
|
47
|
+
if (!file) {
|
|
48
|
+
console.error("No config at " + configPath());
|
|
49
|
+
console.error("Run yf-commit init or set YF_COMMIT_API_KEY.");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
console.error(`provider: ${file.provider || "(none)"}`);
|
|
53
|
+
console.error(`model: ${file.model || "(none)"}`);
|
|
54
|
+
console.error(`baseUrl: ${file.baseUrl || "(none)"}`);
|
|
55
|
+
console.error(`apiKey: ${maskKey(file.apiKey || "")}`);
|
|
56
|
+
console.error(`file: ${configPath()}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function offerInitNow() {
|
|
60
|
+
const yes = await p.confirm({
|
|
61
|
+
message: "Set up now (provider, model, key)?",
|
|
62
|
+
initialValue: true,
|
|
63
|
+
});
|
|
64
|
+
if (p.isCancel(yes) || !yes) {
|
|
65
|
+
p.cancel("Setup skipped.");
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
await runWizard();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function runWizard() {
|
|
73
|
+
p.intro("yf-commit setup");
|
|
74
|
+
|
|
75
|
+
const providerId = await p.select({
|
|
76
|
+
message: "Provider",
|
|
77
|
+
initialValue: "openai",
|
|
78
|
+
options: PROVIDERS.map((prov) => ({
|
|
79
|
+
value: prov.id,
|
|
80
|
+
label: prov.label,
|
|
81
|
+
})),
|
|
82
|
+
});
|
|
83
|
+
if (p.isCancel(providerId)) {
|
|
84
|
+
p.cancel("Setup cancelled.");
|
|
85
|
+
throw new CliError("Setup cancelled.", 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const provider = providerById(providerId);
|
|
89
|
+
const model = await p.select({
|
|
90
|
+
message: "Model",
|
|
91
|
+
initialValue: "auto",
|
|
92
|
+
options: [
|
|
93
|
+
{ value: "auto", label: "Auto (recommended)" },
|
|
94
|
+
...provider.models.map((id) => ({ value: id, label: id })),
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
if (p.isCancel(model)) {
|
|
98
|
+
p.cancel("Setup cancelled.");
|
|
99
|
+
throw new CliError("Setup cancelled.", 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @type {string | undefined} */
|
|
103
|
+
let apiKey;
|
|
104
|
+
if (providerId !== "ollama") {
|
|
105
|
+
const pasted = await p.password({
|
|
106
|
+
message: "API key",
|
|
107
|
+
validate(value) {
|
|
108
|
+
if (!value || !String(value).trim()) return "Paste a non-empty key.";
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
if (p.isCancel(pasted)) {
|
|
112
|
+
p.cancel("Setup cancelled.");
|
|
113
|
+
throw new CliError("Setup cancelled.", 1);
|
|
114
|
+
}
|
|
115
|
+
apiKey = String(pasted).trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const data = {
|
|
119
|
+
provider: providerId,
|
|
120
|
+
baseUrl: provider.baseUrl,
|
|
121
|
+
model,
|
|
122
|
+
};
|
|
123
|
+
if (apiKey) data.apiKey = apiKey;
|
|
124
|
+
saveConfig(data);
|
|
125
|
+
p.outro(`Saved to ${configPath()}`);
|
|
126
|
+
}
|
package/src/llm.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { cleanMessage } from "./prompt.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {object} opts
|
|
6
|
+
* @param {string} opts.baseUrl
|
|
7
|
+
* @param {string} opts.model
|
|
8
|
+
* @param {string} opts.apiKey
|
|
9
|
+
* @param {string} opts.systemPrompt
|
|
10
|
+
* @param {string} opts.userPrompt
|
|
11
|
+
*/
|
|
12
|
+
export async function completeChat({
|
|
13
|
+
baseUrl,
|
|
14
|
+
model,
|
|
15
|
+
apiKey,
|
|
16
|
+
systemPrompt,
|
|
17
|
+
userPrompt,
|
|
18
|
+
}) {
|
|
19
|
+
const root = String(baseUrl || "").replace(/\/+$/, "");
|
|
20
|
+
const url = `${root}/chat/completions`;
|
|
21
|
+
const headers = {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
};
|
|
24
|
+
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
25
|
+
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timer = setTimeout(() => controller.abort(), 60_000);
|
|
28
|
+
|
|
29
|
+
let res;
|
|
30
|
+
try {
|
|
31
|
+
res = await fetch(url, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers,
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
model,
|
|
36
|
+
temperature: 0.3,
|
|
37
|
+
max_tokens: 1500,
|
|
38
|
+
messages: [
|
|
39
|
+
{ role: "system", content: systemPrompt },
|
|
40
|
+
{ role: "user", content: userPrompt },
|
|
41
|
+
],
|
|
42
|
+
}),
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err && err.name === "AbortError") {
|
|
47
|
+
throw new CliError("The model request timed out.");
|
|
48
|
+
}
|
|
49
|
+
throw new CliError(`Could not reach ${root}: ${err.message}`);
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const raw = await res.text();
|
|
55
|
+
let data;
|
|
56
|
+
try {
|
|
57
|
+
data = JSON.parse(raw);
|
|
58
|
+
} catch {
|
|
59
|
+
data = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const msg =
|
|
64
|
+
data?.error?.message ||
|
|
65
|
+
data?.message ||
|
|
66
|
+
raw.slice(0, 400) ||
|
|
67
|
+
res.statusText;
|
|
68
|
+
throw new CliError(`API error ${res.status}: ${msg}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
72
|
+
const message = cleanMessage(content);
|
|
73
|
+
if (!message) {
|
|
74
|
+
throw new CliError("The model returned an empty commit message.");
|
|
75
|
+
}
|
|
76
|
+
return message;
|
|
77
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { TYPE_EMOJI } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const MAX_DIFF_CHARS = 100_000;
|
|
4
|
+
|
|
5
|
+
export function buildSystemPrompt({ forcedType } = {}) {
|
|
6
|
+
const typeLine = forcedType
|
|
7
|
+
? `You MUST use type "${forcedType}" and emoji ${TYPE_EMOJI[forcedType]} on line 1.`
|
|
8
|
+
: "Use conventional commit types: feat, fix, refactor, chore, docs, test.";
|
|
9
|
+
|
|
10
|
+
return `You are a commit message generator. Output only the commit message. No preamble, no markdown fences, no extra commentary.
|
|
11
|
+
|
|
12
|
+
Follow this exact format:
|
|
13
|
+
|
|
14
|
+
emoji type(scope): brief description
|
|
15
|
+
|
|
16
|
+
💡 WHY:
|
|
17
|
+
- …
|
|
18
|
+
|
|
19
|
+
🔧 WHAT CHANGED:
|
|
20
|
+
- …
|
|
21
|
+
|
|
22
|
+
📁 FILES IMPACTED:
|
|
23
|
+
- path/relative/to/this/repo
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
- ${typeLine}
|
|
27
|
+
- Emoji on line 1 (✨ feat, 🐛 fix, ♻️ refactor, 🔧 chore, 📝 docs, 🧪 test).
|
|
28
|
+
- First line under 72 characters.
|
|
29
|
+
- WHY: user/business impact, plain language.
|
|
30
|
+
- WHAT CHANGED: technical, from the diff, not staging stats.
|
|
31
|
+
- FILES IMPACTED: paths relative to this repository's root (the current working directory).
|
|
32
|
+
- Adaptive length: tiny diffs → few bullets; large diffs → more, grouped if needed. Never pad. Never write "Updated N staged file(s)."
|
|
33
|
+
- No footer. Do not add WORKTREE, BRANCH, MACHINE, or hostname lines.
|
|
34
|
+
- Do not mention any product or company unless it appears in the diff.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildUserPrompt(diff) {
|
|
38
|
+
let body = String(diff);
|
|
39
|
+
if (body.length > MAX_DIFF_CHARS) {
|
|
40
|
+
body = `${body.slice(0, MAX_DIFF_CHARS)}\n\n[diff truncated]`;
|
|
41
|
+
}
|
|
42
|
+
return `Generate a commit message for this staged git diff:\n\n${body}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function cleanMessage(text) {
|
|
46
|
+
let t = String(text || "").trim();
|
|
47
|
+
if (!t) return "";
|
|
48
|
+
if (t.startsWith("```")) {
|
|
49
|
+
t = t.replace(/^```[a-zA-Z]*\r?\n?/, "").replace(/\r?\n?```$/, "").trim();
|
|
50
|
+
}
|
|
51
|
+
t = t.replace(/\n(?:WORKTREE|BRANCH|MACHINE)\b.*$/gim, "").trim();
|
|
52
|
+
return t;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function looksLikeLockedFormat(message) {
|
|
56
|
+
return (
|
|
57
|
+
/💡 WHY:/m.test(message) &&
|
|
58
|
+
/🔧 WHAT CHANGED:/m.test(message) &&
|
|
59
|
+
/📁 FILES IMPACTED:/m.test(message)
|
|
60
|
+
);
|
|
61
|
+
}
|
package/src/providers.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Locked V1 provider table. Bump auto + named lists in patch releases. */
|
|
2
|
+
|
|
3
|
+
export const PROVIDERS = [
|
|
4
|
+
{
|
|
5
|
+
id: "openai",
|
|
6
|
+
label: "OpenAI",
|
|
7
|
+
baseUrl: "https://api.openai.com/v1",
|
|
8
|
+
autoModel: "gpt-4o-mini",
|
|
9
|
+
models: ["gpt-4o-mini", "gpt-4o"],
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "deepseek",
|
|
13
|
+
label: "DeepSeek",
|
|
14
|
+
baseUrl: "https://api.deepseek.com/v1",
|
|
15
|
+
autoModel: "deepseek-chat",
|
|
16
|
+
models: ["deepseek-chat"],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "xai",
|
|
20
|
+
label: "xAI (Grok)",
|
|
21
|
+
baseUrl: "https://api.x.ai/v1",
|
|
22
|
+
autoModel: "grok-4-fast-non-reasoning",
|
|
23
|
+
models: ["grok-4-fast-non-reasoning", "grok-4"],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "groq",
|
|
27
|
+
label: "Groq",
|
|
28
|
+
baseUrl: "https://api.groq.com/openai/v1",
|
|
29
|
+
autoModel: "llama-3.3-70b-versatile",
|
|
30
|
+
models: ["llama-3.3-70b-versatile"],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "ollama",
|
|
34
|
+
label: "Ollama (local)",
|
|
35
|
+
baseUrl: "http://127.0.0.1:11434/v1",
|
|
36
|
+
autoModel: "llama3.2",
|
|
37
|
+
models: ["llama3.2"],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_PROVIDER_ID = "openai";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} id
|
|
45
|
+
*/
|
|
46
|
+
export function providerById(id) {
|
|
47
|
+
return PROVIDERS.find((p) => p.id === id) ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} baseUrl
|
|
52
|
+
*/
|
|
53
|
+
export function providerByBaseUrl(baseUrl) {
|
|
54
|
+
const normalized = String(baseUrl || "").replace(/\/+$/, "");
|
|
55
|
+
return (
|
|
56
|
+
PROVIDERS.find(
|
|
57
|
+
(p) => p.baseUrl.replace(/\/+$/, "") === normalized,
|
|
58
|
+
) ?? null
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} providerId
|
|
64
|
+
* @param {string} [baseUrl]
|
|
65
|
+
*/
|
|
66
|
+
export function resolveAutoModel(providerId, baseUrl) {
|
|
67
|
+
const p =
|
|
68
|
+
providerById(providerId) ||
|
|
69
|
+
providerByBaseUrl(baseUrl || "") ||
|
|
70
|
+
providerById(DEFAULT_PROVIDER_ID);
|
|
71
|
+
return p.autoModel;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} providerId
|
|
76
|
+
* @param {string} baseUrl
|
|
77
|
+
*/
|
|
78
|
+
export function isOllama(providerId, baseUrl) {
|
|
79
|
+
if (providerId === "ollama") return true;
|
|
80
|
+
return /11434/.test(String(baseUrl || ""));
|
|
81
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/** Alphabetical 1–6 map: chore, docs, feat, fix, refactor, test */
|
|
4
|
+
export const TYPES = ["chore", "docs", "feat", "fix", "refactor", "test"];
|
|
5
|
+
|
|
6
|
+
export const TYPE_EMOJI = {
|
|
7
|
+
feat: "✨",
|
|
8
|
+
fix: "🐛",
|
|
9
|
+
refactor: "♻️",
|
|
10
|
+
chore: "🔧",
|
|
11
|
+
docs: "📝",
|
|
12
|
+
test: "🧪",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string | null | undefined} raw
|
|
17
|
+
* @returns {string | null}
|
|
18
|
+
*/
|
|
19
|
+
export function parseType(raw) {
|
|
20
|
+
if (raw == null || raw === "") return null;
|
|
21
|
+
const s = String(raw).trim().toLowerCase();
|
|
22
|
+
if (/^[1-6]$/.test(s)) return TYPES[Number(s) - 1];
|
|
23
|
+
if (TYPES.includes(s)) return s;
|
|
24
|
+
throw new CliError(
|
|
25
|
+
`Unknown type "${raw}". Use feat, fix, refactor, chore, docs, test, or 1–6.`,
|
|
26
|
+
);
|
|
27
|
+
}
|