@aegisagentics/agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,239 @@
1
+ # Aegis Agent
2
+
3
+ Local installer, hook runtime, and public npm bootstrapper for Aegis ControlTower coding-agent telemetry.
4
+
5
+ `@aegisagentics/agent` installs a native `aegis-agent` runtime, registers supported local agent hooks, and sends safe, governed telemetry to Aegis ControlTower. The npm package is intentionally thin: it does not ship platform binaries. Release binaries are built by CI, checksummed, uploaded to the Aegis website release path, then downloaded and verified during install.
6
+
7
+ ## Quick Start
8
+
9
+ Create an agent installation in ControlTower, then run the generated command:
10
+
11
+ ```bash
12
+ AEGIS_API_KEY="agk_or_bootstrap_token" npx -y @aegisagentics/agent install \
13
+ --api-base-url "https://stage.0.aegisagentics.com" \
14
+ --otlp-http-base-url "https://otel.stage.0.aegisagentics.com" \
15
+ --installation-id "agent_install_..." \
16
+ --vendors codex
17
+ ```
18
+
19
+ Useful follow-up commands:
20
+
21
+ ```bash
22
+ aegis-agent status
23
+ aegis-agent doctor
24
+ aegis-agent flush
25
+ npx -y @aegisagentics/agent codex-plugin uninstall
26
+ npx -y @aegisagentics/agent uninstall --vendors codex
27
+ ```
28
+
29
+ For local development without downloading a release binary:
30
+
31
+ ```bash
32
+ cargo build --release
33
+ AEGIS_AGENT_BINARY_PATH="$PWD/target/release/aegis-agent" npm exec -- aegis-agent --help
34
+ ```
35
+
36
+ ## What This Repo Contains
37
+
38
+ ```text
39
+ package.json Public npm bootstrapper metadata
40
+ npm/install.js User-facing npx entrypoint
41
+ npm/lib/ Bootstrapper, Codex hook, and release download logic
42
+ npm/test/ Node test suite
43
+ npm/release-manifest.json Publish-time manifest embedded in the npm package
44
+ crates/aegis-agent/ Native Rust runtime
45
+ .github/workflows/release.yml CI release workflow for binaries + npm
46
+ ```
47
+
48
+ ## Install Lifecycle
49
+
50
+ 1. A ControlTower admin creates an agent installation.
51
+ 2. ControlTower returns an install command with API base URL, OTLP collector URL, installation ID, and scoped agent key.
52
+ 3. The npm bootstrapper detects the user's platform.
53
+ 4. If the native runtime is missing, the bootstrapper downloads the matching release artifact from `https://aegisagentics.com/releases/aegis-agent`.
54
+ 5. The bootstrapper verifies byte size and SHA-256 before extracting.
55
+ 6. The native runtime writes local config and owner-only credentials.
56
+ 7. Vendor-specific hook files are installed. Codex currently uses both plugin registration and Aegis-owned direct `hooks.json` entries for reliable local execution.
57
+ 8. Agent hooks send safe metadata, OTLP facts, queue-backed human-readable logs, and governed capture candidates according to local capture policy.
58
+
59
+ ## Local Files
60
+
61
+ ```text
62
+ ~/.config/aegis-agent/config.json
63
+ ~/.config/aegis-agent/credentials.json
64
+ ~/.local/share/aegis-agent/bin/aegis-agent
65
+ ~/.local/share/aegis-agent/queue/events.jsonl
66
+ ~/.local/share/aegis-agent/codex-marketplace
67
+ ```
68
+
69
+ `credentials.json` is written with owner-only permissions on Unix systems. Production installers should move to OS keychain storage where available, but file permissions are enforced today.
70
+
71
+ ## Codex Integration
72
+
73
+ The Codex marketplace generated by this package creates:
74
+
75
+ ```text
76
+ codex-marketplace/.agents/plugins/marketplace.json
77
+ codex-marketplace/plugins/aegis/.codex-plugin/plugin.json
78
+ codex-marketplace/plugins/aegis/hooks/hooks.json
79
+ ```
80
+
81
+ Hook commands point to the installed native runtime:
82
+
83
+ ```bash
84
+ ~/.local/share/aegis-agent/bin/aegis-agent hook --vendor=codex --event=PostToolUse
85
+ ```
86
+
87
+ Codex passes hook payloads on stdin. Command-line arguments only identify the vendor and lifecycle event.
88
+
89
+ ## Telemetry Paths
90
+
91
+ The plugin intentionally separates facts, logs, and artifacts:
92
+
93
+ | Data type | Path | Notes |
94
+ | --- | --- | --- |
95
+ | Run/session/tool facts | Plugin -> OTLP collector -> ControlTower API | Metadata and span facts. Collector forwards agent auth. |
96
+ | Human-readable logs | Plugin -> API -> queue -> worker -> Postgres | Safe operator log lines only. No raw prompts or tool payloads. |
97
+ | Capture artifacts | Plugin/gateway -> Capture API -> queue -> worker -> Presidio -> database | Redacted content only after server-side sanitization. |
98
+
99
+ The local safe queue is removed only after required downstream delivery succeeds. If the collector or API is unavailable, retry with:
100
+
101
+ ```bash
102
+ aegis-agent flush
103
+ ```
104
+
105
+ ## Capture Policy
106
+
107
+ The default capture policy is `metadata`.
108
+
109
+ Stored metadata may include:
110
+
111
+ - vendor and lifecycle event
112
+ - vendor event name
113
+ - session, turn, trace, and span identifiers
114
+ - model and source
115
+ - tool name and tool call id
116
+ - working directory and transcript path when the agent provides them
117
+ - vendor timestamp, status, duration, and raw payload size
118
+
119
+ Raw prompts, agent messages, tool inputs, and tool outputs are not sent in facts, logs, local queues, or ClickHouse telemetry. If `redacted` capture is enabled, content candidates are sent to the capture API for server-side Presidio redaction before persistence.
120
+
121
+ ## Release Architecture
122
+
123
+ The public npm package does not contain Rust binaries. Release files live under the Aegis website bucket and are served through CloudFront:
124
+
125
+ ```text
126
+ https://aegisagentics.com/releases/aegis-agent/channels/stable.json
127
+ https://aegisagentics.com/releases/aegis-agent/versions/<version>/manifest.json
128
+ https://aegisagentics.com/releases/aegis-agent/versions/<version>/<platform>/aegis-agent.tar.gz
129
+ https://aegisagentics.com/releases/aegis-agent/versions/<version>/<platform>/aegis-agent.tar.gz.sha256
130
+ ```
131
+
132
+ Supported platform keys:
133
+
134
+ - `darwin-arm64`
135
+ - `darwin-x64`
136
+ - `linux-arm64`
137
+ - `linux-x64`
138
+
139
+ The release workflow:
140
+
141
+ 1. Validates the release version against `package.json`.
142
+ 2. Runs npm tests.
143
+ 3. Runs Cargo tests.
144
+ 4. Builds native release binaries for supported targets.
145
+ 5. Packages `aegis-agent.tar.gz`.
146
+ 6. Computes SHA-256 and byte size.
147
+ 7. Generates version and channel manifests.
148
+ 8. Uploads release files under `s3://aegis-agentics-website/releases/aegis-agent/*`.
149
+ 9. Invalidates the CloudFront channel and manifest paths.
150
+ 10. Runs `npm pack --dry-run`.
151
+ 11. Publishes `@aegisagentics/agent` to npm.
152
+
153
+ ## GitHub Release Configuration
154
+
155
+ Required repository secrets:
156
+
157
+ ```text
158
+ AWS_ROLE_ARN=<release publisher IAM role ARN from website infrastructure output>
159
+ NPM_TOKEN=<npm automation token with publish rights for @aegisagentics/agent>
160
+ ```
161
+
162
+ Optional repository variables:
163
+
164
+ ```text
165
+ AWS_REGION=us-east-1
166
+ SSM_ROOT_PREFIX=/aegis-agentics
167
+ ```
168
+
169
+ The AWS role is scoped to:
170
+
171
+ - read the website bucket and CloudFront distribution ID from SSM
172
+ - list only the `releases/aegis-agent` prefix
173
+ - put/get objects only under `releases/aegis-agent/*`
174
+ - create CloudFront invalidations
175
+
176
+ It does not grant general website deploy permissions or S3 delete access.
177
+
178
+ ## Publishing a Release
179
+
180
+ The release workflow runs when a tag matching `v*.*.*` is pushed. Update
181
+ `package.json`, `Cargo.toml`, and `Cargo.lock` to the same semver version,
182
+ commit the change, then push `main` and the version tag:
183
+
184
+ ```bash
185
+ git status
186
+ git add package.json Cargo.toml Cargo.lock
187
+ git commit -m "Release 0.1.0"
188
+ git push origin main
189
+ git tag -a v0.1.0 -m "Release v0.1.0"
190
+ git push origin v0.1.0
191
+ ```
192
+
193
+ If `main` already contains the exact release version, only the tag push is
194
+ needed:
195
+
196
+ ```bash
197
+ git tag -a v0.1.0 -m "Release v0.1.0"
198
+ git push origin v0.1.0
199
+ ```
200
+
201
+ Manual release runs are also supported from GitHub Actions with
202
+ `workflow_dispatch`; the requested version must still match `package.json`.
203
+
204
+ ## Development
205
+
206
+ Run the npm tests:
207
+
208
+ ```bash
209
+ npm test
210
+ ```
211
+
212
+ Run Rust tests:
213
+
214
+ ```bash
215
+ cargo test --locked
216
+ ```
217
+
218
+ Validate publish contents:
219
+
220
+ ```bash
221
+ npm pack --dry-run
222
+ ```
223
+
224
+ Useful release-download overrides:
225
+
226
+ ```bash
227
+ AEGIS_AGENT_RELEASE_BASE_URL="https://localhost/releases/aegis-agent"
228
+ AEGIS_AGENT_RELEASE_VERSION="0.1.0"
229
+ AEGIS_AGENT_FORCE_BINARY_DOWNLOAD=1
230
+ ```
231
+
232
+ ## Security Notes
233
+
234
+ - Do not commit `.env`, private keys, generated packages, or local credentials.
235
+ - The npm `files` allowlist keeps the package limited to the bootstrapper, README, and generated release manifest.
236
+ - Archive extraction rejects absolute paths and `..` path traversal.
237
+ - Downloaded binaries are size-checked and SHA-256 checked before install.
238
+ - Activity logs are intentionally human-readable and metadata-only.
239
+ - Tool input/output belongs to the governed capture artifact pipeline, not activity logs.
package/npm/install.js ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { aegisPaths } from "./lib/config.js";
8
+ import { parseBootstrapCommand } from "./lib/cli.js";
9
+ import {
10
+ hookFilesForCodexUninstall,
11
+ installAegisHooksFiles,
12
+ scrubAegisHooksFiles
13
+ } from "./lib/codex-hooks.js";
14
+ import { installCodexPlugin, uninstallCodexPlugin } from "./lib/codex-plugin.js";
15
+ import {
16
+ detectCodexHome,
17
+ prepareCodexPluginInstallTarget
18
+ } from "./lib/codex-runtime.js";
19
+ import { ensureNativeBinary } from "./lib/release-download.js";
20
+
21
+ async function main() {
22
+ const binaryPath = resolveBinaryPath();
23
+ const args = process.argv.slice(2);
24
+ const paths = aegisPaths(process.env.HOME);
25
+
26
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
27
+ printHelp(binaryPath);
28
+ return 0;
29
+ }
30
+
31
+ let command;
32
+ try {
33
+ command = parseBootstrapCommand(args);
34
+ } catch (error) {
35
+ console.error(`aegis-agent: ${error.message}`);
36
+ return 2;
37
+ }
38
+
39
+ if (command.kind === "codex-plugin") {
40
+ if (command.codexPluginAction === "install") {
41
+ const status = await ensureBootstrapBinary(binaryPath);
42
+ if (status !== 0) {
43
+ return status;
44
+ }
45
+ }
46
+ return await runCodexPluginAction(command.codexPluginAction, paths, {
47
+ softFail: false
48
+ });
49
+ }
50
+
51
+ let codexPluginFailed = false;
52
+ if (command.codexPluginAction === "uninstall") {
53
+ const status = await runCodexPluginAction("uninstall", paths, {
54
+ softFail: true
55
+ });
56
+ codexPluginFailed = status !== 0;
57
+ }
58
+
59
+ if (!fs.existsSync(binaryPath) && command.nativeArgs[0] !== "uninstall") {
60
+ const status = await ensureBootstrapBinary(binaryPath);
61
+ if (status !== 0) {
62
+ return status;
63
+ }
64
+ }
65
+
66
+ if (!fs.existsSync(binaryPath)) {
67
+ if (args[0] === "uninstall") {
68
+ console.log(`Aegis Agent runtime already missing at ${binaryPath}`);
69
+ return codexPluginFailed ? 1 : 0;
70
+ }
71
+ printMissingBinary(binaryPath);
72
+ return 1;
73
+ }
74
+
75
+ const result = spawnSync(binaryPath, command.nativeArgs, {
76
+ env: process.env,
77
+ stdio: "inherit"
78
+ });
79
+
80
+ if (result.error) {
81
+ console.error(`aegis-agent: failed to execute ${binaryPath}: ${result.error.message}`);
82
+ return 1;
83
+ }
84
+ const nativeStatus = result.status ?? 1;
85
+ if (nativeStatus !== 0) {
86
+ return nativeStatus;
87
+ }
88
+
89
+ if (command.codexPluginAction === "install") {
90
+ return await runCodexPluginAction("install", paths, {
91
+ softFail: false
92
+ });
93
+ }
94
+
95
+ return codexPluginFailed ? 1 : 0;
96
+ }
97
+
98
+ async function ensureBootstrapBinary(binaryPath) {
99
+ if (process.env.AEGIS_AGENT_BINARY_PATH) {
100
+ return 0;
101
+ }
102
+ if (fs.existsSync(binaryPath) && process.env.AEGIS_AGENT_FORCE_BINARY_DOWNLOAD !== "1") {
103
+ return 0;
104
+ }
105
+ try {
106
+ const result = await ensureNativeBinary({ binaryPath });
107
+ if (result.downloaded) {
108
+ console.log(`Downloaded Aegis Agent runtime: ${binaryPath}`);
109
+ }
110
+ return 0;
111
+ } catch (error) {
112
+ console.error(`aegis-agent: failed to install native runtime: ${error.message}`);
113
+ return 1;
114
+ }
115
+ }
116
+
117
+ function resolveBinaryPath() {
118
+ if (process.env.AEGIS_AGENT_BINARY_PATH) {
119
+ return process.env.AEGIS_AGENT_BINARY_PATH;
120
+ }
121
+ const paths = aegisPaths(process.env.HOME);
122
+ if (fs.existsSync(paths.binaryFile)) {
123
+ return paths.binaryFile;
124
+ }
125
+
126
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
127
+ const devBinary = path.resolve(moduleDir, "..", "target", "release", "aegis-agent");
128
+ if (fs.existsSync(devBinary)) {
129
+ return devBinary;
130
+ }
131
+ return paths.binaryFile;
132
+ }
133
+
134
+ function printHelp(binaryPath) {
135
+ console.log(`Aegis Agent installer bootstrap
136
+
137
+ Usage:
138
+ AEGIS_API_KEY="<one-time-key>" npx -y @aegisagentics/agent install --api-base-url "https://controltower.example.com" --otlp-http-base-url "https://otel.controltower.example.com" --installation-id "agent_install_..." --vendors codex,claude,cursor
139
+ npx -y @aegisagentics/agent codex-plugin install
140
+ npx -y @aegisagentics/agent codex-plugin uninstall
141
+ npx -y @aegisagentics/agent uninstall --vendors codex
142
+ aegis-agent-install install --api-key "$AEGIS_API_KEY" --api-base-url "https://controltower.example.com" --otlp-http-base-url "https://otel.controltower.example.com" --installation-id "agent_install_..." --vendors codex
143
+
144
+ The npm package is a bootstrapper. The hook runtime is the native binary:
145
+ ${binaryPath}
146
+
147
+ For source builds, set AEGIS_AGENT_BINARY_PATH to a locally built aegis-agent binary.
148
+ `);
149
+ }
150
+
151
+ async function runCodexPluginAction(action, paths, { softFail }) {
152
+ try {
153
+ if (action === "install") {
154
+ const target = await prepareCodexPluginInstallTarget({
155
+ userPaths: paths
156
+ });
157
+ if (!fs.existsSync(target.marketplaceFile)) {
158
+ throw new Error(
159
+ `Codex marketplace file not found at ${target.marketplaceFile}. Run the Aegis install command first.`
160
+ );
161
+ }
162
+ const hookInstallResults = await installAegisHooksFiles({
163
+ binaryPath: target.binaryFile,
164
+ files: hookFilesForCodexUninstall({
165
+ codexHome: target.codexHome,
166
+ userPaths: paths
167
+ })
168
+ });
169
+ const addedHooks = hookInstallResults.reduce((sum, result) => sum + result.added, 0);
170
+ const replacedHooks = hookInstallResults.reduce((sum, result) => sum + result.replaced, 0);
171
+ await installCodexPlugin({ marketplaceFile: target.marketplaceFile });
172
+ if (target.mirrored) {
173
+ console.log(`Prepared Codex-isolated Aegis runtime: ${target.codexHome}`);
174
+ }
175
+ console.log(
176
+ `Installed Aegis Codex hooks.json entries: ${addedHooks} added, ${replacedHooks} replaced`
177
+ );
178
+ console.log("Codex plugin installed: aegis@aegis");
179
+ return 0;
180
+ }
181
+
182
+ if (action === "uninstall") {
183
+ let pluginError = null;
184
+ try {
185
+ await uninstallCodexPlugin();
186
+ } catch (error) {
187
+ pluginError = error;
188
+ }
189
+ const scrubResults = await scrubAegisHooksFiles(
190
+ hookFilesForCodexUninstall({
191
+ codexHome: detectCodexHome(),
192
+ userPaths: paths
193
+ })
194
+ );
195
+ const removedHooks = scrubResults.reduce((sum, result) => sum + result.removed, 0);
196
+ if (removedHooks > 0) {
197
+ console.log(`Removed ${removedHooks} Aegis hook entr${removedHooks === 1 ? "y" : "ies"} from Codex hooks.json`);
198
+ }
199
+ if (pluginError) {
200
+ throw pluginError;
201
+ }
202
+ console.log("Codex plugin uninstalled: aegis@aegis");
203
+ return 0;
204
+ }
205
+
206
+ throw new Error(`Unsupported Codex plugin action: ${action}`);
207
+ } catch (error) {
208
+ const message = `Codex plugin ${action} failed: ${error.message}`;
209
+ if (softFail) {
210
+ console.warn(`aegis-agent: ${message}`);
211
+ return 1;
212
+ }
213
+ console.error(`aegis-agent: ${message}`);
214
+ return 1;
215
+ }
216
+ }
217
+
218
+ function printMissingBinary(binaryPath) {
219
+ console.error(`aegis-agent: native binary not found at ${binaryPath}
220
+
221
+ The npm bootstrapper downloads and verifies the release runtime when it is missing.
222
+ For local development:
223
+ cd crates/aegis-agent
224
+ cargo build --release
225
+ AEGIS_AGENT_BINARY_PATH="$PWD/target/release/aegis-agent" node ../../npm/install.js --help
226
+ `);
227
+ }
228
+
229
+ process.exitCode = await main();
package/npm/lib/cli.js ADDED
@@ -0,0 +1,80 @@
1
+ import { normalizeVendorList } from "./config.js";
2
+
3
+ export function parseBootstrapCommand(args) {
4
+ const command = args[0] ?? "";
5
+
6
+ if (command === "codex-plugin") {
7
+ const action = args[1] ?? "";
8
+ if (action !== "install" && action !== "uninstall") {
9
+ throw new Error(
10
+ "Usage: aegis-agent codex-plugin <install|uninstall>"
11
+ );
12
+ }
13
+ return {
14
+ codexPluginAction: action,
15
+ kind: "codex-plugin",
16
+ nativeArgs: []
17
+ };
18
+ }
19
+
20
+ if (command === "install") {
21
+ const vendors = extractVendorList(args, "codex");
22
+ return {
23
+ codexPluginAction: vendors.includes("codex") ? "install" : null,
24
+ kind: "native",
25
+ nativeArgs: args
26
+ };
27
+ }
28
+
29
+ if (command === "uninstall") {
30
+ const vendors = extractVendorList(args, "codex");
31
+ return {
32
+ codexPluginAction: vendors.includes("codex") ? "uninstall" : null,
33
+ kind: "native",
34
+ nativeArgs: stripOption(args, "--vendors")
35
+ };
36
+ }
37
+
38
+ return {
39
+ codexPluginAction: null,
40
+ kind: "native",
41
+ nativeArgs: args
42
+ };
43
+ }
44
+
45
+ function extractVendorList(args, fallback) {
46
+ const values = [];
47
+
48
+ for (let index = 0; index < args.length; index += 1) {
49
+ const arg = args[index];
50
+ if (arg === "--vendors") {
51
+ if (index + 1 >= args.length) {
52
+ throw new Error("--vendors requires a value");
53
+ }
54
+ values.push(args[index + 1]);
55
+ index += 1;
56
+ continue;
57
+ }
58
+ if (arg.startsWith("--vendors=")) {
59
+ values.push(arg.slice("--vendors=".length));
60
+ }
61
+ }
62
+
63
+ return normalizeVendorList(values.length > 0 ? values.join(",") : fallback);
64
+ }
65
+
66
+ function stripOption(args, optionName) {
67
+ const stripped = [];
68
+ for (let index = 0; index < args.length; index += 1) {
69
+ const arg = args[index];
70
+ if (arg === optionName) {
71
+ index += 1;
72
+ continue;
73
+ }
74
+ if (arg.startsWith(`${optionName}=`)) {
75
+ continue;
76
+ }
77
+ stripped.push(arg);
78
+ }
79
+ return stripped;
80
+ }
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { buildCodexHooks } from "./codex.js";
5
+
6
+ const AEGIS_COMMAND_MARKERS = [
7
+ "/aegis-agent/bin/aegis-agent hook --vendor=codex",
8
+ "/aegis-agent hook --vendor=codex",
9
+ "aegis-agent hook --vendor=codex"
10
+ ];
11
+
12
+ export function hookFilesForCodexUninstall({ codexHome, userPaths }) {
13
+ const files = [path.join(userPaths.homeDir, ".codex", "hooks.json")];
14
+ if (codexHome && path.resolve(codexHome) !== path.resolve(userPaths.homeDir)) {
15
+ files.push(path.join(codexHome, "hooks.json"));
16
+ }
17
+ return [...new Set(files.map((file) => path.resolve(file)))];
18
+ }
19
+
20
+ export async function scrubAegisHooksFiles(files) {
21
+ const results = [];
22
+ for (const file of files) {
23
+ results.push({
24
+ file,
25
+ ...(await scrubAegisHooksFile(file))
26
+ });
27
+ }
28
+ return results;
29
+ }
30
+
31
+ export async function installAegisHooksFiles({ binaryPath, files }) {
32
+ const results = [];
33
+ for (const hooksFile of files) {
34
+ results.push({
35
+ file: hooksFile,
36
+ ...(await installAegisHooksFile({ binaryPath, hooksFile }))
37
+ });
38
+ }
39
+ return results;
40
+ }
41
+
42
+ export async function installAegisHooksFile({ binaryPath, hooksFile }) {
43
+ let document = { hooks: {} };
44
+ try {
45
+ document = JSON.parse(await fs.readFile(hooksFile, "utf8"));
46
+ } catch (error) {
47
+ if (error.code !== "ENOENT") {
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
53
+ document = { hooks: {} };
54
+ }
55
+ if (!isObject(document.hooks)) {
56
+ document.hooks = {};
57
+ }
58
+
59
+ const scrubbed = removeAegisHandlers(document);
60
+ const aegisHooks = buildCodexHooks({ binaryPath }).hooks;
61
+
62
+ for (const [eventName, groups] of Object.entries(aegisHooks)) {
63
+ const currentGroups = Array.isArray(scrubbed.document.hooks[eventName])
64
+ ? scrubbed.document.hooks[eventName]
65
+ : [];
66
+ scrubbed.document.hooks[eventName] = [...currentGroups, ...groups];
67
+ }
68
+
69
+ await fs.mkdir(path.dirname(hooksFile), { recursive: true });
70
+ await fs.writeFile(hooksFile, `${JSON.stringify(scrubbed.document, null, 2)}\n`, "utf8");
71
+
72
+ return {
73
+ added: Object.keys(aegisHooks).length,
74
+ replaced: scrubbed.removed
75
+ };
76
+ }
77
+
78
+ export async function scrubAegisHooksFile(file) {
79
+ let raw;
80
+ try {
81
+ raw = await fs.readFile(file, "utf8");
82
+ } catch (error) {
83
+ if (error.code === "ENOENT") {
84
+ return { missing: true, removed: 0, removedFile: false };
85
+ }
86
+ throw error;
87
+ }
88
+
89
+ const document = JSON.parse(raw);
90
+ if (!document || typeof document !== "object" || !isObject(document.hooks)) {
91
+ return { missing: false, removed: 0, removedFile: false };
92
+ }
93
+
94
+ const { document: nextDocument, removed } = removeAegisHandlers(document);
95
+
96
+ if (removed === 0) {
97
+ return { missing: false, removed: 0, removedFile: false };
98
+ }
99
+
100
+ if (Object.keys(nextDocument.hooks).length === 0 && Object.keys(nextDocument).length === 1) {
101
+ await fs.unlink(file);
102
+ return { missing: false, removed, removedFile: true };
103
+ }
104
+
105
+ await fs.writeFile(file, `${JSON.stringify(nextDocument, null, 2)}\n`, "utf8");
106
+ return { missing: false, removed, removedFile: false };
107
+ }
108
+
109
+ function removeAegisHandlers(document) {
110
+ let removed = 0;
111
+ const nextHooks = {};
112
+
113
+ for (const [eventName, groups] of Object.entries(document.hooks ?? {})) {
114
+ if (!Array.isArray(groups)) {
115
+ nextHooks[eventName] = groups;
116
+ continue;
117
+ }
118
+
119
+ const nextGroups = [];
120
+ for (const group of groups) {
121
+ if (!isObject(group) || !Array.isArray(group.hooks)) {
122
+ nextGroups.push(group);
123
+ continue;
124
+ }
125
+
126
+ const nextHandlers = group.hooks.filter((handler) => {
127
+ if (isAegisHookHandler(handler)) {
128
+ removed += 1;
129
+ return false;
130
+ }
131
+ return true;
132
+ });
133
+
134
+ if (nextHandlers.length > 0) {
135
+ nextGroups.push({
136
+ ...group,
137
+ hooks: nextHandlers
138
+ });
139
+ }
140
+ }
141
+
142
+ if (nextGroups.length > 0) {
143
+ nextHooks[eventName] = nextGroups;
144
+ }
145
+ }
146
+
147
+ return {
148
+ document: {
149
+ ...document,
150
+ hooks: nextHooks
151
+ },
152
+ removed
153
+ };
154
+ }
155
+
156
+ function isAegisHookHandler(handler) {
157
+ if (!isObject(handler) || typeof handler.command !== "string") {
158
+ return false;
159
+ }
160
+ return AEGIS_COMMAND_MARKERS.some((marker) => handler.command.includes(marker));
161
+ }
162
+
163
+ function isObject(value) {
164
+ return value !== null && typeof value === "object" && !Array.isArray(value);
165
+ }