@verndale/ai-commit 2.0.0 → 2.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/.env.example ADDED
@@ -0,0 +1,7 @@
1
+ # Copy to .env and/or .env.local in your project root (do not commit secrets).
2
+
3
+ # @verndale/ai-commit — OPENAI_API_KEY: OpenAI API key for conventional commit messages (ai-commit run; optional for prepare-commit-msg with AI).
4
+ OPENAI_API_KEY=
5
+
6
+ # @verndale/ai-commit — COMMIT_AI_MODEL: OpenAI model for commit messages (optional; default gpt-4o-mini).
7
+ # COMMIT_AI_MODEL=
package/README.md CHANGED
@@ -13,11 +13,25 @@ AI-assisted [Conventional Commits](https://www.conventionalcommits.org/) with **
13
13
  pnpm add -D @verndale/ai-commit
14
14
  ```
15
15
 
16
+ The same package works with **npm** or **yarn** (for example `npm install -D @verndale/ai-commit`); use your package manager’s `exec` / `npx` equivalent where the docs show `pnpm exec`.
17
+
18
+ ## Quick setup (deterministic order)
19
+
20
+ 1. **Install** the dev dependency (see [Install](#install)).
21
+ 2. **Init** — From the **git repo root** (where **`package.json`** lives), run **`pnpm exec ai-commit init`**. That merges **`.env`** / **`.env.example`** (see [`.env.example`](.env.example) for keys and comments), runs **`npx husky@9 init`** if Husky is not present, adds missing **`commit`** / **`prepare`** / **`husky`** entries to **`package.json`** when the file exists, and writes **`.husky`** hooks. **Install dependencies** afterward if **`package.json`** changed (`pnpm install`, `npm install`, etc.).
22
+ - Not in a git repo? **init** only updates env files and explains that Git/Husky were skipped.
23
+ - Env files only? Use **`pnpm exec ai-commit init --env-only`**.
24
+ - Hooks only (no **`package.json`** changes)? Use **`pnpm exec ai-commit init --husky`**.
25
+ 3. **Secrets** — Set **`OPENAI_API_KEY`** in `.env` and/or `.env.local` (`.env.local` overrides `.env` for duplicate keys).
26
+
27
+ Use **`ai-commit init --force`** to replace **`.env`** with the bundled template (destructive) or to overwrite existing Husky hook files. **`init` does not** fully replace a committed **`.env.example`**; it only appends missing ai-commit keys there.
28
+
16
29
  ## Environment
17
30
 
18
31
  - **`OPENAI_API_KEY`** — Required for `ai-commit run` (and for AI-filled `prepare-commit-msg` when you want the model). Optional `COMMIT_AI_MODEL` (default `gpt-4o-mini`).
32
+ - **Shared env vars** — If another tool already documents **`OPENAI_API_KEY`** or **`COMMIT_AI_MODEL`**, **`ai-commit init`** adds its own `# @verndale/ai-commit — …` line immediately above the assignment when missing; it does not remove or replace existing comment lines.
19
33
  - The CLI loads **`.env`** then **`.env.local`** from the current working directory (project root); values in `.env.local` override `.env` for the same key.
20
- - **Optional tooling** (see [`.env.example`](./.env.example)): `PR_*` for [`tools/open-pr.js`](./tools/open-pr.js) / the **Create or update PR** workflow; `RELEASE_NOTES_AI_*` for the semantic-release notes plugin. Use a GitHub PAT as **`GH_TOKEN`** (or `GITHUB_TOKEN`) when calling the GitHub API outside Actions.
34
+ - **Optional tooling:** `PR_*` env vars for [`@verndale/ai-pr`](https://www.npmjs.com/package/@verndale/ai-pr) (`pnpm open-pr` in this repo) / the **Create or update PR** workflow; `RELEASE_NOTES_AI_*` for [`tools/semantic-release-notes.cjs`](./tools/semantic-release-notes.cjs). Use a GitHub PAT as **`GH_TOKEN`** (or `GITHUB_TOKEN`) when calling the GitHub API outside Actions.
21
35
 
22
36
  ## Commit policy (v2)
23
37
 
@@ -36,6 +50,7 @@ pnpm add -D @verndale/ai-commit
36
50
  | Command | Purpose |
37
51
  | --- | --- |
38
52
  | `ai-commit run` | Generate a message from the staged diff and run `git commit`. |
53
+ | `ai-commit init [--force] [--env-only] [--husky] [--workspace]` | Merge env keys, then **`npx husky@9 init`** if needed, merge **`package.json`** when present, write hooks. **`--env-only`** stops after env files. **`--husky`** skips `package.json` (hooks + Husky only); use **`--husky --workspace`** to include **`package.json`** again. **`--force`** replaces `.env` / overwrites hooks. |
39
54
  | `ai-commit prepare-commit-msg <file> [source]` | Git `prepare-commit-msg` hook: fill an empty message; skips `merge` / `squash`. |
40
55
  | `ai-commit lint --edit <file>` | Git `commit-msg` hook: run commitlint with this package’s default config. |
41
56
 
@@ -51,7 +66,7 @@ pnpm add -D @verndale/ai-commit
51
66
 
52
67
  ## Husky (manual setup)
53
68
 
54
- Install Husky in your project (`husky` + `"prepare": "husky"` in `package.json` if needed), then add hooks.
69
+ **`pnpm exec ai-commit init`** does this automatically. To add hooks by hand, install Husky (`husky` + `"prepare": "husky"` in `package.json` if needed), then add the snippets below.
55
70
 
56
71
  **`.husky/prepare-commit-msg`**
57
72
 
@@ -71,7 +86,9 @@ pnpm exec ai-commit prepare-commit-msg "$1" "$2"
71
86
  pnpm exec ai-commit lint --edit "$1"
72
87
  ```
73
88
 
74
- Use `npx` or `yarn` instead if that matches your toolchain.
89
+ Hooks created by **`init`** use **`pnpm exec ai-commit`** when **`pnpm-lock.yaml`** exists, otherwise **`npx --no ai-commit`**. Edit the hook files if you use another runner.
90
+
91
+ **If Husky is already set up:** **`init`** does not run **`npx husky@9 init`** when **`.husky/_/husky.sh`** already exists. **`package.json`** is only updated for missing **`commit`**, **`prepare`**, or **`devDependencies.husky`** (nothing is replaced). Hook files **`.husky/prepare-commit-msg`** and **`.husky/commit-msg`** are left alone if they already exist; use **`ai-commit init --force`** to overwrite them with the ai-commit snippets.
75
92
 
76
93
  ## commitlint without a second install
77
94
 
@@ -108,7 +125,7 @@ Copy `.env.example` to `.env` and/or `.env.local` and set **`OPENAI_API_KEY`**.
108
125
  | Workflow | Trigger | Purpose |
109
126
  | --- | --- | --- |
110
127
  | [`.github/workflows/commitlint.yml`](./.github/workflows/commitlint.yml) | PRs to `main`, pushes to non-`main` branches | Commitlint on PR range or last push commit |
111
- | [`.github/workflows/pr.yml`](./.github/workflows/pr.yml) | Pushes (not `main`) and `workflow_dispatch` | Install deps, run **`pnpm open-pr`** (`node tools/open-pr.js`) — set **`PR_HEAD_BRANCH`** / **`PR_BASE_BRANCH`** in CI via env (workflow sets them). Use a PAT secret **`PR_BOT_TOKEN`** if branch protection requires it; otherwise document your org’s policy. |
128
+ | [`.github/workflows/pr.yml`](./.github/workflows/pr.yml) | Pushes (not `main`) and `workflow_dispatch` | Install deps, run **`pnpm open-pr`** ([**`@verndale/ai-pr`**](https://www.npmjs.com/package/@verndale/ai-pr)) — set **`PR_HEAD_BRANCH`** / **`PR_BASE_BRANCH`** in CI via env (workflow sets them). Use a PAT secret **`PR_BOT_TOKEN`** if branch protection requires it; otherwise document your org’s policy. |
112
129
  | [`.github/workflows/release.yml`](./.github/workflows/release.yml) | Push to **`main`** (including when a PR merges) | **`semantic-release`** — version bump, `CHANGELOG.md`, git tag, npm publish (with provenance), GitHub Release |
113
130
 
114
131
  Optional **`pnpm open-pr`** locally: set **`GH_TOKEN`** (or **`GITHUB_TOKEN`**) and branch overrides **`PR_BASE_BRANCH`** / **`PR_HEAD_BRANCH`** as needed.
package/bin/cli.js CHANGED
@@ -10,9 +10,18 @@ require("../lib/load-project-env.js").loadProjectEnv();
10
10
  const { generateAndValidate } = require("../lib/core/generate.js");
11
11
  const {
12
12
  assertInGitRepo,
13
+ isInGitRepo,
13
14
  hasStagedChanges,
14
15
  commitFromFile,
15
16
  } = require("../lib/core/git.js");
17
+ const { mergeAiCommitEnvFile } = require("../lib/init-env.js");
18
+ const {
19
+ detectPackageExec,
20
+ hookScript,
21
+ runHuskyInit,
22
+ mergePackageJsonForAiCommit,
23
+ warnIfPrepareMissingHusky,
24
+ } = require("../lib/init-workspace.js");
16
25
 
17
26
  function presetPath() {
18
27
  return path.join(__dirname, "..", "lib", "commitlint-preset.cjs");
@@ -27,11 +36,13 @@ function printHelp() {
27
36
 
28
37
  Usage:
29
38
  ai-commit run
39
+ ai-commit init [--force] [--env-only] [--husky] [--workspace]
30
40
  ai-commit prepare-commit-msg <file> [source]
31
41
  ai-commit lint --edit <file>
32
42
 
33
43
  Commands:
34
44
  run Generate a message from the staged diff and run git commit.
45
+ init Merge env, then Husky + package.json + hooks (from a git repo). \`--env-only\` stops after env files. \`--husky\` skips package.json. \`--force\` replaces \`.env\` / hooks.
35
46
  prepare-commit-msg Git hook: fill an empty commit message file (merge/squash skipped).
36
47
  lint Run commitlint with the package default config (for commit-msg hook).
37
48
 
@@ -51,6 +62,150 @@ function parseLintArgv(argv) {
51
62
  return { file: argv[i + 1] };
52
63
  }
53
64
 
65
+ function parseInitArgv(argv) {
66
+ let force = false;
67
+ let husky = false;
68
+ let workspace = false;
69
+ let envOnly = false;
70
+ for (const a of argv) {
71
+ if (a === "--force") {
72
+ force = true;
73
+ } else if (a === "--husky") {
74
+ husky = true;
75
+ } else if (a === "--workspace") {
76
+ workspace = true;
77
+ } else if (a === "--env-only") {
78
+ envOnly = true;
79
+ }
80
+ }
81
+ return { force, husky, workspace, envOnly };
82
+ }
83
+
84
+ function cmdInit(argv) {
85
+ const { force, husky, workspace, envOnly } = parseInitArgv(argv);
86
+ const cwd = process.cwd();
87
+ /** Full package.json merge: default on, or `--workspace`; off for `--husky` alone (legacy). */
88
+ const mergePackageJson = !husky || workspace;
89
+ const examplePath = path.join(__dirname, "..", ".env.example");
90
+
91
+ if (!fs.existsSync(examplePath)) {
92
+ throw new Error("Missing bundled .env.example (corrupt install?).");
93
+ }
94
+
95
+ const envDest = path.join(cwd, ".env");
96
+ const envResult = mergeAiCommitEnvFile(envDest, examplePath, { force });
97
+ const envRel = path.relative(cwd, envDest) || ".env";
98
+ switch (envResult.kind) {
99
+ case "replaced":
100
+ process.stdout.write(`Replaced ${envRel} with bundled template (--force).\n`);
101
+ break;
102
+ case "wrote":
103
+ process.stdout.write(`Wrote ${envRel} from bundled template.\n`);
104
+ break;
105
+ case "merged":
106
+ process.stdout.write(`Appended missing @verndale/ai-commit keys to ${envRel}.\n`);
107
+ break;
108
+ case "unchanged":
109
+ process.stdout.write(
110
+ `No missing @verndale/ai-commit keys in ${envRel}; left unchanged. Use --force to replace the file with the bundled template.\n`,
111
+ );
112
+ break;
113
+ default:
114
+ break;
115
+ }
116
+
117
+ const envExampleDest = path.join(cwd, ".env.example");
118
+ if (fs.existsSync(envExampleDest)) {
119
+ const exResult = mergeAiCommitEnvFile(envExampleDest, examplePath, { force: false });
120
+ const exRel = path.relative(cwd, envExampleDest) || ".env.example";
121
+ switch (exResult.kind) {
122
+ case "wrote":
123
+ process.stdout.write(`Wrote ${exRel} from bundled template.\n`);
124
+ break;
125
+ case "merged":
126
+ process.stdout.write(`Appended missing @verndale/ai-commit keys to ${exRel}.\n`);
127
+ break;
128
+ case "unchanged":
129
+ process.stdout.write(`No missing @verndale/ai-commit keys in ${exRel}; left unchanged.\n`);
130
+ break;
131
+ default:
132
+ break;
133
+ }
134
+ }
135
+
136
+ if (envOnly) {
137
+ return;
138
+ }
139
+
140
+ if (!isInGitRepo(cwd)) {
141
+ process.stdout.write(
142
+ "Not a git repository (or git unavailable); skipped Husky and package.json. Re-run from a repo root for hooks and scripts.\n",
143
+ );
144
+ return;
145
+ }
146
+
147
+ const huskyHelper = path.join(cwd, ".husky", "_", "husky.sh");
148
+
149
+ if (!fs.existsSync(huskyHelper)) {
150
+ const r = runHuskyInit(cwd);
151
+ if (!r.ok) {
152
+ process.stderr.write(
153
+ r.error
154
+ ? `husky init failed: ${r.error}\n`
155
+ : `husky init failed (exit ${r.status ?? "unknown"}). Run \`npx husky init\` in this repo, then run ai-commit init again.\n`,
156
+ );
157
+ process.exit(1);
158
+ }
159
+ process.stdout.write("Ran `npx husky@9 init`.\n");
160
+ } else {
161
+ process.stdout.write(
162
+ "Husky already initialized (found .husky/_/husky.sh); skipped `npx husky@9 init`.\n",
163
+ );
164
+ }
165
+
166
+ if (mergePackageJson) {
167
+ const pkgPath = path.join(cwd, "package.json");
168
+ if (fs.existsSync(pkgPath)) {
169
+ const { changed } = mergePackageJsonForAiCommit(pkgPath);
170
+ if (changed) {
171
+ process.stdout.write(
172
+ "Updated package.json (commit script, prepare, and/or devDependencies.husky). Run your package manager install if you added dependencies.\n",
173
+ );
174
+ }
175
+ warnIfPrepareMissingHusky(pkgPath);
176
+ } else {
177
+ process.stdout.write("No package.json in this directory; skipped package.json merge (hooks still written).\n");
178
+ }
179
+ }
180
+
181
+ const huskyDir = path.join(cwd, ".husky");
182
+ if (!fs.existsSync(huskyDir)) {
183
+ fs.mkdirSync(huskyDir, { recursive: true });
184
+ }
185
+
186
+ const execPrefix = detectPackageExec(cwd);
187
+ const preparePath = path.join(huskyDir, "prepare-commit-msg");
188
+ const commitMsgPath = path.join(huskyDir, "commit-msg");
189
+
190
+ for (const [hookPath, hookKind] of [
191
+ [preparePath, "prepare-commit-msg"],
192
+ [commitMsgPath, "commit-msg"],
193
+ ]) {
194
+ const body = hookScript(execPrefix, hookKind);
195
+ if (fs.existsSync(hookPath) && !force) {
196
+ process.stderr.write(`Skipped ${path.relative(cwd, hookPath)} (already exists). Use --force to overwrite.\n`);
197
+ } else {
198
+ fs.writeFileSync(hookPath, body, { encoding: "utf8" });
199
+ try {
200
+ fs.chmodSync(hookPath, 0o755);
201
+ } catch {
202
+ // ignore on platforms that do not support chmod
203
+ }
204
+ process.stdout.write(`Wrote ${path.relative(cwd, hookPath)}.\n`);
205
+ }
206
+ }
207
+ }
208
+
54
209
  function stripGitComments(text) {
55
210
  return text
56
211
  .split("\n")
@@ -124,6 +279,10 @@ async function main() {
124
279
  await cmdRun();
125
280
  return;
126
281
  }
282
+ if (cmd === "init") {
283
+ cmdInit(argv.slice(1));
284
+ return;
285
+ }
127
286
  if (cmd === "prepare-commit-msg") {
128
287
  const file = argv[1];
129
288
  const source = argv[2];
package/lib/core/git.js CHANGED
@@ -22,10 +22,17 @@ function execGit(args, options = {}) {
22
22
  });
23
23
  }
