@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.
@@ -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,3 @@
1
+ export declare function cliInstallClaudeHook(args: string[]): Promise<void>;
2
+ export declare function cliUninstallClaudeHook(args: string[]): Promise<void>;
3
+ //# sourceMappingURL=install-claude-hook.d.ts.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.0.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",