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