@kylecheng3146/agent-ops 0.1.1 → 0.1.3
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 +11 -2
- package/dist/packages/cli/src/bin.js +195 -247
- package/dist/packages/cli/src/commands/hook.js +42 -0
- package/dist/packages/cli/src/commands/init.js +4 -1
- package/dist/packages/cli/src/commands/update.js +4 -1
- package/dist/packages/cli/src/context.js +102 -0
- package/dist/packages/cli/src/hook-entry.js +10 -0
- package/dist/packages/cli/src/hook-process.js +54 -0
- package/dist/packages/cli/src/ui.js +132 -0
- package/dist/packages/cli/src/version.js +3 -0
- package/dist/runtime/src/adapters/claude/config.js +19 -0
- package/dist/runtime/src/adapters/codex/config.js +16 -0
- package/dist/runtime/src/fs/transaction.js +20 -10
- package/dist/runtime/src/install/doctor.js +7 -3
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/hooks.js +77 -0
- package/dist/runtime/src/install/ownership.js +20 -0
- package/dist/runtime/src/install/plan.js +27 -2
- package/dist/runtime/src/install/probes.js +68 -0
- package/dist/runtime/src/install/uninstall.js +28 -0
- package/dist/runtime/src/install/update.js +3 -0
- package/dist/runtime/src/schema/validate.js +57 -0
- package/dist/runtime/src/security/permissions.js +22 -3
- package/package.json +1 -1
- package/schemas/manifest.schema.json +33 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadConfigFile } from "../../../runtime/src/config/load.js";
|
|
5
|
+
import { mergeConfigLayers } from "../../../runtime/src/config/merge.js";
|
|
6
|
+
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
7
|
+
import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
|
|
8
|
+
import { localStatePaths } from "../../../runtime/src/security/permissions.js";
|
|
9
|
+
import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
|
|
10
|
+
export const DEFAULT_CONFIG = {
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
profiles: [],
|
|
13
|
+
verification: { commands: [] },
|
|
14
|
+
pathMappings: [],
|
|
15
|
+
securityExceptions: []
|
|
16
|
+
};
|
|
17
|
+
function defaultConfigLayer() {
|
|
18
|
+
return {
|
|
19
|
+
source: "default",
|
|
20
|
+
sourcePath: "built-in defaults",
|
|
21
|
+
config: DEFAULT_CONFIG
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async function loadOptionalConfig(path) {
|
|
25
|
+
try {
|
|
26
|
+
return await loadConfigFile(path);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof AgentOpsError &&
|
|
30
|
+
error.code === "CONFIG_READ_FAILED" &&
|
|
31
|
+
typeof error.cause === "object" &&
|
|
32
|
+
error.cause !== null &&
|
|
33
|
+
"code" in error.cause &&
|
|
34
|
+
error.cause.code === "ENOENT") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function loadEffectiveConfig(root, scope) {
|
|
41
|
+
const home = process.env.AGENT_OPS_HOME ?? homedir();
|
|
42
|
+
const userPath = join(home, ".agent-ops", "config.json");
|
|
43
|
+
const projectPath = join(root, ".agent-ops", "config.json");
|
|
44
|
+
const layers = [defaultConfigLayer()];
|
|
45
|
+
if (scope === "user") {
|
|
46
|
+
const user = await loadOptionalConfig(userPath);
|
|
47
|
+
if (user !== null) {
|
|
48
|
+
layers.push({
|
|
49
|
+
source: "user",
|
|
50
|
+
sourcePath: user.sourcePath,
|
|
51
|
+
config: user.config
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return mergeConfigLayers(layers);
|
|
55
|
+
}
|
|
56
|
+
if (projectPath !== userPath) {
|
|
57
|
+
const user = await loadOptionalConfig(userPath);
|
|
58
|
+
if (user !== null) {
|
|
59
|
+
layers.push({
|
|
60
|
+
source: "user",
|
|
61
|
+
sourcePath: user.sourcePath,
|
|
62
|
+
config: user.config
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const project = await loadOptionalConfig(projectPath);
|
|
67
|
+
if (project !== null) {
|
|
68
|
+
layers.push({
|
|
69
|
+
source: "project",
|
|
70
|
+
sourcePath: project.sourcePath,
|
|
71
|
+
config: project.config
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return mergeConfigLayers(layers);
|
|
75
|
+
}
|
|
76
|
+
export function repositoryRemoteUrl(root) {
|
|
77
|
+
try {
|
|
78
|
+
return execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
79
|
+
cwd: root,
|
|
80
|
+
encoding: "utf8"
|
|
81
|
+
}).trim();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return `local:${root}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function repositoryTrust(root, config, cliVersion) {
|
|
88
|
+
const home = process.env.AGENT_OPS_HOME ?? homedir();
|
|
89
|
+
const state = localStatePaths(home);
|
|
90
|
+
try {
|
|
91
|
+
const binding = await calculateTrustBinding({
|
|
92
|
+
repositoryPath: root,
|
|
93
|
+
remoteUrl: repositoryRemoteUrl(root),
|
|
94
|
+
configHash: sha256(JSON.stringify(config)),
|
|
95
|
+
runtimeHash: sha256(cliVersion)
|
|
96
|
+
});
|
|
97
|
+
return (await new FileTrustStore(state.trustStore, state.anchorDirectory).status(binding)).status;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return "UNTRUSTED";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Entry point registered in Claude settings as:
|
|
3
|
+
// node <this file> <harness> <event> --managed-by=agent-ops
|
|
4
|
+
import { CLI_VERSION } from "./version.js";
|
|
5
|
+
import { runHookProcess } from "./hook-process.js";
|
|
6
|
+
process.exitCode = await runHookProcess(process.argv.slice(2), {
|
|
7
|
+
stdin: process.stdin,
|
|
8
|
+
writeStdout: (value) => process.stdout.write(value),
|
|
9
|
+
writeStderr: (value) => process.stderr.write(value)
|
|
10
|
+
}, CLI_VERSION);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
|
|
2
|
+
import { loadEffectiveConfig, repositoryTrust } from "./context.js";
|
|
3
|
+
const HARNESSES = new Set(["codex", "claude"]);
|
|
4
|
+
const MAX_HOOK_INPUT_BYTES = 1024 * 1024;
|
|
5
|
+
async function readStdin(stream) {
|
|
6
|
+
const chunks = [];
|
|
7
|
+
let total = 0;
|
|
8
|
+
for await (const chunk of stream) {
|
|
9
|
+
const buffer = Buffer.isBuffer(chunk)
|
|
10
|
+
? chunk
|
|
11
|
+
: Buffer.from(String(chunk), "utf8");
|
|
12
|
+
total += buffer.byteLength;
|
|
13
|
+
if (total > MAX_HOOK_INPUT_BYTES) {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
chunks.push(buffer);
|
|
17
|
+
}
|
|
18
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Runs one hook invocation. Always resolves to exit code 0: a hook that
|
|
22
|
+
* cannot answer must never block the harness it advises.
|
|
23
|
+
*/
|
|
24
|
+
export async function runHookProcess(argv, io, cliVersion) {
|
|
25
|
+
const [harness, event] = argv;
|
|
26
|
+
if (harness === undefined ||
|
|
27
|
+
!HARNESSES.has(harness) ||
|
|
28
|
+
event === undefined ||
|
|
29
|
+
!HOOK_EVENTS.includes(event)) {
|
|
30
|
+
io.writeStderr("Usage: agent-ops hook <codex|claude> <SessionStart|PreToolUse|Stop>\n");
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const root = process.cwd();
|
|
35
|
+
const config = (await loadEffectiveConfig(root, "project")).config;
|
|
36
|
+
const output = await runHookCommand({
|
|
37
|
+
harness: harness,
|
|
38
|
+
event: event,
|
|
39
|
+
stdin: await readStdin(io.stdin),
|
|
40
|
+
config,
|
|
41
|
+
trusted: (await repositoryTrust(root, config, cliVersion)) === "TRUSTED"
|
|
42
|
+
});
|
|
43
|
+
if (output.stdout.length > 0) {
|
|
44
|
+
io.writeStdout(output.stdout);
|
|
45
|
+
}
|
|
46
|
+
if (output.stderr.length > 0) {
|
|
47
|
+
io.writeStderr(output.stderr);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// ponytail: fail-open by design; hook failures stay invisible to the harness.
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// figlet "agent-ops" -f Standard, trimmed of trailing blank lines/columns.
|
|
2
|
+
const BANNER = [
|
|
3
|
+
" _",
|
|
4
|
+
" __ _ __ _ ___ _ __ | |_ ___ _ __ ___ ",
|
|
5
|
+
" / _\` |/ _\` |/ _ \\ '_ \\| __|____ / _ \\| '_ \\/ __|",
|
|
6
|
+
" | (_| | (_| | __/ | | | ||_____| (_) | |_) \\__ \\",
|
|
7
|
+
" \\__,_|\\__, |\\___|_| |_|\\__| \\___/| .__/|___/",
|
|
8
|
+
" |___/ |_|"
|
|
9
|
+
].join("\n");
|
|
10
|
+
const TAGLINE = "loop engineering toolkit";
|
|
11
|
+
const MIN_BANNER_COLUMNS = 54;
|
|
12
|
+
const RAIL = "\u2502";
|
|
13
|
+
const DIAMOND = "\u25c6";
|
|
14
|
+
const DOT_ON = "\u25cf";
|
|
15
|
+
const DOT_OFF = "\u25cb";
|
|
16
|
+
function envColorPreference() {
|
|
17
|
+
if (process.env.NO_COLOR !== undefined) {
|
|
18
|
+
return "off";
|
|
19
|
+
}
|
|
20
|
+
if (process.env.FORCE_COLOR !== undefined) {
|
|
21
|
+
return "on";
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
function useColor(output) {
|
|
26
|
+
const preference = envColorPreference();
|
|
27
|
+
return preference === undefined ? output.isTTY : preference === "on";
|
|
28
|
+
}
|
|
29
|
+
function paint(output, code, text) {
|
|
30
|
+
return useColor(output) ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
31
|
+
}
|
|
32
|
+
const dim = (output, text) => paint(output, "2", text);
|
|
33
|
+
const bold = (output, text) => paint(output, "1", text);
|
|
34
|
+
const green = (output, text) => paint(output, "32", text);
|
|
35
|
+
const cyan = (output, text) => paint(output, "36", text);
|
|
36
|
+
/**
|
|
37
|
+
* Decorative only: skipped for --json output, non-interactive runs, and
|
|
38
|
+
* terminals too narrow to render the wordmark without wrapping.
|
|
39
|
+
*/
|
|
40
|
+
export function writeBanner(output) {
|
|
41
|
+
if (!output.isTTY || (output.columns ?? 0) < MIN_BANNER_COLUMNS) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
output.write(`${cyan(output, BANNER)}\n${dim(output, TAGLINE)}\n\n`);
|
|
45
|
+
}
|
|
46
|
+
function eraseLines(write, count) {
|
|
47
|
+
for (let index = 0; index < count; index += 1) {
|
|
48
|
+
write("\u001b[1A\u001b[2K");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function renderChoice(outputLike, question, value) {
|
|
52
|
+
const yes = value
|
|
53
|
+
? bold(outputLike, green(outputLike, `${DOT_ON} Yes`))
|
|
54
|
+
: dim(outputLike, `${DOT_OFF} Yes`);
|
|
55
|
+
const no = !value
|
|
56
|
+
? bold(outputLike, green(outputLike, `${DOT_ON} No`))
|
|
57
|
+
: dim(outputLike, `${DOT_OFF} No`);
|
|
58
|
+
return [
|
|
59
|
+
`${bold(outputLike, DIAMOND)} ${question}`,
|
|
60
|
+
`${dim(outputLike, RAIL)} ${yes} / ${no}`
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
async function typedFallback(question, io, defaultValue) {
|
|
64
|
+
const { createInterface } = await import("node:readline/promises");
|
|
65
|
+
const readline = createInterface({ input: io.input, output: io.output });
|
|
66
|
+
try {
|
|
67
|
+
const suffix = defaultValue ? "Y/n" : "y/N";
|
|
68
|
+
const answer = (await readline.question(`${question} [${suffix}]: `)).trim().toLowerCase();
|
|
69
|
+
if (answer === "") {
|
|
70
|
+
return defaultValue;
|
|
71
|
+
}
|
|
72
|
+
return answer === "y" || answer === "yes";
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
readline.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Arrow-key Yes/No selector. Falls back to a typed y/N prompt when stdin
|
|
80
|
+
* cannot enter raw mode (piped input, or a stub stream in tests), so the
|
|
81
|
+
* same call works under both a real terminal and test harnesses.
|
|
82
|
+
*/
|
|
83
|
+
export async function selectYesNo(question, io, defaultValue = false) {
|
|
84
|
+
if (typeof io.input.setRawMode !== "function" ||
|
|
85
|
+
io.input.isTTY !== true) {
|
|
86
|
+
return await typedFallback(question, io, defaultValue);
|
|
87
|
+
}
|
|
88
|
+
const outputLike = {
|
|
89
|
+
isTTY: true,
|
|
90
|
+
columns: io.output.columns,
|
|
91
|
+
write: (value) => io.output.write(value)
|
|
92
|
+
};
|
|
93
|
+
const { emitKeypressEvents } = await import("node:readline");
|
|
94
|
+
emitKeypressEvents(io.input);
|
|
95
|
+
io.input.setRawMode(true);
|
|
96
|
+
io.input.resume();
|
|
97
|
+
let value = defaultValue;
|
|
98
|
+
let rendered = renderChoice(outputLike, question, value);
|
|
99
|
+
io.output.write(`${rendered}\n`);
|
|
100
|
+
return await new Promise((resolve) => {
|
|
101
|
+
const cleanup = () => {
|
|
102
|
+
io.input.setRawMode?.(false);
|
|
103
|
+
io.input.pause();
|
|
104
|
+
io.input.removeListener("keypress", onKeypress);
|
|
105
|
+
};
|
|
106
|
+
const redraw = () => {
|
|
107
|
+
eraseLines((value_) => io.output.write(value_), rendered.split("\n").length);
|
|
108
|
+
rendered = renderChoice(outputLike, question, value);
|
|
109
|
+
io.output.write(`${rendered}\n`);
|
|
110
|
+
};
|
|
111
|
+
const onKeypress = (_chunk, key) => {
|
|
112
|
+
if (key?.ctrl === true && key.name === "c") {
|
|
113
|
+
cleanup();
|
|
114
|
+
process.exit(130);
|
|
115
|
+
}
|
|
116
|
+
if (key?.name === "left" ||
|
|
117
|
+
key?.name === "right" ||
|
|
118
|
+
key?.name === "tab" ||
|
|
119
|
+
key?.name === "h" ||
|
|
120
|
+
key?.name === "l") {
|
|
121
|
+
value = !value;
|
|
122
|
+
redraw();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (key?.name === "return" || key?.name === "space") {
|
|
126
|
+
cleanup();
|
|
127
|
+
resolve(value);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
io.input.on("keypress", onKeypress);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -83,6 +83,25 @@ function hookRecord(settings) {
|
|
|
83
83
|
}
|
|
84
84
|
return settings.hooks;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Removes every agent-ops owned handler and leaves foreign hooks untouched.
|
|
88
|
+
*/
|
|
89
|
+
export function stripClaudeManagedHooks(existing) {
|
|
90
|
+
if (!isRecord(existing)) {
|
|
91
|
+
throw new AgentOpsError("CLAUDE_SETTINGS_INVALID", "Claude settings must be a JSON object.");
|
|
92
|
+
}
|
|
93
|
+
const existingHooks = hookRecord(existing);
|
|
94
|
+
const hooks = {};
|
|
95
|
+
for (const [eventName, groups] of Object.entries(existingHooks)) {
|
|
96
|
+
const preserved = groups
|
|
97
|
+
.map(withoutOwnedHandlers)
|
|
98
|
+
.filter((group) => group !== null);
|
|
99
|
+
if (preserved.length > 0) {
|
|
100
|
+
hooks[eventName] = preserved;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ...existing, hooks };
|
|
104
|
+
}
|
|
86
105
|
export function mergeClaudeSettings(existing, managed) {
|
|
87
106
|
if (!isRecord(existing)) {
|
|
88
107
|
throw new AgentOpsError("CLAUDE_SETTINGS_INVALID", "Claude settings must be a JSON object.");
|
|
@@ -67,6 +67,22 @@ function hookRecord(value) {
|
|
|
67
67
|
}
|
|
68
68
|
return value.hooks;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Removes every agent-ops owned handler and leaves foreign hooks untouched.
|
|
72
|
+
*/
|
|
73
|
+
export function stripCodexManagedHooks(existing) {
|
|
74
|
+
const existingHooks = hookRecord(existing);
|
|
75
|
+
const hooks = {};
|
|
76
|
+
for (const [eventName, groups] of Object.entries(existingHooks)) {
|
|
77
|
+
const preserved = groups
|
|
78
|
+
.map(withoutOwnedHandlers)
|
|
79
|
+
.filter((group) => group !== null);
|
|
80
|
+
if (preserved.length > 0) {
|
|
81
|
+
hooks[eventName] = preserved;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { ...existing, hooks };
|
|
85
|
+
}
|
|
70
86
|
export function mergeCodexHookConfig(existing, managed) {
|
|
71
87
|
if (!isRecord(existing)) {
|
|
72
88
|
throw new AgentOpsError("CODEX_HOOK_CONFIG_INVALID", "Codex hook configuration must be a JSON object.");
|
|
@@ -6,6 +6,17 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { sha256 } from "./hash.js";
|
|
7
7
|
import { AgentOpsError, resolveContainedPath } from "./paths.js";
|
|
8
8
|
export { AgentOpsError } from "./paths.js";
|
|
9
|
+
/**
|
|
10
|
+
* Identity must come from bigint stats. Windows file indexes pack a sequence
|
|
11
|
+
* number above the 2^53 boundary, so a numeric `ino` silently rounds and an
|
|
12
|
+
* untouched file looks like it changed.
|
|
13
|
+
*/
|
|
14
|
+
export function fileIdentity(status) {
|
|
15
|
+
return {
|
|
16
|
+
device: status.dev.toString(),
|
|
17
|
+
inode: status.ino.toString()
|
|
18
|
+
};
|
|
19
|
+
}
|
|
9
20
|
const MUTATION_WORKER_PATH = fileURLToPath(new URL("./mutation-worker.js", import.meta.url));
|
|
10
21
|
function isMissing(error) {
|
|
11
22
|
return (typeof error === "object" &&
|
|
@@ -25,10 +36,11 @@ async function captureParentGuard(parent) {
|
|
|
25
36
|
normalizedPath(canonicalPath) !== normalizedPath(parent)) {
|
|
26
37
|
throw new AgentOpsError("PRECONDITION_CHANGED", `Destination directory changed before mutation: ${parent}`);
|
|
27
38
|
}
|
|
39
|
+
const identity = fileIdentity(status);
|
|
28
40
|
return {
|
|
29
41
|
expectedParentPath: canonicalPath,
|
|
30
|
-
parentDevice:
|
|
31
|
-
parentInode:
|
|
42
|
+
parentDevice: identity.device,
|
|
43
|
+
parentInode: identity.inode
|
|
32
44
|
};
|
|
33
45
|
}
|
|
34
46
|
async function runAnchoredMutation(targetPath, action, expectedHash, content, mode) {
|
|
@@ -162,20 +174,21 @@ async function createBackup(snapshot, recoveryDirectory) {
|
|
|
162
174
|
async function snapshotOperation(root, operation) {
|
|
163
175
|
const targetPath = await resolveContainedPath(root, operation.path);
|
|
164
176
|
try {
|
|
165
|
-
const status = await lstat(targetPath);
|
|
177
|
+
const status = await lstat(targetPath, { bigint: true });
|
|
166
178
|
if (!status.isFile()) {
|
|
167
179
|
throw new AgentOpsError("UNSUPPORTED_FILE_TYPE", `Managed target must be a regular file: ${operation.path}`);
|
|
168
180
|
}
|
|
169
181
|
const content = await readFile(targetPath);
|
|
182
|
+
const identity = fileIdentity(status);
|
|
170
183
|
return {
|
|
171
184
|
operation,
|
|
172
185
|
targetPath,
|
|
173
186
|
existed: true,
|
|
174
187
|
content,
|
|
175
|
-
mode: status.mode & 0o777,
|
|
188
|
+
mode: Number(status.mode) & 0o777,
|
|
176
189
|
actualHash: sha256(content),
|
|
177
|
-
device:
|
|
178
|
-
inode:
|
|
190
|
+
device: identity.device,
|
|
191
|
+
inode: identity.inode,
|
|
179
192
|
backupPath: null,
|
|
180
193
|
createdDirectories: []
|
|
181
194
|
};
|
|
@@ -219,10 +232,7 @@ async function currentIdentity(path) {
|
|
|
219
232
|
if (!status.isFile() || status.isSymbolicLink()) {
|
|
220
233
|
throw new AgentOpsError("PRECONDITION_CHANGED", `A managed path is no longer a regular file: ${path}`);
|
|
221
234
|
}
|
|
222
|
-
return
|
|
223
|
-
device: status.dev.toString(),
|
|
224
|
-
inode: status.ino.toString()
|
|
225
|
-
};
|
|
235
|
+
return fileIdentity(status);
|
|
226
236
|
}
|
|
227
237
|
catch (error) {
|
|
228
238
|
if (isMissing(error)) {
|
|
@@ -165,9 +165,13 @@ async function checkProbe(id, probe) {
|
|
|
165
165
|
return check(id, "UNKNOWN", "No probe was provided.");
|
|
166
166
|
}
|
|
167
167
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
const result = await probe();
|
|
169
|
+
const status = typeof result === "boolean" ? (result ? "PASS" : "FAIL") : result;
|
|
170
|
+
return check(id, status, status === "PASS"
|
|
171
|
+
? "Probe passed."
|
|
172
|
+
: status === "FAIL"
|
|
173
|
+
? "Probe failed."
|
|
174
|
+
: "Probe has nothing to verify yet.");
|
|
171
175
|
}
|
|
172
176
|
catch {
|
|
173
177
|
return check(id, "FAIL", "Probe failed.");
|
|
@@ -57,7 +57,7 @@ export function commonHarnessAdapters() {
|
|
|
57
57
|
};
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
|
-
function requestedHarnessIds(harness) {
|
|
60
|
+
export function requestedHarnessIds(harness) {
|
|
61
61
|
return harness === "both" ? ["codex", "claude"] : [harness];
|
|
62
62
|
}
|
|
63
63
|
function selectAdapter(id, adapters) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { buildClaudeHookSettings, mergeClaudeSettings, stripClaudeManagedHooks } from "../adapters/claude/config.js";
|
|
2
|
+
import { buildCodexHookConfig, mergeCodexHookConfig, stripCodexManagedHooks } from "../adapters/codex/config.js";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
export const CLAUDE_HOOK_PATH = ".claude/settings.json";
|
|
5
|
+
export const CODEX_HOOK_PATH = ".codex/hooks.json";
|
|
6
|
+
function format(value) {
|
|
7
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
8
|
+
}
|
|
9
|
+
function parseSettings(path, source) {
|
|
10
|
+
if (source === null || source.trim().length === 0) {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(source);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
throw new AgentOpsError("HOOK_SETTINGS_INVALID_JSON", `Hook settings are not valid JSON: ${path}`, { cause: error });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function hookRegistrationPath(harness, scope) {
|
|
21
|
+
// ponytail: user scope resolves against AGENT_OPS_HOME, so the same
|
|
22
|
+
// relative path serves both scopes.
|
|
23
|
+
void scope;
|
|
24
|
+
return harness === "claude" ? CLAUDE_HOOK_PATH : CODEX_HOOK_PATH;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Builds the merged settings file for one harness, preserving foreign hooks.
|
|
28
|
+
* Returns null when the selected capabilities register no events at all.
|
|
29
|
+
*/
|
|
30
|
+
export function planHookRegistration(options) {
|
|
31
|
+
const path = hookRegistrationPath(options.harness, options.scope);
|
|
32
|
+
const managed = options.harness === "claude"
|
|
33
|
+
? buildClaudeHookSettings(options.capabilities, options.runtimePath)
|
|
34
|
+
: buildCodexHookConfig(options.capabilities);
|
|
35
|
+
const events = Object.keys(managed.hooks);
|
|
36
|
+
if (events.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const existing = parseSettings(path, options.currentSource);
|
|
40
|
+
const merged = options.harness === "claude"
|
|
41
|
+
? mergeClaudeSettings(existing, managed)
|
|
42
|
+
: mergeCodexHookConfig(existing, managed);
|
|
43
|
+
return {
|
|
44
|
+
content: format(merged),
|
|
45
|
+
record: {
|
|
46
|
+
id: `${options.harness}-hooks`,
|
|
47
|
+
path,
|
|
48
|
+
harness: options.harness,
|
|
49
|
+
events,
|
|
50
|
+
owner: "agent-ops"
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function onlyManagedRemains(harness, value) {
|
|
55
|
+
const ownKeys = new Set(harness === "codex" ? ["hooks", "description"] : ["hooks"]);
|
|
56
|
+
const hooks = value.hooks;
|
|
57
|
+
return (Object.keys(value).every((key) => ownKeys.has(key)) &&
|
|
58
|
+
typeof hooks === "object" &&
|
|
59
|
+
hooks !== null &&
|
|
60
|
+
Object.keys(hooks).length === 0);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Strips owned handlers from a hook settings file. The file is removed only
|
|
64
|
+
* when nothing but agent-ops content is left, so foreign settings survive.
|
|
65
|
+
*/
|
|
66
|
+
export function planHookRemoval(record, currentSource) {
|
|
67
|
+
const existing = parseSettings(record.path, currentSource);
|
|
68
|
+
const stripped = record.harness === "claude"
|
|
69
|
+
? stripClaudeManagedHooks(existing)
|
|
70
|
+
: stripCodexManagedHooks(existing);
|
|
71
|
+
return {
|
|
72
|
+
path: record.path,
|
|
73
|
+
content: onlyManagedRemains(record.harness, stripped)
|
|
74
|
+
? null
|
|
75
|
+
: format(stripped)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { applyManagedBlock, managedBlockMarkers } from "../fs/managed-block.js";
|
|
2
2
|
import { AgentOpsError } from "../fs/paths.js";
|
|
3
3
|
import { COMMON_AGENTS_BLOCK, COMMON_CLAUDE_BLOCK } from "./harness.js";
|
|
4
|
+
import { hookRegistrationPath } from "./hooks.js";
|
|
4
5
|
function selectedHarnesses(manifest) {
|
|
5
6
|
return manifest.harness === "both"
|
|
6
7
|
? ["codex", "claude"]
|
|
@@ -24,6 +25,24 @@ function expectedMarker(manifest, id) {
|
|
|
24
25
|
function manifestOwnershipError() {
|
|
25
26
|
return new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", "The manifest does not match a supported managed installation shape.");
|
|
26
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Hook records are optional: installations without hook capabilities, and
|
|
30
|
+
* manifests written before hook registration existed, carry none.
|
|
31
|
+
*/
|
|
32
|
+
function assertSupportedHookRecords(manifest, harnesses) {
|
|
33
|
+
const selected = new Set(harnesses);
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
for (const hook of manifest.hooks ?? []) {
|
|
36
|
+
if (!selected.has(hook.harness) ||
|
|
37
|
+
seen.has(hook.harness) ||
|
|
38
|
+
hook.id !== `${hook.harness}-hooks` ||
|
|
39
|
+
hook.path !== hookRegistrationPath(hook.harness, manifest.scope) ||
|
|
40
|
+
hook.events.length === 0) {
|
|
41
|
+
throw manifestOwnershipError();
|
|
42
|
+
}
|
|
43
|
+
seen.add(hook.harness);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
27
46
|
export function assertSupportedManifestOwnership(manifest) {
|
|
28
47
|
const harnesses = selectedHarnesses(manifest);
|
|
29
48
|
const expectedArtifacts = new Map([
|
|
@@ -53,6 +72,7 @@ export function assertSupportedManifestOwnership(manifest) {
|
|
|
53
72
|
throw manifestOwnershipError();
|
|
54
73
|
}
|
|
55
74
|
}
|
|
75
|
+
assertSupportedHookRecords(manifest, harnesses);
|
|
56
76
|
return expectedMarkers;
|
|
57
77
|
}
|
|
58
78
|
function exactMarkerCount(source, marker) {
|
|
@@ -5,7 +5,8 @@ import { applyManagedBlock, managedBlockMarkers } from "../fs/managed-block.js";
|
|
|
5
5
|
import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
|
|
6
6
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
7
7
|
import { validateConfig } from "../schema/validate.js";
|
|
8
|
-
import { planHarnessContributions } from "./harness.js";
|
|
8
|
+
import { planHarnessContributions, requestedHarnessIds } from "./harness.js";
|
|
9
|
+
import { hookRegistrationPath, planHookRegistration } from "./hooks.js";
|
|
9
10
|
import { resolveProfiles } from "./profiles.js";
|
|
10
11
|
const CONFIG_PATH = ".agent-ops/config.json";
|
|
11
12
|
const MANIFEST_PATH = ".agent-ops/manifest.json";
|
|
@@ -233,12 +234,36 @@ export async function createInstallPlan(options) {
|
|
|
233
234
|
}
|
|
234
235
|
const plannedBlocks = await planBlocks(options.root, contribution.blocks);
|
|
235
236
|
operations.push(...plannedBlocks.operations);
|
|
237
|
+
const hooks = [];
|
|
238
|
+
if (options.hookRuntimePath !== undefined) {
|
|
239
|
+
for (const harness of requestedHarnessIds(options.harness)) {
|
|
240
|
+
const current = await readCurrentFile(options.root, hookRegistrationPath(harness, options.scope));
|
|
241
|
+
const planned = planHookRegistration({
|
|
242
|
+
harness,
|
|
243
|
+
scope: options.scope,
|
|
244
|
+
capabilities: resolved.capabilities,
|
|
245
|
+
runtimePath: options.hookRuntimePath,
|
|
246
|
+
currentSource: current?.content ?? null
|
|
247
|
+
});
|
|
248
|
+
if (planned === null) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
operations.push({
|
|
252
|
+
kind: "write",
|
|
253
|
+
path: planned.record.path,
|
|
254
|
+
content: planned.content,
|
|
255
|
+
expectedHash: current?.hash ?? null
|
|
256
|
+
});
|
|
257
|
+
hooks.push(planned.record);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
236
260
|
const manifest = {
|
|
237
261
|
schemaVersion: SCHEMA_VERSION,
|
|
238
262
|
scope: options.scope,
|
|
239
263
|
harness: options.harness,
|
|
240
264
|
artifacts,
|
|
241
|
-
markers: plannedBlocks.records
|
|
265
|
+
markers: plannedBlocks.records,
|
|
266
|
+
...(hooks.length === 0 ? {} : { hooks })
|
|
242
267
|
};
|
|
243
268
|
operations.push({
|
|
244
269
|
kind: "write",
|