@cg3/equip 0.2.15 → 0.2.17

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
@@ -44,9 +44,9 @@ Some platforms support **lifecycle hooks** — scripts that run automatically at
44
44
  | Claude Code | ✅ | `PostToolUseFailure`, `Stop`, `PreToolUse`, `PostToolUse` |
45
45
  | All others | ❌ | — |
46
46
 
47
- When hooks are supported, equip installs lightweight Node.js scripts to `~/.prior/hooks/` and registers them in the platform's settings. Hooks are a **silent enhancement** — if the platform doesn't support them, equip installs only MCP + rules without any error or warning.
47
+ When hooks are supported, equip writes the consumer-provided scripts to a configurable directory (default: `~/.${name}/hooks/`) and registers them in the platform's settings. Hooks are a **silent enhancement** — if the platform doesn't support them, equip installs only MCP + rules without any error or warning.
48
48
 
49
- Hooks are opt-in per platform via the capabilities model. As more platforms add hook support, equip can enable them without consumer code changes.
49
+ Hook scripts and event bindings are defined by the consumer (your package), not by equip. Equip provides only the installation infrastructure — capabilities detection, file writing, settings registration, and cleanup. As more platforms add hook support, equip can enable them without consumer code changes.
50
50
 
51
51
  ## Quick Start
52
52
 
package/index.js CHANGED
@@ -3,10 +3,13 @@
3
3
 
4
4
  "use strict";
5
5
 
6
+ const path = require("path");
7
+ const os = require("os");
8
+
6
9
  const { detectPlatforms, whichSync, dirExists, fileExists } = require("./lib/detect");
7
10
  const { readMcpEntry, buildHttpConfig, buildHttpConfigWithAuth, buildStdioConfig, installMcp, installMcpJson, installMcpToml, uninstallMcp, updateMcpKey, parseTomlServerEntry, parseTomlSubTables, buildTomlEntry, removeTomlEntry } = require("./lib/mcp");
8
11
  const { parseRulesVersion, installRules, uninstallRules, markerPatterns } = require("./lib/rules");
9
- const { getHookCapabilities, installHooks, uninstallHooks, hasHooks, buildHooksConfig, getHookScripts } = require("./lib/hooks");
12
+ const { getHookCapabilities, installHooks, uninstallHooks, hasHooks, buildHooksConfig } = require("./lib/hooks");
10
13
  const { createManualPlatform, platformName, KNOWN_PLATFORMS } = require("./lib/platforms");
11
14
  const cli = require("./lib/cli");
12
15
 
