@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 +18 -1
- package/index.js +48 -0
- package/lib/hooks.js +307 -0
- package/package.json +1 -1
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.
|
|
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
|
+
};
|