auriga-cli 1.24.0 → 1.26.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/dist/preset.js ADDED
@@ -0,0 +1,84 @@
1
+ // src/preset.ts
2
+ //
3
+ // installPreset —— 「推荐预设安装」的单一编排入口。
4
+ //
5
+ // 预设由三部分组成,按下面的顺序安装:
6
+ // 1. workflow 文档 (CLAUDE.md + AGENTS.md)
7
+ // 2. 工作流 skill (WORKFLOW_SKILLS 全集 —— installSkills 自身已限定)
8
+ // 3. auriga-workflow 插件
9
+ //
10
+ // CLI 的 `--preset`、TUI 的「推荐预设」、Web UI 的一键按钮全部走这一个
11
+ // 函数,因此「预设由什么构成」只有这一处真相。
12
+ //
13
+ // installPreset 返回逐步成败摘要 (PresetStepResult[]):
14
+ // - CLI 用它计算分级退出码(全成功 0 / 部分或全部失败 2),与 runAll
15
+ // 的 graded-exit 语义对齐;
16
+ // - TUI / Web UI 忽略该摘要 —— 各自走 log-and-continue / 流式进度。
17
+ //
18
+ // 三个 installer 经动态 import 引入而非静态 import:这样 preset.ts 是一个
19
+ // 零重依赖的薄编排层 —— 只想读 PRESET_PLUGINS 常量的调用方(参数校验、
20
+ // 帮助文案)不会被迫拉入 plugins.ts 这张大依赖图;installer 模块也只在
21
+ // installPreset 真正被调用时才进入模块图。
22
+ /**
23
+ * 预设安装的插件成员 —— 固定只装 auriga-workflow。
24
+ * installPlugins 的 `selected` 过滤把安装面收敛到这一个插件。
25
+ * `as const` 冻结这个「单一真相」常量,调用方无法 .push() 篡改它。
26
+ */
27
+ export const PRESET_PLUGINS = ["auriga-workflow"];
28
+ /** 预设的安装顺序:文档 → skill → 插件。 */
29
+ const PRESET_STEPS = [
30
+ "workflow",
31
+ "skills",
32
+ "plugins",
33
+ ];
34
+ /**
35
+ * 按 workflow → skills → plugins 顺序执行预设安装。
36
+ *
37
+ * 每一步独立 try/catch:一步失败不阻断后续步骤(与 runAll 的
38
+ * log-and-continue 一致),逐步成败汇总后返回给调用方。
39
+ */
40
+ export async function installPreset(packageRoot, opts) {
41
+ const results = [];
42
+ for (const category of PRESET_STEPS) {
43
+ try {
44
+ await runPresetStep(category, packageRoot, opts);
45
+ results.push({ category, ok: true });
46
+ }
47
+ catch (e) {
48
+ results.push({ category, ok: false, err: e.message });
49
+ }
50
+ }
51
+ return results;
52
+ }
53
+ async function runPresetStep(category, packageRoot, opts) {
54
+ // scope / agent / lang 一并透传给每个 installer —— installer 各取所需
55
+ // (workflow 只用 lang/cwd,skills/plugins 只用 scope/agent),用不到的
56
+ // 字段被忽略。
57
+ const base = {
58
+ interactive: opts.interactive,
59
+ scope: opts.scope,
60
+ agent: opts.agent,
61
+ lang: opts.lang,
62
+ cwd: opts.cwd,
63
+ onLog: opts.onLog,
64
+ };
65
+ switch (category) {
66
+ case "workflow": {
67
+ const { installWorkflow } = await import("./workflow.js");
68
+ return installWorkflow(packageRoot, base);
69
+ }
70
+ case "skills": {
71
+ // installSkills 内部已把安装范围限定到 WORKFLOW_SKILLS 全集 ——
72
+ // 不传 selected 即安装这组工作流 skill,无需在此重复列举。
73
+ const { installSkills } = await import("./skills.js");
74
+ return installSkills(packageRoot, base);
75
+ }
76
+ case "plugins": {
77
+ const { installPlugins } = await import("./plugins.js");
78
+ return installPlugins(packageRoot, {
79
+ ...base,
80
+ selected: [...PRESET_PLUGINS],
81
+ });
82
+ }
83
+ }
84
+ }
@@ -7,7 +7,7 @@ import { loadCatalog } from "./catalog.js";
7
7
  export async function buildScanCatalog(packageRoot) {
8
8
  const dist = loadCatalog(packageRoot);
9
9
  // v1.19.0 dropped update-available status. The scanner is now presence-
10
- // only: skills / hooks / plugins / workflow all report installed iff their
10
+ // only: skills / plugins / workflow all report installed iff their
11
11
  // truth source exists, not-installed otherwise. No version / hash / event
12
12
  // comparison happens, so the build-time catalog is reduced to the bare
13
13
  // {description, agents?, external?} shape per entry.
@@ -35,9 +35,5 @@ export async function buildScanCatalog(packageRoot) {
35
35
  ...(entry.external === true ? { external: true } : {}),
36
36
  };
37
37
  }
