@webywz/vibe-commit 0.1.1 → 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,131 +1,120 @@
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/@webywz/vibe-commit.svg)](https://www.npmjs.com/package/@webywz/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.
12
20
 
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
- ```
21
+ 与 [diff-vibe](https://github.com/webywz/diff-vibe) 成对:这边写 commit,那边写 PR。
45
22
 
46
23
  ---
47
24
 
48
25
  ## Install · 安装
49
26
 
50
27
  ```bash
51
- npm install -g @webywz/vibe-commit
52
- # or, no install:
28
+ npm i -g @webywz/vibe-commit
53
29
  npx @webywz/vibe-commit
54
30
  ```
55
31
 
56
- Requires **Node.js 18.18+** and `git`. After install, the CLI binary is still `vibe-commit`.
32
+ Requires **Node.js 18.18+** and `git`. Package: [`@webywz/vibe-commit`](https://www.npmjs.com/package/@webywz/vibe-commit). Binary: `vibe-commit`.
57
33
 
58
- ```bash
59
- npm i -g @webywz/vibe-commit # 全局安装
60
- npx @webywz/vibe-commit # 一次性运行
61
- ```
34
+ 需要 **Node.js 18.18+** 与 `git`。包名 `@webywz/vibe-commit`,命令仍是 `vibe-commit`。
62
35
 
63
36
  ---
64
37
 
65
- ## Usage · 用法
38
+ ## Quick start · 快速开始
66
39
 
67
40
  ```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
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
71
47
  ```
72
48
 
73
- | Flag | What it does · 作用 |
49
+ | Flag / command | What it does · 作用 |
74
50
  | --- | --- |
75
- | `--commit` | Run `git commit` with the proposed message · 用生成的信息执行提交 |
76
- | `--dry-run` | Print only; never commit (default, and wins over `--commit`) |
51
+ | `--commit` | Run `git commit` with the proposed message · 用生成信息执行提交 |
52
+ | `--dry-run` | Print only; never commit (default) · 只打印,不提交(默认) |
77
53
  | `--type <type>` | Force `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` |
78
54
  | `--scope <scope>` | Override inferred scope · 覆盖推断出的 scope |
79
55
  | `--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 |
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 · 帮助 / 版本 |
84
61
 
85
- ### Vibes
62
+ **Vibes** · 语气
86
63
 
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.
64
+ - **pro** — default. Imperative, review-ready. · 默认:祈使句,可进 review。
65
+ - **terse** — headline only, filler gone. · 只留标题,去掉废话。
66
+ - **playful** — a little personality, still safe for a real repo. · 带一点性格,但仍适合正经仓库。
90
67
 
91
- 默认 **pro**:专业、祈使句、可直接进 code review。`terse` 更短;`playful` 带一点俏皮,但不会变成段子。
68
+ ```text
69
+ $ vibe-commit
92
70
 
93
- ---
71
+ vibe-commit · heuristic · feat
94
72
 
95
- ## How it works · 原理
73
+ 1 file · +124 −8
96
74
 
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).
75
+ proposed
107
76
 
108
- 只看 **已暂存** 的 diff。启发式始终可用;配置了 API Key 才走模型,失败自动回退。
77
+ feat(heuristic): infer conventional commit type from staged diffs
109
78
 
110
- ---
79
+ next vibe-commit --commit
80
+ ```
111
81
 
112
- ## AI (optional)
82
+ ### Git hook · 钩子
113
83
 
114
84
  ```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
85
+ vibe-commit install
86
+ vibe-commit install --path ~/projects/app
87
+ vibe-commit uninstall
120
88
  ```
121
89
 
122
- Copy `.env.example` if you keep secrets in a file. vibe-commit never sends unstaged files, and it truncates huge diffs.
90
+ After install, plain `git commit` (no `-m`) fills an empty or comment-only message file with a conventional proposal.
123
91
 
124
- 兼容 OpenAI Chat Completions。没 Key、超时、4xx/5xx → 静默回退到离线启发式,CLI 仍然能给出一条能用的信息。
92
+ 安装后,裸 `git commit`(没有 `-m`)会在空 / 仅注释的提交说明里填入提案。
125
93
 
126
- ---
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 退出,不打断提交 |
99
+
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.
101
+
102
+ 钩子默认离线;设 `VIBE_COMMIT_HOOK_AI=1` 才允许 AI。用标记块保证干净卸载。已有外来钩子会备份并链式调用。
127
103
 
128
- ## Library
104
+ ### AI (optional) · AI(可选)
105
+
106
+ ```bash
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
111
+ ```
112
+
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.
114
+
115
+ 兼容 OpenAI Chat Completions。没 Key、超时或 4xx/5xx → 静默回退离线启发式。可参考 `.env.example`。不会上传未暂存文件;过大 diff 会截断。
116
+
117
+ ### Library · 库
129
118
 
130
119
  ```ts
131
120
  import { proposeHeuristic, formatCommit } from "@webywz/vibe-commit";
@@ -137,21 +126,33 @@ console.log(formatCommit(message));
137
126
 
138
127
  ---
139
128
 
140
- ## Develop · 开发
129
+ ## Features · 特性
130
+
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`。
135
+ - `README` / `docs/` → `docs`
136
+ - `*.test.ts` / `test/` → `test`
137
+ - `.github/workflows` → `ci`
138
+ - lockfiles / `package.json` → `chore`
139
+ - `fix` / `bug` / `crash` in the diff → `fix`
140
+ - new files / `export function` → `feat`
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` 钩子。
145
+
146
+ ---
147
+
148
+ ## Links · 链接
149
+
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/` (shebang + executable bit on `dist/cli.js`) |
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.1",
3
+ "version": "0.1.2",
4
4
  "description": "Stylish conventional commit messages with personality. Offline-first, AI-optional.",
5
5
  "keywords": [
6
6
  "git",