@webywz/vibe-commit 0.0.0-stage → 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 webywz
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 CHANGED
@@ -1,3 +1,157 @@
1
- # Temporary Holding Version
1
+ # vibe-commit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Stylish conventional commits. Offline-first. AI-optional.**
4
+
5
+ Stage your files, run one command, get a commit message that looks like you meant it.
6
+
7
+ 一条命令,根据暂存区 diff 写出有品味的 [Conventional Commits](https://www.conventionalcommits.org/)。**无 API Key 也能用**,有 Key 时再可选 AI。
8
+
9
+ [![CI](https://github.com/webywz/vibe-commit/actions/workflows/ci.yml/badge.svg)](https://github.com/webywz/vibe-commit/actions/workflows/ci.yml)
10
+ [![npm](https://img.shields.io/npm/v/vibe-commit.svg)](https://www.npmjs.com/package/vibe-commit)
11
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
12
+
13
+ ---
14
+
15
+ ## Demo
16
+
17
+ ```text
18
+ $ git add src/heuristic.ts
19
+ $ vibe-commit
20
+
21
+ vibe-commit · heuristic · feat
22
+
23
+ 1 file · +124 −8
24
+
25
+ M src/heuristic.ts +124 −8
26
+
27
+ proposed
28
+
29
+ feat(heuristic): infer conventional commit type from staged diffs
30
+
31
+ next vibe-commit --commit
32
+ ```
33
+
34
+ ```text
35
+ $ vibe-commit --vibe playful --commit
36
+ feat(cli): ship offline heuristic commit messages — quietly useful
37
+ committed
38
+ ```
39
+
40
+ Pipe-friendly when stdout is not a TTY:
41
+
42
+ ```bash
43
+ git commit -m "$(vibe-commit --dry-run)"
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Install · 安装
49
+
50
+ ```bash
51
+ npm install -g vibe-commit
52
+ # or, no install:
53
+ npx vibe-commit
54
+ ```
55
+
56
+ Requires **Node.js 18.18+** and `git`.
57
+
58
+ ```bash
59
+ npm i -g vibe-commit # 全局安装
60
+ npx vibe-commit # 一次性运行
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Usage · 用法
66
+
67
+ ```bash
68
+ git add -p # stage the work you actually want to commit
69
+ vibe-commit # inspect the staged diff, print a proposal
70
+ vibe-commit --commit
71
+ ```
72
+
73
+ | Flag | What it does · 作用 |
74
+ | --- | --- |
75
+ | `--commit` | Run `git commit` with the proposed message · 用生成的信息执行提交 |
76
+ | `--dry-run` | Print only; never commit (default, and wins over `--commit`) |
77
+ | `--type <type>` | Force `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` |
78
+ | `--scope <scope>` | Override inferred scope · 覆盖推断出的 scope |
79
+ | `--vibe <style>` | `playful` \| `pro` \| `terse` (default: `pro`) |
80
+ | `--no-ai` | Skip the API even if `OPENAI_API_KEY` is set |
81
+ | `--json` | Machine-readable proposal |
82
+ | `-h`, `--help` | Help |
83
+ | `-v`, `--version` | Version |
84
+
85
+ ### Vibes
86
+
87
+ - **pro** — default. Imperative, review-ready, conventional.
88
+ - **terse** — headline only, filler words gone.
89
+ - **playful** — a little personality, still safe for a real repo.
90
+
91
+ 默认 **pro**:专业、祈使句、可直接进 code review。`terse` 更短;`playful` 带一点俏皮,但不会变成段子。
92
+
93
+ ---
94
+
95
+ ## How it works · 原理
96
+
97
+ 1. Read **staged** changes only (`git diff --cached`). Unstaged noise is ignored.
98
+ 2. **Offline heuristic** (always on): file paths + hunk keywords + added identifiers → `type`, `scope`, `subject`.
99
+ - `README` / `docs/` → `docs`
100
+ - `*.test.ts` / `test/` → `test`
101
+ - `.github/workflows` → `ci`
102
+ - lockfiles / `package.json` → `chore`
103
+ - `fix` / `bug` / `crash` in the diff → `fix`
104
+ - new files / `export function` → `feat`
105
+ 3. If `OPENAI_API_KEY` is set, ask an OpenAI-compatible model and **fall back to the heuristic** on any failure.
106
+ 4. Print a proposal. With `--commit`, run `git commit` (headline + optional body as two `-m` flags — no shell interpolation).
107
+
108
+ 只看 **已暂存** 的 diff。启发式始终可用;配置了 API Key 才走模型,失败自动回退。
109
+
110
+ ---
111
+
112
+ ## AI (optional)
113
+
114
+ ```bash
115
+ export OPENAI_API_KEY=sk-...
116
+ # optional:
117
+ export OPENAI_MODEL=gpt-4o-mini
118
+ export OPENAI_BASE_URL=https://api.openai.com/v1 # any compatible gateway
119
+ export VIBE_COMMIT_VIBE=pro
120
+ ```
121
+
122
+ Copy `.env.example` if you keep secrets in a file. vibe-commit never sends unstaged files, and it truncates huge diffs.
123
+
124
+ 兼容 OpenAI Chat Completions。没 Key、超时、4xx/5xx → 静默回退到离线启发式,CLI 仍然能给出一条能用的信息。
125
+
126
+ ---
127
+
128
+ ## Library
129
+
130
+ ```ts
131
+ import { proposeHeuristic, formatCommit } from "vibe-commit";
132
+
133
+ const message = proposeHeuristic({ diff, vibe: "pro" });
134
+ console.log(formatCommit(message));
135
+ // feat(auth): add login
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Develop · 开发
141
+
142
+ ```bash
143
+ git clone https://github.com/webywz/vibe-commit
144
+ cd vibe-commit
145
+ npm install
146
+ npm test
147
+ npm run build
148
+ node dist/cli.js --help
149
+ ```
150
+
151
+ | Script | |
152
+ | --- | --- |
153
+ | `npm test` | Unit + e2e tests (`node:test` + `tsx`) |
154
+ | `npm run build` | Emit `dist/` |
155
+ | `npm run typecheck` | `tsc --noEmit` |
156
+
157
+ MIT © webywz — see [LICENSE](./LICENSE).
package/dist/ai.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { ProposedMessage, StagedDiff, Vibe } from "./types.ts";
2
+ import type { CommitType } from "./types.ts";
3
+ export interface AiContext {
4
+ diff: StagedDiff;
5
+ vibe: Vibe;
6
+ type?: CommitType;
7
+ scope?: string;
8
+ }
9
+ export declare function proposeWithAi(context: AiContext, env?: NodeJS.ProcessEnv): Promise<ProposedMessage | null>;
package/dist/ai.js ADDED
@@ -0,0 +1,83 @@
1
+ import { parseCommit } from "./format.js";
2
+ const MAX_DIFF_CHARS = 12_000;
3
+ export async function proposeWithAi(context, env = process.env) {
4
+ const apiKey = env.OPENAI_API_KEY?.trim();
5
+ if (!apiKey)
6
+ return null;
7
+ const baseUrl = (env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/+$/, "");
8
+ const model = env.OPENAI_MODEL?.trim() || "gpt-4o-mini";
9
+ try {
10
+ const response = await fetch(`${baseUrl}/chat/completions`, {
11
+ method: "POST",
12
+ headers: {
13
+ "content-type": "application/json",
14
+ authorization: `Bearer ${apiKey}`,
15
+ },
16
+ body: JSON.stringify({
17
+ model,
18
+ temperature: context.vibe === "playful" ? 0.7 : 0.2,
19
+ messages: [
20
+ { role: "system", content: systemPrompt(context.vibe) },
21
+ { role: "user", content: userPrompt(context) },
22
+ ],
23
+ }),
24
+ });
25
+ if (!response.ok)
26
+ return null;
27
+ const payload = (await response.json());
28
+ const content = payload.choices?.[0]?.message?.content;
29
+ if (!content)
30
+ return null;
31
+ const parsed = parseCommit(content, "ai");
32
+ if (!parsed)
33
+ return null;
34
+ if (context.type)
35
+ parsed.type = context.type;
36
+ if (context.scope)
37
+ parsed.scope = context.scope;
38
+ return parsed;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function systemPrompt(vibe) {
45
+ const vibeLine = vibe === "playful"
46
+ ? "Tone: witty, still professional; no memes, no emoji, no slang that would embarrass a code review."
47
+ : vibe === "terse"
48
+ ? "Tone: as short as possible. Headline only, no body."
49
+ : "Tone: clear, professional, imperative mood.";
50
+ return `You write conventional commit messages.
51
+ Return only the commit message, no quotes, no markdown fences.
52
+ Format: type(scope): subject
53
+ Optional body after a blank line.
54
+ Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
55
+ Subject: lowercase, imperative, no trailing period, <= 72 chars.
56
+ ${vibeLine}`;
57
+ }
58
+ function userPrompt(context) {
59
+ const files = context.diff.files
60
+ .map((file) => {
61
+ const rename = file.fromPath ? ` (from ${file.fromPath})` : "";
62
+ return `${file.status} ${file.path}${rename} +${file.additions} -${file.deletions}`;
63
+ })
64
+ .join("\n");
65
+ const hints = [
66
+ context.type ? `Forced type: ${context.type}` : null,
67
+ context.scope ? `Forced scope: ${context.scope}` : null,
68
+ ]
69
+ .filter(Boolean)
70
+ .join("\n");
71
+ const raw = context.diff.raw.slice(0, MAX_DIFF_CHARS);
72
+ return [
73
+ hints,
74
+ "Staged files:",
75
+ files,
76
+ "",
77
+ "Diff:",
78
+ raw || "(binary or empty hunks)",
79
+ ]
80
+ .filter(Boolean)
81
+ .join("\n");
82
+ }
83
+ //# sourceMappingURL=ai.js.map
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv?: string[]): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import pc from "picocolors";
6
+ import { formatCommit } from "./format.js";
7
+ import { commitWithMessage, GitError, inspectStaged } from "./git.js";
8
+ import { helpText, parseArgs, UsageError } from "./parse-args.js";
9
+ import { proposeMessage } from "./propose.js";
10
+ import { renderError, renderProposal, renderVersion } from "./render.js";
11
+ export async function main(argv = process.argv.slice(2)) {
12
+ let options;
13
+ try {
14
+ options = parseArgs(argv);
15
+ }
16
+ catch (error) {
17
+ if (error instanceof UsageError) {
18
+ process.stderr.write(`${renderError(error.message)}\n\n${helpText()}`);
19
+ return 1;
20
+ }
21
+ throw error;
22
+ }
23
+ if (options.help) {
24
+ process.stdout.write(helpText());
25
+ return 0;
26
+ }
27
+ if (options.version) {
28
+ process.stdout.write(`${renderVersion(readVersion())}\n`);
29
+ return 0;
30
+ }
31
+ const piped = !process.stdout.isTTY;
32
+ try {
33
+ const diff = inspectStaged();
34
+ const message = await proposeMessage(diff, {
35
+ type: options.type,
36
+ scope: options.scope,
37
+ vibe: options.vibe,
38
+ preferAi: !options.noAi,
39
+ });
40
+ const formatted = formatCommit(message);
41
+ const shouldCommit = options.commit && !options.dryRun;
42
+ if (options.json) {
43
+ process.stdout.write(`${JSON.stringify({ ...message, formatted }, null, 2)}\n`);
44
+ }
45
+ else if (piped) {
46
+ process.stdout.write(`${formatted}\n`);
47
+ }
48
+ else {
49
+ process.stdout.write(renderProposal(diff, message));
50
+ }
51
+ if (shouldCommit) {
52
+ commitWithMessage(formatted);
53
+ if (!piped && !options.json) {
54
+ process.stdout.write(` ${pc.green("committed")}\n\n`);
55
+ }
56
+ }
57
+ return 0;
58
+ }
59
+ catch (error) {
60
+ const text = error instanceof GitError || error instanceof Error
61
+ ? error.message
62
+ : "Unexpected error";
63
+ process.stderr.write(`${renderError(text)}\n`);
64
+ return 1;
65
+ }
66
+ }
67
+ function readVersion() {
68
+ try {
69
+ const here = dirname(fileURLToPath(import.meta.url));
70
+ const pkgPath = join(here, "..", "package.json");
71
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
72
+ return pkg.version ?? "0.0.0";
73
+ }
74
+ catch {
75
+ return "0.0.0";
76
+ }
77
+ }
78
+ function isDirectRun() {
79
+ const entry = process.argv[1];
80
+ if (!entry)
81
+ return false;
82
+ try {
83
+ return import.meta.url === pathToFileURL(entry).href;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ if (isDirectRun()) {
90
+ main().then((code) => process.exit(code), (error) => {
91
+ process.stderr.write(`${renderError(error instanceof Error ? error.message : "Unexpected error")}\n`);
92
+ process.exit(1);
93
+ });
94
+ }
95
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,10 @@
1
+ import { type Engine, type ProposedMessage } from "./types.ts";
2
+ export declare function formatCommit(message: ProposedMessage): string;
3
+ export declare function normalizeSubject(subject: string): string;
4
+ export declare function clip(text: string, max: number): string;
5
+ export declare function parseCommit(text: string, engine?: Engine): ProposedMessage | null;
6
+ export declare function stripFences(text: string): string;
7
+ export declare function splitHeadline(formatted: string): {
8
+ headline: string;
9
+ body?: string;
10
+ };
package/dist/format.js ADDED
@@ -0,0 +1,72 @@
1
+ import { COMMIT_TYPES, } from "./types.js";
2
+ const TYPE_PATTERN = COMMIT_TYPES.join("|");
3
+ const HEADER_RE = new RegExp(`^(${TYPE_PATTERN})(?:\\(([a-zA-Z0-9][\\w./-]{0,62})\\))?(!)?:\\s*(.+)$`);
4
+ export function formatCommit(message) {
5
+ const bang = message.breaking ? "!" : "";
6
+ const head = message.scope
7
+ ? `${message.type}(${message.scope})${bang}: ${normalizeSubject(message.subject)}`
8
+ : `${message.type}${bang}: ${normalizeSubject(message.subject)}`;
9
+ const body = message.body?.trim();
10
+ return body ? `${head}\n\n${body}` : head;
11
+ }
12
+ export function normalizeSubject(subject) {
13
+ let text = subject.replace(/\s+/g, " ").trim();
14
+ text = text.replace(/^[`'"]+|[`'"]+$/g, "");
15
+ text = text.replace(/\.+$/, "");
16
+ if (!text)
17
+ return "update project files";
18
+ if (/^[A-Z][a-z]/.test(text) && !/^[A-Z]{2,}/.test(text)) {
19
+ text = text.charAt(0).toLowerCase() + text.slice(1);
20
+ }
21
+ return clip(text, 72);
22
+ }
23
+ export function clip(text, max) {
24
+ if (text.length <= max)
25
+ return text;
26
+ const sliced = text.slice(0, max - 1);
27
+ const atWord = sliced.lastIndexOf(" ");
28
+ const base = atWord >= 40 ? sliced.slice(0, atWord) : sliced;
29
+ return `${base.replace(/[ ,;:.-]+$/, "")}…`;
30
+ }
31
+ export function parseCommit(text, engine = "heuristic") {
32
+ const cleaned = stripFences(text).trim();
33
+ if (!cleaned)
34
+ return null;
35
+ const lines = cleaned.split(/\r?\n/);
36
+ const headerLine = lines.find((line) => HEADER_RE.test(line.trim()));
37
+ if (!headerLine)
38
+ return null;
39
+ const match = headerLine.trim().match(HEADER_RE);
40
+ if (!match)
41
+ return null;
42
+ const type = match[1];
43
+ const scope = match[2];
44
+ const breaking = Boolean(match[3]);
45
+ const subject = normalizeSubject(match[4] ?? "");
46
+ const headerIndex = lines.findIndex((line) => line.trim() === headerLine.trim());
47
+ const rest = lines
48
+ .slice(headerIndex + 1)
49
+ .join("\n")
50
+ .trim();
51
+ return {
52
+ type,
53
+ scope,
54
+ subject,
55
+ body: rest || undefined,
56
+ breaking,
57
+ engine,
58
+ };
59
+ }
60
+ export function stripFences(text) {
61
+ return text
62
+ .replace(/^\s*```(?:\w+)?\s*/m, "")
63
+ .replace(/\s*```\s*$/m, "")
64
+ .replace(/^\s*(?:commit message|message)\s*:\s*/i, "")
65
+ .trim();
66
+ }
67
+ export function splitHeadline(formatted) {
68
+ const [headline = formatted, ...rest] = formatted.split("\n");
69
+ const body = rest.join("\n").trim();
70
+ return { headline, body: body || undefined };
71
+ }
72
+ //# sourceMappingURL=format.js.map
package/dist/git.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { StagedDiff } from "./types.ts";
2
+ export declare class GitError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export declare function inspectStaged(cwd?: string): StagedDiff;
6
+ export declare function commitWithMessage(message: string, cwd?: string): string;
7
+ export declare function assertRepo(cwd?: string): void;
package/dist/git.js ADDED
@@ -0,0 +1,55 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { assembleDiff } from "./parse-diff.js";
3
+ export class GitError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "GitError";
7
+ }
8
+ }
9
+ export function inspectStaged(cwd = process.cwd()) {
10
+ assertRepo(cwd);
11
+ const nameStatus = git(["diff", "--cached", "--name-status", "-M"], cwd);
12
+ const numstat = git(["diff", "--cached", "--numstat", "-M"], cwd);
13
+ const raw = git(["diff", "--cached", "-M", "--unified=3"], cwd);
14
+ const diff = assembleDiff(nameStatus, numstat, raw);
15
+ if (diff.files.length === 0) {
16
+ throw new GitError("No staged changes. Stage files first: git add -p (or git add <path>)");
17
+ }
18
+ return diff;
19
+ }
20
+ export function commitWithMessage(message, cwd = process.cwd()) {
21
+ assertRepo(cwd);
22
+ const [headline, ...rest] = message.split("\n");
23
+ const body = rest.join("\n").trim();
24
+ const args = ["commit", "-m", headline ?? message];
25
+ if (body)
26
+ args.push("-m", body);
27
+ return git(args, cwd);
28
+ }
29
+ export function assertRepo(cwd = process.cwd()) {
30
+ try {
31
+ const inside = git(["rev-parse", "--is-inside-work-tree"], cwd).trim();
32
+ if (inside !== "true")
33
+ throw new Error("not a repo");
34
+ }
35
+ catch {
36
+ throw new GitError("Not a git repository. Run this inside a project with git.");
37
+ }
38
+ }
39
+ function git(args, cwd) {
40
+ try {
41
+ return execFileSync("git", args, {
42
+ cwd,
43
+ encoding: "utf8",
44
+ stdio: ["ignore", "pipe", "pipe"],
45
+ });
46
+ }
47
+ catch (error) {
48
+ const stderr = error && typeof error === "object" && "stderr" in error
49
+ ? String(error.stderr)
50
+ : "";
51
+ const detail = stderr.trim() || (error instanceof Error ? error.message : "git failed");
52
+ throw new GitError(detail);
53
+ }
54
+ }
55
+ //# sourceMappingURL=git.js.map
@@ -0,0 +1,15 @@
1
+ import type { CommitType, DiffHunk, ProposedMessage, StagedDiff, StagedFile, Vibe } from "./types.ts";
2
+ export interface HeuristicInput {
3
+ diff: StagedDiff;
4
+ type?: CommitType;
5
+ scope?: string;
6
+ vibe: Vibe;
7
+ }
8
+ export declare function proposeHeuristic(input: HeuristicInput): ProposedMessage;
9
+ export declare function inferType(diff: StagedDiff): CommitType;
10
+ export declare function primaryRole(path: string): CommitType;
11
+ export declare function classifyPath(path: string): CommitType[];
12
+ export declare function inferScope(files: StagedFile[]): string | undefined;
13
+ export declare function inferSubject(diff: StagedDiff, type: CommitType, scope?: string): string;
14
+ export declare function collectAddedIdentifiers(hunks: DiffHunk[]): string[];
15
+ export declare function stem(path: string): string;