@compr/opscontext-mcp 2.1.0 → 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 CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to OpsContext for AI Agents (previously ContextEngine — MCP server + CLI) are documented here.
4
4
 
5
+ ## [2.1.1] — 2026-06-23 — Phase 1c: one-command install + Claude Code terminal capture
6
+
7
+ Closes the last surface gap from 2.1.0. Before this patch, "use OpsContext" meant running `nohup npx ...` every time the Mac restarted and hand-editing `~/.claude/settings.json` to wire Claude Code hooks. Now both are single commands. This is the release that lets non-technical users actually adopt OpsContext.
8
+
9
+ ### Added
10
+ - **`src/install-autostart.ts`** (LOCK `[AUTOSTART-INSTALL]`) — installs a macOS LaunchAgent at `~/Library/LaunchAgents/com.opscontext.mcp.plist`. Set-and-forget — server starts at every login, KeepAlive restarts on crash, logs to `~/.contextengine/logs/mcp-{stdout,stderr}.log`. Companion `uninstall-autostart` + `autostart-status` commands. Auto-detects either a global npm install or a dev tree; pins node path absolutely (launchd has no PATH).
11
+ - **`src/install-claude-hook.ts`** (LOCK `[CLAUDE-HOOK-INSTALL]`) — copies `defaults/claude-code-hook.sh` to `~/.claude/hooks/opscontext-emit.sh` and splices three entries into `~/.claude/settings.json` under `hooks`: `UserPromptSubmit` → `vscode.prompt_submit`, `PostToolUse` (`.*` matcher) → `vscode.tool_call`, `SessionStart` → `vscode.session_start`. **Idempotent + preserves every existing hook entry** (backs up settings.json before any change). Closes the terminal-side capture gap — every Claude Code session in any project now feeds the audit log.
12
+ - **`defaults/claude-code-hook.sh`** — the actual shell hook: ~3 KB, pure bash + jq + curl, ~28 ms latency per invocation, **silent on every failure** (never blocks Claude Code), 1-second hard timeout on the HTTP call. Posts to `127.0.0.1:7842/events` with shared secret in `X-OpsContext-Secret` header. LOCK `[OPSCONTEXT-CC-HOOK]`.
13
+ - **New CLI subcommands:**
14
+ - `opscontext install-autostart [--force]`
15
+ - `opscontext uninstall-autostart`
16
+ - `opscontext autostart-status`
17
+ - `opscontext install-claude-hook`
18
+ - `opscontext uninstall-claude-hook`
19
+
20
+ ### Fixed
21
+ - **`chrome-extension/src/options/options.html`** — Secret field placeholder said `32 hex chars` but the actual secret is 64 hex (256-bit). Updated to `64 hex chars` to match what `init-extension-secret` writes. No backwards-compat issue — placeholder text only, not validation.
22
+ - **`chrome-extension` content scripts now bundled as IIFE** (companion `@compr/opscontext-chrome@0.1.1`). The MV3 manifest can't put `"type": "module"` on `content_scripts` entries — only on `background`. The previous build shipped `dist/content/claude.js` with top-level `import` statements which Chrome silently rejected, dark-launching the entire capture surface (Options page still saved the secret fine because popup/options DO support modules via inline `<script type="module">`). New `scripts/bundle-content.mjs` runs esbuild after `tsc` to inline all `./shared/*` and `../lib/*` imports into a single IIFE per content entry. LOCK `[CONTENT-SCRIPT-BUNDLE]`. Reload the unpacked extension after rebuild for the fix to take effect.
23
+
24
+ ### Why this matters
25
+ - Before: `nohup npx -y --package=@compr/opscontext-mcp@2.1.0 -- opscontext > /tmp/opscontext-mcp.log 2>&1 < /dev/null &` every reboot, plus hand-editing JSON for Claude Code hooks. Friction kills adoption.
26
+ - After: `opscontext install-autostart && opscontext install-claude-hook`. Two commands, ever. Server auto-starts at login forever; terminal Claude Code sessions feed audit log automatically.
27
+
28
+ ### Architecture notes
29
+ - Hook namespace stays `vscode.*` (not `claude_code.*`) so the published 2.1.0 detector heuristics fire today without a parallel namespace migration. `payload.surface = "claude-code"` disambiguates source for any caller that cares.
30
+ - LaunchAgent uses `gui/$UID` domain (per-user, no root) — same pattern as the user's existing `com.invocme.backup-*` plists.
31
+ - Hook deliberately emits on PostToolUse ONLY (not PreToolUse) — emitting both would double-count for the `stuck` heuristic and skew `silent_failure` counts.
32
+ - All transport via HTTP `POST /events` not direct file writes — keeps event writes serialized through the running MCP server's single in-process chain cache, sidestepping the historic concurrent-write race (8 chain breaks on 2026-06-10/11, all `system` actor, all pre-flag-day; zero breaks since).
33
+
34
+ ### Known surface gaps still open
35
+ - `Stop` hook event not emitted (would enable "assistant gave up mid-task" detection — Phase 3.1).
36
+ - No Linux support yet for `install-autostart` (systemd --user unit equivalent is ~30 min of work).
37
+ - No tool-result exit codes from VS Code extension yet (candidate for vscode-ext 0.10).
38
+
5
39
  ## [2.1.0] — 2026-06-23 — Phase 1: cross-surface capture + drift detector + local event ingest
