auriga-cli 1.0.0 → 1.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 +24 -4
- package/README.zh-CN.md +24 -4
- package/dist/cli.js +11 -1
- package/dist/hooks.d.ts +126 -0
- package/dist/hooks.js +705 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +14 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -12,7 +12,9 @@ This repo itself is a fully configured harness project. You can clone it to see
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| **Workflow** | `CLAUDE.md` development workflow: requirement clarification -> TDD -> Review, Harness principles, Subagent usage guide |
|
|
14
14
|
| **Skills** | Development process skills — brainstorming, systematic-debugging, TDD, verification, planning, playwright |
|
|
15
|
-
| **
|
|
15
|
+
| **Recommended Skills** | Optional utility skills (e.g. `ui-ux-pro-max`) you can add on top of the workflow skills |
|
|
16
|
+
| **Plugins** | Recommended Claude Code plugins — skill-creator, claude-md-management, codex |
|
|
17
|
+
| **Hooks** | Claude Code hooks (currently: `notify` — native macOS notification with brand icon + sound) |
|
|
16
18
|
|
|
17
19
|
## Quick Start
|
|
18
20
|
|
|
@@ -26,10 +28,12 @@ Interactive menu — select what to install:
|
|
|
26
28
|
? Select module types to install:
|
|
27
29
|
◉ Workflow — CLAUDE.md + AGENTS.md
|
|
28
30
|
◉ Skills — Development process skills
|
|
31
|
+
◉ Recommended Skills — Extra utility skills
|
|
29
32
|
◉ Plugins — Claude Code plugins
|
|
33
|
+
◉ Hooks — Claude Code hooks
|
|
30
34
|
```
|
|
31
35
|
|
|
32
|
-
Each module supports scope selection (Skills: project/global, Plugins: user/project).
|
|
36
|
+
Each module supports scope selection (Skills: project/global, Plugins: user/project, Hooks: project local / project / user).
|
|
33
37
|
|
|
34
38
|
## Module Details
|
|
35
39
|
|
|
@@ -64,13 +68,29 @@ Installs selected plugins via `claude plugins install`, automatically adding req
|
|
|
64
68
|
|---|---|
|
|
65
69
|
| skill-creator | Create and manage custom skills |
|
|
66
70
|
| claude-md-management | Audit and improve CLAUDE.md |
|
|
67
|
-
| hookify | Create hooks from conversation analysis |
|
|
68
71
|
| codex | Codex cross-model collaboration |
|
|
69
72
|
|
|
73
|
+
### Hooks
|
|
74
|
+
|
|
75
|
+
Installs Claude Code hooks into a chosen scope. Each hook is self-contained under `.claude/hooks/<name>/` and can be customized without editing code.
|
|
76
|
+
|
|
77
|
+
| Hook | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| notify | Native macOS notification when Claude needs your attention. Auto-installs `terminal-notifier` via Homebrew. Customize sound and icon by editing `.claude/hooks/notify/config.json` and replacing `.claude/hooks/notify/icon.png`. macOS-only at runtime; silent no-op on other platforms. |
|
|
80
|
+
|
|
81
|
+
Scope choices:
|
|
82
|
+
|
|
83
|
+
- **Project local** (recommended for cross-platform teams): files under `./.claude/hooks/`, registered in `./.claude/settings.local.json` — per-developer, not committed.
|
|
84
|
+
- **Project**: same files, registered in `./.claude/settings.json` — shared with the team via git.
|
|
85
|
+
- **User**: files under `~/.claude/hooks/`, registered in `~/.claude/settings.json` — global across all your projects.
|
|
86
|
+
|
|
87
|
+
Re-running the installer preserves your customized `config.json` and `icon.png`, overwrites the runtime, and never produces duplicate hook entries (idempotent merge by sentinel marker).
|
|
88
|
+
|
|
70
89
|
## Requirements
|
|
71
90
|
|
|
72
91
|
- Node.js >= 18
|
|
73
|
-
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (required for Plugins
|
|
92
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (required for Plugins and Hooks modules)
|
|
93
|
+
- [Homebrew](https://brew.sh) (recommended for the `notify` hook to install `terminal-notifier`)
|
|
74
94
|
|
|
75
95
|
## License
|
|
76
96
|
|
package/README.zh-CN.md
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| **Workflow** | `CLAUDE.md` 开发工作流:需求澄清 → TDD → Review,Harness 原则,Subagent 使用指南 |
|
|
14
14
|
| **Skills** | 开发流程 skills —— brainstorming、systematic-debugging、TDD、verification、planning、playwright |
|
|
15
|
-
| **
|
|
15
|
+
| **Recommended Skills** | 可选的工具类 skills(如 `ui-ux-pro-max`),在 workflow skills 之外按需追加 |
|
|
16
|
+
| **Plugins** | 推荐的 Claude Code 插件 —— skill-creator、claude-md-management、codex |
|
|
17
|
+
| **Hooks** | Claude Code hooks(当前包含 `notify` —— 带品牌图标和提示音的原生 macOS 通知) |
|
|
16
18
|
|
|
17
19
|
## 快速开始
|
|
18
20
|
|
|
@@ -26,10 +28,12 @@ npx auriga-cli
|
|
|
26
28
|
? 选择要安装的模块类型:
|
|
27
29
|
◉ Workflow — CLAUDE.md + AGENTS.md
|
|
28
30
|
◉ Skills — 开发流程 skills
|
|
31
|
+
◉ Recommended Skills — 额外的工具 skills
|
|
29
32
|
◉ Plugins — Claude Code 插件
|
|
33
|
+
◉ Hooks — Claude Code hooks
|
|
30
34
|
```
|
|
31
35
|
|
|
32
|
-
每个模块支持作用域选择(Skills: project/global,Plugins: user/project)。
|
|
36
|
+
每个模块支持作用域选择(Skills: project/global,Plugins: user/project,Hooks: project local / project / user)。
|
|
33
37
|
|
|
34
38
|
## 模块详情
|
|
35
39
|
|
|
@@ -64,13 +68,29 @@ npx auriga-cli
|
|
|
64
68
|
|---|---|
|
|
65
69
|
| skill-creator | 创建和管理自定义 skills |
|
|
66
70
|
| claude-md-management | 审计和改进 CLAUDE.md |
|
|
67
|
-
| hookify | 从对话分析创建 hooks |
|
|
68
71
|
| codex | Codex 跨模型协作 |
|
|
69
72
|
|
|
73
|
+
### Hooks
|
|
74
|
+
|
|
75
|
+
把 Claude Code hooks 安装到选定的作用域。每个 hook 都是 `.claude/hooks/<name>/` 下一个自包含目录,可以**不改代码**自定义。
|
|
76
|
+
|
|
77
|
+
| Hook | 说明 |
|
|
78
|
+
|---|---|
|
|
79
|
+
| notify | 当 Claude 需要你关注时弹一条原生 macOS 通知。会自动通过 Homebrew 安装 `terminal-notifier`。改 `.claude/hooks/notify/config.json` 即可换提示音、替换 `.claude/hooks/notify/icon.png` 即可换图标。仅 macOS 运行时生效,其它平台静默 no-op。 |
|
|
80
|
+
|
|
81
|
+
作用域选择:
|
|
82
|
+
|
|
83
|
+
- **Project local**(推荐给跨平台团队):文件落在 `./.claude/hooks/`,注册到 `./.claude/settings.local.json` —— 每个开发者各自安装,不进 git。
|
|
84
|
+
- **Project**:同样的文件,注册到 `./.claude/settings.json` —— 整个团队共享。
|
|
85
|
+
- **User**:文件落在 `~/.claude/hooks/`,注册到 `~/.claude/settings.json` —— 全局生效。
|
|
86
|
+
|
|
87
|
+
重新跑安装器时会保留你修改过的 `config.json` 和 `icon.png`,覆盖运行时本身,并通过 marker 字段幂等去重,绝不会产生重复的 hook 条目。
|
|
88
|
+
|
|
70
89
|
## 环境要求
|
|
71
90
|
|
|
72
91
|
- Node.js >= 18
|
|
73
|
-
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)(Plugins 模块需要)
|
|
92
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)(Plugins 和 Hooks 模块需要)
|
|
93
|
+
- [Homebrew](https://brew.sh)(`notify` hook 用来安装 `terminal-notifier`,可选)
|
|
74
94
|
|
|
75
95
|
## License
|
|
76
96
|
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fetchContentRoot, printBanner, withEsc } from "./utils.js";
|
|
|
5
5
|
import { installWorkflow } from "./workflow.js";
|
|
6
6
|
import { installSkills, installRecommendedSkills } from "./skills.js";
|
|
7
7
|
import { installPlugins } from "./plugins.js";
|
|
8
|
+
import { installHooks } from "./hooks.js";
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
10
|
const { version } = require("../package.json");
|
|
10
11
|
async function main() {
|
|
@@ -38,10 +39,15 @@ async function main() {
|
|
|
38
39
|
checked: true,
|
|
39
40
|
},
|
|
40
41
|
{
|
|
41
|
-
name: "Plugins — Claude Code plugins (skill-creator,
|
|
42
|
+
name: "Plugins — Claude Code plugins (skill-creator, claude-md-management, codex...)",
|
|
42
43
|
value: "plugins",
|
|
43
44
|
checked: true,
|
|
44
45
|
},
|
|
46
|
+
{
|
|
47
|
+
name: "Hooks — Claude Code hooks (notifications, etc.)",
|
|
48
|
+
value: "hooks",
|
|
49
|
+
checked: true,
|
|
50
|
+
},
|
|
45
51
|
],
|
|
46
52
|
}));
|
|
47
53
|
if (moduleTypes.length === 0) {
|
|
@@ -64,6 +70,10 @@ async function main() {
|
|
|
64
70
|
console.log("\n--- Plugins ---\n");
|
|
65
71
|
await installPlugins(packageRoot);
|
|
66
72
|
}
|
|
73
|
+
if (moduleTypes.includes("hooks")) {
|
|
74
|
+
console.log("\n--- Hooks ---\n");
|
|
75
|
+
await installHooks(packageRoot);
|
|
76
|
+
}
|
|
67
77
|
console.log("\n\u2728 Installation complete!\n");
|
|
68
78
|
}
|
|
69
79
|
main().catch((err) => {
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export interface HookDep {
|
|
2
|
+
name: string;
|
|
3
|
+
via: "brew";
|
|
4
|
+
optional?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface HookSettingsEvent {
|
|
7
|
+
event: string;
|
|
8
|
+
matcher?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HookDef {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
runtimePlatforms: string[];
|
|
14
|
+
settingsEvents: HookSettingsEvent[];
|
|
15
|
+
command: string;
|
|
16
|
+
files: string[];
|
|
17
|
+
preserveFiles?: string[];
|
|
18
|
+
deps?: HookDep[];
|
|
19
|
+
marker: string;
|
|
20
|
+
/**
|
|
21
|
+
* Per-hook customization hints rendered in the post-install summary.
|
|
22
|
+
* The literal `{hookDir}` is substituted with the hook's resolved
|
|
23
|
+
* install directory at print time. Empty / omitted → installer falls
|
|
24
|
+
* back to a generic "see <dir>/README.md" pointer.
|
|
25
|
+
*/
|
|
26
|
+
customizeHints?: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface HooksConfig {
|
|
29
|
+
hooks: HookDef[];
|
|
30
|
+
}
|
|
31
|
+
export interface SettingsHookAction {
|
|
32
|
+
type: "command";
|
|
33
|
+
command: string;
|
|
34
|
+
_marker?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface SettingsHookGroup {
|
|
37
|
+
matcher?: string;
|
|
38
|
+
hooks: SettingsHookAction[];
|
|
39
|
+
}
|
|
40
|
+
export interface SettingsFile {
|
|
41
|
+
hooks?: Record<string, SettingsHookGroup[]>;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Pure, idempotent settings merge. Deep-clones input, dedupes by two
|
|
46
|
+
* checks in priority order:
|
|
47
|
+
*
|
|
48
|
+
* 1. sentinel `_marker` field — primary key. Survives path drift, lets
|
|
49
|
+
* a future uninstall command find our entries unambiguously.
|
|
50
|
+
* 2. command-string equality — secondary, catches the case where the
|
|
51
|
+
* user (or another tool) already added an equivalent entry by hand
|
|
52
|
+
* and never wrote our marker. Without this fallback we would happily
|
|
53
|
+
* append a duplicate next to it and the hook would fire twice.
|
|
54
|
+
*
|
|
55
|
+
* Throws if `settings.hooks[event]` exists but is not an array — that
|
|
56
|
+
* means the user has hand-edited their settings into a shape we do not
|
|
57
|
+
* recognize, and silently replacing it with an empty array would lose
|
|
58
|
+
* data. Callers should catch and surface the error to the user.
|
|
59
|
+
*/
|
|
60
|
+
export declare function addHookToSettings(settings: SettingsFile, event: string, command: string, marker: string): {
|
|
61
|
+
settings: SettingsFile;
|
|
62
|
+
mutated: boolean;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Pure inverse of addHookToSettings: removes every action carrying
|
|
66
|
+
* `_marker` from every event in the settings tree. Returns the mutated
|
|
67
|
+
* copy and the count of actions removed. If a group becomes empty after
|
|
68
|
+
* removal, the whole group is dropped; if an event becomes empty, the
|
|
69
|
+
* event key is dropped.
|
|
70
|
+
*/
|
|
71
|
+
export declare function removeHookFromSettings(settings: SettingsFile, marker: string): {
|
|
72
|
+
settings: SettingsFile;
|
|
73
|
+
removed: number;
|
|
74
|
+
};
|
|
75
|
+
type Scope = "project-local" | "project" | "user";
|
|
76
|
+
export declare function loadHooksConfig(packageRoot: string): HooksConfig;
|
|
77
|
+
export interface InstallHookResult {
|
|
78
|
+
hook: string;
|
|
79
|
+
written: number;
|
|
80
|
+
preserved: number;
|
|
81
|
+
scope: Scope;
|
|
82
|
+
hookDir: string;
|
|
83
|
+
settingsPath: string;
|
|
84
|
+
settingsMutated: boolean;
|
|
85
|
+
settingsError?: string;
|
|
86
|
+
aborted?: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Non-interactive single-hook install. Driven by installHooks (which
|
|
90
|
+
* collects user choices via prompts) and by tools/verify-hooks.mjs (which
|
|
91
|
+
* exercises the install path end-to-end without prompts).
|
|
92
|
+
*
|
|
93
|
+
* Failure ordering matters: deps run first (no state changes), then
|
|
94
|
+
* settings is read AND parsed (still no state changes), and only after
|
|
95
|
+
* parsing succeeds do we touch the filesystem to copy hook files. A
|
|
96
|
+
* malformed settings file therefore aborts cleanly and leaves nothing
|
|
97
|
+
* behind.
|
|
98
|
+
*/
|
|
99
|
+
export declare function installHook(hook: HookDef, scope: Scope, projectBase: string, packageRoot: string): Promise<InstallHookResult>;
|
|
100
|
+
/**
|
|
101
|
+
* Scan all 3 scope settings files for a hook's marker, returning every
|
|
102
|
+
* scope where the marker is currently present and is NOT the scope the
|
|
103
|
+
* caller is about to install into. Used by installHooks to detect
|
|
104
|
+
* cross-scope leftovers from a previous install — which would cause the
|
|
105
|
+
* hook to fire multiple times if not cleaned up.
|
|
106
|
+
*
|
|
107
|
+
* Pure-ish: reads files but does not mutate. Silently skips files that
|
|
108
|
+
* fail to parse — surfacing those errors is the install path's job.
|
|
109
|
+
*/
|
|
110
|
+
export interface StaleScope {
|
|
111
|
+
scope: Scope;
|
|
112
|
+
settingsPath: string;
|
|
113
|
+
count: number;
|
|
114
|
+
}
|
|
115
|
+
export declare function findStaleScopes(hook: HookDef, currentScope: Scope, projectBase: string): StaleScope[];
|
|
116
|
+
/**
|
|
117
|
+
* Remove every action carrying `hook.marker` from the given scope's
|
|
118
|
+
* settings file. Atomic write, snapshot-once .bak. Returns the count of
|
|
119
|
+
* actions removed (0 if nothing matched or file did not exist).
|
|
120
|
+
*/
|
|
121
|
+
export declare function cleanHookFromScope(hook: HookDef, scope: Scope, projectBase: string): {
|
|
122
|
+
removed: number;
|
|
123
|
+
settingsPath: string;
|
|
124
|
+
};
|
|
125
|
+
export declare function installHooks(packageRoot: string): Promise<void>;
|
|
126
|
+
export {};
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { checkbox, confirm, input, select } from "@inquirer/prompts";
|
|
7
|
+
import { exec, fetchExtraContentBinary, log, withEsc, } from "./utils.js";
|
|
8
|
+
// --- Registry validation ---
|
|
9
|
+
// hooks.json is fetched at runtime from raw.githubusercontent.com, so any
|
|
10
|
+
// downstream code that interpolates registry values into shell commands or
|
|
11
|
+
// filesystem paths is one force-push away from RCE / arbitrary-file-write
|
|
12
|
+
// for every user running `npx auriga-cli`. Validate every untrusted value
|
|
13
|
+
// once at load time, then trust it through the rest of the install flow.
|
|
14
|
+
const HOOK_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
15
|
+
const DEP_NAME_RE = /^[a-z0-9][a-z0-9._+-]*$/;
|
|
16
|
+
const EVENT_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
17
|
+
// Whitelist for hook command templates. The registry is fetched from raw
|
|
18
|
+
// GitHub at runtime, and the command string is written verbatim into
|
|
19
|
+
// settings.json then executed by Claude Code on every hook fire — so an
|
|
20
|
+
// unconstrained string here is direct registry-RCE. We require:
|
|
21
|
+
// <runtime> "$HOOK_DIR/<flat-basename>.<ext>"
|
|
22
|
+
// where runtime ∈ {node, python3, bash}, the path literal starts with
|
|
23
|
+
// $HOOK_DIR/, the basename is a flat alphanumeric identifier (no slashes,
|
|
24
|
+
// no dots — so no nested paths and no `..` traversal), the extension is
|
|
25
|
+
// alphanumeric, and there are no trailing arguments. Anything else is
|
|
26
|
+
// rejected at load time. Adding a runtime, allowing args, or relaxing
|
|
27
|
+
// the form requires a code change here, intentionally — see the security
|
|
28
|
+
// review trail in PR #7 for context.
|
|
29
|
+
const COMMAND_RE = /^(node|python3|bash) "\$HOOK_DIR\/[A-Za-z0-9_-]+\.[A-Za-z0-9]+"$/;
|
|
30
|
+
function isSafeRelativePath(file) {
|
|
31
|
+
if (typeof file !== "string" || file.length === 0)
|
|
32
|
+
return false;
|
|
33
|
+
if (file.startsWith("/") || file.startsWith("\\"))
|
|
34
|
+
return false;
|
|
35
|
+
if (file.includes("\0"))
|
|
36
|
+
return false;
|
|
37
|
+
const normalized = path.posix.normalize(file);
|
|
38
|
+
if (normalized !== file)
|
|
39
|
+
return false;
|
|
40
|
+
if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../"))
|
|
41
|
+
return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
function validateHookEntry(hook, idx) {
|
|
45
|
+
if (!hook || typeof hook !== "object") {
|
|
46
|
+
throw new Error(`hooks.json: hooks[${idx}] is not an object`);
|
|
47
|
+
}
|
|
48
|
+
const h = hook;
|
|
49
|
+
if (typeof h.name !== "string" || !HOOK_NAME_RE.test(h.name)) {
|
|
50
|
+
throw new Error(`hooks.json: hooks[${idx}].name must match ${HOOK_NAME_RE} (got ${JSON.stringify(h.name)})`);
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(h.files)) {
|
|
53
|
+
throw new Error(`hooks.json: hooks[${idx}].files must be an array`);
|
|
54
|
+
}
|
|
55
|
+
for (const f of h.files) {
|
|
56
|
+
if (!isSafeRelativePath(f)) {
|
|
57
|
+
throw new Error(`hooks.json: hooks[${idx}].files contains unsafe path ${JSON.stringify(f)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (h.preserveFiles !== undefined) {
|
|
61
|
+
if (!Array.isArray(h.preserveFiles)) {
|
|
62
|
+
throw new Error(`hooks.json: hooks[${idx}].preserveFiles must be an array`);
|
|
63
|
+
}
|
|
64
|
+
for (const f of h.preserveFiles) {
|
|
65
|
+
if (!isSafeRelativePath(f)) {
|
|
66
|
+
throw new Error(`hooks.json: hooks[${idx}].preserveFiles contains unsafe path ${JSON.stringify(f)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (h.deps !== undefined) {
|
|
71
|
+
if (!Array.isArray(h.deps)) {
|
|
72
|
+
throw new Error(`hooks.json: hooks[${idx}].deps must be an array`);
|
|
73
|
+
}
|
|
74
|
+
for (const d of h.deps) {
|
|
75
|
+
if (!d || typeof d !== "object") {
|
|
76
|
+
throw new Error(`hooks.json: hooks[${idx}].deps entry is not an object`);
|
|
77
|
+
}
|
|
78
|
+
const dn = d.name;
|
|
79
|
+
if (typeof dn !== "string" || !DEP_NAME_RE.test(dn)) {
|
|
80
|
+
throw new Error(`hooks.json: hooks[${idx}].deps name must match ${DEP_NAME_RE} (got ${JSON.stringify(dn)})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(h.runtimePlatforms)) {
|
|
85
|
+
throw new Error(`hooks.json: hooks[${idx}].runtimePlatforms must be an array`);
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(h.settingsEvents)) {
|
|
88
|
+
throw new Error(`hooks.json: hooks[${idx}].settingsEvents must be an array`);
|
|
89
|
+
}
|
|
90
|
+
for (const evt of h.settingsEvents) {
|
|
91
|
+
if (!evt || typeof evt !== "object") {
|
|
92
|
+
throw new Error(`hooks.json: hooks[${idx}].settingsEvents entry is not an object`);
|
|
93
|
+
}
|
|
94
|
+
const en = evt.event;
|
|
95
|
+
if (typeof en !== "string" || !EVENT_NAME_RE.test(en)) {
|
|
96
|
+
throw new Error(`hooks.json: hooks[${idx}].settingsEvents.event must match ${EVENT_NAME_RE} (got ${JSON.stringify(en)})`);
|
|
97
|
+
}
|
|
98
|
+
const matcher = evt.matcher;
|
|
99
|
+
if (matcher !== undefined && (typeof matcher !== "string" || !EVENT_NAME_RE.test(matcher))) {
|
|
100
|
+
throw new Error(`hooks.json: hooks[${idx}].settingsEvents.matcher must match ${EVENT_NAME_RE} (got ${JSON.stringify(matcher)})`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (typeof h.command !== "string" || !COMMAND_RE.test(h.command)) {
|
|
104
|
+
throw new Error(`hooks.json: hooks[${idx}].command must match the safe template ${COMMAND_RE} (got ${JSON.stringify(h.command)})`);
|
|
105
|
+
}
|
|
106
|
+
if (typeof h.marker !== "string" || h.marker.length === 0) {
|
|
107
|
+
throw new Error(`hooks.json: hooks[${idx}].marker must be a non-empty string`);
|
|
108
|
+
}
|
|
109
|
+
if (h.customizeHints !== undefined) {
|
|
110
|
+
if (!Array.isArray(h.customizeHints)) {
|
|
111
|
+
throw new Error(`hooks.json: hooks[${idx}].customizeHints must be an array`);
|
|
112
|
+
}
|
|
113
|
+
for (const hint of h.customizeHints) {
|
|
114
|
+
if (typeof hint !== "string" || hint.length === 0 || hint.length > 200) {
|
|
115
|
+
throw new Error(`hooks.json: hooks[${idx}].customizeHints entries must be non-empty strings ≤200 chars`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Pure, idempotent settings merge. Deep-clones input, dedupes by two
|
|
122
|
+
* checks in priority order:
|
|
123
|
+
*
|
|
124
|
+
* 1. sentinel `_marker` field — primary key. Survives path drift, lets
|
|
125
|
+
* a future uninstall command find our entries unambiguously.
|
|
126
|
+
* 2. command-string equality — secondary, catches the case where the
|
|
127
|
+
* user (or another tool) already added an equivalent entry by hand
|
|
128
|
+
* and never wrote our marker. Without this fallback we would happily
|
|
129
|
+
* append a duplicate next to it and the hook would fire twice.
|
|
130
|
+
*
|
|
131
|
+
* Throws if `settings.hooks[event]` exists but is not an array — that
|
|
132
|
+
* means the user has hand-edited their settings into a shape we do not
|
|
133
|
+
* recognize, and silently replacing it with an empty array would lose
|
|
134
|
+
* data. Callers should catch and surface the error to the user.
|
|
135
|
+
*/
|
|
136
|
+
export function addHookToSettings(settings, event, command, marker) {
|
|
137
|
+
const next = JSON.parse(JSON.stringify(settings ?? {}));
|
|
138
|
+
if (next.hooks !== undefined && (typeof next.hooks !== "object" || Array.isArray(next.hooks))) {
|
|
139
|
+
throw new Error(`settings.hooks exists but is not an object; refusing to clobber it`);
|
|
140
|
+
}
|
|
141
|
+
if (!next.hooks)
|
|
142
|
+
next.hooks = {};
|
|
143
|
+
const existing = next.hooks[event];
|
|
144
|
+
if (existing !== undefined && !Array.isArray(existing)) {
|
|
145
|
+
throw new Error(`settings.hooks.${event} exists but is not an array; refusing to clobber it`);
|
|
146
|
+
}
|
|
147
|
+
const list = existing ?? [];
|
|
148
|
+
for (const group of list) {
|
|
149
|
+
if (!group?.hooks || !Array.isArray(group.hooks))
|
|
150
|
+
continue;
|
|
151
|
+
for (const action of group.hooks) {
|
|
152
|
+
if (!action)
|
|
153
|
+
continue;
|
|
154
|
+
if (action._marker === marker) {
|
|
155
|
+
next.hooks[event] = list;
|
|
156
|
+
return { settings: next, mutated: false };
|
|
157
|
+
}
|
|
158
|
+
if (action.type === "command" && action.command === command) {
|
|
159
|
+
// A pre-existing entry (manual or from another tool) already
|
|
160
|
+
// points at the same command. Coexist with it; do not add a
|
|
161
|
+
// duplicate. We deliberately do NOT stamp our marker onto someone
|
|
162
|
+
// else's entry — that would silently take ownership of it.
|
|
163
|
+
next.hooks[event] = list;
|
|
164
|
+
return { settings: next, mutated: false };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
list.push({
|
|
169
|
+
hooks: [{ type: "command", command, _marker: marker }],
|
|
170
|
+
});
|
|
171
|
+
next.hooks[event] = list;
|
|
172
|
+
return { settings: next, mutated: true };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Pure inverse of addHookToSettings: removes every action carrying
|
|
176
|
+
* `_marker` from every event in the settings tree. Returns the mutated
|
|
177
|
+
* copy and the count of actions removed. If a group becomes empty after
|
|
178
|
+
* removal, the whole group is dropped; if an event becomes empty, the
|
|
179
|
+
* event key is dropped.
|
|
180
|
+
*/
|
|
181
|
+
export function removeHookFromSettings(settings, marker) {
|
|
182
|
+
const next = JSON.parse(JSON.stringify(settings ?? {}));
|
|
183
|
+
if (!next.hooks || typeof next.hooks !== "object" || Array.isArray(next.hooks)) {
|
|
184
|
+
return { settings: next, removed: 0 };
|
|
185
|
+
}
|
|
186
|
+
let removed = 0;
|
|
187
|
+
for (const event of Object.keys(next.hooks)) {
|
|
188
|
+
const list = next.hooks[event];
|
|
189
|
+
if (!Array.isArray(list))
|
|
190
|
+
continue;
|
|
191
|
+
const newGroups = [];
|
|
192
|
+
for (const group of list) {
|
|
193
|
+
if (!group?.hooks || !Array.isArray(group.hooks)) {
|
|
194
|
+
newGroups.push(group);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const remainingActions = group.hooks.filter((action) => {
|
|
198
|
+
if (action && action._marker === marker) {
|
|
199
|
+
removed++;
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
204
|
+
if (remainingActions.length > 0) {
|
|
205
|
+
newGroups.push({ ...group, hooks: remainingActions });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (newGroups.length > 0) {
|
|
209
|
+
next.hooks[event] = newGroups;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
delete next.hooks[event];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { settings: next, removed };
|
|
216
|
+
}
|
|
217
|
+
const settingsBackedUp = new Set();
|
|
218
|
+
function resolveScope(scope, projectBase, hookName) {
|
|
219
|
+
if (scope === "user") {
|
|
220
|
+
const home = os.homedir();
|
|
221
|
+
const dir = path.join(home, ".claude", "hooks", hookName);
|
|
222
|
+
return {
|
|
223
|
+
scope,
|
|
224
|
+
hookDir: dir,
|
|
225
|
+
settingsPath: path.join(home, ".claude", "settings.json"),
|
|
226
|
+
commandHookDir: dir,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const projectClaude = path.join(projectBase, ".claude");
|
|
230
|
+
return {
|
|
231
|
+
scope,
|
|
232
|
+
hookDir: path.join(projectClaude, "hooks", hookName),
|
|
233
|
+
settingsPath: scope === "project-local"
|
|
234
|
+
? path.join(projectClaude, "settings.local.json")
|
|
235
|
+
: path.join(projectClaude, "settings.json"),
|
|
236
|
+
commandHookDir: `$CLAUDE_PROJECT_DIR/.claude/hooks/${hookName}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function scopeChoices() {
|
|
240
|
+
return [
|
|
241
|
+
{
|
|
242
|
+
name: "Project local — files in ./.claude/hooks/, settings in ./.claude/settings.local.json (per-developer, not committed)",
|
|
243
|
+
value: "project-local",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "Project — files in ./.claude/hooks/, settings in ./.claude/settings.json (committed, shared with team)",
|
|
247
|
+
value: "project",
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: "User — files in ~/.claude/hooks/, settings in ~/.claude/settings.json (global, all your projects)",
|
|
251
|
+
value: "user",
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
function depReady(dep) {
|
|
256
|
+
try {
|
|
257
|
+
exec(`which ${dep.name}`);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function brewAvailable() {
|
|
265
|
+
try {
|
|
266
|
+
exec("which brew");
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function installDep(dep) {
|
|
274
|
+
// Defense-in-depth: the registry validator already enforced this regex,
|
|
275
|
+
// but re-check here so a future code path that constructs a HookDep
|
|
276
|
+
// outside the validator still can't shell-inject through this function.
|
|
277
|
+
if (!DEP_NAME_RE.test(dep.name)) {
|
|
278
|
+
log.error(`refusing to install dep with unsafe name: ${JSON.stringify(dep.name)}`);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
console.log(` Installing ${dep.name} via Homebrew (may prompt for password)...`);
|
|
282
|
+
// argv form, NOT shell-interpolated — registry compromise can't escape into a shell command.
|
|
283
|
+
const result = spawnSync("brew", ["install", dep.name], { stdio: "inherit" });
|
|
284
|
+
return result.status === 0;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Pre-flight: ensure all deps are present (or gracefully degraded) before
|
|
288
|
+
* touching any files. Returns false to hard-abort the hook install.
|
|
289
|
+
*/
|
|
290
|
+
function preflightDeps(hook) {
|
|
291
|
+
for (const dep of hook.deps ?? []) {
|
|
292
|
+
if (depReady(dep)) {
|
|
293
|
+
log.ok(`${dep.name} ready`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (dep.via === "brew") {
|
|
297
|
+
if (brewAvailable()) {
|
|
298
|
+
if (installDep(dep)) {
|
|
299
|
+
log.ok(`${dep.name} installed`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (dep.optional) {
|
|
303
|
+
log.warn(`${dep.name} install failed; runtime fallback will be used`);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
log.error(`${dep.name} install failed (required); aborting`);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (dep.optional) {
|
|
310
|
+
log.warn(`Homebrew not found; ${dep.name} will be skipped. Runtime fallback will be used (no brand icon). Install brew at https://brew.sh and re-run for full features.`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
log.error(`Homebrew not found and ${dep.name} is required. Install brew at https://brew.sh, then re-run.`);
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Lazy-fetch a hook's payload files into `packageRoot` so they can be
|
|
321
|
+
* copied from there into the user's target directory.
|
|
322
|
+
*
|
|
323
|
+
* IMPORTANT: in production, `packageRoot` is the temp dir created by
|
|
324
|
+
* `fetchContentRoot()` (utils.ts) — not the npm package install dir.
|
|
325
|
+
* Only `.claude/hooks/hooks.json` is preloaded by `CONTENT_FILES`; we
|
|
326
|
+
* fetch each hook's individual files on demand here so users who pick
|
|
327
|
+
* no hooks pay no network cost. In DEV mode `packageRoot` is the live
|
|
328
|
+
* repo root, so the files are already on disk and we skip the fetch.
|
|
329
|
+
*
|
|
330
|
+
* The hook payload list is owned by `hook.files` in `hooks.json`, which
|
|
331
|
+
* loadHooksConfig already validated for path-traversal safety, so each
|
|
332
|
+
* `file` here is a known-good relative path.
|
|
333
|
+
*/
|
|
334
|
+
async function ensureHookFilesFetched(hook, packageRoot) {
|
|
335
|
+
if (process.env.DEV === "1")
|
|
336
|
+
return;
|
|
337
|
+
for (const file of hook.files) {
|
|
338
|
+
const repoPath = path.posix.join(".claude/hooks", hook.name, file);
|
|
339
|
+
const localPath = path.join(packageRoot, repoPath);
|
|
340
|
+
if (fs.existsSync(localPath))
|
|
341
|
+
continue;
|
|
342
|
+
await fetchExtraContentBinary(packageRoot, repoPath);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function copyHookFiles(hook, packageRoot, destDir) {
|
|
346
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
347
|
+
const preserve = new Set(hook.preserveFiles ?? []);
|
|
348
|
+
let written = 0;
|
|
349
|
+
let preserved = 0;
|
|
350
|
+
for (const file of hook.files) {
|
|
351
|
+
const dest = path.join(destDir, file);
|
|
352
|
+
if (preserve.has(file) && fs.existsSync(dest)) {
|
|
353
|
+
preserved++;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const src = path.join(packageRoot, ".claude", "hooks", hook.name, file);
|
|
357
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
358
|
+
fs.copyFileSync(src, dest);
|
|
359
|
+
written++;
|
|
360
|
+
}
|
|
361
|
+
return { written, preserved };
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Snapshot a settings file to `.bak` before the first mutation in this
|
|
365
|
+
* session. The naive `copyFileSync(src, dst)` follows symlinks, which
|
|
366
|
+
* would let a local attacker pre-symlink `settings.json.bak` at, say,
|
|
367
|
+
* `~/.ssh/authorized_keys` and have us clobber the target on the next
|
|
368
|
+
* install — same threat class as the tmp-file TOCTOU that
|
|
369
|
+
* `atomicWriteFile` plugs. We use the same defense: read the source,
|
|
370
|
+
* write to a fresh fd opened with O_CREAT|O_EXCL|O_WRONLY (refuses any
|
|
371
|
+
* pre-existing path, including a symlink), then rely on the no-op-if-
|
|
372
|
+
* already-backed-up-this-session guard for re-runs.
|
|
373
|
+
*
|
|
374
|
+
* If the .bak already exists from a previous session, leave it alone —
|
|
375
|
+
* the FIRST backup is the one that captures the user's pre-auriga state,
|
|
376
|
+
* which is what they care about restoring to.
|
|
377
|
+
*/
|
|
378
|
+
function backupOnce(filePath) {
|
|
379
|
+
if (settingsBackedUp.has(filePath))
|
|
380
|
+
return;
|
|
381
|
+
settingsBackedUp.add(filePath);
|
|
382
|
+
if (!fs.existsSync(filePath))
|
|
383
|
+
return;
|
|
384
|
+
const bakPath = filePath + ".bak";
|
|
385
|
+
if (fs.existsSync(bakPath))
|
|
386
|
+
return;
|
|
387
|
+
const data = fs.readFileSync(filePath);
|
|
388
|
+
const fd = fs.openSync(bakPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
389
|
+
try {
|
|
390
|
+
fs.writeSync(fd, data);
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
fs.closeSync(fd);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Read and JSON.parse a settings file. Returns {} for missing file.
|
|
398
|
+
* Throws on parse error so the caller can abort cleanly *before* any
|
|
399
|
+
* file copy, instead of leaving orphan hook files in the target after a
|
|
400
|
+
* mid-flight failure.
|
|
401
|
+
*/
|
|
402
|
+
function readSettings(settingsPath) {
|
|
403
|
+
if (!fs.existsSync(settingsPath))
|
|
404
|
+
return {};
|
|
405
|
+
try {
|
|
406
|
+
return JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
throw new Error(`${settingsPath} is not valid JSON: ${e.message}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Apply a hook's settingsEvents to an already-parsed settings object,
|
|
414
|
+
* write the result atomically if anything changed. The caller MUST have
|
|
415
|
+
* pre-validated the file via readSettings() before any file copy.
|
|
416
|
+
*/
|
|
417
|
+
function writeMergedSettings(resolved, hook, parsed) {
|
|
418
|
+
let mutated = false;
|
|
419
|
+
let next = parsed;
|
|
420
|
+
for (const evt of hook.settingsEvents) {
|
|
421
|
+
const cmd = hook.command.replace(/\$HOOK_DIR/g, resolved.commandHookDir);
|
|
422
|
+
const result = addHookToSettings(next, evt.event, cmd, hook.marker);
|
|
423
|
+
if (result.mutated)
|
|
424
|
+
mutated = true;
|
|
425
|
+
next = result.settings;
|
|
426
|
+
}
|
|
427
|
+
if (mutated) {
|
|
428
|
+
backupOnce(resolved.settingsPath);
|
|
429
|
+
fs.mkdirSync(path.dirname(resolved.settingsPath), { recursive: true });
|
|
430
|
+
atomicWriteFile(resolved.settingsPath, JSON.stringify(next, null, 2) + "\n");
|
|
431
|
+
}
|
|
432
|
+
return { mutated };
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Write `content` to `filePath` atomically and TOCTOU-safely.
|
|
436
|
+
*
|
|
437
|
+
* A predictable tmp name like `settings.json.tmp` lets a local attacker
|
|
438
|
+
* pre-create that path as a symlink pointing at, say, ~/.ssh/authorized_keys
|
|
439
|
+
* — the next install would then clobber the link target. Defenses: random
|
|
440
|
+
* suffix so the tmp name can't be predicted, plus O_CREAT|O_EXCL so we
|
|
441
|
+
* refuse to open the path at all if anything (file or symlink) exists
|
|
442
|
+
* there. Restrictive 0o600 perms in case the parent directory is
|
|
443
|
+
* world-writable. Final rename(2) is the atomic step.
|
|
444
|
+
*/
|
|
445
|
+
function atomicWriteFile(filePath, content) {
|
|
446
|
+
const dir = path.dirname(filePath);
|
|
447
|
+
const base = path.basename(filePath);
|
|
448
|
+
const suffix = crypto.randomBytes(8).toString("hex");
|
|
449
|
+
const tmp = path.join(dir, `.${base}.${suffix}.tmp`);
|
|
450
|
+
const fd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
451
|
+
try {
|
|
452
|
+
fs.writeSync(fd, content);
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
fs.closeSync(fd);
|
|
456
|
+
}
|
|
457
|
+
fs.renameSync(tmp, filePath);
|
|
458
|
+
}
|
|
459
|
+
export function loadHooksConfig(packageRoot) {
|
|
460
|
+
const configPath = path.join(packageRoot, ".claude", "hooks", "hooks.json");
|
|
461
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
462
|
+
if (!raw || !Array.isArray(raw.hooks)) {
|
|
463
|
+
throw new Error(`${configPath} must have a "hooks" array at the top level`);
|
|
464
|
+
}
|
|
465
|
+
raw.hooks.forEach((h, i) => validateHookEntry(h, i));
|
|
466
|
+
return raw;
|
|
467
|
+
}
|
|
468
|
+
function relativeFromCwd(absPath) {
|
|
469
|
+
const rel = path.relative(process.cwd(), absPath);
|
|
470
|
+
return rel.startsWith("..") ? absPath : rel;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Non-interactive single-hook install. Driven by installHooks (which
|
|
474
|
+
* collects user choices via prompts) and by tools/verify-hooks.mjs (which
|
|
475
|
+
* exercises the install path end-to-end without prompts).
|
|
476
|
+
*
|
|
477
|
+
* Failure ordering matters: deps run first (no state changes), then
|
|
478
|
+
* settings is read AND parsed (still no state changes), and only after
|
|
479
|
+
* parsing succeeds do we touch the filesystem to copy hook files. A
|
|
480
|
+
* malformed settings file therefore aborts cleanly and leaves nothing
|
|
481
|
+
* behind.
|
|
482
|
+
*/
|
|
483
|
+
export async function installHook(hook, scope, projectBase, packageRoot) {
|
|
484
|
+
const resolved = resolveScope(scope, projectBase, hook.name);
|
|
485
|
+
const base = {
|
|
486
|
+
hook: hook.name,
|
|
487
|
+
written: 0,
|
|
488
|
+
preserved: 0,
|
|
489
|
+
scope,
|
|
490
|
+
hookDir: resolved.hookDir,
|
|
491
|
+
settingsPath: resolved.settingsPath,
|
|
492
|
+
settingsMutated: false,
|
|
493
|
+
};
|
|
494
|
+
if (!preflightDeps(hook)) {
|
|
495
|
+
return { ...base, aborted: "deps preflight failed" };
|
|
496
|
+
}
|
|
497
|
+
// Pre-validate settings BEFORE any filesystem writes. If the file is
|
|
498
|
+
// malformed we abort here, before copyHookFiles, so the caller never
|
|
499
|
+
// ends up with orphan hook files in the target.
|
|
500
|
+
let parsedSettings;
|
|
501
|
+
try {
|
|
502
|
+
parsedSettings = readSettings(resolved.settingsPath);
|
|
503
|
+
}
|
|
504
|
+
catch (e) {
|
|
505
|
+
return { ...base, aborted: e.message };
|
|
506
|
+
}
|
|
507
|
+
await ensureHookFilesFetched(hook, packageRoot);
|
|
508
|
+
const { written, preserved } = copyHookFiles(hook, packageRoot, resolved.hookDir);
|
|
509
|
+
let mutated = false;
|
|
510
|
+
let settingsError;
|
|
511
|
+
try {
|
|
512
|
+
mutated = writeMergedSettings(resolved, hook, parsedSettings).mutated;
|
|
513
|
+
}
|
|
514
|
+
catch (e) {
|
|
515
|
+
settingsError = e.message;
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
...base,
|
|
519
|
+
written,
|
|
520
|
+
preserved,
|
|
521
|
+
settingsMutated: mutated,
|
|
522
|
+
settingsError,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
export function findStaleScopes(hook, currentScope, projectBase) {
|
|
526
|
+
const all = ["project-local", "project", "user"];
|
|
527
|
+
const stale = [];
|
|
528
|
+
for (const s of all) {
|
|
529
|
+
if (s === currentScope)
|
|
530
|
+
continue;
|
|
531
|
+
const r = resolveScope(s, projectBase, hook.name);
|
|
532
|
+
if (!fs.existsSync(r.settingsPath))
|
|
533
|
+
continue;
|
|
534
|
+
let parsed;
|
|
535
|
+
try {
|
|
536
|
+
parsed = JSON.parse(fs.readFileSync(r.settingsPath, "utf8"));
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const removed = removeHookFromSettings(parsed, hook.marker).removed;
|
|
542
|
+
if (removed > 0) {
|
|
543
|
+
stale.push({ scope: s, settingsPath: r.settingsPath, count: removed });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return stale;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Remove every action carrying `hook.marker` from the given scope's
|
|
550
|
+
* settings file. Atomic write, snapshot-once .bak. Returns the count of
|
|
551
|
+
* actions removed (0 if nothing matched or file did not exist).
|
|
552
|
+
*/
|
|
553
|
+
export function cleanHookFromScope(hook, scope, projectBase) {
|
|
554
|
+
const r = resolveScope(scope, projectBase, hook.name);
|
|
555
|
+
if (!fs.existsSync(r.settingsPath)) {
|
|
556
|
+
return { removed: 0, settingsPath: r.settingsPath };
|
|
557
|
+
}
|
|
558
|
+
let parsed;
|
|
559
|
+
try {
|
|
560
|
+
parsed = JSON.parse(fs.readFileSync(r.settingsPath, "utf8"));
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return { removed: 0, settingsPath: r.settingsPath };
|
|
564
|
+
}
|
|
565
|
+
const result = removeHookFromSettings(parsed, hook.marker);
|
|
566
|
+
if (result.removed > 0) {
|
|
567
|
+
backupOnce(r.settingsPath);
|
|
568
|
+
atomicWriteFile(r.settingsPath, JSON.stringify(result.settings, null, 2) + "\n");
|
|
569
|
+
}
|
|
570
|
+
return { removed: result.removed, settingsPath: r.settingsPath };
|
|
571
|
+
}
|
|
572
|
+
export async function installHooks(packageRoot) {
|
|
573
|
+
const config = loadHooksConfig(packageRoot);
|
|
574
|
+
const compatible = config.hooks.filter((h) => h.runtimePlatforms.includes(process.platform));
|
|
575
|
+
if (compatible.length === 0) {
|
|
576
|
+
log.warn(`No hooks available for your platform (${process.platform}). Skipping.`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const selected = await withEsc(checkbox({
|
|
580
|
+
message: "Select hooks to install:",
|
|
581
|
+
choices: compatible.map((h) => ({
|
|
582
|
+
name: `${h.name} — ${h.description}`,
|
|
583
|
+
value: h,
|
|
584
|
+
checked: true,
|
|
585
|
+
})),
|
|
586
|
+
}));
|
|
587
|
+
if (selected.length === 0) {
|
|
588
|
+
log.skip("No hooks selected");
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// Lazily prompted on the first project-scoped hook, then reused. Users
|
|
592
|
+
// who pick only "user" scope are never asked about a project directory.
|
|
593
|
+
let projectBaseResolved = null;
|
|
594
|
+
async function ensureProjectBase() {
|
|
595
|
+
if (projectBaseResolved !== null)
|
|
596
|
+
return projectBaseResolved;
|
|
597
|
+
const projectBase = await withEsc(input({
|
|
598
|
+
message: "Hooks install target directory:",
|
|
599
|
+
default: process.cwd(),
|
|
600
|
+
}));
|
|
601
|
+
const resolvedPath = path.resolve(projectBase);
|
|
602
|
+
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) {
|
|
603
|
+
log.error(`Not a valid directory: ${resolvedPath}`);
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
projectBaseResolved = resolvedPath;
|
|
607
|
+
return projectBaseResolved;
|
|
608
|
+
}
|
|
609
|
+
for (const hook of selected) {
|
|
610
|
+
console.log(`\n· ${hook.name}`);
|
|
611
|
+
// Per-hook scope is intentional (not a single upfront prompt like
|
|
612
|
+
// plugins.ts / skills.ts): a future user may want personal dev tools
|
|
613
|
+
// at user level and project-specific hooks at project level. The
|
|
614
|
+
// single-hook case is functionally identical to a single prompt.
|
|
615
|
+
const scope = await withEsc(select({
|
|
616
|
+
message: `Where to install the ${hook.name} hook?`,
|
|
617
|
+
choices: scopeChoices(),
|
|
618
|
+
default: "project-local",
|
|
619
|
+
}));
|
|
620
|
+
// User scope mutates ~/.claude/settings.json — global, affects every
|
|
621
|
+
// project on this machine. A passive select label and a one-line warn
|
|
622
|
+
// both scroll past quickly. Make the user explicitly opt in to the
|
|
623
|
+
// global mutation; default to "no" so a missed Enter is the safe path.
|
|
624
|
+
if (scope === "user") {
|
|
625
|
+
const proceed = await withEsc(confirm({
|
|
626
|
+
message: `Modify your global ~/.claude/settings.json? This affects every project on this machine. A .bak snapshot is taken before any change.`,
|
|
627
|
+
default: false,
|
|
628
|
+
}));
|
|
629
|
+
if (!proceed) {
|
|
630
|
+
log.skip(`${hook.name} skipped (user cancelled global install)`);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Project scopes need a target directory; user scope does not.
|
|
635
|
+
let projectBaseForHook = "";
|
|
636
|
+
if (scope !== "user") {
|
|
637
|
+
const base = await ensureProjectBase();
|
|
638
|
+
if (base === null)
|
|
639
|
+
continue;
|
|
640
|
+
projectBaseForHook = base;
|
|
641
|
+
}
|
|
642
|
+
// Cross-scope cleanup: if this hook's marker is already present in a
|
|
643
|
+
// *different* scope's settings file, leaving it there means the hook
|
|
644
|
+
// will fire from both scopes. Detect, prompt, clean before installing.
|
|
645
|
+
const stale = findStaleScopes(hook, scope, projectBaseForHook);
|
|
646
|
+
for (const entry of stale) {
|
|
647
|
+
log.warn(`Found existing ${hook.name} hook in ${relativeFromCwd(entry.settingsPath)} (${entry.scope} scope, ${entry.count} entr${entry.count === 1 ? "y" : "ies"})`);
|
|
648
|
+
const remove = await withEsc(confirm({
|
|
649
|
+
message: `Remove the stale registration so the hook only fires once?`,
|
|
650
|
+
default: true,
|
|
651
|
+
}));
|
|
652
|
+
if (remove) {
|
|
653
|
+
const cleaned = cleanHookFromScope(hook, entry.scope, projectBaseForHook);
|
|
654
|
+
log.ok(`removed ${cleaned.removed} from ${relativeFromCwd(cleaned.settingsPath)}`);
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
// The user explicitly chose not to clean — make the consequence
|
|
658
|
+
// visible so it isn't a silent footgun. The hook will fire from
|
|
659
|
+
// BOTH scopes on every Notification event.
|
|
660
|
+
log.warn(`${hook.name} will fire from BOTH ${entry.scope} and ${scope} on every event. Run \`auriga-cli\` again or edit ${relativeFromCwd(entry.settingsPath)} to clean it up later.`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
let result;
|
|
664
|
+
try {
|
|
665
|
+
result = await installHook(hook, scope, projectBaseForHook, packageRoot);
|
|
666
|
+
}
|
|
667
|
+
catch (e) {
|
|
668
|
+
log.error(`${hook.name}: ${e.message}`);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (result.aborted) {
|
|
672
|
+
log.error(`${hook.name} aborted: ${result.aborted}`);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const settingsRel = relativeFromCwd(result.settingsPath);
|
|
676
|
+
const dirRel = relativeFromCwd(result.hookDir);
|
|
677
|
+
const summary = result.preserved > 0
|
|
678
|
+
? `${hook.name} hook installed at ${dirRel} (${result.written} written, ${result.preserved} preserved)`
|
|
679
|
+
: `${hook.name} hook installed at ${dirRel}`;
|
|
680
|
+
log.ok(summary);
|
|
681
|
+
if (result.settingsError) {
|
|
682
|
+
log.error(`${hook.name}: ${result.settingsError}`);
|
|
683
|
+
log.warn(`Files were copied to ${dirRel} but settings not updated. Add the hook entry manually if you want it active.`);
|
|
684
|
+
}
|
|
685
|
+
else if (result.settingsMutated) {
|
|
686
|
+
log.ok(`registered in ${settingsRel}`);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
log.skip(`already registered in ${settingsRel}`);
|
|
690
|
+
}
|
|
691
|
+
// Per-hook customize tips, sourced from registry metadata so adding a
|
|
692
|
+
// new hook doesn't require touching the installer. `{hookDir}` is
|
|
693
|
+
// substituted with the resolved install directory.
|
|
694
|
+
const hints = hook.customizeHints ?? [];
|
|
695
|
+
if (hints.length > 0) {
|
|
696
|
+
console.log(` Customize ${hook.name}:`);
|
|
697
|
+
for (const hint of hints) {
|
|
698
|
+
console.log(` • ${hint.replace(/\{hookDir\}/g, dirRel)}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
console.log(` See ${dirRel}/README.md for customization options.`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface LangOption {
|
|
|
32
32
|
export declare const LANGUAGES: LangOption[];
|
|
33
33
|
export declare function fetchContentRoot(): Promise<string>;
|
|
34
34
|
export declare function fetchExtraContent(tmpDir: string, file: string): Promise<void>;
|
|
35
|
+
export declare function fetchExtraContentBinary(tmpDir: string, file: string): Promise<void>;
|
|
35
36
|
export declare function withEsc<T>(prompt: Promise<T> & {
|
|
36
37
|
cancel?: () => void;
|
|
37
38
|
}): Promise<T>;
|
package/dist/utils.js
CHANGED
|
@@ -28,6 +28,7 @@ const CONTENT_FILES = [
|
|
|
28
28
|
"CLAUDE.md",
|
|
29
29
|
"skills-lock.json",
|
|
30
30
|
".claude/plugins.json",
|
|
31
|
+
".claude/hooks/hooks.json",
|
|
31
32
|
];
|
|
32
33
|
async function fetchFile(file) {
|
|
33
34
|
const url = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${file}`;
|
|
@@ -36,6 +37,13 @@ async function fetchFile(file) {
|
|
|
36
37
|
throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
|
37
38
|
return res.text();
|
|
38
39
|
}
|
|
40
|
+
async function fetchFileBinary(file) {
|
|
41
|
+
const url = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${file}`;
|
|
42
|
+
const res = await fetch(url);
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
|
45
|
+
return Buffer.from(await res.arrayBuffer());
|
|
46
|
+
}
|
|
39
47
|
export async function fetchContentRoot() {
|
|
40
48
|
if (process.env.DEV === "1") {
|
|
41
49
|
return getPackageRoot();
|
|
@@ -55,6 +63,12 @@ export async function fetchExtraContent(tmpDir, file) {
|
|
|
55
63
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
56
64
|
fs.writeFileSync(dest, content);
|
|
57
65
|
}
|
|
66
|
+
export async function fetchExtraContentBinary(tmpDir, file) {
|
|
67
|
+
const buf = await fetchFileBinary(file);
|
|
68
|
+
const dest = path.join(tmpDir, file);
|
|
69
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
70
|
+
fs.writeFileSync(dest, buf);
|
|
71
|
+
}
|
|
58
72
|
// --- ESC support ---
|
|
59
73
|
export function withEsc(prompt) {
|
|
60
74
|
const onKeypress = (_, key) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auriga-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Plugins)",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Recommended Skills, Plugins, Hooks)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"auriga-cli": "dist/cli.js"
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "tsc",
|
|
14
14
|
"dev": "tsc --watch",
|
|
15
|
-
"start": "node dist/cli.js"
|
|
15
|
+
"start": "node dist/cli.js",
|
|
16
|
+
"test": "tsc -p tsconfig.test.json && DEV=1 node --test dist-test/tests/hooks.test.js",
|
|
17
|
+
"test:watch": "tsc -p tsconfig.test.json --watch & node --test --watch dist-test/tests/hooks.test.js"
|
|
16
18
|
},
|
|
17
19
|
"engines": {
|
|
18
20
|
"node": ">=18"
|