@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 +34 -0
- package/defaults/claude-code-hook.sh +100 -0
- package/dist/cli.js +35 -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
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,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.
|
|
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",
|