@cg3/equip 0.2.13 → 0.2.15

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
@@ -35,6 +35,19 @@ These platforms get MCP server config but don't have a writable global rules fil
35
35
 
36
36
  For these platforms, `installRules()` returns `{ action: "clipboard" }` if the platform is in the configurable `clipboardPlatforms` list (default: `["cursor", "vscode"]`), or `{ action: "skipped" }` otherwise. It's up to the consumer to decide how to handle this — e.g., copying rules to the clipboard, printing instructions, or skipping silently.
37
37
 
38
+ ### Hooks — Structural Enforcement
39
+
40
+ Some platforms support **lifecycle hooks** — scripts that run automatically at key moments (e.g., after a tool fails, when the agent finishes responding). Hooks provide structural enforcement that behavioral rules alone cannot:
41
+
42
+ | Platform | Hooks Support | Events |
43
+ |---|---|---|
44
+ | Claude Code | ✅ | `PostToolUseFailure`, `Stop`, `PreToolUse`, `PostToolUse` |
45
+ | All others | ❌ | — |
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.
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.
50
+
38
51
  ## Quick Start
39
52
 
40
53
  ```bash
@@ -122,6 +135,10 @@ for (const p of platforms) {
122
135
  - `equip.uninstallRules(platform, dryRun?)` — Remove behavioral rules
123
136
  - `equip.readMcp(platform)` — Check if MCP is configured
124
137
  - `equip.buildConfig(platformId, apiKey, transport?)` — Build MCP config object
138
+ - `equip.installHooks(platform, options?)` — Install lifecycle hooks (if supported)
139
+ - `equip.uninstallHooks(platform, options?)` — Remove hooks
140
+ - `equip.hasHooks(platform, options?)` — Check if hooks are installed
141
+ - `equip.supportsHooks(platform)` — Check if platform supports hooks
125
142
 
126
143
  ### Primitives
127
144
 
@@ -151,7 +168,7 @@ Behavioral rules (the `.md` files equip installs) are stronger — they live in
151
168
  - **No platform hooks exist** to enforce tool calls — the agent always decides whether to act
152
169
  - **No open standard** for server-initiated actions (MCP sampling exists in spec but isn't widely implemented)
153
170
 
154
- Equip gives you the best available distribution: MCP config for tool availability + behavioral rules for usage guidance. For guaranteed tool invocation, platform-level middleware (IDE extensions, framework hooks) is required but that doesn't exist as open infrastructure yet.
171
+ Equip gives you the best available distribution: MCP config for tool availability + behavioral rules for usage guidance + lifecycle hooks for structural enforcement on platforms that support them. Hooks bridge the gap between "the agent knows the rules" and "the agent actually follows them" by injecting reminders at the exact moment an error occurs.
155
172
 
156
173
  ## License
157
174
 
package/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const { detectPlatforms, whichSync, dirExists, fileExists } = require("./lib/detect");
7
7
  const { readMcpEntry, buildHttpConfig, buildHttpConfigWithAuth, buildStdioConfig, installMcp, installMcpJson, installMcpToml, uninstallMcp, updateMcpKey, parseTomlServerEntry, parseTomlSubTables, buildTomlEntry, removeTomlEntry } = require("./lib/mcp");
8
8
  const { parseRulesVersion, installRules, uninstallRules, markerPatterns } = require("./lib/rules");
9
+ const { getHookCapabilities, installHooks, uninstallHooks, hasHooks, buildHooksConfig, getHookScripts } = require("./lib/hooks");
9
10
  const { createManualPlatform, platformName, KNOWN_PLATFORMS } = require("./lib/platforms");
10
11
  const cli = require("./lib/cli");
11
12
 
@@ -147,6 +148,46 @@ class Equip {
147
148
  readMcp(platform) {
148
149
  return readMcpEntry(platform.configPath, platform.rootKey, this.name, platform.configFormat || "json");
149
150
  }
151
+
152
+ /**
153
+ * Install lifecycle hooks on a platform (if supported).
154
+ * Hooks provide structural enforcement (e.g., search reminders on errors).
155
+ * @param {object} platform - Platform object from detect()
156
+ * @param {object} [options] - { hookDir, dryRun }
157
+ * @returns {{ installed: boolean, scripts: string[], hookDir: string } | null}
158
+ */
159
+ installHooks(platform, options = {}) {
160
+ return installHooks(platform, options);
161
+ }
162
+
163
+ /**
164
+ * Uninstall hooks from a platform.
165
+ * @param {object} platform - Platform object
166
+ * @param {object} [options] - { hookDir, dryRun }
167
+ * @returns {boolean}
168
+ */
169
+ uninstallHooks(platform, options = {}) {
170
+ return uninstallHooks(platform, options);
171
+ }
172
+
173
+ /**
174
+ * Check if hooks are installed on a platform.
175
+ * @param {object} platform - Platform object
176
+ * @param {object} [options] - { hookDir }
177
+ * @returns {boolean}
178
+ */
179
+ hasHooks(platform, options = {}) {
180
+ return hasHooks(platform, options);
181
+ }
182
+
183
+ /**
184
+ * Check if a platform supports hooks.
185
+ * @param {object} platform - Platform object
186
+ * @returns {boolean}
187
+ */
188
+ supportsHooks(platform) {
189
+ return !!getHookCapabilities(platform.platform);
190
+ }
150
191
  }
151
192
 
152
193
  module.exports = {
@@ -173,5 +214,12 @@ module.exports = {
173
214
  createManualPlatform,
174
215
  platformName,
175
216
  KNOWN_PLATFORMS,
217
+ // Hooks
218
+ getHookCapabilities,
219
+ installHooks,
220
+ uninstallHooks,
221
+ hasHooks,
222
+ buildHooksConfig,
223
+ getHookScripts,
176
224
  cli,
177
225
  };
package/lib/hooks.js ADDED
@@ -0,0 +1,307 @@
1
+ // Hook installation for platforms that support lifecycle hooks.
2
+ // Zero dependencies.
3
+
4
+ "use strict";
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const os = require("os");
9
+
10
+ // ─── Platform Hook Capabilities ──────────────────────────────
11
+
12
+ /**
13
+ * Which platforms support hooks and what events they handle.
14
+ * Returns null if the platform doesn't support hooks.
15
+ */
16
+ function getHookCapabilities(platformId) {
17
+ const caps = {
18
+ "claude-code": {
19
+ settingsPath: () => path.join(os.homedir(), ".claude", "settings.json"),
20
+ events: ["PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop"],
21
+ format: "claude-code",
22
+ },
23
+ // Future: cursor, etc.
24
+ };
25
+ return caps[platformId] || null;
26
+ }
27
+
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
+ // ─── Hook Config Generation ─────────────────────────────────
106
+
107
+ /**
108
+ * Build hooks config object for a platform.
109
+ * @param {string} hookDir - Absolute path to directory containing hook scripts
110
+ * @param {string} platformId - Platform id
111
+ * @returns {object} Hooks config in the platform's format
112
+ */
113
+ function buildHooksConfig(hookDir, platformId) {
114
+ const caps = getHookCapabilities(platformId);
115
+ if (!caps) return null;
116
+
117
+ if (caps.format === "claude-code") {
118
+ const config = {};
119
+
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
+ }
129
+
130
+ if (caps.events.includes("Stop")) {
131
+ config.Stop = [{
132
+ hooks: [{
133
+ type: "command",
134
+ command: `node "${path.join(hookDir, "prior-completion-check.js")}"`,
135
+ }],
136
+ }];
137
+ }
138
+
139
+ return config;
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ // ─── Installation ────────────────────────────────────────────
146
+
147
+ /**
148
+ * Install hook scripts to disk and register them in platform settings.
149
+ * @param {object} platform - Platform object from detect()
150
+ * @param {object} [options] - { hookDir, dryRun }
151
+ * @returns {{ installed: boolean, scripts: string[], hookDir: string } | null}
152
+ */
153
+ function installHooks(platform, options = {}) {
154
+ const caps = getHookCapabilities(platform.platform);
155
+ if (!caps) return null;
156
+
157
+ const hookDir = options.hookDir || path.join(os.homedir(), ".prior", "hooks");
158
+ const dryRun = options.dryRun || false;
159
+
160
+ // 1. Write hook scripts
161
+ const scripts = getHookScripts();
162
+ const installedScripts = [];
163
+
164
+ if (!dryRun) {
165
+ fs.mkdirSync(hookDir, { recursive: true });
166
+ }
167
+
168
+ for (const [filename, content] of Object.entries(scripts)) {
169
+ const filePath = path.join(hookDir, filename);
170
+ if (!dryRun) {
171
+ fs.writeFileSync(filePath, content, { mode: 0o755 });
172
+ }
173
+ installedScripts.push(filename);
174
+ }
175
+
176
+ // 2. Register hooks in platform settings
177
+ const hooksConfig = buildHooksConfig(hookDir, platform.platform);
178
+ if (!hooksConfig) return null;
179
+
180
+ if (!dryRun) {
181
+ const settingsPath = caps.settingsPath();
182
+ let settings = {};
183
+ try {
184
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
185
+ } catch { /* file doesn't exist yet */ }
186
+
187
+ // Merge hooks — preserve existing non-Prior hooks
188
+ if (!settings.hooks) settings.hooks = {};
189
+
190
+ for (const [event, hookGroups] of Object.entries(hooksConfig)) {
191
+ if (!settings.hooks[event]) {
192
+ settings.hooks[event] = hookGroups;
193
+ } else {
194
+ // Remove existing Prior hooks for this event, then add new ones
195
+ settings.hooks[event] = settings.hooks[event].filter(
196
+ group => !group.hooks?.some(h => h.command && h.command.includes(".prior"))
197
+ );
198
+ settings.hooks[event].push(...hookGroups);
199
+ }
200
+ }
201
+
202
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
203
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
204
+ }
205
+
206
+ return { installed: true, scripts: installedScripts, hookDir };
207
+ }
208
+
209
+ /**
210
+ * Uninstall hook scripts and remove from platform settings.
211
+ * @param {object} platform - Platform object
212
+ * @param {object} [options] - { hookDir, dryRun }
213
+ * @returns {boolean} Whether anything was removed
214
+ */
215
+ function uninstallHooks(platform, options = {}) {
216
+ const caps = getHookCapabilities(platform.platform);
217
+ if (!caps) return false;
218
+
219
+ const hookDir = options.hookDir || path.join(os.homedir(), ".prior", "hooks");
220
+ const dryRun = options.dryRun || false;
221
+ let removed = false;
222
+
223
+ // 1. Remove hook scripts
224
+ const scripts = getHookScripts();
225
+ for (const filename of Object.keys(scripts)) {
226
+ const filePath = path.join(hookDir, filename);
227
+ try {
228
+ if (fs.statSync(filePath).isFile()) {
229
+ if (!dryRun) fs.unlinkSync(filePath);
230
+ removed = true;
231
+ }
232
+ } catch { /* doesn't exist */ }
233
+ }
234
+
235
+ // Clean up empty hooks dir
236
+ if (!dryRun) {
237
+ try { fs.rmdirSync(hookDir); } catch { /* not empty or doesn't exist */ }
238
+ }
239
+
240
+ // 2. Remove from platform settings
241
+ if (!dryRun) {
242
+ const settingsPath = caps.settingsPath();
243
+ try {
244
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
245
+ if (settings.hooks) {
246
+ let changed = false;
247
+ for (const event of Object.keys(settings.hooks)) {
248
+ const before = settings.hooks[event].length;
249
+ settings.hooks[event] = settings.hooks[event].filter(
250
+ group => !group.hooks?.some(h => h.command && h.command.includes(".prior"))
251
+ );
252
+ if (settings.hooks[event].length === 0) {
253
+ delete settings.hooks[event];
254
+ }
255
+ if (settings.hooks[event]?.length !== before) changed = true;
256
+ }
257
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
258
+ if (changed) {
259
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
260
+ removed = true;
261
+ }
262
+ }
263
+ } catch { /* file doesn't exist */ }
264
+ }
265
+
266
+ return removed;
267
+ }
268
+
269
+ /**
270
+ * Check if hooks are installed for a platform.
271
+ * @param {object} platform - Platform object
272
+ * @param {object} [options] - { hookDir }
273
+ * @returns {boolean}
274
+ */
275
+ function hasHooks(platform, options = {}) {
276
+ const caps = getHookCapabilities(platform.platform);
277
+ if (!caps) return false;
278
+
279
+ const hookDir = options.hookDir || path.join(os.homedir(), ".prior", "hooks");
280
+
281
+ // Check scripts exist
282
+ const scripts = getHookScripts();
283
+ for (const filename of Object.keys(scripts)) {
284
+ try {
285
+ if (!fs.statSync(path.join(hookDir, filename)).isFile()) return false;
286
+ } catch { return false; }
287
+ }
288
+
289
+ // Check settings registration
290
+ try {
291
+ const settings = JSON.parse(fs.readFileSync(caps.settingsPath(), "utf-8"));
292
+ 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")))
295
+ );
296
+ return hasPriorHook;
297
+ } catch { return false; }
298
+ }
299
+
300
+ module.exports = {
301
+ getHookCapabilities,
302
+ getHookScripts,
303
+ buildHooksConfig,
304
+ installHooks,
305
+ uninstallHooks,
306
+ hasHooks,
307
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cg3/equip",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Universal MCP + behavioral rules installer for AI coding agents",
5
5
  "main": "index.js",
6
6
  "bin": {