@webywz/vibe-commit 0.1.0 → 0.1.2

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 CHANGED
@@ -1,29 +1,77 @@
1
1
  # vibe-commit
2
2
 
3
- **Stylish conventional commits. Offline-first. AI-optional.**
3
+ <p align="center">
4
+ <a href="https://github.com/webywz/vibe-commit/actions/workflows/ci.yml"><img src="https://github.com/webywz/vibe-commit/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
5
+ <a href="https://www.npmjs.com/package/@webywz/vibe-commit"><img src="https://img.shields.io/npm/v/@webywz/vibe-commit.svg" alt="npm" /></a>
6
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT" /></a>
7
+ </p>
4
8
 
5
- Stage your files, run one command, get a commit message that looks like you meant it.
9
+ <p align="center">
10
+ <strong>Stylish conventional commits. Offline-first. AI-optional.</strong><br/>
11
+ Stage your files, run one command, get a commit message that looks like you meant it.
12
+ </p>
6
13
 
7
- 一条命令,根据暂存区 diff 写出有品味的 [Conventional Commits](https://www.conventionalcommits.org/)。**无 API Key 也能用**,有 Key 时再可选 AI。
14
+ <p align="center">
15
+ 有品味的 Conventional Commits。离线优先,AI 可选。<br/>
16
+ 暂存改动,一条命令,写出像你认真写过的提交信息。
17
+ </p>
8
18
 
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)
19
+ Companion spirit to [diff-vibe](https://github.com/webywz/diff-vibe): commits here, pull requests there.
20
+
21
+ 与 [diff-vibe](https://github.com/webywz/diff-vibe) 成对:这边写 commit,那边写 PR。
12
22
 
13
23
  ---
14
24
 
15
- ## Demo
25
+ ## Install · 安装
26
+
27
+ ```bash
28
+ npm i -g @webywz/vibe-commit
29
+ npx @webywz/vibe-commit
30
+ ```
31
+
32
+ Requires **Node.js 18.18+** and `git`. Package: [`@webywz/vibe-commit`](https://www.npmjs.com/package/@webywz/vibe-commit). Binary: `vibe-commit`.
33
+
34
+ 需要 **Node.js 18.18+** 与 `git`。包名 `@webywz/vibe-commit`,命令仍是 `vibe-commit`。
35
+
36
+ ---
37
+
38
+ ## Quick start · 快速开始
39
+
40
+ ```bash
41
+ git add -p
42
+ vibe-commit # propose from staged diff
43
+ vibe-commit --vibe playful --commit # write the commit
44
+ vibe-commit --dry-run # print only (default; wins over --commit)
45
+ git commit -m "$(vibe-commit --dry-run)"
46
+ vibe-commit install # optional prepare-commit-msg hook
47
+ ```
48
+
49
+ | Flag / command | What it does · 作用 |
50
+ | --- | --- |
51
+ | `--commit` | Run `git commit` with the proposed message · 用生成信息执行提交 |
52
+ | `--dry-run` | Print only; never commit (default) · 只打印,不提交(默认) |
53
+ | `--type <type>` | Force `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` |
54
+ | `--scope <scope>` | Override inferred scope · 覆盖推断出的 scope |
55
+ | `--vibe <style>` | `playful` \| `pro` \| `terse` (default: `pro`) |
56
+ | `--no-ai` | Skip the API even if `OPENAI_API_KEY` is set · 即使有 Key 也不走 AI |
57
+ | `--json` | Machine-readable proposal · 机器可读提案 |
58
+ | `install [--path]` | Install the prepare-commit-msg hook · 安装钩子 |
59
+ | `uninstall [--path]` | Remove the managed hook · 卸载钩子 |
60
+ | `-h` / `-v` | Help / version · 帮助 / 版本 |
61
+
62
+ **Vibes** · 语气
63
+
64
+ - **pro** — default. Imperative, review-ready. · 默认:祈使句,可进 review。
65
+ - **terse** — headline only, filler gone. · 只留标题,去掉废话。
66
+ - **playful** — a little personality, still safe for a real repo. · 带一点性格,但仍适合正经仓库。
16
67
 
17
68
  ```text
18
- $ git add src/heuristic.ts
19
69
  $ vibe-commit
20
70
 
21
71
  vibe-commit · heuristic · feat
22
72
 
23
73
  1 file · +124 −8
24
74
 
25
- M src/heuristic.ts +124 −8
26
-
27
75
  proposed
28
76
 
29
77
  feat(heuristic): infer conventional commit type from staged diffs
@@ -31,127 +79,80 @@ $ vibe-commit
31
79
  next vibe-commit --commit
32
80
  ```
33
81
 
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:
82
+ ### Git hook · 钩子
41
83
 
42
84
  ```bash
43
- git commit -m "$(vibe-commit --dry-run)"
85
+ vibe-commit install
86
+ vibe-commit install --path ~/projects/app
87
+ vibe-commit uninstall
44
88
  ```
45
89
 
46
- ---
47
-
48
- ## Install · 安装
90
+ After install, plain `git commit` (no `-m`) fills an empty or comment-only message file with a conventional proposal.
49
91
 
50
- ```bash
51
- npm install -g vibe-commit
52
- # or, no install:
53
- npx vibe-commit
54
- ```
92
+ 安装后,裸 `git commit`(没有 `-m`)会在空 / 仅注释的提交说明里填入提案。
55
93
 
56
- Requires **Node.js 18.18+** and `git`.
94
+ | Situation · 情况 | Behavior · 行为 |
95
+ | --- | --- |
96
+ | Source is `merge`, `squash`, or `commit` (amend) | Leave the message alone · 不改 |
97
+ | Message already has non-comment text (e.g. `git commit -m`) | Never overwrite · 绝不覆盖 |
98
+ | No staged changes / generator error | Exit 0 — never break the commit · 以 0 退出,不打断提交 |
57
99
 
58
- ```bash
59
- npm i -g vibe-commit # 全局安装
60
- npx vibe-commit # 一次性运行
61
- ```
100
+ Hook stays **offline by default** (`VIBE_COMMIT_HOOK_AI=1` to allow AI). Unique markers (`# BEGIN VIBE-COMMIT` … `# END VIBE-COMMIT`) keep uninstall clean. A foreign `prepare-commit-msg` is backed up to `prepare-commit-msg.vibe-backup` and chained after ours.
62
101
 
63
- ---
102
+ 钩子默认离线;设 `VIBE_COMMIT_HOOK_AI=1` 才允许 AI。用标记块保证干净卸载。已有外来钩子会备份并链式调用。
64
103
 
65
- ## Usage · 用法
104
+ ### AI (optional) · AI(可选)
66
105
 
67
106
  ```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
107
+ export OPENAI_API_KEY=sk-...
108
+ export OPENAI_MODEL=gpt-4o-mini # optional
109
+ export OPENAI_BASE_URL=https://api.openai.com/v1
110
+ export VIBE_COMMIT_VIBE=pro # or VIBE
71
111
  ```
72
112
 
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 |
113
+ OpenAI-compatible Chat Completions. Missing key, timeout, or 4xx/5xx → silent fallback to the offline heuristic. See [`.env.example`](.env.example). Unstaged files are never sent; huge diffs are truncated.
84
114
 
85
- ### Vibes
115
+ 兼容 OpenAI Chat Completions。没 Key、超时或 4xx/5xx → 静默回退离线启发式。可参考 `.env.example`。不会上传未暂存文件;过大 diff 会截断。
86
116
 
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.
117
+ ### Library · 库
90
118
 
91
- 默认 **pro**:专业、祈使句、可直接进 code review。`terse` 更短;`playful` 带一点俏皮,但不会变成段子。
119
+ ```ts
120
+ import { proposeHeuristic, formatCommit } from "@webywz/vibe-commit";
121
+
122
+ const message = proposeHeuristic({ diff, vibe: "pro" });
123
+ console.log(formatCommit(message));
124
+ // feat(auth): add login
125
+ ```
92
126
 
93
127
  ---
94
128
 
95
- ## How it works · 原理
129
+ ## Features · 特性
96
130
 
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`.
131
+ 1. Reads **staged** changes only (`git diff --cached`). Unstaged noise is ignored.
132
+ 只读 **已暂存** 改动;忽略未暂存噪音。
133
+ 2. **Offline heuristic** always on: paths + hunk keywords + added identifiers → `type`, `scope`, `subject`.
134
+ **离线启发式**始终开启:路径 + hunk 关键词 + 新增标识 → `type` / `scope` / `subject`。
99
135
  - `README` / `docs/` → `docs`
100
136
  - `*.test.ts` / `test/` → `test`
101
137
  - `.github/workflows` → `ci`
102
138
  - lockfiles / `package.json` → `chore`
103
139
  - `fix` / `bug` / `crash` in the diff → `fix`
104
140
  - 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 仍然能给出一条能用的信息。
141
+ 3. Optional AI draft with automatic heuristic fallback.
142
+ 可选 AI 草稿,失败自动回退启发式。
143
+ 4. Managed `prepare-commit-msg` hook via `install` / `uninstall`.
144
+ 通过 `install` / `uninstall` 管理 `prepare-commit-msg` 钩子。
125
145
 
126
146
  ---
127
147
 
128
- ## Library
148
+ ## Links · 链接
129
149
 
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 · 开发
150
+ - npm · [`@webywz/vibe-commit`](https://www.npmjs.com/package/@webywz/vibe-commit)
151
+ - Source · [github.com/webywz/vibe-commit](https://github.com/webywz/vibe-commit)
152
+ - Related · [diff-vibe](https://github.com/webywz/diff-vibe) · [prompt-diff](https://github.com/webywz/prompt-diff)
153
+ - License · [MIT](./LICENSE)
141
154
 
142
155
  ```bash
143
- git clone https://github.com/webywz/vibe-commit
144
- cd vibe-commit
145
- npm install
146
- npm test
147
- npm run build
156
+ npm install && npm test && npm run build
148
157
  node dist/cli.js --help
149
158
  ```
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/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import pc from "picocolors";
6
6
  import { formatCommit } from "./format.js";
7
7
  import { commitWithMessage, GitError, inspectStaged } from "./git.js";
8
+ import { installHook, uninstallHook, runPrepareCommitMsg } from "./hook.js";
8
9
  import { helpText, parseArgs, UsageError } from "./parse-args.js";
9
10
  import { proposeMessage } from "./propose.js";
10
11
  import { renderError, renderProposal, renderVersion } from "./render.js";
@@ -28,6 +29,15 @@ export async function main(argv = process.argv.slice(2)) {
28
29
  process.stdout.write(`${renderVersion(readVersion())}\n`);
29
30
  return 0;
30
31
  }
32
+ if (options.command === "install") {
33
+ return runInstall(options.path);
34
+ }
35
+ if (options.command === "uninstall") {
36
+ return runUninstall(options.path);
37
+ }
38
+ if (options.command === "prepare-commit-msg") {
39
+ return runHookPrepare(options.messageFile, options.commitSource);
40
+ }
31
41
  const piped = !process.stdout.isTTY;
32
42
  try {
33
43
  const diff = inspectStaged();
@@ -64,6 +74,64 @@ export async function main(argv = process.argv.slice(2)) {
64
74
  return 1;
65
75
  }
66
76
  }
77
+ function runInstall(repoPath) {
78
+ try {
79
+ const target = resolve(repoPath ?? process.cwd());
80
+ const result = installHook(target);
81
+ const action = result.updated
82
+ ? "updated"
83
+ : result.created
84
+ ? "installed"
85
+ : "installed";
86
+ process.stdout.write(`${pc.green("ok")} prepare-commit-msg hook ${action}\n` +
87
+ ` ${result.hookPath}\n`);
88
+ if (result.backedUp) {
89
+ process.stdout.write(` previous hook backed up as prepare-commit-msg.vibe-backup\n`);
90
+ }
91
+ process.stdout.write(` tip: plain \`git commit\` (no -m) will fill an empty message\n`);
92
+ return 0;
93
+ }
94
+ catch (error) {
95
+ const text = error instanceof GitError || error instanceof Error
96
+ ? error.message
97
+ : "Unexpected error";
98
+ process.stderr.write(`${renderError(text)}\n`);
99
+ return 1;
100
+ }
101
+ }
102
+ function runUninstall(repoPath) {
103
+ try {
104
+ const target = resolve(repoPath ?? process.cwd());
105
+ const result = uninstallHook(target);
106
+ if (!result.removed) {
107
+ process.stdout.write(`${pc.yellow("skip")} no managed vibe-commit hook found\n` +
108
+ ` ${result.hookPath}\n`);
109
+ return 0;
110
+ }
111
+ process.stdout.write(`${pc.green("ok")} prepare-commit-msg hook removed\n` +
112
+ ` ${result.hookPath}\n`);
113
+ if (result.restored) {
114
+ process.stdout.write(` previous hook restored from backup\n`);
115
+ }
116
+ return 0;
117
+ }
118
+ catch (error) {
119
+ const text = error instanceof GitError || error instanceof Error
120
+ ? error.message
121
+ : "Unexpected error";
122
+ process.stderr.write(`${renderError(text)}\n`);
123
+ return 1;
124
+ }
125
+ }
126
+ async function runHookPrepare(messageFile, commitSource) {
127
+ try {
128
+ await runPrepareCommitMsg(messageFile, commitSource, process.cwd());
129
+ }
130
+ catch {
131
+ // Never fail a commit because the hook misfired.
132
+ }
133
+ return 0;
134
+ }
67
135
  function readVersion() {
68
136
  try {
69
137
  const here = dirname(fileURLToPath(import.meta.url));
package/dist/hook.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ export declare const HOOK_BEGIN = "# BEGIN VIBE-COMMIT";
2
+ export declare const HOOK_END = "# END VIBE-COMMIT";
3
+ export declare const HOOK_NAME = "prepare-commit-msg";
4
+ export declare const HOOK_BACKUP_NAME = "prepare-commit-msg.vibe-backup";
5
+ export declare function hasNonCommentContent(content: string): boolean;
6
+ /** Skip merge/squash/amend, or when the message file already has user text. */
7
+ export declare function shouldSkipPrepareCommitMsg(messageFileContent: string, source?: string): boolean;
8
+ /**
9
+ * prepare-commit-msg runner. Offline by default (set VIBE_COMMIT_HOOK_AI=1 to enable AI).
10
+ * Never throws into git — failures are swallowed as "noop".
11
+ */
12
+ export declare function runPrepareCommitMsg(messageFile: string, source?: string, cwd?: string, env?: NodeJS.ProcessEnv): Promise<"wrote" | "skipped" | "noop">;
13
+ export interface HookPaths {
14
+ repoPath: string;
15
+ hooksDir: string;
16
+ hookPath: string;
17
+ backupPath: string;
18
+ }
19
+ export declare function resolveHookPaths(repoPath?: string): HookPaths;
20
+ export declare function resolveGitHooksDir(repoPath: string): string;
21
+ export declare function resolveInstallCommand(argv1?: string | undefined, execPath?: string): {
22
+ execPath: string;
23
+ cliPath: string;
24
+ };
25
+ export declare function buildManagedHookBlock(execPath: string, cliPath: string): string;
26
+ export declare function shellQuote(value: string): string;
27
+ export declare function replaceManagedBlock(source: string, block: string): string;
28
+ export declare function stripManagedBlock(source: string): string;
29
+ export declare function installHook(repoPath?: string, command?: {
30
+ execPath: string;
31
+ cliPath: string;
32
+ }): {
33
+ hookPath: string;
34
+ created: boolean;
35
+ updated: boolean;
36
+ backedUp: boolean;
37
+ };
38
+ export declare function uninstallHook(repoPath?: string): {
39
+ hookPath: string;
40
+ removed: boolean;
41
+ restored: boolean;
42
+ };
package/dist/hook.js ADDED
@@ -0,0 +1,235 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { formatCommit } from "./format.js";
6
+ import { assertRepo, GitError, inspectStaged } from "./git.js";
7
+ import { defaultVibeFromEnv } from "./parse-args.js";
8
+ import { proposeMessage } from "./propose.js";
9
+ export const HOOK_BEGIN = "# BEGIN VIBE-COMMIT";
10
+ export const HOOK_END = "# END VIBE-COMMIT";
11
+ export const HOOK_NAME = "prepare-commit-msg";
12
+ export const HOOK_BACKUP_NAME = "prepare-commit-msg.vibe-backup";
13
+ const SKIP_SOURCES = new Set(["merge", "squash", "commit"]);
14
+ export function hasNonCommentContent(content) {
15
+ return content.split(/\r?\n/).some((line) => {
16
+ const trimmed = line.trim();
17
+ return trimmed.length > 0 && !trimmed.startsWith("#");
18
+ });
19
+ }
20
+ /** Skip merge/squash/amend, or when the message file already has user text. */
21
+ export function shouldSkipPrepareCommitMsg(messageFileContent, source) {
22
+ const src = (source ?? "").trim().toLowerCase();
23
+ if (SKIP_SOURCES.has(src))
24
+ return true;
25
+ return hasNonCommentContent(messageFileContent);
26
+ }
27
+ /**
28
+ * prepare-commit-msg runner. Offline by default (set VIBE_COMMIT_HOOK_AI=1 to enable AI).
29
+ * Never throws into git — failures are swallowed as "noop".
30
+ */
31
+ export async function runPrepareCommitMsg(messageFile, source, cwd = process.cwd(), env = process.env) {
32
+ let content = "";
33
+ try {
34
+ content = readFileSync(messageFile, "utf8");
35
+ }
36
+ catch {
37
+ return "noop";
38
+ }
39
+ if (shouldSkipPrepareCommitMsg(content, source))
40
+ return "skipped";
41
+ try {
42
+ const diff = inspectStaged(cwd);
43
+ // Avoid hanging `git commit` on network — hooks stay offline unless opted in.
44
+ const useAi = env.VIBE_COMMIT_HOOK_AI === "1";
45
+ const message = await proposeMessage(diff, {
46
+ vibe: defaultVibeFromEnv(env),
47
+ preferAi: useAi,
48
+ }, env);
49
+ const formatted = formatCommit(message);
50
+ const comments = content
51
+ .split(/\r?\n/)
52
+ .filter((line) => line.trim().startsWith("#"))
53
+ .join("\n");
54
+ const next = comments
55
+ ? `${formatted}\n\n${comments}\n`
56
+ : `${formatted}\n`;
57
+ writeFileSync(messageFile, next, "utf8");
58
+ return "wrote";
59
+ }
60
+ catch {
61
+ return "noop";
62
+ }
63
+ }
64
+ export function resolveHookPaths(repoPath = process.cwd()) {
65
+ assertRepo(repoPath);
66
+ const hooksDir = resolveGitHooksDir(repoPath);
67
+ return {
68
+ repoPath,
69
+ hooksDir,
70
+ hookPath: join(hooksDir, HOOK_NAME),
71
+ backupPath: join(hooksDir, HOOK_BACKUP_NAME),
72
+ };
73
+ }
74
+ export function resolveGitHooksDir(repoPath) {
75
+ try {
76
+ const hooksPath = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
77
+ cwd: repoPath,
78
+ encoding: "utf8",
79
+ stdio: ["ignore", "pipe", "pipe"],
80
+ }).trim();
81
+ return isAbsolute(hooksPath) ? hooksPath : join(repoPath, hooksPath);
82
+ }
83
+ catch {
84
+ throw new GitError("Could not resolve .git/hooks for this repository.");
85
+ }
86
+ }
87
+ export function resolveInstallCommand(argv1 = process.argv[1], execPath = process.execPath) {
88
+ let cliPath;
89
+ if (argv1 && existsSync(argv1)) {
90
+ cliPath = argv1;
91
+ }
92
+ else {
93
+ const here = dirname(fileURLToPath(import.meta.url));
94
+ for (const name of ["cli.js", "cli.ts"]) {
95
+ const candidate = join(here, name);
96
+ if (existsSync(candidate)) {
97
+ cliPath = candidate;
98
+ break;
99
+ }
100
+ }
101
+ }
102
+ if (!cliPath) {
103
+ throw new GitError("Could not locate the vibe-commit CLI entry. Reinstall @webywz/vibe-commit and retry.");
104
+ }
105
+ // Source installs (tsx) need the tsx binary; published dist uses node + cli.js.
106
+ if (cliPath.endsWith(".ts")) {
107
+ const tsx = findTsxBinary(cliPath);
108
+ if (tsx)
109
+ return { execPath: tsx, cliPath };
110
+ }
111
+ return { execPath, cliPath };
112
+ }
113
+ function findTsxBinary(cliPath) {
114
+ let dir = dirname(cliPath);
115
+ for (let i = 0; i < 6; i += 1) {
116
+ const bin = join(dir, "node_modules", ".bin", "tsx");
117
+ if (existsSync(bin))
118
+ return bin;
119
+ const parent = dirname(dir);
120
+ if (parent === dir)
121
+ break;
122
+ dir = parent;
123
+ }
124
+ return undefined;
125
+ }
126
+ export function buildManagedHookBlock(execPath, cliPath) {
127
+ const node = shellQuote(execPath);
128
+ const cli = shellQuote(cliPath);
129
+ return [
130
+ HOOK_BEGIN,
131
+ "# Managed by @webywz/vibe-commit — do not edit this block",
132
+ `${node} ${cli} prepare-commit-msg "$1" "$2" || true`,
133
+ HOOK_END,
134
+ ].join("\n");
135
+ }
136
+ export function shellQuote(value) {
137
+ return `'${value.replace(/'/g, `'\\''`)}'`;
138
+ }
139
+ export function replaceManagedBlock(source, block) {
140
+ const begin = source.indexOf(HOOK_BEGIN);
141
+ const end = source.indexOf(HOOK_END);
142
+ if (begin === -1 || end === -1 || end < begin) {
143
+ return `${source.trimEnd()}\n\n${block}\n`;
144
+ }
145
+ const afterEnd = end + HOOK_END.length;
146
+ return `${source.slice(0, begin)}${block}${source.slice(afterEnd)}`;
147
+ }
148
+ export function stripManagedBlock(source) {
149
+ const begin = source.indexOf(HOOK_BEGIN);
150
+ const end = source.indexOf(HOOK_END);
151
+ if (begin === -1 || end === -1 || end < begin)
152
+ return source;
153
+ const afterEnd = end + HOOK_END.length;
154
+ const before = source.slice(0, begin).replace(/\n+$/, "\n");
155
+ const after = source.slice(afterEnd).replace(/^\n+/, "\n");
156
+ return `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimStart();
157
+ }
158
+ function ensureTrailingNewline(text) {
159
+ return text.endsWith("\n") ? text : `${text}\n`;
160
+ }
161
+ function isShebangOnly(text) {
162
+ const trimmed = text.trim();
163
+ return (trimmed === "" ||
164
+ trimmed === "#!/bin/sh" ||
165
+ trimmed === "#!/usr/bin/env bash" ||
166
+ trimmed === "#!/bin/bash");
167
+ }
168
+ export function installHook(repoPath = process.cwd(), command = resolveInstallCommand()) {
169
+ const paths = resolveHookPaths(repoPath);
170
+ mkdirSync(paths.hooksDir, { recursive: true });
171
+ const block = buildManagedHookBlock(command.execPath, command.cliPath);
172
+ const existing = existsSync(paths.hookPath)
173
+ ? readFileSync(paths.hookPath, "utf8")
174
+ : null;
175
+ let backedUp = false;
176
+ let created = false;
177
+ let updated = false;
178
+ if (existing === null) {
179
+ writeFileSync(paths.hookPath, `#!/bin/sh\n${block}\n`, { mode: 0o755 });
180
+ created = true;
181
+ }
182
+ else if (existing.includes(HOOK_BEGIN) && existing.includes(HOOK_END)) {
183
+ const next = replaceManagedBlock(existing, block);
184
+ writeFileSync(paths.hookPath, ensureTrailingNewline(next), {
185
+ mode: 0o755,
186
+ });
187
+ updated = true;
188
+ }
189
+ else {
190
+ // Foreign hook: back it up, install wrapper that runs us then chains.
191
+ writeFileSync(paths.backupPath, existing, { mode: 0o755 });
192
+ chmodSync(paths.backupPath, 0o755);
193
+ backedUp = true;
194
+ const wrapper = [
195
+ "#!/bin/sh",
196
+ block,
197
+ "",
198
+ "# Chain the previous prepare-commit-msg hook",
199
+ `if [ -x ${shellQuote(paths.backupPath)} ]; then`,
200
+ ` ${shellQuote(paths.backupPath)} "$@" || exit $?`,
201
+ "fi",
202
+ "",
203
+ ].join("\n");
204
+ writeFileSync(paths.hookPath, wrapper, { mode: 0o755 });
205
+ created = true;
206
+ }
207
+ chmodSync(paths.hookPath, 0o755);
208
+ return { hookPath: paths.hookPath, created, updated, backedUp };
209
+ }
210
+ export function uninstallHook(repoPath = process.cwd()) {
211
+ const paths = resolveHookPaths(repoPath);
212
+ if (!existsSync(paths.hookPath)) {
213
+ return { hookPath: paths.hookPath, removed: false, restored: false };
214
+ }
215
+ const existing = readFileSync(paths.hookPath, "utf8");
216
+ const hasManaged = existing.includes(HOOK_BEGIN) && existing.includes(HOOK_END);
217
+ if (!hasManaged) {
218
+ return { hookPath: paths.hookPath, removed: false, restored: false };
219
+ }
220
+ if (existsSync(paths.backupPath)) {
221
+ renameSync(paths.backupPath, paths.hookPath);
222
+ chmodSync(paths.hookPath, 0o755);
223
+ return { hookPath: paths.hookPath, removed: true, restored: true };
224
+ }
225
+ const stripped = stripManagedBlock(existing).trim();
226
+ if (isShebangOnly(stripped)) {
227
+ unlinkSync(paths.hookPath);
228
+ return { hookPath: paths.hookPath, removed: true, restored: false };
229
+ }
230
+ writeFileSync(paths.hookPath, ensureTrailingNewline(stripped), {
231
+ mode: 0o755,
232
+ });
233
+ return { hookPath: paths.hookPath, removed: true, restored: false };
234
+ }
235
+ //# sourceMappingURL=hook.js.map
package/dist/index.d.ts CHANGED
@@ -4,7 +4,8 @@ export { inspectStaged, commitWithMessage, GitError } from "./git.ts";
4
4
  export { proposeHeuristic, inferType, inferScope, inferSubject, classifyPath, } from "./heuristic.ts";
