x7-code-line 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/README.md ADDED
@@ -0,0 +1,333 @@
1
+ # x7-code-line
2
+
3
+ `x7-code-line` 是一个用于安装 Cursor、Codex 和 Git hooks 的 npm 模块。它用于在 agent 工作流中区分普通用户已有改动和 AI 生成改动,并在提交信息中自动追加 AI 行数 trailer。
4
+
5
+ ## 功能概览
6
+
7
+ - 安装 Cursor 项目 hooks:写入当前安装目录的 `.cursor/hooks.json`。
8
+ - 安装 Codex 项目 hooks:写入当前安装目录的 `.codex/hooks.json`。
9
+ - 安装 Git hooks:为目标项目写入 `.git/hooks/pre-commit` 和 `.git/hooks/commit-msg`。
10
+ - 初始化中心缓存目录:写入当前安装目录的 `.x7-code-line/`。
11
+ - 支持多项目:多项目配置只用于替换目标项目的 Git hooks,以及在 `prompt-submit` / `stop` 时遍历读取这些项目的 `git diff`。
12
+ - 记录普通 diff 行:prompt 提交时读取 `git diff`,把尚未进入 AI 缓存的 diff 行写入普通缓存。
13
+ - 记录 AI diff 行:agent 结束时再次读取 `git diff`,把不属于普通缓存的 diff 行写入 AI 缓存。
14
+ - 计算提交 AI 行数:`pre-commit` 对比当前仓库 staged diff 和中心 AI 缓存,计算本次提交真实包含的 AI diff 行数。
15
+ - 写入 commit trailer:`commit-msg` 追加或替换 `x7-ai-lines: <数量>`。
16
+
17
+ ## 安装行为
18
+
19
+ 作为 npm 依赖安装时,`postinstall` 脚本使用 `INIT_CWD` 作为当前安装目录,也就是执行 `npm install` 时所在的项目目录。
20
+
21
+ 当前安装目录会创建或覆盖:
22
+
23
+ ```text
24
+ .x7-code-line/install.json
25
+ .x7-code-line/projects.json
26
+ .x7-code-line/ai-diff-line-ids.json
27
+ .x7-code-line/normal-diff-line-ids.json
28
+ .cursor/hooks.json
29
+ .codex/hooks.json
30
+ ```
31
+
32
+ 如果目标项目存在 `.git`,会创建或覆盖:
33
+
34
+ ```text
35
+ .git/hooks/pre-commit
36
+ .git/hooks/commit-msg
37
+ ```
38
+
39
+ 多项目配置只影响两类行为:
40
+
41
+ - 为配置中的项目目录替换 `.git/hooks/pre-commit` 和 `.git/hooks/commit-msg`。
42
+ - 执行 `prompt-submit` / `stop` 时遍历配置中的项目目录读取 `git diff`。
43
+
44
+ `.cursor/hooks.json`、`.codex/hooks.json` 和 `.x7-code-line/` 只会写入当前安装目录,不会写入每个多项目目标目录。
45
+
46
+ ## Cursor Hooks
47
+
48
+ Cursor 模板写入 `.cursor/hooks.json`:
49
+
50
+ ```json
51
+ {
52
+ "hooks": {
53
+ "beforeSubmitPrompt": [
54
+ {
55
+ "command": "npx --no-install x7-code-line prompt-submit"
56
+ }
57
+ ],
58
+ "stop": [
59
+ {
60
+ "command": "npx --no-install x7-code-line stop"
61
+ }
62
+ ]
63
+ }
64
+ }
65
+ ```
66
+
67
+ ## Codex Hooks
68
+
69
+ Codex 模板写入 `.codex/hooks.json`:
70
+
71
+ ```json
72
+ {
73
+ "hooks": {
74
+ "UserPromptSubmit": [
75
+ {
76
+ "command": "npx --no-install x7-code-line prompt-submit"
77
+ }
78
+ ],
79
+ "Stop": [
80
+ {
81
+ "command": "npx --no-install x7-code-line stop"
82
+ }
83
+ ]
84
+ }
85
+ }
86
+ ```
87
+
88
+ ## Diff 行缓存
89
+
90
+ `prompt-submit` 会进入每个已缓存的项目目录,执行:
91
+
92
+ ```sh
93
+ git diff --no-ext-diff --unified=0 --no-color
94
+ ```
95
+
96
+ 然后对每个变更行计算 ID。ID 由以下信息生成:
97
+
98
+ - 项目绝对路径
99
+ - 文件路径
100
+ - 变更类型:`add` 或 `delete`
101
+ - 行号
102
+ - 行内容
103
+
104
+ 如果该 ID 不在 AI diff 缓存中,就写入普通 diff 缓存:
105
+
106
+ ```text
107
+ .x7-code-line/normal-diff-line-ids.json
108
+ ```
109
+
110
+ `stop` 会再次读取每个项目的 `git diff` 并计算行 ID。如果某个 ID 不在普通 diff 缓存中,就写入 AI diff 缓存:
111
+
112
+ ```text
113
+ .x7-code-line/ai-diff-line-ids.json
114
+ ```
115
+
116
+ 缓存按项目绝对路径分组,结构类似:
117
+
118
+ ```json
119
+ {
120
+ "projects": {
121
+ "/path/to/repo-a": ["line-id-1"],
122
+ "/path/to/repo-b": ["line-id-2"]
123
+ }
124
+ }
125
+ ```
126
+
127
+ ## Git Hooks
128
+
129
+ 安装器会生成 shell hook,并写入中心缓存目录:
130
+
131
+ ```sh
132
+ X7_CODE_LINE_BASE='<当前安装目录>'
133
+ export X7_CODE_LINE_BASE
134
+ npx --no-install x7-code-line git-pre-commit
135
+ ```
136
+
137
+ `pre-commit` 会读取当前仓库的 staged diff:
138
+
139
+ ```sh
140
+ git diff --cached --no-ext-diff --unified=0 --no-color
141
+ ```
142
+
143
+ 然后使用当前仓库绝对路径从中心缓存中取出对应项目的 AI diff 行 ID,计算本次提交真正包含的 AI diff 行数,并写入:
144
+
145
+ ```text
146
+ .x7-code-line/pending-commit.json
147
+ ```
148
+
149
+ `commit-msg` 会读取该结果,并追加或替换 trailer:
150
+
151
+ ```text
152
+ x7-ai-lines: 3
153
+ ```
154
+
155
+ 如果没有命中 AI diff 行,则写入:
156
+
157
+ ```text
158
+ x7-ai-lines: 0
159
+ ```
160
+
161
+ ## 多项目安装
162
+
163
+ 通过 CLI 参数指定多个目标目录:
164
+
165
+ ```sh
166
+ npx --no-install x7-code-line install --dir ../repo-a --dir ../repo-b
167
+ ```
168
+
169
+ 也可以在安装时通过环境变量指定。
170
+
171
+ macOS / Linux 使用 `:` 作为目录分隔符:
172
+
173
+ ```sh
174
+ X7_CODE_LINE_DIRS="../repo-a:../repo-b" npm install x7-code-line
175
+ ```
176
+
177
+ Windows 使用 `;` 作为目录分隔符:
178
+
179
+ ```powershell
180
+ $env:X7_CODE_LINE_DIRS = "../repo-a;../repo-b"
181
+ npm install x7-code-line
182
+ ```
183
+
184
+ ## 配置文件
185
+
186
+ 可以通过 `--config` 或 `X7_CODE_LINE_CONFIG` 指定 JSON 配置文件:
187
+
188
+ ```json
189
+ {
190
+ "dirs": ["../repo-a", "../repo-b"],
191
+ "gitDirs": [".git"]
192
+ }
193
+ ```
194
+
195
+ 字段说明:
196
+
197
+ - `dirs`:目标项目目录列表。
198
+ - `gitDirs`:相对于每个目标项目目录的 Git 目录列表。
199
+
200
+ ## macOS 适配说明
201
+
202
+ macOS 下整体可以直接使用,但需要注意以下几点:
203
+
204
+ - Git hooks 使用 `#!/bin/sh`,macOS 自带 `/bin/sh`,无需额外安装 shell。
205
+ - 安装器会对生成的 hook 文件执行 `chmod 755`。如果你手动复制 hook 文件,也需要执行:
206
+
207
+ ```sh
208
+ chmod +x .git/hooks/pre-commit .git/hooks/commit-msg
209
+ ```
210
+
211
+ - macOS / Linux 的多项目环境变量使用 `:` 分隔目录;不要使用 Windows 的 `;`。
212
+ - 如果 Cursor 或 Codex 不是从终端启动,GUI 进程可能拿不到你的 shell `PATH`,导致 hook 中的 `npx` 找不到。遇到这种情况,优先从终端启动 Cursor/Codex,或确保 Node.js / npm 的安装路径已经进入 GUI 应用可见的 `PATH`。
213
+ - 如果项目路径包含空格,安装器生成的 Git hook 会对 `X7_CODE_LINE_BASE` 做 shell quote,通常可以正常工作。
214
+ - 使用 nvm、fnm、asdf 等 Node 版本管理器时,macOS GUI 应用经常不会加载对应初始化脚本。建议在运行 hooks 前确认:
215
+
216
+ ```sh
217
+ which node
218
+ which npm
219
+ which npx
220
+ ```
221
+
222
+ 如果这些命令在 Cursor/Codex hook 环境中不可用,需要调整启动方式或 PATH。
223
+
224
+ ## Windows 适配说明
225
+
226
+ - Git for Windows 会用 Git Bash 执行 `.git/hooks/*`,因此 `#!/bin/sh` hook 可以运行。
227
+ - PowerShell 下设置多项目环境变量时使用 `$env:X7_CODE_LINE_DIRS`。
228
+ - Windows 目录分隔符使用 `;`,例如:
229
+
230
+ ```powershell
231
+ $env:X7_CODE_LINE_DIRS = "D:\repo-a;D:\repo-b"
232
+ ```
233
+
234
+ ## CLI 命令
235
+
236
+ ```sh
237
+ x7-code-line install [--config path] [--dir path ...]
238
+ x7-code-line prompt-submit
239
+ x7-code-line stop
240
+ x7-code-line git-pre-commit
241
+ x7-code-line git-commit-msg <commit-msg-file>
242
+ ```
243
+
244
+ ## 本地验证
245
+
246
+ ```sh
247
+ npm test
248
+ npm pack --dry-run
249
+ ```
250
+
251
+ ## 发布到 npm
252
+
253
+ 首次发布和后续更新都建议先在本地完成校验:
254
+
255
+ ```sh
256
+ npm test
257
+ npm pack --dry-run
258
+ ```
259
+
260
+ 确认当前登录的 npm 账号:
261
+
262
+ ```sh
263
+ npm whoami
264
+ ```
265
+
266
+ 如果还没有登录:
267
+
268
+ ```sh
269
+ npm login
270
+ ```
271
+
272
+ ### 首次发布
273
+
274
+ 确认 `package.json` 中的包名和版本号正确后,执行:
275
+
276
+ ```sh
277
+ npm publish
278
+ ```
279
+
280
+ 如果 npm 账号开启了 2FA,通常需要附带 OTP:
281
+
282
+ ```sh
283
+ npm publish --otp <6位验证码>
284
+ ```
285
+
286
+ 如果将来改为 scope 包并需要公开发布,则使用:
287
+
288
+ ```sh
289
+ npm publish --access public --otp <6位验证码>
290
+ ```
291
+
292
+ ### 更新已发布版本
293
+
294
+ 每次发布新版本前,先提升版本号:
295
+
296
+ ```sh
297
+ npm version patch
298
+ ```
299
+
300
+ 也可以按需要使用:
301
+
302
+ ```sh
303
+ npm version minor
304
+ npm version major
305
+ ```
306
+
307
+ 然后重新发布:
308
+
309
+ ```sh
310
+ npm publish --otp <6位验证码>
311
+ ```
312
+
313
+ ### 常见发布流程
314
+
315
+ ```sh
316
+ npm test
317
+ npm pack --dry-run
318
+ npm version patch
319
+ npm publish --otp <6位验证码>
320
+ ```
321
+
322
+ ### 2FA 与 token
323
+
324
+ 如果 npm 账号开启了两步验证,发布时需要满足以下任一条件:
325
+
326
+ - 直接在 `npm publish` 时传入 `--otp`。
327
+ - 使用具备 publish 权限并允许 bypass 2FA 的 npm access token。
328
+
329
+ 手动在本机发布时,最直接的方式通常是:
330
+
331
+ ```sh
332
+ npm publish --otp <当前验证码>
333
+ ```
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runCli } = require("../src/cli");
4
+
5
+ runCli(process.argv.slice(2)).catch((error) => {
6
+ console.error(`[x7-code-line] ${error.message}`);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ npx --no-install x7-code-line git-commit-msg "$1"
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ npx --no-install x7-code-line git-pre-commit "$@"
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "x7-code-line",
3
+ "version": "0.1.0",
4
+ "description": "Install Cursor, Codex, and git hooks for x7 code line workflows.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "x7-code-line": "bin/x7-code-line.js"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node src/postinstall.js",
11
+ "test": "node test/install.test.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "hooks/",
16
+ "src/",
17
+ "templates/"
18
+ ],
19
+ "keywords": [
20
+ "cursor",
21
+ "codex",
22
+ "hooks",
23
+ "git-hooks"
24
+ ],
25
+ "author": "",
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=18"
29
+ }
30
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+
3
+ const {
4
+ AI_DIFF_CACHE_FILE,
5
+ NORMAL_DIFF_CACHE_FILE,
6
+ addProjectId,
7
+ getProjectIds,
8
+ readLineIdCache,
9
+ readProjects,
10
+ writeLineIdCache
11
+ } = require("./cache");
12
+ const { getGitDiffLineIds } = require("./diff-lines");
13
+
14
+ function handlePromptSubmit(baseDir = process.env.INIT_CWD || process.cwd()) {
15
+ const projects = readProjects(baseDir);
16
+ const aiCache = readLineIdCache(baseDir, AI_DIFF_CACHE_FILE);
17
+ const normalCache = readLineIdCache(baseDir, NORMAL_DIFF_CACHE_FILE);
18
+
19
+ for (const projectDir of projects) {
20
+ const aiIds = getProjectIds(aiCache, projectDir);
21
+ for (const id of safelyGetDiffLineIds(projectDir)) {
22
+ if (!aiIds.has(id)) {
23
+ addProjectId(normalCache, projectDir, id);
24
+ }
25
+ }
26
+ }
27
+
28
+ writeLineIdCache(baseDir, NORMAL_DIFF_CACHE_FILE, normalCache);
29
+ }
30
+
31
+ function handleStop(baseDir = process.env.INIT_CWD || process.cwd()) {
32
+ const projects = readProjects(baseDir);
33
+ const aiCache = readLineIdCache(baseDir, AI_DIFF_CACHE_FILE);
34
+ const normalCache = readLineIdCache(baseDir, NORMAL_DIFF_CACHE_FILE);
35
+
36
+ for (const projectDir of projects) {
37
+ const normalIds = getProjectIds(normalCache, projectDir);
38
+ for (const id of safelyGetDiffLineIds(projectDir)) {
39
+ if (!normalIds.has(id)) {
40
+ addProjectId(aiCache, projectDir, id);
41
+ }
42
+ }
43
+ }
44
+
45
+ writeLineIdCache(baseDir, AI_DIFF_CACHE_FILE, aiCache);
46
+ }
47
+
48
+ function safelyGetDiffLineIds(projectDir) {
49
+ try {
50
+ return getGitDiffLineIds(projectDir);
51
+ } catch (error) {
52
+ if (process.env.X7_CODE_LINE_DEBUG === "1") {
53
+ console.error(`[x7-code-line] skip ${projectDir}: ${error.message}`);
54
+ }
55
+ return [];
56
+ }
57
+ }
58
+
59
+ module.exports = {
60
+ handlePromptSubmit,
61
+ handleStop
62
+ };
package/src/cache.js ADDED
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const CACHE_DIR_NAME = ".x7-code-line";
7
+ const PROJECTS_CACHE_FILE = "projects.json";
8
+ const AI_DIFF_CACHE_FILE = "ai-diff-line-ids.json";
9
+ const NORMAL_DIFF_CACHE_FILE = "normal-diff-line-ids.json";
10
+ const PENDING_COMMIT_FILE = "pending-commit.json";
11
+
12
+ function getCacheDir(baseDir = process.env.INIT_CWD || process.cwd()) {
13
+ return path.join(path.resolve(baseDir), CACHE_DIR_NAME);
14
+ }
15
+
16
+ function ensureCacheFiles(baseDir) {
17
+ const cacheDir = getCacheDir(baseDir);
18
+ fs.mkdirSync(cacheDir, { recursive: true });
19
+ ensureJson(path.join(cacheDir, PROJECTS_CACHE_FILE), { projects: [] });
20
+ ensureJson(path.join(cacheDir, AI_DIFF_CACHE_FILE), { projects: {} });
21
+ ensureJson(path.join(cacheDir, NORMAL_DIFF_CACHE_FILE), { projects: {} });
22
+ return cacheDir;
23
+ }
24
+
25
+ function writeProjects(baseDir, projectDirs) {
26
+ const cacheDir = ensureCacheFiles(baseDir);
27
+ const projects = [...new Set(projectDirs.map((dir) => path.resolve(dir)))];
28
+ writeJson(path.join(cacheDir, PROJECTS_CACHE_FILE), { projects });
29
+ }
30
+
31
+ function readProjects(baseDir = process.env.INIT_CWD || process.cwd()) {
32
+ const cacheDir = ensureCacheFiles(baseDir);
33
+ const cache = readJson(path.join(cacheDir, PROJECTS_CACHE_FILE), { projects: [] });
34
+ const projects = Array.isArray(cache.projects) ? cache.projects : [];
35
+ return projects.map((project) => path.resolve(project));
36
+ }
37
+
38
+ function readLineIdCache(baseDir, fileName) {
39
+ const cacheDir = ensureCacheFiles(baseDir);
40
+ const cache = readJson(path.join(cacheDir, fileName), { projects: {} });
41
+ return cache && typeof cache === "object" && !Array.isArray(cache) ? cache : { projects: {} };
42
+ }
43
+
44
+ function writeLineIdCache(baseDir, fileName, cache) {
45
+ const cacheDir = ensureCacheFiles(baseDir);
46
+ writeJson(path.join(cacheDir, fileName), cache);
47
+ }
48
+
49
+ function getProjectIds(cache, projectDir) {
50
+ const key = path.resolve(projectDir);
51
+ const ids = cache.projects && Array.isArray(cache.projects[key]) ? cache.projects[key] : [];
52
+ return new Set(ids);
53
+ }
54
+
55
+ function addProjectId(cache, projectDir, id) {
56
+ const key = path.resolve(projectDir);
57
+ if (!cache.projects || typeof cache.projects !== "object" || Array.isArray(cache.projects)) {
58
+ cache.projects = {};
59
+ }
60
+ if (!Array.isArray(cache.projects[key])) {
61
+ cache.projects[key] = [];
62
+ }
63
+ if (!cache.projects[key].includes(id)) {
64
+ cache.projects[key].push(id);
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ function ensureJson(filePath, defaultValue) {
71
+ if (!fs.existsSync(filePath)) {
72
+ writeJson(filePath, defaultValue);
73
+ }
74
+ }
75
+
76
+ function readJson(filePath, defaultValue) {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
79
+ } catch {
80
+ return defaultValue;
81
+ }
82
+ }
83
+
84
+ function writeJson(filePath, value) {
85
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
86
+ }
87
+
88
+ module.exports = {
89
+ AI_DIFF_CACHE_FILE,
90
+ CACHE_DIR_NAME,
91
+ NORMAL_DIFF_CACHE_FILE,
92
+ PENDING_COMMIT_FILE,
93
+ PROJECTS_CACHE_FILE,
94
+ addProjectId,
95
+ ensureCacheFiles,
96
+ getCacheDir,
97
+ getProjectIds,
98
+ readLineIdCache,
99
+ readProjects,
100
+ writeLineIdCache,
101
+ writeProjects
102
+ };
package/src/cli.js ADDED
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { handlePromptSubmit, handleStop } = require("./agent-hooks");
6
+ const { install } = require("./install");
7
+ const { appendCommitTrailer } = require("./git-trailer");
8
+ const {
9
+ AI_DIFF_CACHE_FILE,
10
+ PENDING_COMMIT_FILE,
11
+ getCacheDir,
12
+ getProjectIds,
13
+ readLineIdCache
14
+ } = require("./cache");
15
+ const { getGitDiffLineIds } = require("./diff-lines");
16
+
17
+ async function runCli(argv) {
18
+ const [command, ...rest] = argv;
19
+
20
+ switch (command) {
21
+ case "install":
22
+ return install(parseInstallOptions(rest));
23
+ case "prompt-submit":
24
+ case "cursor-before-submit-prompt":
25
+ case "codex-user-prompt-submit":
26
+ return handlePromptSubmit();
27
+ case "stop":
28
+ case "cursor-stop":
29
+ case "codex-stop":
30
+ return handleStop();
31
+ case "git-pre-commit":
32
+ return runGitPreCommit();
33
+ case "git-commit-msg":
34
+ return runGitCommitMsg(rest);
35
+ case "-h":
36
+ case "--help":
37
+ case undefined:
38
+ return printHelp();
39
+ default:
40
+ throw new Error(`Unknown command: ${command}`);
41
+ }
42
+ }
43
+
44
+ function parseInstallOptions(args) {
45
+ const options = {
46
+ targetDirs: [],
47
+ configPath: undefined
48
+ };
49
+
50
+ for (let index = 0; index < args.length; index += 1) {
51
+ const arg = args[index];
52
+ if (arg === "--config" || arg === "-c") {
53
+ options.configPath = requireValue(args, (index += 1), arg);
54
+ } else if (arg === "--dir" || arg === "-d") {
55
+ options.targetDirs.push(requireValue(args, (index += 1), arg));
56
+ } else if (arg === "--dirs") {
57
+ options.targetDirs.push(...requireValue(args, (index += 1), arg).split(","));
58
+ } else {
59
+ options.targetDirs.push(arg);
60
+ }
61
+ }
62
+
63
+ return options;
64
+ }
65
+
66
+ function requireValue(args, index, flag) {
67
+ const value = args[index];
68
+ if (!value || value.startsWith("-")) {
69
+ throw new Error(`${flag} requires a value`);
70
+ }
71
+ return value;
72
+ }
73
+
74
+ async function runGitPreCommit() {
75
+ const baseDir = getBaseDir();
76
+ const projectDir = process.cwd();
77
+ const aiCache = readLineIdCache(baseDir, AI_DIFF_CACHE_FILE);
78
+ const aiIds = getProjectIds(aiCache, projectDir);
79
+ const stagedIds = getGitDiffLineIds(projectDir, { staged: true });
80
+ const aiLineCount = stagedIds.filter((id) => aiIds.has(id)).length;
81
+ const pendingPath = path.join(getCacheDir(baseDir), PENDING_COMMIT_FILE);
82
+ fs.writeFileSync(
83
+ pendingPath,
84
+ `${JSON.stringify({ projects: { [path.resolve(projectDir)]: { aiLineCount } } }, null, 2)}\n`
85
+ );
86
+
87
+ if (process.env.X7_CODE_LINE_DEBUG === "1") {
88
+ console.error(`[x7-code-line] git pre-commit ai lines: ${aiLineCount}`);
89
+ }
90
+ }
91
+
92
+ async function runGitCommitMsg(args) {
93
+ const commitMsgPath = args[0];
94
+ if (!commitMsgPath) {
95
+ throw new Error("git-commit-msg requires a commit message file path");
96
+ }
97
+
98
+ appendCommitTrailer(path.resolve(commitMsgPath), readPendingAiLineCount(getBaseDir(), process.cwd()));
99
+ }
100
+
101
+ function getBaseDir() {
102
+ return process.env.X7_CODE_LINE_BASE || process.env.INIT_CWD || process.cwd();
103
+ }
104
+
105
+ function readPendingAiLineCount(baseDir, projectDir) {
106
+ const pendingPath = path.join(getCacheDir(baseDir), PENDING_COMMIT_FILE);
107
+ try {
108
+ const pending = JSON.parse(fs.readFileSync(pendingPath, "utf8"));
109
+ const project = pending.projects && pending.projects[path.resolve(projectDir)];
110
+ return Number.isInteger(project && project.aiLineCount) ? project.aiLineCount : 0;
111
+ } catch {
112
+ return 0;
113
+ }
114
+ }
115
+
116
+ function printHelp() {
117
+ const text = [
118
+ "Usage:",
119
+ " x7-code-line install [--config path] [--dir path ...]",
120
+ " x7-code-line prompt-submit",
121
+ " x7-code-line stop",
122
+ " x7-code-line git-pre-commit",
123
+ " x7-code-line git-commit-msg <commit-msg-file>"
124
+ ].join("\n");
125
+ fs.writeSync(process.stdout.fd, `${text}\n`);
126
+ }
127
+
128
+ module.exports = {
129
+ runCli
130
+ };
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const path = require("node:path");
5
+ const { execFileSync } = require("node:child_process");
6
+
7
+ function getGitDiffLineIds(projectDir, options = {}) {
8
+ const args = ["diff", "--no-ext-diff", "--unified=0", "--no-color"];
9
+ if (options.staged) {
10
+ args.splice(1, 0, "--cached");
11
+ }
12
+
13
+ const diff = execFileSync("git", args, {
14
+ cwd: projectDir,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "ignore"]
17
+ });
18
+
19
+ return parseDiffLineIds(projectDir, diff);
20
+ }
21
+
22
+ function parseDiffLineIds(projectDir, diff) {
23
+ const ids = [];
24
+ let currentFile = "";
25
+ let oldLine = 0;
26
+ let newLine = 0;
27
+
28
+ for (const line of diff.split(/\r?\n/u)) {
29
+ if (line.startsWith("+++ b/")) {
30
+ currentFile = line.slice("+++ b/".length);
31
+ continue;
32
+ }
33
+ if (line.startsWith("+++ ")) {
34
+ currentFile = line.slice("+++ ".length);
35
+ continue;
36
+ }
37
+
38
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/u.exec(line);
39
+ if (hunk) {
40
+ oldLine = Number(hunk[1]);
41
+ newLine = Number(hunk[2]);
42
+ continue;
43
+ }
44
+
45
+ if (!currentFile || line.startsWith("diff --git") || line.startsWith("index ")) {
46
+ continue;
47
+ }
48
+
49
+ if (line.startsWith("+") && !line.startsWith("+++")) {
50
+ ids.push(makeLineId(projectDir, currentFile, "add", newLine, line.slice(1)));
51
+ newLine += 1;
52
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
53
+ ids.push(makeLineId(projectDir, currentFile, "delete", oldLine, line.slice(1)));
54
+ oldLine += 1;
55
+ } else if (line.startsWith(" ")) {
56
+ oldLine += 1;
57
+ newLine += 1;
58
+ }
59
+ }
60
+
61
+ return ids;
62
+ }
63
+
64
+ function makeLineId(projectDir, filePath, changeType, lineNumber, content) {
65
+ return crypto
66
+ .createHash("sha256")
67
+ .update(
68
+ JSON.stringify({
69
+ project: path.resolve(projectDir),
70
+ file: filePath,
71
+ changeType,
72
+ lineNumber,
73
+ content
74
+ })
75
+ )
76
+ .digest("hex");
77
+ }
78
+
79
+ module.exports = {
80
+ getGitDiffLineIds,
81
+ makeLineId,
82
+ parseDiffLineIds
83
+ };
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const { execFileSync } = require("node:child_process");
6
+
7
+ const TRAILER_TOKEN = "x7-ai-lines";
8
+
9
+ function appendCommitTrailer(commitMsgPath, aiLineCount = 0) {
10
+ const original = fs.readFileSync(commitMsgPath, "utf8");
11
+ const withTrailer = addTrailerWithGit(original, aiLineCount) || addTrailer(original, aiLineCount);
12
+ fs.writeFileSync(commitMsgPath, withTrailer);
13
+ }
14
+
15
+ function addTrailerWithGit(message, aiLineCount) {
16
+ try {
17
+ return execFileSync(
18
+ "git",
19
+ ["interpret-trailers", "--if-exists=replace", `--trailer=${TRAILER_TOKEN}=${aiLineCount}`],
20
+ {
21
+ input: message,
22
+ encoding: "utf8",
23
+ stdio: ["pipe", "pipe", "ignore"]
24
+ }
25
+ );
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ function addTrailer(message, aiLineCount) {
32
+ const normalized = message.replace(/\s+$/u, "");
33
+ const trailerPattern = new RegExp(`^${TRAILER_TOKEN}:`, "imu");
34
+ const withoutExisting = normalized
35
+ .split(/\r?\n/u)
36
+ .filter((line) => !trailerPattern.test(line))
37
+ .join(os.EOL)
38
+ .replace(/\s+$/u, "");
39
+
40
+ return `${withoutExisting}${os.EOL}${os.EOL}${TRAILER_TOKEN}: ${aiLineCount}${os.EOL}`;
41
+ }
42
+
43
+ module.exports = {
44
+ TRAILER_TOKEN,
45
+ appendCommitTrailer
46
+ };
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+
3
+ const { install } = require("./install");
4
+
5
+ module.exports = {
6
+ install
7
+ };
package/src/install.js ADDED
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { CACHE_DIR_NAME, ensureCacheFiles, writeProjects } = require("./cache");
6
+
7
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
8
+
9
+ function install(options = {}) {
10
+ const config = readConfig(options.configPath || process.env.X7_CODE_LINE_CONFIG);
11
+ const targetDirs = normalizeTargetDirs(options.targetDirs, config);
12
+ const cacheBaseDir = path.resolve(options.cacheBaseDir || process.env.INIT_CWD || process.cwd());
13
+
14
+ installCurrentProjectFiles(cacheBaseDir);
15
+ for (const targetDir of targetDirs) {
16
+ installGitHooksInto(targetDir, config, cacheBaseDir);
17
+ }
18
+ writeProjects(cacheBaseDir, targetDirs);
19
+
20
+ return targetDirs;
21
+ }
22
+
23
+ function installFromPostinstall() {
24
+ const envDirs = splitList(process.env.X7_CODE_LINE_DIRS);
25
+ const targetDirs = envDirs.length > 0 ? envDirs : [process.env.INIT_CWD || process.cwd()];
26
+
27
+ return install({
28
+ configPath: process.env.X7_CODE_LINE_CONFIG,
29
+ targetDirs
30
+ });
31
+ }
32
+
33
+ function installCurrentProjectFiles(cacheBaseDir) {
34
+ const resolvedTarget = path.resolve(cacheBaseDir);
35
+ ensureDirectory(resolvedTarget);
36
+
37
+ const cacheDir = path.join(resolvedTarget, CACHE_DIR_NAME);
38
+ ensureCacheFiles(resolvedTarget);
39
+ writeJson(path.join(cacheDir, "install.json"), {
40
+ packageName: "x7-code-line",
41
+ installedAt: new Date().toISOString(),
42
+ moduleRoot: PACKAGE_ROOT
43
+ });
44
+
45
+ copyFile(
46
+ path.join(PACKAGE_ROOT, "templates", ".cursor", "hooks.json"),
47
+ path.join(resolvedTarget, ".cursor", "hooks.json")
48
+ );
49
+ copyFile(
50
+ path.join(PACKAGE_ROOT, "templates", ".codex", "hooks.json"),
51
+ path.join(resolvedTarget, ".codex", "hooks.json")
52
+ );
53
+ }
54
+
55
+ function installGitHooksInto(targetDir, config = {}, cacheBaseDir = process.cwd()) {
56
+ const resolvedTarget = path.resolve(targetDir);
57
+ ensureDirectory(resolvedTarget);
58
+ const gitTargets = resolveGitTargets(resolvedTarget, config);
59
+ for (const gitDir of gitTargets) {
60
+ installGitHooks(gitDir, cacheBaseDir);
61
+ }
62
+ }
63
+
64
+ function installGitHooks(gitDir, cacheBaseDir) {
65
+ const hooksDir = path.join(gitDir, "hooks");
66
+ ensureDirectory(hooksDir);
67
+
68
+ writeExecutable(path.join(hooksDir, "pre-commit"), makeGitHook("git-pre-commit", cacheBaseDir));
69
+ writeExecutable(path.join(hooksDir, "commit-msg"), makeGitHook("git-commit-msg \"$1\"", cacheBaseDir));
70
+ }
71
+
72
+ function resolveGitTargets(targetDir, config = {}) {
73
+ const configured = Array.isArray(config.gitDirs) ? config.gitDirs : [];
74
+ const gitDirs = configured.map((gitDir) => path.resolve(targetDir, gitDir));
75
+ const defaultGitDir = path.join(targetDir, ".git");
76
+
77
+ if (fs.existsSync(defaultGitDir)) {
78
+ gitDirs.unshift(defaultGitDir);
79
+ }
80
+
81
+ return [...new Set(gitDirs)].filter((gitDir) => {
82
+ try {
83
+ return fs.statSync(gitDir).isDirectory();
84
+ } catch {
85
+ return false;
86
+ }
87
+ });
88
+ }
89
+
90
+ function readConfig(configPath) {
91
+ if (!configPath) {
92
+ return {};
93
+ }
94
+
95
+ const resolvedConfigPath = path.resolve(configPath);
96
+ const raw = fs.readFileSync(resolvedConfigPath, "utf8");
97
+ const config = JSON.parse(raw);
98
+
99
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
100
+ throw new Error(`Config must be a JSON object: ${resolvedConfigPath}`);
101
+ }
102
+
103
+ return config;
104
+ }
105
+
106
+ function normalizeTargetDirs(targetDirs = [], config = {}) {
107
+ const configuredDirs = Array.isArray(config.dirs) ? config.dirs : [];
108
+ const dirs = [...targetDirs, ...configuredDirs].map((dir) => String(dir || "").trim()).filter(Boolean);
109
+
110
+ if (dirs.length === 0) {
111
+ return [process.env.INIT_CWD || process.cwd()];
112
+ }
113
+
114
+ return [...new Set(dirs)].map((dir) => path.resolve(dir));
115
+ }
116
+
117
+ function splitList(value) {
118
+ return String(value || "")
119
+ .split(path.delimiter)
120
+ .map((entry) => entry.trim())
121
+ .filter(Boolean);
122
+ }
123
+
124
+ function copyFile(source, destination) {
125
+ ensureDirectory(path.dirname(destination));
126
+ fs.copyFileSync(source, destination);
127
+ try {
128
+ fs.chmodSync(destination, 0o755);
129
+ } catch {
130
+ // Windows and some package managers may ignore chmod.
131
+ }
132
+ }
133
+
134
+ function writeExecutable(destination, content) {
135
+ ensureDirectory(path.dirname(destination));
136
+ fs.writeFileSync(destination, content);
137
+ try {
138
+ fs.chmodSync(destination, 0o755);
139
+ } catch {
140
+ // Windows and some package managers may ignore chmod.
141
+ }
142
+ }
143
+
144
+ function makeGitHook(command, cacheBaseDir) {
145
+ return [
146
+ "#!/bin/sh",
147
+ "set -eu",
148
+ `X7_CODE_LINE_BASE=${shellQuote(path.resolve(cacheBaseDir))}`,
149
+ "export X7_CODE_LINE_BASE",
150
+ `npx --no-install x7-code-line ${command}`,
151
+ ""
152
+ ].join("\n");
153
+ }
154
+
155
+ function shellQuote(value) {
156
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
157
+ }
158
+
159
+ function writeJson(destination, value) {
160
+ fs.writeFileSync(destination, `${JSON.stringify(value, null, 2)}\n`);
161
+ }
162
+
163
+ function ensureDirectory(directory) {
164
+ fs.mkdirSync(directory, { recursive: true });
165
+ }
166
+
167
+ module.exports = {
168
+ CACHE_DIR_NAME,
169
+ install,
170
+ installFromPostinstall
171
+ };
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+
3
+ const { installFromPostinstall } = require("./install");
4
+
5
+ try {
6
+ const installed = installFromPostinstall();
7
+ if (process.env.X7_CODE_LINE_DEBUG === "1") {
8
+ console.error(`[x7-code-line] installed into ${installed.join(", ")}`);
9
+ }
10
+ } catch (error) {
11
+ console.error(`[x7-code-line] postinstall failed: ${error.message}`);
12
+ process.exit(1);
13
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "command": "npx --no-install x7-code-line prompt-submit"
6
+ }
7
+ ],
8
+ "Stop": [
9
+ {
10
+ "command": "npx --no-install x7-code-line stop"
11
+ }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "beforeSubmitPrompt": [
4
+ {
5
+ "command": "npx --no-install x7-code-line prompt-submit"
6
+ }
7
+ ],
8
+ "stop": [
9
+ {
10
+ "command": "npx --no-install x7-code-line stop"
11
+ }
12
+ ]
13
+ }
14
+ }