6
40
 
7
41
  The first feature release after the OpsContext rebrand. Closes the wedge the audit identified: **no other tool captures AI interactions across browser + IDE + terminal and feeds them into a tamper-evident audit log with policy enforcement**. Now we do.
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # OpsContext — Claude Code hook emitter
3
+ #
4
+ # 🔒 LOCKED [OPSCONTEXT-CC-HOOK] — 2026-06-23
5
+ # ⛔ NEVER block on success or fail loudly. Claude Code waits for hooks to
6
+ # complete before continuing — any error must exit 0 + silent.
7
+ # ⛔ NEVER emit on PreToolUse. PostToolUse alone — PreToolUse would double-
8
+ # count vs PostToolUse for the `stuck` heuristic and skew `silent_failure`.
9
+ # ⛔ NEVER print to stdout (would be interpreted as a hook decision message).
10
+ # WHY: This hook is the ONLY way Claude Code terminal sessions get into the
11
+ # OpsContext audit log. If it's slow or breaks, the user disables it and
12
+ # loses cross-surface drift visibility — the entire wedge collapses.
13
+ # FIX: To support a new Claude Code hook event, add a case branch. Keep the
14
+ # exit-0-on-any-error discipline. Events go via HTTP (NOT direct file
15
+ # write) so the running MCP server's in-process chain cache prevents the
16
+ # concurrent-write race.
17
+
18
+ set +e
19
+
20
+ EVENT_KIND="${1:-}"
21
+ SECRET_FILE="$HOME/.contextengine/extension-secret"
22
+ ENDPOINT="${OPSCONTEXT_EVENT_URL:-http://127.0.0.1:7842/events}"
23
+
24
+ # Bail fast if not initialized — never block Claude Code
25
+ [ -r "$SECRET_FILE" ] || exit 0
26
+ SECRET=$(cat "$SECRET_FILE" 2>/dev/null)
27
+ [ -n "$SECRET" ] || exit 0
28
+
29
+ INPUT=$(cat)
30
+ [ -n "$INPUT" ] || exit 0
31
+
32
+ NOW=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
33
+
34
+ case "$EVENT_KIND" in
35
+ UserPromptSubmit)
36
+ PAYLOAD=$(printf '%s' "$INPUT" | jq -c --arg ts "$NOW" '{
37
+ v: 1, ts: $ts, event: "vscode.prompt_submit", actor: "claude-code",
38
+ payload: {
39
+ surface: "claude-code",
40
+ text: ((.prompt // "")[:4000]),
41
+ session: (.session_id // ""),
42
+ cwd: (.cwd // ""),
43
+ char_count: ((.prompt // "") | length)
44
+ }
45
+ }' 2>/dev/null)
46
+ ;;
47
+ PostToolUse)
48
+ PAYLOAD=$(printf '%s' "$INPUT" | jq -c --arg ts "$NOW" '{
49
+ v: 1, ts: $ts, event: "vscode.tool_call", actor: "claude-code",
50
+ payload: ({
51
+ surface: "claude-code",
52
+ tool: (.tool_name // ""),
53
+ args_preview: (
54
+ (.tool_input.command
55
+ // .tool_input.file_path
56
+ // .tool_input.pattern
57
+ // (.tool_input | tostring)
58
+ // ""
59
+ )[:200]
60
+ ),
61
+ session: (.session_id // ""),
62
+ cwd: (.cwd // "")
63
+ } + (
64
+ if (.tool_response.is_error == true)
65
+ or ((.tool_response.error // "") != "")
66
+ or ((.tool_response.interrupt // false) == true)
67
+ then { error: ((.tool_response.error
68
+ // (.tool_response.content | tostring)
69
+ // "tool reported error")[:500]) }
70
+ else {}
71
+ end
72
+ ))
73
+ }' 2>/dev/null)
74
+ ;;
75
+ SessionStart)
76
+ PAYLOAD=$(printf '%s' "$INPUT" | jq -c --arg ts "$NOW" '{
77
+ v: 1, ts: $ts, event: "vscode.session_start", actor: "claude-code",
78
+ payload: {
79
+ surface: "claude-code",
80
+ session: (.session_id // ""),
81
+ cwd: (.cwd // ""),
82
+ source: (.source // "")
83
+ }
84
+ }' 2>/dev/null)
85
+ ;;
86
+ *)
87
+ exit 0
88
+ ;;
89
+ esac
90
+
91
+ [ -n "$PAYLOAD" ] || exit 0
92
+
93
+ # POST with 1s hard timeout. Any error → silent (curl >/dev/null 2>&1, exit 0).
94
+ curl -sS --max-time 1.0 \
95
+ -H "Content-Type: application/json" \
96
+ -H "X-OpsContext-Secret: $SECRET" \
97
+ --data "{\"events\":[$PAYLOAD]}" \
98
+ "$ENDPOINT" >/dev/null 2>&1
99
+
100
+ exit 0
package/dist/cli.js CHANGED
@@ -1784,6 +1784,11 @@ Usage:
1784
1784
  Author + validate the declarative .contextengine/policy.json
1785
1785
  contextengine init-extension-secret [--force]
1786
1786
  Generate ~/.contextengine/extension-secret for the browser ext
1787
+ contextengine install-autostart [--force]
1788
+ Install macOS LaunchAgent so MCP server auto-starts at login
1789
+ (uninstall-autostart / autostart-status — companion commands)
1790
+ contextengine install-claude-hook Wire Claude Code terminal sessions into the OpsContext audit log
1791
+ (UserPromptSubmit + PostToolUse + SessionStart hook entries)
1787
1792
  contextengine watch [--json] [--severity info|warn|critical] [--once] [--window SECONDS]
1788
1793
  Stream drift / loop / stuck-tool / fabrication alerts from the audit log
1789
1794
  contextengine emit-event <kind> <payload-json> [--actor NAME]
@@ -2004,6 +2009,36 @@ else if (command === "init-extension-secret") {
2004
2009
  process.exit(1);
2005
2010
  });
2006
2011
  }
2012
+ else if (command === "install-autostart") {
2013
+ import("./install-autostart.js").then((m) => m.cliInstallAutostart(process.argv.slice(3))).catch((err) => {
2014
+ console.error("Error:", err instanceof Error ? err.message : err);
2015
+ process.exit(1);
2016
+ });
2017
+ }
2018
+ else if (command === "uninstall-autostart") {
2019
+ import("./install-autostart.js").then((m) => m.cliUninstallAutostart(process.argv.slice(3))).catch((err) => {
2020
+ console.error("Error:", err instanceof Error ? err.message : err);
2021
+ process.exit(1);
2022
+ });
2023
+ }
2024
+ else if (command === "autostart-status") {
2025
+ import("./install-autostart.js").then((m) => m.cliAutostartStatus(process.argv.slice(3))).catch((err) => {
2026
+ console.error("Error:", err instanceof Error ? err.message : err);
2027
+ process.exit(1);
2028
+ });
2029
+ }
2030
+ else if (command === "install-claude-hook") {
2031
+ import("./install-claude-hook.js").then((m) => m.cliInstallClaudeHook(process.argv.slice(3))).catch((err) => {
2032
+ console.error("Error:", err instanceof Error ? err.message : err);
2033
+ process.exit(1);
2034
+ });
2035
+ }
2036
+ else if (command === "uninstall-claude-hook") {
2037
+ import("./install-claude-hook.js").then((m) => m.cliUninstallClaudeHook(process.argv.slice(3))).catch((err) => {
2038
+ console.error("Error:", err instanceof Error ? err.message : err);
2039
+ process.exit(1);
2040
+ });
2041
+ }
2007
2042
  else if (command === "stats") {
2008
2043
  cliStats();
2009
2044
  }
@@ -0,0 +1,4 @@
1
+ export declare function cliInstallAutostart(args: string[]): Promise<void>;
2
+ export declare function cliUninstallAutostart(args: string[]): Promise<void>;
3
+ export declare function cliAutostartStatus(args: string[]): Promise<void>;
4
+ //# sourceMappingURL=install-autostart.d.ts.map
@@ -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.1.0",
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",