5
5
  export { parseArgs, helpText, UsageError } from "./parse-args.ts";
6
6
  export { assembleDiff, parseUnifiedDiff } from "./parse-diff.ts";
7
+ export { installHook, uninstallHook, runPrepareCommitMsg, shouldSkipPrepareCommitMsg, hasNonCommentContent, HOOK_BEGIN, HOOK_END, } from "./hook.ts";
7
8
  export { proposeMessage } from "./propose.ts";
8
9
  export { applyVibe } from "./vibe.ts";
9
- export type { CliOptions, CommitType, ProposedMessage, ProposeOptions, StagedDiff, StagedFile, Vibe, } from "./types.ts";
10
+ export type { CliCommand, CliOptions, CommitType, ProposedMessage, ProposeOptions, StagedDiff, StagedFile, Vibe, } from "./types.ts";
10
11
  export { COMMIT_TYPES, VIBES } from "./types.ts";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export { inspectStaged, commitWithMessage, GitError } from "./git.js";
4
4
  export { proposeHeuristic, inferType, inferScope, inferSubject, classifyPath, } from "./heuristic.js";
5
5
  export { parseArgs, helpText, UsageError } from "./parse-args.js";
6
6
  export { assembleDiff, parseUnifiedDiff } from "./parse-diff.js";