24
24
 
25
- function assertInGitRepo(cwd = process.cwd()) {
25
+ function isInGitRepo(cwd = process.cwd()) {
26
26
  try {
27
27
  execGit(["rev-parse", "--git-dir"], { cwd });
28
+ return true;
28
29
  } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function assertInGitRepo(cwd = process.cwd()) {
35
+ if (!isInGitRepo(cwd)) {
29
36
  const err = new Error("Not a git repository (or git not available).");
30
37
  err.code = "ENOTGIT";
31
38
  throw err;
@@ -89,6 +96,7 @@ module.exports = {
89
96
  GIT_DIFF_MAX_BUFFER,
90
97
  DIFF_EXCLUDE_PATHSPECS,
91
98
  execGit,
99
+ isInGitRepo,
92
100
  assertInGitRepo,
93
101
  getStagedDiff,
94
102
  getChangedFiles,
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+
5
+ /** Detect our doc line so we do not duplicate or replace other packages’ comments. */
6
+ const MARKER_PREFIX = "# @verndale/ai-commit — ";
7
+
8
+ const DOC_OPENAI = [
9
+ `${MARKER_PREFIX}OPENAI_API_KEY: OpenAI API key for conventional commit messages (ai-commit run; optional for prepare-commit-msg with AI).`,
10
+ ];
11
+
12
+ const DOC_COMMIT_MODEL = [
13
+ `${MARKER_PREFIX}COMMIT_AI_MODEL: OpenAI model for commit messages (optional; default gpt-4o-mini).`,
14
+ ];
15
+
16
+ /**
17
+ * Keys assigned on non-comment lines (`KEY=value` or `export KEY=value`).
18
+ * @param {string} text
19
+ * @returns {Set<string>}
20
+ */
21
+ function parseDotenvAssignedKeys(text) {
22
+ const keys = new Set();
23
+ for (const line of text.split(/\r?\n/)) {
24
+ const t = line.trim();
25
+ if (!t || t.startsWith("#")) {
26
+ continue;
27
+ }
28
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(t);
29
+ if (m) {
30
+ keys.add(m[1]);
31
+ }
32
+ }
33
+ return keys;
34
+ }
35
+
36
+ function hasOurDocForKey(lines, key) {
37
+ const needle = `${MARKER_PREFIX}${key}:`;
38
+ return lines.some((line) => line.includes(needle));
39
+ }
40
+
41
+ /**
42
+ * Insert ai-commit doc lines immediately before an assignment line, without changing
43
+ * existing comments above that line (we insert after those lines, before the key line).
44
+ * @param {string[]} lines mutable
45
+ * @param {RegExp} assignmentRegex
46
+ * @param {string[]} docLines
47
+ * @param {string} key for marker check
48
+ * @returns {boolean} whether lines were mutated
49
+ */
50
+ function injectDocBeforeAssignment(lines, assignmentRegex, docLines, key) {
51
+ if (hasOurDocForKey(lines, key)) {
52
+ return false;
53
+ }
54
+ const idx = lines.findIndex((line) => assignmentRegex.test(line));
55
+ if (idx === -1) {
56
+ return false;
57
+ }
58
+ lines.splice(idx, 0, ...docLines);
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * For keys already present (possibly with another package’s comments), add our doc line(s)
64
+ * above the assignment if missing. Does not remove or edit existing comment lines.
65
+ * @param {string} content
66
+ * @returns {string}
67
+ */
68
+ function injectAiCommitDocsForExistingKeys(content) {
69
+ const lines = content.split(/\r?\n/);
70
+ let changed = false;
71
+
72
+ if (
73
+ injectDocBeforeAssignment(
74
+ lines,
75
+ /^\s*OPENAI_API_KEY\s*=/,
76
+ DOC_OPENAI,
77
+ "OPENAI_API_KEY",
78
+ )
79
+ ) {
80
+ changed = true;
81
+ }
82
+
83
+ if (!hasOurDocForKey(lines, "COMMIT_AI_MODEL")) {
84
+ let idx = lines.findIndex((line) => /^\s*COMMIT_AI_MODEL\s*=/.test(line));
85
+ if (idx === -1) {
86
+ idx = lines.findIndex((line) => /^\s*#\s*COMMIT_AI_MODEL\s*=/.test(line));
87
+ }
88
+ if (idx !== -1) {
89
+ lines.splice(idx, 0, ...DOC_COMMIT_MODEL);
90
+ changed = true;
91
+ }
92
+ }
93
+
94
+ return changed ? lines.join("\n") : content;
95
+ }
96
+
97
+ /**
98
+ * Build text to append so OPENAI_API_KEY / COMMIT_AI_MODEL placeholders exist.
99
+ * Returns null if nothing to add.
100
+ * @param {string} existing
101
+ * @returns {string | null}
102
+ */
103
+ function buildAiCommitEnvAppend(existing) {
104
+ const keys = parseDotenvAssignedKeys(existing);
105
+ const hasCommitPlaceholder =
106
+ keys.has("COMMIT_AI_MODEL") ||
107
+ /^\s*#\s*COMMIT_AI_MODEL\s*=/m.test(existing) ||
108
+ /^\s*COMMIT_AI_MODEL\s*=/m.test(existing);
109
+ const parts = [];
110
+ if (!keys.has("OPENAI_API_KEY")) {
111
+ parts.push(`${DOC_OPENAI[0]}\nOPENAI_API_KEY=\n`);
112
+ }
113
+ if (!keys.has("COMMIT_AI_MODEL") && !hasCommitPlaceholder) {
114
+ parts.push(`${DOC_COMMIT_MODEL[0]}\n# COMMIT_AI_MODEL=\n`);
115
+ }
116
+ if (parts.length === 0) {
117
+ return null;
118
+ }
119
+ return parts.join("\n");
120
+ }
121
+
122
+ /**
123
+ * Merge bundled ai-commit env keys into a file. Never removes existing lines.
124
+ * @param {string} destPath
125
+ * @param {string} bundledPath
126
+ * @param {{ force?: boolean }} [options]
127
+ * @returns {{ kind: 'replaced' | 'wrote' | 'merged' | 'unchanged' }}
128
+ */
129
+ function mergeAiCommitEnvFile(destPath, bundledPath, options = {}) {
130
+ const { force = false } = options;
131
+ const bundled = fs.readFileSync(bundledPath, "utf8");
132
+
133
+ if (force) {
134
+ fs.writeFileSync(destPath, bundled, "utf8");
135
+ return { kind: "replaced" };
136
+ }
137
+
138
+ let existing = "";
139
+ if (fs.existsSync(destPath)) {
140
+ existing = fs.readFileSync(destPath, "utf8");
141
+ }
142
+
143
+ if (!existing.trim()) {
144
+ fs.writeFileSync(destPath, bundled, "utf8");
145
+ return { kind: "wrote" };
146
+ }
147
+
148
+ let text = injectAiCommitDocsForExistingKeys(existing);
149
+ const append = buildAiCommitEnvAppend(text);
150
+ if (append !== null) {
151
+ const sep = text.endsWith("\n") ? "" : "\n";
152
+ text = `${text}${sep}${append}`;
153
+ }
154
+
155
+ if (text === existing) {
156
+ return { kind: "unchanged" };
157
+ }
158
+
159
+ fs.writeFileSync(destPath, text, "utf8");
160
+ return { kind: "merged" };
161
+ }
162
+
163
+ module.exports = {
164
+ parseDotenvAssignedKeys,
165
+ buildAiCommitEnvAppend,
166
+ injectAiCommitDocsForExistingKeys,
167
+ mergeAiCommitEnvFile,
168
+ };
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { spawnSync } = require("child_process");
6
+
7
+ const HUSKY_RANGE = "^9.1.7";
8
+
9
+ /**
10
+ * @param {string} cwd
11
+ * @returns {string} `pnpm exec` when a pnpm lockfile exists; otherwise `npx --no` (npm and Yarn).
12
+ */
13
+ function detectPackageExec(cwd) {
14
+ if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))) {
15
+ return "pnpm exec";
16
+ }
17
+ return "npx --no";
18
+ }
19
+
20
+ /**
21
+ * @param {string} execPrefix from detectPackageExec
22
+ * @param {"prepare-commit-msg" | "commit-msg"} hook
23
+ */
24
+ function hookScript(execPrefix, hook) {
25
+ const cmd =
26
+ hook === "prepare-commit-msg"
27
+ ? `${execPrefix} ai-commit prepare-commit-msg "$1" "$2"`
28
+ : `${execPrefix} ai-commit lint --edit "$1"`;
29
+ return `#!/usr/bin/env sh
30
+ . "$(dirname -- "$0")/_/husky.sh"
31
+
32
+ ${cmd}
33
+ `;
34
+ }
35
+
36
+ /**
37
+ * Run Husky’s initializer when `.husky/_/husky.sh` is missing (installs husky, creates `.husky`, sets prepare).
38
+ * @param {string} cwd
39
+ * @returns {{ ok: boolean, status: number | null, error?: string }}
40
+ */
41
+ function runHuskyInit(cwd) {
42
+ const npx = process.platform === "win32" ? "npx.cmd" : "npx";
43
+ const r = spawnSync(npx, ["--yes", "husky@9", "init"], {
44
+ cwd,
45
+ stdio: "inherit",
46
+ shell: process.platform === "win32",
47
+ });
48
+ if (r.error) {
49
+ return { ok: false, status: null, error: r.error.message };
50
+ }
51
+ const status = r.status ?? 1;
52
+ return { ok: status === 0, status };
53
+ }
54
+
55
+ /**
56
+ * Ensure `commit` script, `prepare` for husky, and `devDependencies.husky`. Does not remove existing scripts.
57
+ * @param {string} packageJsonPath
58
+ * @returns {{ changed: boolean }}
59
+ */
60
+ function mergePackageJsonForAiCommit(packageJsonPath) {
61
+ const raw = fs.readFileSync(packageJsonPath, "utf8");
62
+ const pkg = JSON.parse(raw);
63
+ let changed = false;
64
+
65
+ pkg.scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
66
+ if (!pkg.scripts.commit) {
67
+ pkg.scripts.commit = "ai-commit run";
68
+ changed = true;
69
+ }
70
+ if (!pkg.scripts.prepare) {
71
+ pkg.scripts.prepare = "husky";
72
+ changed = true;
73
+ }
74
+
75
+ pkg.devDependencies =
76
+ pkg.devDependencies && typeof pkg.devDependencies === "object"
77
+ ? pkg.devDependencies
78
+ : {};
79
+ if (!pkg.devDependencies.husky) {
80
+ pkg.devDependencies.husky = HUSKY_RANGE;
81
+ changed = true;
82
+ }
83
+
84
+ if (changed) {
85
+ fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
86
+ }
87
+
88
+ return { changed };
89
+ }
90
+
91
+ /**
92
+ * If `prepare` exists but does not run husky, print a hint (do not rewrite arbitrary scripts).
93
+ * @param {string} packageJsonPath
94
+ */
95
+ function warnIfPrepareMissingHusky(packageJsonPath) {
96
+ const raw = fs.readFileSync(packageJsonPath, "utf8");
97
+ const pkg = JSON.parse(raw);
98
+ const p = pkg.scripts && pkg.scripts.prepare;
99
+ if (typeof p === "string" && p.trim() && !/\bhusky\b/.test(p)) {
100
+ process.stderr.write(
101
+ "warning: package.json has a \"prepare\" script that does not mention husky; ensure Husky runs on install (see https://typicode.github.io/husky/).\n",
102
+ );
103
+ }
104
+ }
105
+
106
+ module.exports = {
107
+ HUSKY_RANGE,
108
+ detectPackageExec,
109
+ hookScript,
110
+ runHuskyInit,
111
+ mergePackageJsonForAiCommit,
112
+ warnIfPrepareMissingHusky,
113
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verndale/ai-commit",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "AI-assisted conventional commits with bundled commitlint — one install, aligned rules",
5
5
  "license": "MIT",
6
6
  "author": "Verndale",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "bin",
30
30
  "lib",
31
+ ".env.example",
31
32
  "README.md",
32
33
  "LICENSE"
33
34
  ],
@@ -42,9 +43,9 @@
42
43
  "prepare": "husky",
43
44
  "commit": "node ./bin/cli.js run",
44
45
  "lint:commit": "commitlint --edit .git/COMMIT_EDITMSG --config lib/commitlint-preset.cjs",
45
- "open-pr": "node tools/open-pr.js",
46
+ "open-pr": "ai-pr",
46
47
  "release": "semantic-release",
47
- "prepublishOnly": "node -e \"require('./lib/rules.js'); require('./lib/commitlint-preset.cjs');\""
48
+ "prepublishOnly": "node -e \"require('./lib/rules.js'); require('./lib/commitlint-preset.cjs'); require('./lib/init-env.js'); require('./lib/init-workspace.js');\""
48
49
  },
49
50
  "dependencies": {
50
51
  "@commitlint/cli": "^20.5.0",
@@ -56,6 +57,7 @@
56
57
  "openai": "^6.33.0"
57
58
  },
58
59
  "devDependencies": {
60
+ "@verndale/ai-pr": "^1.0.0",
59
61
  "@semantic-release/changelog": "^6.0.3",
60
62
  "@semantic-release/commit-analyzer": "^13.0.1",
61
63
  "@semantic-release/git": "^10.0.1",