@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 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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from "../src/index.js";
4
+
5
+ run(process.argv);
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
@@ -0,0 +1,11 @@
1
+ export class CliError extends Error {
2
+ /**
3
+ * @param {string} message
4
+ * @param {number} [exitCode]
5
+ */
6
+ constructor(message, exitCode = 1) {
7
+ super(message);
8
+ this.name = "CliError";
9
+ this.exitCode = exitCode;
10
+ }
11
+ }
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
+ }
@@ -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
+ }