7
+ export { installHook, uninstallHook, runPrepareCommitMsg, shouldSkipPrepareCommitMsg, hasNonCommentContent, HOOK_BEGIN, HOOK_END, } from "./hook.js";
7
8
  export { proposeMessage } from "./propose.js";
8
9
  export { applyVibe } from "./vibe.js";
9
10
  export { COMMIT_TYPES, VIBES } from "./types.js";
@@ -3,6 +3,12 @@ const HELP = `vibe-commit — stylish conventional commits, offline-first
3
3
 
4
4
  Usage
5
5
  vibe-commit [options]
6
+ vibe-commit install [--path <repo>]
7
+ vibe-commit uninstall [--path <repo>]
8
+
9
+ Commands
10
+ install Install a prepare-commit-msg hook in the repo
11
+ uninstall Remove the managed prepare-commit-msg hook
6
12
 
7
13
  Options
8
14
  --commit Create the commit with the proposed message
@@ -12,6 +18,7 @@ Options
12
18
  --vibe <style> playful | pro | terse (default: pro)
13
19
  --no-ai Skip AI even if OPENAI_API_KEY is set
14
20
  --json Print a machine-readable proposal
21
+ --path <repo> Repo root for install/uninstall (default: cwd)
15
22
  -h, --help Show help
16
23
  -v, --version Show version
17
24
 
@@ -20,12 +27,20 @@ Env
20
27
  OPENAI_MODEL Default: gpt-4o-mini
21
28
  OPENAI_BASE_URL Default: https://api.openai.com/v1
22
29
  VIBE / VIBE_COMMIT_VIBE Default vibe when --vibe is omitted
30
+ VIBE_COMMIT_HOOK_AI Set to 1 to allow AI inside the git hook (off by default)
31
+
32
+ Git hook
33
+ After \`vibe-commit install\`, plain \`git commit\` (no -m) fills an empty /
34
+ comment-only message file with a vibe-commit proposal. Skips merge, squash,
35
+ amend (source=commit), and any message that already has non-comment text.
23
36
 
24
37
  Examples
25
38
  git add -p
26
39
  vibe-commit
27
40
  vibe-commit --vibe playful --commit
28
41
  git commit -m "$(vibe-commit --dry-run)"
42
+ vibe-commit install
43
+ vibe-commit uninstall --path ~/projects/app
29
44
  `;