@@ -37,6 +40,12 @@ class Equip {
37
40
  * @param {string} config.stdio.command - Command to run
38
41
  * @param {string[]} config.stdio.args - Command arguments
39
42
  * @param {string} config.stdio.envKey - Env var name for API key
43
+ * @param {Array} [config.hooks] - Lifecycle hook definitions
44
+ * @param {string} config.hooks[].event - Hook event name (e.g., "PostToolUseFailure")
45
+ * @param {string} [config.hooks[].matcher] - Regex matcher for event filtering (e.g., "Bash")
46
+ * @param {string} config.hooks[].script - Hook script content (Node.js)
47
+ * @param {string} config.hooks[].name - Script filename (without .js extension)
48
+ * @param {string} [config.hookDir] - Directory for hook scripts (default: ~/.${name}/hooks)
40
49
  */
41
50
  constructor(config) {
42
51
  if (!config.name) throw new Error("Equip: name is required");
@@ -46,6 +55,8 @@ class Equip {
46
55
  this.serverUrl = config.serverUrl;
47
56
  this.rules = config.rules || null;
48
57
  this.stdio = config.stdio || null;
58
+ this.hookDefs = config.hooks || null;
59
+ this.hookDir = config.hookDir || path.join(os.homedir(), `.${config.name}`, "hooks");
49
60
  }
50
61
 
51
62
  /**
@@ -151,13 +162,16 @@ class Equip {
151
162
 
152
163
  /**
153
164
  * Install lifecycle hooks on a platform (if supported).
154
- * Hooks provide structural enforcement (e.g., search reminders on errors).
165
+ * Uses hook definitions from constructor config.
155
166
  * @param {object} platform - Platform object from detect()
156
167
  * @param {object} [options] - { hookDir, dryRun }
157
168
  * @returns {{ installed: boolean, scripts: string[], hookDir: string } | null}
158
169
  */
159
170
  installHooks(platform, options = {}) {
160
- return installHooks(platform, options);
171
+ if (!this.hookDefs) return null;
172
+ const opts = { ...options };
173
+ if (this.hookDir && !opts.hookDir) opts.hookDir = this.hookDir;
174
+ return installHooks(platform, this.hookDefs, opts);
161
175
  }
162
176
 
163
177
  /**
@@ -167,7 +181,10 @@ class Equip {
167
181
  * @returns {boolean}
168
182
  */
169
183
  uninstallHooks(platform, options = {}) {
170
- return uninstallHooks(platform, options);
184
+ if (!this.hookDefs) return false;
185
+ const opts = { ...options };
186
+ if (this.hookDir && !opts.hookDir) opts.hookDir = this.hookDir;
187
+ return uninstallHooks(platform, this.hookDefs, opts);
171
188
  }
172
189
 
173
190
  /**
@@ -177,16 +194,19 @@ class Equip {
177
194
  * @returns {boolean}
178
195
  */
179
196
  hasHooks(platform, options = {}) {
180
- return hasHooks(platform, options);
197
+ if (!this.hookDefs) return false;
198
+ const opts = { ...options };
199
+ if (this.hookDir && !opts.hookDir) opts.hookDir = this.hookDir;
200
+ return hasHooks(platform, this.hookDefs, opts);
181
201
  }
182
202
 
183
203
  /**
184
- * Check if a platform supports hooks.
204
+ * Check if a platform supports hooks and this instance has hook definitions.
185
205
  * @param {object} platform - Platform object
186
206
  * @returns {boolean}
187
207
  */
188
208
  supportsHooks(platform) {
189
- return !!getHookCapabilities(platform.platform);
209
+ return !!this.hookDefs && this.hookDefs.length > 0 && !!getHookCapabilities(platform.platform);
190
210
  }
191
211
  }
192
212
 
@@ -220,6 +240,5 @@ module.exports = {
220
240
  uninstallHooks,
221
241
  hasHooks,
222
242
  buildHooksConfig,
223
- getHookScripts,
224
243
  cli,
225
244
  };
package/lib/hooks.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Hook installation for platforms that support lifecycle hooks.
2
+ // Equip provides the infrastructure; consumers provide hook definitions.
2
3
  // Zero dependencies.
3
4
 
4
5
  "use strict";
@@ -17,7 +18,9 @@ function getHookCapabilities(platformId) {
17
18
  const caps = {
18
19
  "claude-code": {
19
20
  settingsPath: () => path.join(os.homedir(), ".claude", "settings.json"),
20
- events: ["PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop"],
21
+ events: ["PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop",
22
+ "SessionStart", "SessionEnd", "UserPromptSubmit", "Notification",
23
+ "SubagentStart", "SubagentStop", "PreCompact", "TaskCompleted"],
21
24
  format: "claude-code",
22
25
  },
23
26
  // Future: cursor, etc.
@@ -25,118 +28,38 @@ function getHookCapabilities(platformId) {
25
28
  return caps[platformId] || null;
26
29
  }
27
30
 
28
- // ─── Hook Script Content ─────────────────────────────────────
29
-
30
- /**
31
- * Returns hook script content keyed by filename.
32
- * Scripts are platform-agnostic Node.js — they read JSON from stdin and write JSON to stdout.
33
- */
34
- function getHookScripts() {
35
- return {
36
- "prior-search-nudge.js": `#!/usr/bin/env node
37
- // Prior hook: Nudge agent to search Prior after a tool failure.
38
- // Fires on PostToolUseFailure for Bash commands.
39
- // Injects additionalContext reminding the agent to search.
40
-
41
- const chunks = [];
42
- process.stdin.on("data", c => chunks.push(c));
43
- process.stdin.on("end", () => {
44
- try {
45
- const input = JSON.parse(Buffer.concat(chunks).toString());
46
- const toolName = input.tool_name || "";
47
- const error = input.error || "";
48
-
49
- // Only nudge on Bash failures (compile errors, runtime errors, etc.)
50
- if (toolName !== "Bash") { process.exit(0); return; }
51
-
52
- // Skip trivial failures (empty output, permission prompts)
53
- if (!error || error.length < 20) { process.exit(0); return; }
54
-
55
- // Inject search reminder as additional context
56
- const output = {
57
- hookSpecificOutput: {
58
- hookEventName: "PostToolUseFailure",
59
- additionalContext: "\\u26a0\\ufe0f Error detected. Per Prior protocol: search this error before attempting a fix. Use prior_search with the exact error text. Your user has credits for this — not searching wastes their investment."
60
- }
61
- };
62
- process.stdout.write(JSON.stringify(output));
63
- } catch {
64
- // Never block on hook errors
65
- process.exit(0);
66
- }
67
- });
68
- `,
69
-
70
- "prior-completion-check.js": `#!/usr/bin/env node
71
- // Prior hook: Remind agent about contribution offer at task completion.
72
- // Fires on Stop event.
73
- // Injects additionalContext if the agent didn't mention Prior contributions.
74
-
75
- const chunks = [];
76
- process.stdin.on("data", c => chunks.push(c));
77
- process.stdin.on("end", () => {
78
- try {
79
- const input = JSON.parse(Buffer.concat(chunks).toString());
80
- const lastResponse = input.last_assistant_message || "";
81
-
82
- // Check if the agent already mentioned contributions
83
- const hasContribution = /contribute.*prior|prior.*contribut|No contribution needed/i.test(lastResponse);
84
- if (hasContribution) { process.exit(0); return; }
85
-
86
- // Check if this looks like a task completion (not just a question/answer)
87
- const looksLikeTask = /fix|implement|build|debug|error|compil|test|deploy|configur|migrat|setup|install/i.test(lastResponse);
88
- if (!looksLikeTask) { process.exit(0); return; }
89
-
90
- const output = {
91
- hookSpecificOutput: {
92
- hookEventName: "Stop",
93
- additionalContext: "Reminder: Your completion summary should end with either (a) an offer to contribute fixes to Prior, or (b) \\"No contribution needed: all fixes were first-attempt.\\""
94
- }
95
- };
96
- process.stdout.write(JSON.stringify(output));
97
- } catch {
98
- process.exit(0);
99
- }
100
- });
101
- `,
102
- };
103
- }
104
-
105
31
  // ─── Hook Config Generation ─────────────────────────────────
106
32
 
107
33
  /**
108
- * Build hooks config object for a platform.
34
+ * Build platform-specific hooks config from consumer-defined hook definitions.
35
+ * @param {Array} hookDefs - Array of { event, matcher?, script, name }
109
36
  * @param {string} hookDir - Absolute path to directory containing hook scripts
110
37
  * @param {string} platformId - Platform id
111
- * @returns {object} Hooks config in the platform's format
38
+ * @returns {object|null} Hooks config in the platform's format
112
39
  */
113
- function buildHooksConfig(hookDir, platformId) {
40
+ function buildHooksConfig(hookDefs, hookDir, platformId) {
114
41
  const caps = getHookCapabilities(platformId);
115
- if (!caps) return null;
42
+ if (!caps || !hookDefs || hookDefs.length === 0) return null;
116
43
 
117
44
  if (caps.format === "claude-code") {
118
45
  const config = {};
119
46
 
120
- if (caps.events.includes("PostToolUseFailure")) {
121
- config.PostToolUseFailure = [{
122
- matcher: "Bash",
123
- hooks: [{
124
- type: "command",
125
- command: `node "${path.join(hookDir, "prior-search-nudge.js")}"`,
126
- }],
127
- }];
128
- }
47
+ for (const def of hookDefs) {
48
+ if (!caps.events.includes(def.event)) continue;
129
49
 
130
- if (caps.events.includes("Stop")) {
131
- config.Stop = [{
50
+ const entry = {
132
51
  hooks: [{
133
52
  type: "command",
134
- command: `node "${path.join(hookDir, "prior-completion-check.js")}"`,
53
+ command: `node "${path.join(hookDir, def.name + ".js")}"`,
135
54
  }],
136
- }];
55
+ };
56
+ if (def.matcher) entry.matcher = def.matcher;
57
+
58
+ if (!config[def.event]) config[def.event] = [];
59
+ config[def.event].push(entry);
137
60
  }
138
61
 
139
- return config;
62
+ return Object.keys(config).length > 0 ? config : null;
140
63
  }
141
64
 
142
65
  return null;
@@ -147,34 +70,38 @@ function buildHooksConfig(hookDir, platformId) {
147
70
  /**
148
71
  * Install hook scripts to disk and register them in platform settings.
149
72
  * @param {object} platform - Platform object from detect()
150
- * @param {object} [options] - { hookDir, dryRun }
73
+ * @param {Array} hookDefs - Array of { event, matcher?, script, name }
74
+ * @param {object} [options] - { hookDir, dryRun, marker }
151
75
  * @returns {{ installed: boolean, scripts: string[], hookDir: string } | null}
152
76
  */
153
- function installHooks(platform, options = {}) {
77
+ function installHooks(platform, hookDefs, options = {}) {
154
78
  const caps = getHookCapabilities(platform.platform);
155
- if (!caps) return null;
79
+ if (!caps || !hookDefs || hookDefs.length === 0) return null;
156
80
 
157
- const hookDir = options.hookDir || path.join(os.homedir(), ".prior", "hooks");
81
+ if (!options.hookDir) throw new Error("hookDir is required");
82
+ const hookDir = options.hookDir;
158
83
  const dryRun = options.dryRun || false;
159
84
 
160
85
  // 1. Write hook scripts
161
- const scripts = getHookScripts();
162
86
  const installedScripts = [];
163
87
 
164
88
  if (!dryRun) {
165
89
  fs.mkdirSync(hookDir, { recursive: true });
166
90
  }
167
91
 
168
- for (const [filename, content] of Object.entries(scripts)) {
169
- const filePath = path.join(hookDir, filename);
92
+ for (const def of hookDefs) {
93
+ if (!caps.events.includes(def.event)) continue;
94
+ const filePath = path.join(hookDir, def.name + ".js");
170
95
  if (!dryRun) {
171
- fs.writeFileSync(filePath, content, { mode: 0o755 });
96
+ fs.writeFileSync(filePath, def.script, { mode: 0o755 });
172
97
  }
173
- installedScripts.push(filename);
98
+ installedScripts.push(def.name + ".js");
174
99
  }
175
100
 
101
+ if (installedScripts.length === 0) return null;
102
+
176
103
  // 2. Register hooks in platform settings
177
- const hooksConfig = buildHooksConfig(hookDir, platform.platform);
104
+ const hooksConfig = buildHooksConfig(hookDefs, hookDir, platform.platform);
178
105
  if (!hooksConfig) return null;
179
106
 
180
107
  if (!dryRun) {
@@ -184,16 +111,17 @@ function installHooks(platform, options = {}) {
184
111
  settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
185
112
  } catch { /* file doesn't exist yet */ }
186
113
 
187
- // Merge hooks — preserve existing non-Prior hooks
114
+ // Merge hooks — preserve existing non-marker hooks
188
115
  if (!settings.hooks) settings.hooks = {};
189
116
 
190
117
  for (const [event, hookGroups] of Object.entries(hooksConfig)) {
191
118
  if (!settings.hooks[event]) {
192
119
  settings.hooks[event] = hookGroups;
193
120
  } else {
194
- // Remove existing Prior hooks for this event, then add new ones
121
+ // Remove existing hooks from this marker, then add new ones
122
+ const hookDirNorm = hookDir.replace(/\\/g, "/");
195
123
  settings.hooks[event] = settings.hooks[event].filter(
196
- group => !group.hooks?.some(h => h.command && h.command.includes(".prior"))
124
+ group => !group.hooks?.some(h => h.command && h.command.replace(/\\/g, "/").includes(hookDirNorm))
197
125
  );
198
126
  settings.hooks[event].push(...hookGroups);
199
127
  }
@@ -209,21 +137,22 @@ function installHooks(platform, options = {}) {
209
137
  /**
210
138
  * Uninstall hook scripts and remove from platform settings.
211
139
  * @param {object} platform - Platform object
140
+ * @param {Array} hookDefs - Array of { event, matcher?, script, name } (need names to know what to remove)
212
141
  * @param {object} [options] - { hookDir, dryRun }
213
142
  * @returns {boolean} Whether anything was removed
214
143
  */
215
- function uninstallHooks(platform, options = {}) {
144
+ function uninstallHooks(platform, hookDefs, options = {}) {
216
145
  const caps = getHookCapabilities(platform.platform);
217
- if (!caps) return false;
146
+ if (!caps || !hookDefs || hookDefs.length === 0) return false;
218
147
 
219
- const hookDir = options.hookDir || path.join(os.homedir(), ".prior", "hooks");
148
+ if (!options.hookDir) throw new Error("hookDir is required");
149
+ const hookDir = options.hookDir;
220
150
  const dryRun = options.dryRun || false;
221
151
  let removed = false;
222
152
 
223
153
  // 1. Remove hook scripts
224
- const scripts = getHookScripts();
225
- for (const filename of Object.keys(scripts)) {
226
- const filePath = path.join(hookDir, filename);
154
+ for (const def of hookDefs) {
155
+ const filePath = path.join(hookDir, def.name + ".js");
227
156
  try {
228
157
  if (fs.statSync(filePath).isFile()) {
229
158
  if (!dryRun) fs.unlinkSync(filePath);
@@ -244,10 +173,11 @@ function uninstallHooks(platform, options = {}) {
244
173
  const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
245
174
  if (settings.hooks) {
246
175
  let changed = false;
176
+ const hookDirNorm = hookDir.replace(/\\/g, "/");
247
177
  for (const event of Object.keys(settings.hooks)) {
248
178
  const before = settings.hooks[event].length;
249
179
  settings.hooks[event] = settings.hooks[event].filter(
250
- group => !group.hooks?.some(h => h.command && h.command.includes(".prior"))
180
+ group => !group.hooks?.some(h => h.command && h.command.replace(/\\/g, "/").includes(hookDirNorm))
251
181
  );
252
182
  if (settings.hooks[event].length === 0) {
253
183
  delete settings.hooks[event];
@@ -269,20 +199,21 @@ function uninstallHooks(platform, options = {}) {
269
199
  /**
270
200
  * Check if hooks are installed for a platform.
271
201
  * @param {object} platform - Platform object
202
+ * @param {Array} hookDefs - Array of { event, matcher?, script, name }
272
203
  * @param {object} [options] - { hookDir }
273
204
  * @returns {boolean}
274
205
  */
275
- function hasHooks(platform, options = {}) {
206
+ function hasHooks(platform, hookDefs, options = {}) {
276
207
  const caps = getHookCapabilities(platform.platform);
277
- if (!caps) return false;
208
+ if (!caps || !hookDefs || hookDefs.length === 0) return false;
278
209
 
279
- const hookDir = options.hookDir || path.join(os.homedir(), ".prior", "hooks");
210
+ if (!options.hookDir) throw new Error("hookDir is required");
211
+ const hookDir = options.hookDir;
280
212
 
281
213
  // Check scripts exist
282
- const scripts = getHookScripts();
283
- for (const filename of Object.keys(scripts)) {
214
+ for (const def of hookDefs) {
284
215
  try {
285
- if (!fs.statSync(path.join(hookDir, filename)).isFile()) return false;
216
+ if (!fs.statSync(path.join(hookDir, def.name + ".js")).isFile()) return false;
286
217
  } catch { return false; }
287
218
  }
288
219
 
@@ -290,16 +221,16 @@ function hasHooks(platform, options = {}) {
290
221
  try {
291
222
  const settings = JSON.parse(fs.readFileSync(caps.settingsPath(), "utf-8"));
292
223
  if (!settings.hooks) return false;
293
- const hasPriorHook = Object.values(settings.hooks).some(groups =>
294
- groups.some(g => g.hooks?.some(h => h.command && h.command.includes(".prior")))
224
+ const hookDirNorm = hookDir.replace(/\\/g, "/");
225
+ const hasRegistered = Object.values(settings.hooks).some(groups =>
226
+ groups.some(g => g.hooks?.some(h => h.command && h.command.replace(/\\/g, "/").includes(hookDirNorm)))
295
227
  );
296
- return hasPriorHook;
228
+ return hasRegistered;
297
229
  } catch { return false; }
298
230
  }
299
231
 
300
232
  module.exports = {
301
233
  getHookCapabilities,
302
- getHookScripts,
303
234
  buildHooksConfig,
304
235
  installHooks,
305
236
  uninstallHooks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cg3/equip",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Universal MCP + behavioral rules installer for AI coding agents",
5
5
  "main": "index.js",
6
6
  "bin": {