@compr/opscontext-mcp 2.0.2 → 2.1.1
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/CHANGELOG.md +77 -0
- package/defaults/claude-code-hook.sh +100 -0
- package/dist/audit.d.ts +1 -1
- package/dist/cli.js +246 -0
- package/dist/detector.d.ts +64 -0
- package/dist/detector.js +336 -0
- package/dist/http-server.d.ts +30 -0
- package/dist/http-server.js +242 -0
- package/dist/index.js +44 -0
- package/dist/install-autostart.d.ts +4 -0
- package/dist/install-autostart.js +300 -0
- package/dist/install-claude-hook.d.ts +3 -0
- package/dist/install-claude-hook.js +180 -0
- package/package.json +1 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// 🔒 LOCKED [AUTOSTART-INSTALL] — 2026-06-23
|
|
2
|
+
// ⛔ NEVER bootstrap into a `system/` domain (would need root + run as root).
|
|
3
|
+
// Use `gui/$UID` — per-user agent, started at user login, runs as the user.
|
|
4
|
+
// ⛔ NEVER write the plist before checking if a server is already listening on
|
|
5
|
+
// the port. A pre-existing process means we'd race with the launchd-managed
|
|
6
|
+
// one for port 7842.
|
|
7
|
+
// ⛔ NEVER ship a plist that calls `npx -y @latest` — every restart would
|
|
8
|
+
// fetch the registry, eating ~3s and breaking offline. Pin a specific
|
|
9
|
+
// node path + a specific dist path.
|
|
10
|
+
// WHY: This is the "set it and forget it" entrypoint for non-technical users.
|
|
11
|
+
// If it fails silently or starts duplicating processes, the entire
|
|
12
|
+
// auto-capture story collapses and the user has to type `nohup npx ...`
|
|
13
|
+
// forever — defeating the whole point.
|
|
14
|
+
// FIX: To add platform support beyond macOS, branch on process.platform and
|
|
15
|
+
// add equivalent systemd / NSSM logic. Keep `gui/$UID` and KeepAlive
|
|
16
|
+
// discipline in any new platform.
|
|
17
|
+
import { existsSync, writeFileSync, mkdirSync } from "fs";
|
|
18
|
+
import { join, dirname } from "path";
|
|
19
|
+
import { homedir, platform } from "os";
|
|
20
|
+
import { execSync } from "child_process";
|
|
21
|
+
const LABEL = "com.opscontext.mcp";
|
|
22
|
+
const PLIST_FILE = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
23
|
+
const LOG_DIR = join(homedir(), ".contextengine", "logs");
|
|
24
|
+
const PORT = 7842;
|
|
25
|
+
/** Resolve an absolute node binary path that launchd can find without PATH. */
|
|
26
|
+
function detectNodePath() {
|
|
27
|
+
// process.execPath is the node that's running THIS script — absolute path.
|
|
28
|
+
// launchd runs without the user's interactive shell, so we MUST pass an
|
|
29
|
+
// absolute path (no PATH lookup of "node" works under launchd).
|
|
30
|
+
return process.execPath;
|
|
31
|
+
}
|
|
32
|
+
/** Find a stable path to the opscontext entrypoint that survives version
|
|
33
|
+
* upgrades. Order: (1) globally installed bin → resolve symlink to real path;
|
|
34
|
+
* (2) ./dist/index.js next to this module (dev tree). Falls through with
|
|
35
|
+
* null if neither is found. */
|
|
36
|
+
function detectOpscontextEntry() {
|
|
37
|
+
// Try global install via `npm root -g`
|
|
38
|
+
try {
|
|
39
|
+
const globalRoot = execSync("npm root -g 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
40
|
+
const candidate = join(globalRoot, "@compr", "opscontext-mcp", "dist", "index.js");
|
|
41
|
+
if (existsSync(candidate))
|
|
42
|
+
return { kind: "global", path: candidate };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* no npm root available; fall through */
|
|
46
|
+
}
|
|
47
|
+
// Try dev tree relative to this module's location (dist/install-autostart.js)
|
|
48
|
+
// → __dirname/.. would be dist/, then ../ would be repo root, then dist/index.js
|
|
49
|
+
try {
|
|
50
|
+
// import.meta.url style would be nicer but cli.ts is CommonJS-ish; use __dirname
|
|
51
|
+
// via require.resolve fallback. We're loaded from dist/, so look at sibling.
|
|
52
|
+
const here = dirname(__filename || "");
|
|
53
|
+
const candidate = join(here, "index.js");
|
|
54
|
+
if (existsSync(candidate))
|
|
55
|
+
return { kind: "devtree", path: candidate };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function buildPlist(nodePath, entryPath, nodeBinDir) {
|
|
63
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
64
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
65
|
+
<plist version="1.0">
|
|
66
|
+
<dict>
|
|
67
|
+
<key>Label</key>
|
|
68
|
+
<string>${LABEL}</string>
|
|
69
|
+
|
|
70
|
+
<key>ProgramArguments</key>
|
|
71
|
+
<array>
|
|
72
|
+
<string>${nodePath}</string>
|
|
73
|
+
<string>${entryPath}</string>
|
|
74
|
+
</array>
|
|
75
|
+
|
|
76
|
+
<key>EnvironmentVariables</key>
|
|
77
|
+
<dict>
|
|
78
|
+
<key>PATH</key>
|
|
79
|
+
<string>${nodeBinDir}:/usr/local/bin:/usr/bin:/bin</string>
|
|
80
|
+
<key>HOME</key>
|
|
81
|
+
<string>${homedir()}</string>
|
|
82
|
+
<key>OPSCONTEXT_SKIP_CLAUDE_MEMORY</key>
|
|
83
|
+
<string>1</string>
|
|
84
|
+
</dict>
|
|
85
|
+
|
|
86
|
+
<key>WorkingDirectory</key>
|
|
87
|
+
<string>${homedir()}</string>
|
|
88
|
+
|
|
89
|
+
<key>RunAtLoad</key>
|
|
90
|
+
<true/>
|
|
91
|
+
|
|
92
|
+
<key>KeepAlive</key>
|
|
93
|
+
<true/>
|
|
94
|
+
|
|
95
|
+
<key>ThrottleInterval</key>
|
|
96
|
+
<integer>10</integer>
|
|
97
|
+
|
|
98
|
+
<key>StandardOutPath</key>
|
|
99
|
+
<string>${join(LOG_DIR, "mcp-stdout.log")}</string>
|
|
100
|
+
|
|
101
|
+
<key>StandardErrorPath</key>
|
|
102
|
+
<string>${join(LOG_DIR, "mcp-stderr.log")}</string>
|
|
103
|
+
|
|
104
|
+
<key>ProcessType</key>
|
|
105
|
+
<string>Background</string>
|
|
106
|
+
</dict>
|
|
107
|
+
</plist>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
function isMacOS() {
|
|
111
|
+
return platform() === "darwin";
|
|
112
|
+
}
|
|
113
|
+
function userId() {
|
|
114
|
+
return process.getuid?.() ?? 501;
|
|
115
|
+
}
|
|
116
|
+
function portIsOurs() {
|
|
117
|
+
try {
|
|
118
|
+
execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN >/dev/null 2>&1`, { stdio: "ignore" });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function waitForPort(timeoutSec = 30) {
|
|
126
|
+
const start = Date.now();
|
|
127
|
+
while (Date.now() - start < timeoutSec * 1000) {
|
|
128
|
+
if (portIsOurs())
|
|
129
|
+
return true;
|
|
130
|
+
execSync("sleep 1");
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
export async function cliInstallAutostart(args) {
|
|
135
|
+
const help = args.includes("-h") || args.includes("--help");
|
|
136
|
+
if (help) {
|
|
137
|
+
console.log(`Usage: opscontext install-autostart [--force]
|
|
138
|
+
|
|
139
|
+
Installs OpsContext as a macOS LaunchAgent so the MCP server starts
|
|
140
|
+
automatically at every login and restarts if it crashes.
|
|
141
|
+
|
|
142
|
+
After running this once, you never need to start the server manually again.
|
|
143
|
+
Browser extension events + Claude Code hook events + VS Code emitter events
|
|
144
|
+
all flow through the auto-started server.
|
|
145
|
+
|
|
146
|
+
--force Re-create the plist even if one already exists (use after a
|
|
147
|
+
node version upgrade or after moving the install location).
|
|
148
|
+
|
|
149
|
+
To stop / uninstall: opscontext uninstall-autostart
|
|
150
|
+
To check status: opscontext autostart-status
|
|
151
|
+
To view server logs: tail -f ~/.contextengine/logs/mcp-stderr.log
|
|
152
|
+
`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!isMacOS()) {
|
|
156
|
+
console.error(`❌ install-autostart currently supports macOS only (this is ${platform()}).`);
|
|
157
|
+
console.error(` For Linux: write a systemd --user unit. For Windows: NSSM or Task Scheduler.`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
const force = args.includes("--force") || args.includes("-f");
|
|
161
|
+
if (existsSync(PLIST_FILE) && !force) {
|
|
162
|
+
console.error(`❌ ${PLIST_FILE} already exists.`);
|
|
163
|
+
console.error(` Pass --force to overwrite, or run: opscontext autostart-status`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
const nodePath = detectNodePath();
|
|
167
|
+
const entry = detectOpscontextEntry();
|
|
168
|
+
if (!entry) {
|
|
169
|
+
console.error(`❌ Could not locate opscontext entrypoint.`);
|
|
170
|
+
console.error(` Either install globally: npm install -g @compr/opscontext-mcp`);
|
|
171
|
+
console.error(` Or run from a clone: cd .../ContextEngine && npm run build`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
// Ensure log dir
|
|
175
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
176
|
+
mkdirSync(dirname(PLIST_FILE), { recursive: true });
|
|
177
|
+
const nodeBinDir = dirname(nodePath);
|
|
178
|
+
const plist = buildPlist(nodePath, entry.path, nodeBinDir);
|
|
179
|
+
writeFileSync(PLIST_FILE, plist);
|
|
180
|
+
console.log(`✅ Wrote ${PLIST_FILE}`);
|
|
181
|
+
console.log(` node: ${nodePath}`);
|
|
182
|
+
console.log(` entry: ${entry.path} (${entry.kind})`);
|
|
183
|
+
// Stop any currently-running unmanaged opscontext server on the port —
|
|
184
|
+
// it would race with launchd for port 7842.
|
|
185
|
+
if (portIsOurs()) {
|
|
186
|
+
console.log(` detected existing process on :${PORT} — relying on launchctl bootout to clean it.`);
|
|
187
|
+
}
|
|
188
|
+
// Idempotent bootstrap: bootout (ignore failure) → bootstrap
|
|
189
|
+
const uid = userId();
|
|
190
|
+
try {
|
|
191
|
+
execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* not loaded — fine */
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
execSync(`launchctl bootstrap gui/${uid} ${PLIST_FILE}`, { stdio: "inherit" });
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
console.error(`❌ launchctl bootstrap failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
console.log(` waiting for the server to bind port ${PORT}...`);
|
|
204
|
+
if (waitForPort(30)) {
|
|
205
|
+
console.log(`✅ OpsContext is now running as a LaunchAgent (started at every login).`);
|
|
206
|
+
console.log(``);
|
|
207
|
+
console.log(`Verify: curl -s http://127.0.0.1:${PORT}/health | jq .`);
|
|
208
|
+
console.log(`Logs: tail -f ~/.contextengine/logs/mcp-stderr.log`);
|
|
209
|
+
console.log(`Stop: opscontext uninstall-autostart`);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
console.error(`⚠️ Server didn't bind port ${PORT} within 30s.`);
|
|
213
|
+
console.error(` Check the logs: tail -50 ~/.contextengine/logs/mcp-stderr.log`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function cliUninstallAutostart(args) {
|
|
218
|
+
const help = args.includes("-h") || args.includes("--help");
|
|
219
|
+
if (help) {
|
|
220
|
+
console.log(`Usage: opscontext uninstall-autostart
|
|
221
|
+
|
|
222
|
+
Removes the LaunchAgent and stops the OpsContext MCP server. The audit log
|
|
223
|
+
and extension secret are NOT touched — only the auto-start wiring goes away.
|
|
224
|
+
You can re-install with: opscontext install-autostart`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!isMacOS()) {
|
|
228
|
+
console.error(`❌ Only macOS LaunchAgents supported here.`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
const uid = userId();
|
|
232
|
+
let removed = false;
|
|
233
|
+
try {
|
|
234
|
+
execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
|
|
235
|
+
removed = true;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* not loaded */
|
|
239
|
+
}
|
|
240
|
+
if (existsSync(PLIST_FILE)) {
|
|
241
|
+
const { unlinkSync } = await import("fs");
|
|
242
|
+
unlinkSync(PLIST_FILE);
|
|
243
|
+
console.log(`✅ Removed ${PLIST_FILE}`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
console.log(` (no plist at ${PLIST_FILE})`);
|
|
247
|
+
}
|
|
248
|
+
if (removed) {
|
|
249
|
+
console.log(`✅ Stopped the running ${LABEL} agent.`);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
console.log(` (no running ${LABEL} agent found)`);
|
|
253
|
+
}
|
|
254
|
+
console.log(``);
|
|
255
|
+
console.log(`Audit log and extension secret kept at ~/.contextengine/ — re-install any time.`);
|
|
256
|
+
}
|
|
257
|
+
export async function cliAutostartStatus(args) {
|
|
258
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
259
|
+
console.log(`Usage: opscontext autostart-status
|
|
260
|
+
|
|
261
|
+
Shows whether OpsContext is configured to auto-start (LaunchAgent present),
|
|
262
|
+
whether it's currently running (port 7842 listening), and the path to the
|
|
263
|
+
running entrypoint.`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!isMacOS()) {
|
|
267
|
+
console.log(`platform: ${platform()} (LaunchAgent applies to macOS only)`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const plistExists = existsSync(PLIST_FILE);
|
|
271
|
+
const portUp = portIsOurs();
|
|
272
|
+
const uid = userId();
|
|
273
|
+
let launchctlState = "not loaded";
|
|
274
|
+
try {
|
|
275
|
+
const out = execSync(`launchctl print gui/${uid}/${LABEL} 2>/dev/null || true`, { encoding: "utf-8" });
|
|
276
|
+
const match = out.match(/state\s*=\s*(\S+)/);
|
|
277
|
+
if (match)
|
|
278
|
+
launchctlState = match[1];
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* ignore */
|
|
282
|
+
}
|
|
283
|
+
console.log(`OpsContext auto-start status`);
|
|
284
|
+
console.log(`─────────────────────────────`);
|
|
285
|
+
console.log(` plist: ${plistExists ? "✅ " + PLIST_FILE : "❌ not installed (run: opscontext install-autostart)"}`);
|
|
286
|
+
console.log(` launchctl: ${launchctlState}`);
|
|
287
|
+
console.log(` port ${PORT}: ${portUp ? "✅ listening" : "❌ not listening"}`);
|
|
288
|
+
if (portUp) {
|
|
289
|
+
try {
|
|
290
|
+
const health = execSync(`curl -sf http://127.0.0.1:${PORT}/health`, { encoding: "utf-8", timeout: 2000 });
|
|
291
|
+
console.log(` health: ${health.trim()}`);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
console.log(` health: ⚠ port open but /health didn't respond`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
console.log(``);
|
|
298
|
+
console.log(`Logs: ~/.contextengine/logs/mcp-stderr.log`);
|
|
299
|
+
}
|
|
300
|
+
//# sourceMappingURL=install-autostart.js.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// 🔒 LOCKED [CLAUDE-HOOK-INSTALL] — 2026-06-23
|
|
2
|
+
// ⛔ NEVER overwrite existing entries in hooks.PostToolUse — must APPEND.
|
|
3
|
+
// Users (and CE itself via the dogfood settings) commonly have
|
|
4
|
+
// matcher-specific PostToolUse entries (e.g. "Read|Edit|Write" gating)
|
|
5
|
+
// that would be silently destroyed by a replace.
|
|
6
|
+
// ⛔ NEVER write to ~/.claude/settings.json without parsing first. A typo
|
|
7
|
+
// or non-JSON state means Claude Code refuses to start.
|
|
8
|
+
// ⛔ NEVER emit on PreToolUse — would double-count vs PostToolUse for the
|
|
9
|
+
// `stuck` heuristic and skew `silent_failure` counts.
|
|
10
|
+
// WHY: Claude Code hook wiring is the ONLY way the user's terminal Claude
|
|
11
|
+
// Code sessions get into the OpsContext audit log. The installer has to
|
|
12
|
+
// be safe (idempotent, preserve existing) AND legible (clear error
|
|
13
|
+
// messages) AND fast (one command). If users have to hand-edit JSON,
|
|
14
|
+
// they won't.
|
|
15
|
+
// FIX: To add a new hook event, extend EVENT_KINDS + the splice block.
|
|
16
|
+
// Keep the "preserve existing" discipline in every code path.
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
21
|
+
const SETTINGS_FILE = join(CLAUDE_DIR, "settings.json");
|
|
22
|
+
const HOOKS_DIR = join(CLAUDE_DIR, "hooks");
|
|
23
|
+
const HOOK_SCRIPT = join(HOOKS_DIR, "opscontext-emit.sh");
|
|
24
|
+
const EVENT_KINDS = ["UserPromptSubmit", "PostToolUse", "SessionStart"];
|
|
25
|
+
function readSettings() {
|
|
26
|
+
if (!existsSync(SETTINGS_FILE))
|
|
27
|
+
return {};
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
throw new Error(`${SETTINGS_FILE} is not valid JSON — refusing to touch. (${err instanceof Error ? err.message : err})`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function backupSettings() {
|
|
36
|
+
if (!existsSync(SETTINGS_FILE))
|
|
37
|
+
return "";
|
|
38
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
39
|
+
const backup = `${SETTINGS_FILE}.bak-pre-opscontext-${ts}`;
|
|
40
|
+
copyFileSync(SETTINGS_FILE, backup);
|
|
41
|
+
return backup;
|
|
42
|
+
}
|
|
43
|
+
function hookAlreadyWired(entries, hookScript) {
|
|
44
|
+
if (!entries)
|
|
45
|
+
return false;
|
|
46
|
+
return entries.some((e) => e.hooks?.some((h) => h.command?.startsWith(hookScript)));
|
|
47
|
+
}
|
|
48
|
+
/** Path to the reference hook script bundled with this package. */
|
|
49
|
+
function bundledHookSource() {
|
|
50
|
+
// dist/install-claude-hook.js → ../defaults/claude-code-hook.sh in dev tree,
|
|
51
|
+
// or .../node_modules/@compr/opscontext-mcp/defaults/claude-code-hook.sh
|
|
52
|
+
// when globally / locally installed via npm. Both follow the same relative
|
|
53
|
+
// shape because npm copies defaults/ via the `files` whitelist.
|
|
54
|
+
const candidates = [
|
|
55
|
+
join(__dirname || "", "..", "defaults", "claude-code-hook.sh"),
|
|
56
|
+
join(__dirname || "", "defaults", "claude-code-hook.sh"),
|
|
57
|
+
];
|
|
58
|
+
for (const c of candidates) {
|
|
59
|
+
if (existsSync(c))
|
|
60
|
+
return c;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
export async function cliInstallClaudeHook(args) {
|
|
65
|
+
const help = args.includes("-h") || args.includes("--help");
|
|
66
|
+
if (help) {
|
|
67
|
+
console.log(`Usage: opscontext install-claude-hook
|
|
68
|
+
|
|
69
|
+
Wires OpsContext into Claude Code's hook system so every terminal Claude
|
|
70
|
+
Code session sends prompts + tool calls to the OpsContext audit log.
|
|
71
|
+
|
|
72
|
+
Events emitted (all go through the local HTTP endpoint, never the network):
|
|
73
|
+
• UserPromptSubmit → vscode.prompt_submit (feeds the loop heuristic)
|
|
74
|
+
• PostToolUse → vscode.tool_call (feeds stuck + silent_failure)
|
|
75
|
+
• SessionStart → vscode.session_start
|
|
76
|
+
|
|
77
|
+
The installer:
|
|
78
|
+
1. Copies the bundled hook script to ~/.claude/hooks/opscontext-emit.sh
|
|
79
|
+
2. Splices three entries into ~/.claude/settings.json under "hooks"
|
|
80
|
+
3. Preserves every existing hook entry (idempotent, safe to re-run)
|
|
81
|
+
|
|
82
|
+
A timestamped backup is written next to settings.json before any change.
|
|
83
|
+
|
|
84
|
+
Pre-req: the MCP server must be auto-started or running (otherwise the hook
|
|
85
|
+
silently no-ops, which is the safe default — you won't lose events later).
|
|
86
|
+
Run: opscontext install-autostart
|
|
87
|
+
`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Step 1: Install / verify the hook script
|
|
91
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
92
|
+
const src = bundledHookSource();
|
|
93
|
+
if (!src) {
|
|
94
|
+
console.error(`❌ Could not find bundled hook script defaults/claude-code-hook.sh.`);
|
|
95
|
+
console.error(` This means the install is incomplete. Reinstall opscontext:`);
|
|
96
|
+
console.error(` npm install -g @compr/opscontext-mcp`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
copyFileSync(src, HOOK_SCRIPT);
|
|
100
|
+
chmodSync(HOOK_SCRIPT, 0o755);
|
|
101
|
+
console.log(`✅ Installed hook script: ${HOOK_SCRIPT}`);
|
|
102
|
+
// Step 2: Splice into settings.json
|
|
103
|
+
const settings = readSettings();
|
|
104
|
+
const backup = backupSettings();
|
|
105
|
+
if (backup)
|
|
106
|
+
console.log(`✅ Backed up settings.json → ${backup}`);
|
|
107
|
+
settings.hooks ??= {};
|
|
108
|
+
const hookCmdPrefix = `${HOOK_SCRIPT}`; // command string starts with this
|
|
109
|
+
let added = 0;
|
|
110
|
+
let skipped = 0;
|
|
111
|
+
for (const kind of EVENT_KINDS) {
|
|
112
|
+
settings.hooks[kind] ??= [];
|
|
113
|
+
if (hookAlreadyWired(settings.hooks[kind], hookCmdPrefix)) {
|
|
114
|
+
skipped++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const entry = {
|
|
118
|
+
hooks: [
|
|
119
|
+
{
|
|
120
|
+
type: "command",
|
|
121
|
+
command: `${HOOK_SCRIPT} ${kind}`,
|
|
122
|
+
timeout: 5,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
// PostToolUse needs a matcher (PreToolUse/PostToolUse are tool-matched);
|
|
127
|
+
// ".*" matches every tool. Other events are not tool-scoped.
|
|
128
|
+
if (kind === "PostToolUse")
|
|
129
|
+
entry.matcher = ".*";
|
|
130
|
+
settings.hooks[kind].push(entry);
|
|
131
|
+
added++;
|
|
132
|
+
}
|
|
133
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
|
|
134
|
+
console.log(`✅ ${added} hook entries added, ${skipped} already present.`);
|
|
135
|
+
console.log(``);
|
|
136
|
+
console.log(`Test live:`);
|
|
137
|
+
console.log(` 1. Open a NEW VS Code terminal (settings.json is read at session start).`);
|
|
138
|
+
console.log(` 2. Run \`claude\` and ask anything — Claude will use tools.`);
|
|
139
|
+
console.log(` 3. In any other terminal:`);
|
|
140
|
+
console.log(` tail -f ~/.contextengine/audit.log | grep --line-buffered '"actor":"claude-code"'`);
|
|
141
|
+
console.log(``);
|
|
142
|
+
console.log(`To remove: opscontext uninstall-claude-hook (or hand-edit ~/.claude/settings.json)`);
|
|
143
|
+
}
|
|
144
|
+
export async function cliUninstallClaudeHook(args) {
|
|
145
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
146
|
+
console.log(`Usage: opscontext uninstall-claude-hook
|
|
147
|
+
|
|
148
|
+
Removes OpsContext hook entries from ~/.claude/settings.json. The hook
|
|
149
|
+
script file (~/.claude/hooks/opscontext-emit.sh) is left in place — delete
|
|
150
|
+
manually if you want it gone. The audit log is NOT touched.`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const settings = readSettings();
|
|
154
|
+
if (!settings.hooks) {
|
|
155
|
+
console.log(` (no hooks block in settings.json — nothing to remove)`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const backup = backupSettings();
|
|
159
|
+
if (backup)
|
|
160
|
+
console.log(`✅ Backed up settings.json → ${backup}`);
|
|
161
|
+
let removed = 0;
|
|
162
|
+
for (const kind of EVENT_KINDS) {
|
|
163
|
+
const entries = settings.hooks[kind];
|
|
164
|
+
if (!entries)
|
|
165
|
+
continue;
|
|
166
|
+
const filtered = entries.filter((e) => !e.hooks?.some((h) => h.command?.includes("opscontext-emit.sh")));
|
|
167
|
+
removed += entries.length - filtered.length;
|
|
168
|
+
if (filtered.length === 0) {
|
|
169
|
+
delete settings.hooks[kind];
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
settings.hooks[kind] = filtered;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
|
|
176
|
+
console.log(`✅ Removed ${removed} hook entries.`);
|
|
177
|
+
console.log(` Hook script kept at: ${HOOK_SCRIPT}`);
|
|
178
|
+
console.log(` Audit log untouched.`);
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=install-claude-hook.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|