30
45
  export function defaultVibeFromEnv(env = process.env) {
31
46
  const raw = (env.VIBE_COMMIT_VIBE ?? env.VIBE ?? "pro").trim().toLowerCase();
@@ -33,6 +48,7 @@ export function defaultVibeFromEnv(env = process.env) {
33
48
  }
34
49
  export function parseArgs(argv, env = process.env) {
35
50
  const options = {
51
+ command: "propose",
36
52
  commit: false,
37
53
  dryRun: false,
38
54
  vibe: defaultVibeFromEnv(env),
@@ -41,7 +57,15 @@ export function parseArgs(argv, env = process.env) {
41
57
  help: false,
42
58
  version: false,
43
59
  };
44
- for (let i = 0; i < argv.length; i += 1) {
60
+ let i = 0;
61
+ if (argv[0] && !argv[0].startsWith("-")) {
62
+ const cmd = parseCommand(argv[0]);
63
+ if (cmd) {
64
+ options.command = cmd;
65
+ i = 1;
66
+ }
67
+ }
68
+ for (; i < argv.length; i += 1) {
45
69
  const arg = argv[i];
46
70
  if (arg === undefined)
47
71
  break;
@@ -86,13 +110,49 @@ export function parseArgs(argv, env = process.env) {
86
110
  options.vibe = parseVibe(value);
87
111
  continue;
88
112
  }
113
+ if (arg === "--path" || arg.startsWith("--path=")) {
114
+ const value = arg.includes("=") ? arg.slice("--path=".length) : argv[++i];
115
+ if (!value)
116
+ throw new UsageError("Missing value for --path");
117
+ options.path = value;
118
+ continue;
119
+ }
89
120
  if (arg.startsWith("-")) {
90
121
  throw new UsageError(`Unknown option: ${arg}`);
91
122
  }
123
+ if (options.command === "prepare-commit-msg") {
124
+ if (!options.messageFile) {
125
+ options.messageFile = arg;
126
+ continue;
127
+ }
128
+ if (!options.commitSource) {
129
+ options.commitSource = arg;
130
+ continue;
131
+ }
132
+ throw new UsageError(`Unexpected argument: ${arg}`);
133
+ }
92
134
  throw new UsageError(`Unexpected argument: ${arg}`);
93
135
  }
136
+ if (options.command === "prepare-commit-msg" && !options.messageFile) {
137
+ throw new UsageError("prepare-commit-msg requires a message file path (git passes this automatically)");
138
+ }
139
+ if (options.path &&
140
+ options.command !== "install" &&
141
+ options.command !== "uninstall") {
142
+ throw new UsageError("--path is only valid with install or uninstall");
143
+ }
94
144
  return options;
95
145
  }
146
+ function parseCommand(value) {
147
+ switch (value) {
148
+ case "install":
149
+ case "uninstall":
150
+ case "prepare-commit-msg":
151
+ return value;
152
+ default:
153
+ return null;
154
+ }
155
+ }
96
156
  export function parseType(value) {
97
157
  const raw = value?.trim().toLowerCase();
98
158
  if (!raw)
package/dist/types.d.ts CHANGED
@@ -35,7 +35,9 @@ export interface ProposeOptions {
35
35
  vibe: Vibe;
36
36
  preferAi?: boolean;
37
37
  }
38
+ export type CliCommand = "propose" | "install" | "uninstall" | "prepare-commit-msg";
38
39
  export interface CliOptions {
40
+ command: CliCommand;
39
41
  commit: boolean;
40
42
  dryRun: boolean;
41
43
  type?: CommitType;
@@ -45,4 +47,10 @@ export interface CliOptions {
45
47
  json: boolean;
46
48
  help: boolean;
47
49
  version: boolean;
50
+ /** Target repo for install/uninstall (default: cwd). */
51
+ path?: string;
52
+ /** Message file path for prepare-commit-msg. */
53
+ messageFile?: string;
54
+ /** Git prepare-commit-msg source (message|template|merge|squash|commit). */
55
+ commitSource?: string;
48
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webywz/vibe-commit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Stylish conventional commit messages with personality. Offline-first, AI-optional.",
5
5
  "keywords": [
6
6
  "git",
@@ -23,7 +23,10 @@
23
23
  "author": "webywz",
24
24
  "type": "module",
25
25
  "bin": {
26
- "vibe-commit": "./dist/cli.js"
26
+ "vibe-commit": "dist/cli.js"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
27
30
  },
28
31
  "exports": {
29
32
  ".": {
@@ -43,7 +46,7 @@
43
46
  "node": ">=18.18"
44
47
  },
45
48
  "scripts": {
46
- "build": "tsc",
49
+ "build": "tsc && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
47
50
  "clean": "rm -rf dist",
48
51
  "prepublishOnly": "npm run build",
49
52
  "test": "tsx --test test/*.test.ts",