38
- const hooks = {};
39
- for (const entry of dist.hooks) {
40
- hooks[entry.name] = { description: entry.description };
41
- }
42
- return { skills, recommendedSkills, plugins, hooks };
38
+ return { skills, recommendedSkills, plugins };
43
39
  }
package/dist/server.d.ts CHANGED
@@ -5,9 +5,12 @@ export interface ApplyHandlerOptions {
5
5
  * translate into the per-installer flag (`--scope project|user`). The
6
6
  * workflow handler ignores it (workflow has no scope concept). */
7
7
  scope?: "project" | "user";
8
- /** Workflow CLAUDE.md language variant. Only meaningful for the workflow
9
- * handler; other handlers ignore it. Omitted = "en". */
8
+ /** Workflow CLAUDE.md language variant. Meaningful for the workflow and
9
+ * preset handlers; other handlers ignore it. Omitted = "en". */
10
10
  lang?: "en" | "zh-CN";
11
+ /** Preset install runtime. Only meaningful for the preset handler;
12
+ * other handlers ignore it. Omitted = "both". */
13
+ agent?: "claude" | "codex" | "both";
11
14
  }
12
15
  export type ApplyHandler = (action: ApplyAction, name: string, opts: ApplyHandlerOptions) => Promise<void>;
13
16
  export interface ApplyHandlers {
@@ -15,14 +18,14 @@ export interface ApplyHandlers {
15
18
  skill: ApplyHandler;
16
19
  "recommended-skill": ApplyHandler;
17
20
  plugin: ApplyHandler;
18
- hook: ApplyHandler;
21
+ preset: ApplyHandler;
19
22
  }
20
23
  export interface ApplyCatalog {
21
24
  workflow: Set<string>;
22
25
  skill: Set<string>;
23
26
  "recommended-skill": Set<string>;
24
27
  plugin: Set<string>;
25
- hook: Set<string>;
28
+ preset: Set<string>;
26
29
  }
27
30
  export interface StartServerOptions {
28
31
  port?: number;
package/dist/server.js CHANGED
@@ -169,11 +169,12 @@ const VALID_CATEGORIES = new Set([
169
169
  "skill",
170
170
  "recommended-skill",
171
171
  "plugin",
172
- "hook",
172
+ "preset",
173
173
  ]);
174
174
  const VALID_ACTIONS = new Set(["install", "uninstall"]);
175
175
  const VALID_SCOPES = new Set(["project", "user"]);
176
176
  const VALID_LANGS = new Set(["en", "zh-CN"]);
177
+ const VALID_AGENTS = new Set(["claude", "codex", "both"]);
177
178
  function parseApplyRequest(raw) {
178
179
  let parsed;
179
180
  try {
@@ -190,7 +191,7 @@ function parseApplyRequest(raw) {
190
191
  for (const it of items) {
191
192
  if (!it || typeof it !== "object")
192
193
  return null;
193
- const { category, name, action, scope, lang } = it;
194
+ const { category, name, action, scope, lang, agent } = it;
194
195
  if (typeof category !== "string" || !VALID_CATEGORIES.has(category)) {
195
196
  return null;
196
197
  }
@@ -207,12 +208,21 @@ function parseApplyRequest(raw) {
207
208
  if (category === "workflow")
208
209
  return null;
209
210
  }
210
- // Lang is optional and only meaningful for category="workflow". Any
211
- // other pairing is a client bug and we reject loudly.
211
+ // Lang is optional and meaningful for category="workflow" and
212
+ // category="preset" (the preset installs the workflow doc). Any other
213
+ // pairing is a client bug and we reject loudly.
212
214
  if (lang !== undefined) {
213
215
  if (typeof lang !== "string" || !VALID_LANGS.has(lang))
214
216
  return null;
215
- if (category !== "workflow")
217
+ if (category !== "workflow" && category !== "preset")
218
+ return null;
219
+ }
220
+ // Agent is optional and only meaningful for category="preset" (the
221
+ // per-plugin agent is derived from the catalog, not client-supplied).
222
+ if (agent !== undefined) {
223
+ if (typeof agent !== "string" || !VALID_AGENTS.has(agent))
224
+ return null;
225
+ if (category !== "preset")
216
226
  return null;
217
227
  }
218
228
  }
@@ -375,6 +385,7 @@ export async function startServer(opts) {
375
385
  onLog: (line, level) => emit(job, { type: "item:log", index: i, line, level }),
376
386
  scope: item.scope,
377
387
  lang: item.lang,
388
+ agent: item.agent,
378
389
  });
379
390
  emit(job, { type: "item:done", index: i, success: true });
380
391
  }
@@ -752,7 +763,7 @@ function parseScopesParam(searchParams) {
752
763
  const raw = searchParams.get("scopes");
753
764
  if (!raw)
754
765
  return null;
755
- const allowedCategories = new Set(["workflow", "skills", "plugins", "hooks"]);
766
+ const allowedCategories = new Set(["workflow", "skills", "plugins"]);
756
767
  const allowedScopes = new Set(["user", "project"]);
757
768
  const out = {};
758
769
  for (const pair of raw.split(",")) {
@@ -801,5 +812,5 @@ const defaultHandlersNotConfigured = {
801
812
  skill: handlerNotConfigured,
802
813
  "recommended-skill": handlerNotConfigured,
803
814
  plugin: handlerNotConfigured,
804
- hook: handlerNotConfigured,
815
+ preset: handlerNotConfigured,
805
816
  };
package/dist/state.d.ts CHANGED
@@ -18,9 +18,6 @@ export interface Catalog {
18
18
  * reporting; that surface was removed). */
19
19
  external?: boolean;
20
20
  }>;
21
- hooks: Record<string, {
22
- description: string;
23
- }>;
24
21
  }
25
22
  export interface ScanOptions {
26
23
  /** Run `claude plugins list` for the given scope. The scope argument is
@@ -37,13 +34,11 @@ export interface ScanOptions {
37
34
  readCodexPluginsDir?: () => Promise<Map<string, string>>;
38
35
  /** Per-category scope picker. Each field is independently routed to the
39
36
  * right truth source. Defaults match the Web UI's per-column picker:
40
- * workflow = 'project', skills = 'project',
41
- * plugins = 'user', hooks = 'user'. */
37
+ * workflow = 'project', skills = 'project', plugins = 'user'. */
42
38
  scopes?: {
43
39
  workflow?: ScanScope;
44
40
  skills?: ScanScope;
45
41
  plugins?: ScanScope;
46
- hooks?: ScanScope;
47
42
  };
48
43
  /** Test-time HOME override. When unset the scanner reads os.homedir()
49
44
  * (which itself consults process.env.HOME / USERPROFILE), so tests that
package/dist/state.js CHANGED
@@ -8,7 +8,6 @@
8
8
  // <proj>/.claude/skills/<name>/SKILL.md (project scope)
9
9
  // Plugins(Claude): execPluginList(scope) + settings.json enabledPlugins
10
10
  // Plugins(Codex): ~/.codex/config.toml + ~/.codex/plugins/cache (user only)
11
- // Hooks: <scope>/.claude/settings.json `hooks` segment, matched by _marker
12
11
  //
13
12
  // Scanner is presence-only: states are `installed` / `not-installed` /
14
13
  // `partial-install` (dual-Agent half-install). v1.19.0 dropped
@@ -46,7 +45,6 @@ const DEFAULT_SCOPES = {
46
45
  workflow: "project",
47
46
  skills: "project",
48
47
  plugins: "user",
49
- hooks: "user",
50
48
  };
51
49
  export async function scanState(projectRoot, catalog, opts = {}) {
52
50
  const warnings = [];
@@ -56,7 +54,6 @@ export async function scanState(projectRoot, catalog, opts = {}) {
56
54
  const skills = scanSkills(scopes.skills, projectRoot, home, catalog.skills,
57
55
  /* recommended */ false, warnings);
58
56
  const recommendedSkills = scanRecommendedSkills(scopes.skills, projectRoot, home, catalog.recommendedSkills, warnings);
59
- const hooks = scanHooks(scopes.hooks, projectRoot, home, catalog.hooks, warnings);
60
57
  const claudePluginEntries = filterPluginsByAgent(catalog.plugins, "claude");
61
58
  const codexPluginEntries = filterPluginsByAgent(catalog.plugins, "codex");
62
59
  const claudePlugins = await scanClaudePlugins(scopes.plugins, claudePluginEntries, opts.execPluginList, warnings);
@@ -79,7 +76,6 @@ export async function scanState(projectRoot, catalog, opts = {}) {
79
76
  skills,
80
77
  recommendedSkills,
81
78
  plugins: mergePluginsById([...claudePlugins, ...codexPlugins]),
82
- hooks,
83
79
  warnings,
84
80
  };
85
81
  }
@@ -462,106 +458,6 @@ function parseCodexEnabledPluginIds(tomlContent) {
462
458
  return ids;
463
459
  }
464
460
  // ---------------------------------------------------------------------------
465
- // Hooks — read from <scope>/.claude/settings.json `hooks` segment, matched by
466
- // `_marker` sentinel against catalog hook names. Settings.json shape (Claude
467
- // Code convention):
468
- //
469
- // {
470
- // "hooks": {
471
- // "<EventName>": [
472
- // {
473
- // "matcher": "<pattern>",
474
- // "if": "<optional Claude-Code filter>",
475
- // "hooks": [
476
- // { "type": "command", "command": "...", "_marker": "<name>" }
477
- // ]
478
- // }
479
- // ]
480
- // }
481
- // }
482
- //
483
- // ---------------------------------------------------------------------------
484
- function settingsPathForScope(scope, projectRoot, home) {
485
- if (scope === "user")
486
- return path.join(home, ".claude", "settings.json");
487
- return path.join(projectRoot, ".claude", "settings.json");
488
- }
489
- /** Returns the set of `_marker` sentinel values present in the settings
490
- * `hooks` segment. Malformed sub-shapes are skipped silently. v1.19.0
491
- * reduced this from a full {event, matcher, if, command} record (used for
492
- * drift detection) to a presence-only Set — re-install is the update
493
- * path now, so the scanner doesn't need to compare entry shapes. */
494
- function indexSettingsMarkers(settings) {
495
- const out = new Set();
496
- if (!settings || typeof settings !== "object" || Array.isArray(settings))
497
- return out;
498
- const hooksSeg = settings.hooks;
499
- if (!hooksSeg || typeof hooksSeg !== "object" || Array.isArray(hooksSeg))
500
- return out;
501
- for (const blocks of Object.values(hooksSeg)) {
502
- if (!Array.isArray(blocks))
503
- continue;
504
- for (const block of blocks) {
505
- if (!block || typeof block !== "object" || Array.isArray(block))
506
- continue;
507
- const actions = block.hooks;
508
- if (!Array.isArray(actions))
509
- continue;
510
- for (const action of actions) {
511
- if (!action || typeof action !== "object" || Array.isArray(action))
512
- continue;
513
- const marker = action._marker;
514
- if (typeof marker === "string")
515
- out.add(marker);
516
- }
517
- }
518
- }
519
- return out;
520
- }
521
- function scanHooks(scope, projectRoot, home, catalogHooks, warnings) {
522
- const settingsPath = settingsPathForScope(scope, projectRoot, home);
523
- let settingsRaw = null;
524
- let settingsErr = null;
525
- try {
526
- settingsRaw = fs.readFileSync(settingsPath, "utf8");
527
- }
528
- catch (err) {
529
- if (err && err.code === "ENOENT") {
530
- settingsErr = "absent";
531
- }
532
- else {
533
- settingsErr = "unreadable";
534
- }
535
- }
536
- let parsed = null;
537
- if (settingsRaw !== null) {
538
- try {
539
- parsed = JSON.parse(settingsRaw);
540
- }
541
- catch {
542
- settingsErr = "unreadable";
543
- parsed = null;
544
- }
545
- }
546
- if (settingsErr === "unreadable") {
547
- warnings.push({
548
- code: "settings-unreadable",
549
- message: `Settings file unreadable or corrupt JSON: ${settingsPath}`,
550
- });
551
- }
552
- const markers = indexSettingsMarkers(parsed);
553
- const out = [];
554
- for (const [name, def] of Object.entries(catalogHooks)) {
555
- out.push({
556
- name,
557
- description: def.description,
558
- status: markers.has(name) ? "installed" : "not-installed",
559
- observedScope: scope,
560
- });
561
- }
562
- return out;
563
- }
564
- // ---------------------------------------------------------------------------
565
461
  // Default external-I/O implementations (used when ScanOptions are not
566
462
  // injected — server.ts wires these up in production).
567
463
  // ---------------------------------------------------------------------------
package/dist/types.d.ts CHANGED
@@ -4,5 +4,5 @@
4
4
  * forcing leaf renderers (help.ts, guide.ts) to depend on the CLI
5
5
  * entrypoint just to pull one union.
6
6
  */
7
- export type CategoryName = "workflow" | "skills" | "recommended" | "plugins" | "hooks";
7
+ export type CategoryName = "workflow" | "skills" | "recommended" | "plugins";
8
8
  export declare const CATEGORY_NAMES: readonly CategoryName[];
package/dist/types.js CHANGED
@@ -9,5 +9,4 @@ export const CATEGORY_NAMES = [
9
9
  "skills",
10
10
  "recommended",
11
11
  "plugins",
12
- "hooks",
13
12
  ];
package/dist/utils.d.ts CHANGED
@@ -33,10 +33,16 @@ export interface InstallOpts {
33
33
  lang?: string;
34
34
  /** workflow only — install target directory (absolute or cwd-relative). */
35
35
  cwd?: string;
36
- /** skills / recommended / plugins / hooks — `"user"` means install globally. */
36
+ /** skills / recommended / plugins — `"user"` means install globally. */
37
37
  scope?: "project" | "user";
38
38
  /** plugins only — runtime to install plugins for. Defaults to Claude Code. */
39
39
  agent?: PluginAgent;
40
+ /**
41
+ * plugins only — plugin names to drop from the interactive selection
42
+ * list. The TUI's「其他插件」item sets this to `["auriga-workflow"]`
43
+ * so the plugin already covered by the preset isn't offered twice.
44
+ */
45
+ excludePlugins?: string[];
40
46
  /**
41
47
  * sub-item filter. `undefined` = full set of this category.
42
48
  * Names are validated against the catalog by the CLI layer; installers
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "auriga-cli",
3
- "version": "1.24.0",
4
- "description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Recommended Skills, Plugins, Hooks)",
3
+ "version": "1.26.0",
4
+ "description": "Interactive CLI to install Claude Code harness modules (Workflow, Skills, Recommended Skills, Plugins)",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -25,8 +25,8 @@
25
25
  "dev": "tsc --watch",
26
26
  "start": "node dist/cli.js",
27
27
  "pretest": "npm run build",
28
- "test": "tsc -p tsconfig.test.json && DEV=1 node --test --experimental-test-module-mocks dist-test/tests/hooks.test.js dist-test/tests/hooks-uninstall.test.js dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-install.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js dist-test/tests/spec-design.test.js dist-test/tests/plugin-skill-frontmatter.test.js",
29
- "test:watch": "tsc -p tsconfig.test.json --watch & node --test --watch --experimental-test-module-mocks dist-test/tests/hooks.test.js dist-test/tests/hooks-uninstall.test.js dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-install.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js dist-test/tests/spec-design.test.js dist-test/tests/plugin-skill-frontmatter.test.js",
28
+ "test": "tsc -p tsconfig.test.json && DEV=1 node --test --experimental-test-module-mocks dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/preset.test.js dist-test/tests/legacy-menu.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-install.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js dist-test/tests/spec-design.test.js dist-test/tests/plugin-skill-frontmatter.test.js",
29
+ "test:watch": "tsc -p tsconfig.test.json --watch & node --test --watch --experimental-test-module-mocks dist-test/tests/skills.test.js dist-test/tests/skills-uninstall.test.js dist-test/tests/catalog.test.js dist-test/tests/cli-parse.test.js dist-test/tests/install-nontty.test.js dist-test/tests/preset.test.js dist-test/tests/legacy-menu.test.js dist-test/tests/plugins.test.js dist-test/tests/plugins-uninstall.test.js dist-test/tests/content-fetch.test.js dist-test/tests/utils.test.js dist-test/tests/guide.test.js dist-test/tests/validators.test.js dist-test/tests/entrypoint.test.js dist-test/tests/state.test.js dist-test/tests/server.test.js dist-test/tests/server-auth.test.js dist-test/tests/server-apply.test.js dist-test/tests/apply-handlers.test.js dist-test/tests/ui-fetch.test.js dist-test/tests/workflow-install.test.js dist-test/tests/workflow-uninstall.test.js dist-test/tests/tarball-shape.test.js dist-test/tests/spec-design.test.js dist-test/tests/plugin-skill-frontmatter.test.js",
30
30
  "pretest:e2e": "npm run build",
31
31
  "test:e2e": "tsc -p tsconfig.test.json && node --test dist-test/tests/e2e-install.test.js",
32
32
  "pretest:web-ui-e2e": "npm run build && npm --prefix ui ci && npm --prefix ui run build",
package/dist/hooks.d.ts DELETED
@@ -1,236 +0,0 @@
1
- import { type InstallOpts } from "./utils.js";
2
- export interface HookDep {
3
- name: string;
4
- via: "brew";
5
- optional?: boolean;
6
- }
7
- export interface HookSettingsEvent {
8
- event: string;
9
- /** Tool-name / regex filter; mapped onto the container-level
10
- * `matcher` field in settings.json. Absent → every tool fires. */
11
- matcher?: string;
12
- /** Permission-rule-syntax filter — mapped onto the nested action-level
13
- * `if` field in settings.json. Format: `<ToolName>(<substring>)`, e.g.
14
- * `Bash(gh pr create)` — see IF_RE for the exact grammar. Lets the
15
- * Claude Code runtime skip hook dispatch when the tool input doesn't
16
- * match, avoiding a Node subprocess spawn per unrelated call.
17
- * Absent → no registry-level content filter (the hook script runs
18
- * for every invocation that passed `matcher`). */
19
- if?: string;
20
- }
21
- export interface HookDef {
22
- name: string;
23
- description: string;
24
- runtimePlatforms: string[];
25
- settingsEvents: HookSettingsEvent[];
26
- command: string;
27
- files: string[];
28
- preserveFiles?: string[];
29
- deps?: HookDep[];
30
- marker: string;
31
- /**
32
- * Per-hook customization hints rendered in the post-install summary.
33
- * The literal `{hookDir}` is substituted with the hook's resolved
34
- * install directory at print time. Empty / omitted → installer falls
35
- * back to a generic "see <dir>/README.md" pointer.
36
- */
37
- customizeHints?: string[];
38
- /**
39
- * Whether the hook is part of the default-on set. `false` makes the
40
- * hook opt-in: non-interactive `install hooks` with no `--hook` filter
41
- * skips it, and the interactive checkbox leaves it unchecked. Absent
42
- * / `true` → installed by default. Used for hooks with intrusive
43
- * side effects (OS notifications, brew deps, platform constraints)
44
- * that users probably want to pick up consciously.
45
- */
46
- defaultOn?: boolean;
47
- }
48
- export interface HooksConfig {
49
- hooks: HookDef[];
50
- }
51
- export interface SettingsHookAction {
52
- type: "command";
53
- command: string;
54
- _marker?: string;
55
- /** Per-action permission-rule filter (Claude Code ≥ 2026-04 schema).
56
- * Format: `<ToolName>(<substring>)`. Older runtimes ignore unknown
57
- * fields, so emitting this is forward-safe. */
58
- if?: string;
59
- }
60
- export interface SettingsHookGroup {
61
- matcher?: string;
62
- hooks: SettingsHookAction[];
63
- }
64
- export interface SettingsFile {
65
- hooks?: Record<string, SettingsHookGroup[]>;
66
- [key: string]: unknown;
67
- }
68
- /**
69
- * Pure, idempotent settings merge. Deep-clones input, dedupes by two
70
- * checks in priority order:
71
- *
72
- * 1. sentinel `_marker` field — primary key. Survives path drift, lets
73
- * a future uninstall command find our entries unambiguously.
74
- * 2. command-string equality — secondary, catches the case where the
75
- * user (or another tool) already added an equivalent entry by hand
76
- * and never wrote our marker. Without this fallback we would happily
77
- * append a duplicate next to it and the hook would fire twice.
78
- *
79
- * `options.matcher` writes to the container-level `matcher` (tool-name
80
- * filter); `options.ifRule` writes to the action-level `if` (permission-
81
- * rule substring filter, Claude Code ≥ 2026-04). Either or both may be
82
- * absent.
83
- *
84
- * Upgrade path: if an entry with our marker already exists but its
85
- * matcher / if disagrees with the desired values, we update those two
86
- * fields in place (preserving everything else — command, sibling
87
- * actions, the user's other groups — untouched). This is the path for
88
- * a user who installed an older registry version and re-runs the
89
- * installer after hooks.json changed. Pure no-op when the existing
90
- * fields already match.
91
- *
92
- * Inputs are defense-in-depth revalidated here against IF_RE + the
93
- * event-name regex even though registry callers already passed
94
- * loadHooksConfig, so a direct library caller can't write malformed
95
- * values into settings.json by bypassing the registry loader.
96
- *
97
- * Throws if `settings.hooks[event]` exists but is not an array — that
98
- * means the user has hand-edited their settings into a shape we do not
99
- * recognize, and silently replacing it with an empty array would lose
100
- * data. Callers should catch and surface the error to the user.
101
- */
102
- export declare function addHookToSettings(settings: SettingsFile, event: string, command: string, marker: string, options?: {
103
- matcher?: string;
104
- ifRule?: string;
105
- }): {
106
- settings: SettingsFile;
107
- mutated: boolean;
108
- };
109
- /**
110
- * Pure inverse of addHookToSettings: removes every action carrying
111
- * `_marker` from every event in the settings tree. Returns the mutated
112
- * copy and the count of actions removed. If a group becomes empty after
113
- * removal, the whole group is dropped; if an event becomes empty, the
114
- * event key is dropped.
115
- */
116
- export declare function removeHookFromSettings(settings: SettingsFile, marker: string): {
117
- settings: SettingsFile;
118
- removed: number;
119
- };
120
- type Scope = "project-local" | "project" | "user";
121
- /**
122
- * Non-interactive scope map for hooks.
123
- *
124
- * Non-interactive surface only knows about two values — `project` (the
125
- * default) and `user`. `project-local` exists only in the TTY menu; it's
126
- * a per-developer uncommitted scope and carries enough "did you really
127
- * mean this?" surface area that we gate it behind an interactive
128
- * confirmation rather than exposing it as a CLI flag value.
129
- *
130
- * Exported so `tests/hooks.test.ts` can lock the contract down as a
131
- * unit test.
132
- */
133
- export declare function mapNonInteractiveScope(scope: string | undefined): Scope;
134
- export declare function depBinary(dep: HookDep): string;
135
- export declare function loadHooksConfig(packageRoot: string): HooksConfig;
136
- export interface InstallHookResult {
137
- hook: string;
138
- written: number;
139
- preserved: number;
140
- scope: Scope;
141
- hookDir: string;
142
- settingsPath: string;
143
- settingsMutated: boolean;
144
- settingsError?: string;
145
- aborted?: string;
146
- }
147
- /**
148
- * Non-interactive single-hook install. Driven by installHooks (which
149
- * collects user choices via prompts) and by tools/verify-hooks.mjs (which
150
- * exercises the install path end-to-end without prompts).
151
- *
152
- * Failure ordering matters: deps run first (no state changes), then
153
- * settings is read AND parsed (still no state changes), and only after
154
- * parsing succeeds do we touch the filesystem to copy hook files. A
155
- * malformed settings file therefore aborts cleanly and leaves nothing
156
- * behind.
157
- */
158
- export declare function installHook(hook: HookDef, scope: Scope, projectBase: string, packageRoot: string): Promise<InstallHookResult>;
159
- /**
160
- * Scan all 3 scope settings files for a hook's marker, returning every
161
- * scope where the marker is currently present and is NOT the scope the
162
- * caller is about to install into. Used by installHooks to detect
163
- * cross-scope leftovers from a previous install — which would cause the
164
- * hook to fire multiple times if not cleaned up.
165
- *
166
- * Pure-ish: reads files but does not mutate. Silently skips files that
167
- * fail to parse — surfacing those errors is the install path's job.
168
- */
169
- export interface StaleScope {
170
- scope: Scope;
171
- settingsPath: string;
172
- count: number;
173
- }
174
- export declare function findStaleScopes(hook: HookDef, currentScope: Scope, projectBase: string): StaleScope[];
175
- /**
176
- * Remove every action carrying `hook.marker` from the given scope's
177
- * settings file. Atomic write, snapshot-once .bak. Returns the count of
178
- * actions removed (0 if nothing matched or file did not exist).
179
- */
180
- export declare function cleanHookFromScope(hook: HookDef, scope: Scope, projectBase: string): {
181
- removed: number;
182
- settingsPath: string;
183
- };
184
- /**
185
- * Non-interactive selection resolver for hooks.
186
- *
187
- * Diverges from resolvePluginSelection in the no-filter case. Hooks can
188
- * carry intrusive side effects (OS notifications, brew deps), so the
189
- * safe default is NOT "install everything". Three cases:
190
- *
191
- * - undefined (no --hook passed) → default-on set (filter on defaultOn !== false)
192
- * - ["*"] (explicit opt-in to everything) → full compatible set
193
- * - explicit names → exactly those (even if defaultOn is false)
194
- */
195
- export declare function resolveHookSelection(compatible: HookDef[], selected: string[] | undefined): HookDef[];
196
- /**
197
- * Given the full registry and the platform-filtered compatible subset,
198
- * return the names in `selected` that refer to real hooks but aren't
199
- * available on the current platform. Empty result means the selection
200
- * is either fully compatible or references unknown hooks (that case is
201
- * left to the catalog validator — we don't pretend unknown names are
202
- * platform issues).
203
- */
204
- export declare function findIncompatibleExplicit(all: HookDef[], compatible: HookDef[], selected: string[]): string[];
205
- export declare function installHooks(packageRoot: string, opts: InstallOpts): Promise<void>;
206
- /**
207
- * Uninstall a single hook. Defaults to project scope; explicit
208
- * `scope:"user"` cleans `~/.claude/...` instead.
209
- *
210
- * Project scope (default):
211
- * - rm `<cwd>/.claude/hooks/<name>/` directory.
212
- * - Strip the hook's marker from `<cwd>/.claude/settings.json` AND
213
- * `<cwd>/.claude/settings.local.json` (project + project-local share
214
- * the on-disk hook dir, so cleaning both settings files keeps users
215
- * who switched scopes from accumulating dangling registrations).
216
- *
217
- * User scope:
218
- * - rm `~/.claude/hooks/<name>/` directory.
219
- * - Strip the hook's marker from `~/.claude/settings.json`.
220
- * - Project files are NOT touched.
221
- *
222
- * Marker discovery: tries the live registry at `<cwd>` (or the npx
223
- * package root if that fails) so we use the same marker the install path
224
- * stamped in. If the registry can't resolve the hook (renamed / removed
225
- * upstream), we fall back to a `auriga:<name>` convention — every shipped
226
- * hook to date follows it, so the fallback is reliable for the common
227
- * case.
228
- *
229
- * Idempotent: missing hook dir / missing settings / absent marker → no-op.
230
- */
231
- export declare function uninstallHook(name: string, opts: {
232
- cwd: string;
233
- scope?: "project" | "user";
234
- onLog?: (line: string) => void;
235
- }): Promise<void>;
236
- export {};