@kylecheng3146/agent-ops 0.1.3 → 0.1.5
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 +88 -13
- package/dist/packages/cli/src/args.js +39 -6
- package/dist/packages/cli/src/bin.js +33 -11
- package/dist/packages/cli/src/cli.js +8 -8
- package/dist/packages/cli/src/commands/doctor.js +31 -9
- package/dist/packages/cli/src/commands/hook.js +18 -16
- package/dist/packages/cli/src/commands/init.js +29 -7
- package/dist/packages/cli/src/commands/review.js +5 -1
- package/dist/packages/cli/src/commands/uninstall.js +6 -5
- package/dist/packages/cli/src/commands/update.js +13 -5
- package/dist/packages/cli/src/context.js +9 -3
- package/dist/packages/cli/src/hook-process.js +109 -7
- package/dist/packages/cli/src/plan-output.js +9 -4
- package/dist/packages/cli/src/public-plan.js +62 -0
- package/dist/packages/cli/src/ui.js +242 -1
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +68 -2
- package/dist/runtime/src/adapters/claude/config.js +0 -8
- package/dist/runtime/src/adapters/claude/events.js +26 -0
- package/dist/runtime/src/adapters/claude/output.js +6 -6
- package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
- package/dist/runtime/src/adapters/codex/config.js +29 -11
- package/dist/runtime/src/adapters/codex/events.js +26 -0
- package/dist/runtime/src/adapters/codex/output.js +10 -0
- package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
- package/dist/runtime/src/adapters/opencode/config.js +170 -0
- package/dist/runtime/src/adapters/opencode/events.js +49 -0
- package/dist/runtime/src/adapters/opencode/input.js +32 -0
- package/dist/runtime/src/adapters/opencode/output.js +23 -0
- package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
- package/dist/runtime/src/config/explain.js +7 -0
- package/dist/runtime/src/config/hash.js +24 -0
- package/dist/runtime/src/config/merge.js +6 -2
- package/dist/runtime/src/config/migrate.js +19 -4
- package/dist/runtime/src/contracts.js +10 -1
- package/dist/runtime/src/fs/manifest.js +32 -1
- package/dist/runtime/src/fs/transaction.js +14 -2
- package/dist/runtime/src/hooks/advisory.js +16 -0
- package/dist/runtime/src/hooks/stop-service.js +70 -0
- package/dist/runtime/src/hooks/stop-verify.js +4 -1
- package/dist/runtime/src/install/doctor.js +119 -9
- package/dist/runtime/src/install/harness.js +296 -35
- package/dist/runtime/src/install/hooks.js +22 -17
- package/dist/runtime/src/install/ownership.js +110 -34
- package/dist/runtime/src/install/plan.js +207 -28
- package/dist/runtime/src/install/probes.js +9 -43
- package/dist/runtime/src/install/profiles.js +8 -1
- package/dist/runtime/src/install/surface-inspection.js +296 -0
- package/dist/runtime/src/install/surfaces.js +11 -0
- package/dist/runtime/src/install/uninstall.js +10 -3
- package/dist/runtime/src/install/update.js +8 -2
- package/dist/runtime/src/schema/validate.js +37 -18
- package/dist/runtime/src/task/service.js +3 -3
- package/dist/runtime/src/task/store.js +12 -3
- package/dist/runtime/src/verify/command-executor.js +113 -0
- package/dist/runtime/src/verify/evidence.js +4 -24
- package/dist/runtime/src/verify/service.js +20 -92
- package/dist/runtime/src/verify/spawn.js +6 -1
- package/docs/en/guides/configuration.md +83 -0
- package/docs/en/guides/quickstart.md +9 -0
- package/docs/en/guides/security.md +5 -0
- package/docs/en/spec/README.md +9 -0
- package/docs/en/spec/harness-adapters.md +66 -2
- package/docs/en/spec/maintenance.md +11 -0
- package/docs/en/spec/review.md +11 -0
- package/docs/zh-TW/guides/configuration.md +77 -0
- package/docs/zh-TW/guides/quickstart.md +9 -0
- package/docs/zh-TW/guides/security.md +5 -0
- package/docs/zh-TW/spec/README.md +9 -0
- package/docs/zh-TW/spec/harness-adapters.md +57 -3
- package/docs/zh-TW/spec/maintenance.md +11 -1
- package/docs/zh-TW/spec/review.md +10 -0
- package/package.json +8 -4
- package/postinstall.cjs +102 -0
- package/schemas/config.schema.json +55 -1
- package/schemas/manifest.schema.json +7 -2
- package/templates/common/AGENTS.block.md +2 -1
- package/templates/common/CLAUDE.block.md +2 -1
- package/dist/runtime/src/review/claude-runner.js +0 -4
- package/dist/runtime/src/review/codex-runner.js +0 -4
|
@@ -1,7 +1,32 @@
|
|
|
1
1
|
import { CliArgumentError } from "./args.js";
|
|
2
|
+
import { HARNESS_IDS, resolveHarnessSelection } from "../../../runtime/src/install/harness.js";
|
|
3
|
+
import { selectOption, selectOptions } from "./ui.js";
|
|
2
4
|
const SCOPES = new Set(["project", "user"]);
|
|
3
|
-
const HARNESSES = new Set(["both", "claude", "codex"]);
|
|
4
5
|
const PROFILES = new Set(["advisory", "core", "guardrails"]);
|
|
6
|
+
const DEFAULT_HARNESS = [];
|
|
7
|
+
const SCOPE_CHOICES = [
|
|
8
|
+
{ label: "project", value: "project" },
|
|
9
|
+
{ label: "user", value: "user" }
|
|
10
|
+
];
|
|
11
|
+
const HARNESS_CHOICES = HARNESS_IDS.map((id) => ({ label: id, value: id }));
|
|
12
|
+
const PROFILE_CHOICES = [
|
|
13
|
+
{
|
|
14
|
+
label: "core",
|
|
15
|
+
value: "core",
|
|
16
|
+
description: "Base rules, task tracking, verification, and review guidance."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
label: "advisory",
|
|
20
|
+
value: "advisory",
|
|
21
|
+
description: "Adds informational SessionStart summaries and local logs; never blocks."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
label: "guardrails",
|
|
25
|
+
value: "guardrails",
|
|
26
|
+
description: "Blocks high-confidence unsafe commands; Stop verification is a separate opt-in feature."
|
|
27
|
+
}
|
|
28
|
+
];
|
|
29
|
+
const WIZARD_SUBTITLE = "Safe setup for Codex, Claude Code, and opencode with profile-driven rules, verification, and hooks.";
|
|
5
30
|
async function createPromptSession(io) {
|
|
6
31
|
if (!io.isTTY) {
|
|
7
32
|
throw new CliArgumentError("CLI_INTERACTIVE_REQUIRED", "Missing init choices require an interactive terminal.");
|
|
@@ -32,6 +57,16 @@ function selectValue(raw, fallback, allowed, label) {
|
|
|
32
57
|
}
|
|
33
58
|
return value;
|
|
34
59
|
}
|
|
60
|
+
function selectHarness(raw) {
|
|
61
|
+
if (raw.trim() === "") {
|
|
62
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", "Choose at least one harness.");
|
|
63
|
+
}
|
|
64
|
+
const selection = resolveHarnessSelection(raw);
|
|
65
|
+
if (selection === null) {
|
|
66
|
+
throw new CliArgumentError("CLI_INVALID_VALUE", `Harness must be a unique comma-separated list of ${HARNESS_IDS.join(", ")}.`);
|
|
67
|
+
}
|
|
68
|
+
return selection;
|
|
69
|
+
}
|
|
35
70
|
function selectProfiles(raw) {
|
|
36
71
|
const values = raw.trim() === ""
|
|
37
72
|
? ["core"]
|
|
@@ -53,12 +88,43 @@ export async function completeInitChoices(args, io) {
|
|
|
53
88
|
if (!io.isTTY) {
|
|
54
89
|
throw new CliArgumentError("CLI_INTERACTIVE_REQUIRED", "Non-interactive init requires --scope, --harness, and at least one --profile.");
|
|
55
90
|
}
|
|
91
|
+
const canUseRawSelectors = io.input !== undefined &&
|
|
92
|
+
io.output !== undefined &&
|
|
93
|
+
typeof io.input.setRawMode === "function" &&
|
|
94
|
+
io.input.isTTY === true;
|
|
95
|
+
if (canUseRawSelectors) {
|
|
96
|
+
const selectorIo = {
|
|
97
|
+
input: io.input,
|
|
98
|
+
output: io.output
|
|
99
|
+
};
|
|
100
|
+
selectorIo.output.write(`${WIZARD_SUBTITLE}\n\n`);
|
|
101
|
+
const scope = args.scope ?? await selectOption("Scope", SCOPE_CHOICES, selectorIo);
|
|
102
|
+
const harness = args.harness ??
|
|
103
|
+
await selectOptions("Harness (multi-select: ↑↓ move, Space toggle, Enter confirm)", HARNESS_CHOICES, selectorIo, [...DEFAULT_HARNESS], {
|
|
104
|
+
selectAll: true,
|
|
105
|
+
selectAllLabel: "Select all",
|
|
106
|
+
selectAllDescription: "Install for every supported harness."
|
|
107
|
+
});
|
|
108
|
+
const profiles = args.profiles.length > 0
|
|
109
|
+
? args.profiles
|
|
110
|
+
: await selectOptions("Profiles (multi-select: ↑↓ move, Space toggle, Enter confirm)", PROFILE_CHOICES, selectorIo, [], {
|
|
111
|
+
selectAll: true,
|
|
112
|
+
selectAllLabel: "Select all",
|
|
113
|
+
selectAllDescription: "Enable core, advisory, and guardrails together."
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
...args,
|
|
117
|
+
scope,
|
|
118
|
+
harness,
|
|
119
|
+
profiles
|
|
120
|
+
};
|
|
121
|
+
}
|
|
56
122
|
const session = await createPromptSession(io);
|
|
57
123
|
try {
|
|
58
124
|
const scope = args.scope ??
|
|
59
125
|
selectValue(await session.question("Scope (project/user) [project]: "), "project", SCOPES, "scope");
|
|
60
126
|
const harness = args.harness ??
|
|
61
|
-
|
|
127
|
+
selectHarness(await session.question(`Harness (${HARNESS_IDS.join(",")}): `));
|
|
62
128
|
const profiles = args.profiles.length > 0
|
|
63
129
|
? args.profiles
|
|
64
130
|
: selectProfiles(await session.question("Profiles (core,advisory,guardrails) [core]: "));
|
|
@@ -10,14 +10,6 @@ export function claudeSettingsTarget(scope) {
|
|
|
10
10
|
requiresWorkspaceTrust: false
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
-
export function claudeRoutingBlock() {
|
|
14
|
-
return [
|
|
15
|
-
"## Loop Engineering",
|
|
16
|
-
"",
|
|
17
|
-
"Load `.agent-ops/CLAUDE.md` as concise project context.",
|
|
18
|
-
""
|
|
19
|
-
].join("\n");
|
|
20
|
-
}
|
|
21
13
|
function commandHook(event, runtimePath) {
|
|
22
14
|
return {
|
|
23
15
|
type: "command",
|
|
@@ -6,3 +6,29 @@ export const CLAUDE_SUPPORTED_EVENTS = [
|
|
|
6
6
|
export function claudeNonInteractiveTrust(printMode) {
|
|
7
7
|
return printMode ? "dialog-skipped" : "interactive-dialog";
|
|
8
8
|
}
|
|
9
|
+
export const CLAUDE_CAPABILITY_REGISTRATIONS = [
|
|
10
|
+
{
|
|
11
|
+
capability: "lifecycle-summary",
|
|
12
|
+
normalizedEvent: "session-start",
|
|
13
|
+
nativeEvent: "SessionStart",
|
|
14
|
+
surfaceId: "claude-settings",
|
|
15
|
+
support: "supported",
|
|
16
|
+
runtimeFailure: "fail-open"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
capability: "command-policy",
|
|
20
|
+
normalizedEvent: "command",
|
|
21
|
+
nativeEvent: "PreToolUse",
|
|
22
|
+
surfaceId: "claude-settings",
|
|
23
|
+
support: "supported",
|
|
24
|
+
runtimeFailure: "fail-closed"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
capability: "optional-stop-verify",
|
|
28
|
+
normalizedEvent: "stop",
|
|
29
|
+
nativeEvent: "Stop",
|
|
30
|
+
surfaceId: "claude-settings",
|
|
31
|
+
support: "supported",
|
|
32
|
+
runtimeFailure: "fail-open"
|
|
33
|
+
}
|
|
34
|
+
];
|
|
@@ -6,6 +6,12 @@ function json(value) {
|
|
|
6
6
|
};
|
|
7
7
|
}
|
|
8
8
|
export function claudeHookOutput(event, result) {
|
|
9
|
+
if (event === "Stop" && result.evidence !== undefined) {
|
|
10
|
+
return json({
|
|
11
|
+
systemMessage: `agent-ops: ${result.code}`,
|
|
12
|
+
evidence: result.evidence
|
|
13
|
+
});
|
|
14
|
+
}
|
|
9
15
|
if (result.action === "continue" && result.status === "PASS") {
|
|
10
16
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
11
17
|
}
|
|
@@ -18,12 +24,6 @@ export function claudeHookOutput(event, result) {
|
|
|
18
24
|
}
|
|
19
25
|
});
|
|
20
26
|
}
|
|
21
|
-
if (event === "Stop" && result.action === "block") {
|
|
22
|
-
return json({
|
|
23
|
-
decision: "block",
|
|
24
|
-
reason: result.code
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
27
|
return json({
|
|
28
28
|
systemMessage: `agent-ops: ${result.code}`
|
|
29
29
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const CLAUDE_SURFACE_IDS = {
|
|
2
|
+
settings: "claude-settings",
|
|
3
|
+
projectLocal: "project-local",
|
|
4
|
+
userSettings: "user-settings",
|
|
5
|
+
plugin: "plugin"
|
|
6
|
+
};
|
|
7
|
+
export function claudeSurfaces(scope) {
|
|
8
|
+
if (scope === "project") {
|
|
9
|
+
return [
|
|
10
|
+
{
|
|
11
|
+
id: CLAUDE_SURFACE_IDS.settings,
|
|
12
|
+
path: ".claude/settings.json",
|
|
13
|
+
scope: "project",
|
|
14
|
+
access: "managed-default",
|
|
15
|
+
representation: "json"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: CLAUDE_SURFACE_IDS.projectLocal,
|
|
19
|
+
path: ".claude/settings.local.json",
|
|
20
|
+
scope: "project",
|
|
21
|
+
access: "managed-opt-in",
|
|
22
|
+
representation: "json"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: CLAUDE_SURFACE_IDS.userSettings,
|
|
26
|
+
path: "~/.claude/settings.json",
|
|
27
|
+
scope: "external",
|
|
28
|
+
access: "inspect-only",
|
|
29
|
+
representation: "json"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: CLAUDE_SURFACE_IDS.plugin,
|
|
33
|
+
path: "~/.claude/plugins",
|
|
34
|
+
scope: "external",
|
|
35
|
+
access: "inspect-only",
|
|
36
|
+
representation: "javascript"
|
|
37
|
+
}
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
id: CLAUDE_SURFACE_IDS.settings,
|
|
43
|
+
path: ".claude/settings.json",
|
|
44
|
+
scope: "user",
|
|
45
|
+
access: "managed-default",
|
|
46
|
+
representation: "json"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: CLAUDE_SURFACE_IDS.projectLocal,
|
|
50
|
+
path: "project/.claude/settings.local.json",
|
|
51
|
+
scope: "external",
|
|
52
|
+
access: "inspect-only",
|
|
53
|
+
representation: "json"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: CLAUDE_SURFACE_IDS.userSettings,
|
|
57
|
+
path: "project/.claude/settings.json",
|
|
58
|
+
scope: "external",
|
|
59
|
+
access: "inspect-only",
|
|
60
|
+
representation: "json"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: CLAUDE_SURFACE_IDS.plugin,
|
|
64
|
+
path: "~/.claude/plugins",
|
|
65
|
+
scope: "external",
|
|
66
|
+
access: "inspect-only",
|
|
67
|
+
representation: "javascript"
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
}
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { AgentOpsError } from "../../fs/paths.js";
|
|
2
|
-
const
|
|
2
|
+
export const CODEX_MANAGED_MARKER = "--managed-by=agent-ops";
|
|
3
|
+
/**
|
|
4
|
+
* Releases up to 0.1.4 registered a bare `agent-ops` command resolved through
|
|
5
|
+
* PATH. Detection still recognizes it so an update replaces it instead of
|
|
6
|
+
* leaving the hijackable handler behind.
|
|
7
|
+
*/
|
|
8
|
+
const LEGACY_COMMAND_PREFIX = "agent-ops hook codex ";
|
|
3
9
|
function isRecord(value) {
|
|
4
10
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
11
|
}
|
|
6
|
-
function
|
|
7
|
-
|
|
12
|
+
function assertRuntimePath(runtimePath) {
|
|
13
|
+
if (runtimePath.length === 0 ||
|
|
14
|
+
runtimePath.length > 4096 ||
|
|
15
|
+
runtimePath.includes("\0") ||
|
|
16
|
+
// The command is a single shell string, so a quote would break the
|
|
17
|
+
// argument boundary the surrounding quotes establish.
|
|
18
|
+
runtimePath.includes('"')) {
|
|
19
|
+
throw new AgentOpsError("CODEX_HOOK_PATH_INVALID", "Codex hook runtime path is invalid.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function commandHook(event, runtimePath) {
|
|
23
|
+
const command = `node "${runtimePath}" codex ${event} ${CODEX_MANAGED_MARKER}`;
|
|
8
24
|
return {
|
|
9
25
|
type: "command",
|
|
10
26
|
command,
|
|
@@ -13,10 +29,10 @@ function commandHook(event) {
|
|
|
13
29
|
statusMessage: `Running agent-ops ${event}`
|
|
14
30
|
};
|
|
15
31
|
}
|
|
16
|
-
function matcherGroup(event) {
|
|
32
|
+
function matcherGroup(event, runtimePath) {
|
|
17
33
|
return {
|
|
18
34
|
...(event === "PreToolUse" ? { matcher: "^Bash$" } : {}),
|
|
19
|
-
hooks: [commandHook(event)]
|
|
35
|
+
hooks: [commandHook(event, runtimePath)]
|
|
20
36
|
};
|
|
21
37
|
}
|
|
22
38
|
export function codexHookTarget(scope) {
|
|
@@ -26,26 +42,28 @@ export function codexHookTarget(scope) {
|
|
|
26
42
|
requiresProjectTrust: scope === "project"
|
|
27
43
|
};
|
|
28
44
|
}
|
|
29
|
-
export function buildCodexHookConfig(capabilities) {
|
|
45
|
+
export function buildCodexHookConfig(capabilities, runtimePath) {
|
|
46
|
+
assertRuntimePath(runtimePath);
|
|
30
47
|
const hooks = {};
|
|
31
48
|
if (capabilities.includes("lifecycle-summary")) {
|
|
32
|
-
hooks.SessionStart = [matcherGroup("SessionStart")];
|
|
49
|
+
hooks.SessionStart = [matcherGroup("SessionStart", runtimePath)];
|
|
33
50
|
}
|
|
34
51
|
if (capabilities.includes("command-policy")) {
|
|
35
|
-
hooks.PreToolUse = [matcherGroup("PreToolUse")];
|
|
52
|
+
hooks.PreToolUse = [matcherGroup("PreToolUse", runtimePath)];
|
|
36
53
|
}
|
|
37
54
|
if (capabilities.includes("optional-stop-verify")) {
|
|
38
|
-
hooks.Stop = [matcherGroup("Stop")];
|
|
55
|
+
hooks.Stop = [matcherGroup("Stop", runtimePath)];
|
|
39
56
|
}
|
|
40
57
|
return {
|
|
41
|
-
description: "
|
|
58
|
+
description: "agent-ops lifecycle hooks.",
|
|
42
59
|
hooks
|
|
43
60
|
};
|
|
44
61
|
}
|
|
45
62
|
function isOwnedHandler(hook) {
|
|
46
63
|
return (isRecord(hook) &&
|
|
47
64
|
typeof hook.command === "string" &&
|
|
48
|
-
hook.command.
|
|
65
|
+
(hook.command.includes(CODEX_MANAGED_MARKER) ||
|
|
66
|
+
hook.command.startsWith(LEGACY_COMMAND_PREFIX)));
|
|
49
67
|
}
|
|
50
68
|
function withoutOwnedHandlers(value) {
|
|
51
69
|
if (!isRecord(value) || !Array.isArray(value.hooks)) {
|
|
@@ -6,3 +6,29 @@ export const CODEX_SUPPORTED_EVENTS = [
|
|
|
6
6
|
export function codexMatcherSupport(event) {
|
|
7
7
|
return event === "PreToolUse" ? "tool-name" : "unsupported";
|
|
8
8
|
}
|
|
9
|
+
export const CODEX_CAPABILITY_REGISTRATIONS = [
|
|
10
|
+
{
|
|
11
|
+
capability: "lifecycle-summary",
|
|
12
|
+
normalizedEvent: "session-start",
|
|
13
|
+
nativeEvent: "SessionStart",
|
|
14
|
+
surfaceId: "codex-hooks",
|
|
15
|
+
support: "supported",
|
|
16
|
+
runtimeFailure: "fail-open"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
capability: "command-policy",
|
|
20
|
+
normalizedEvent: "command",
|
|
21
|
+
nativeEvent: "PreToolUse",
|
|
22
|
+
surfaceId: "codex-hooks",
|
|
23
|
+
support: "unknown",
|
|
24
|
+
runtimeFailure: "native-unknown"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
capability: "optional-stop-verify",
|
|
28
|
+
normalizedEvent: "stop",
|
|
29
|
+
nativeEvent: "Stop",
|
|
30
|
+
surfaceId: "codex-hooks",
|
|
31
|
+
support: "unsupported",
|
|
32
|
+
runtimeFailure: "fail-open"
|
|
33
|
+
}
|
|
34
|
+
];
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
export const CODEX_NON_ZERO_EXIT_BEHAVIOR = "UNKNOWN";
|
|
2
2
|
export const CODEX_PRE_TOOL_BLOCKING = "UNKNOWN";
|
|
3
3
|
export function codexHookOutput(event, result) {
|
|
4
|
+
if (event === "Stop" && result.evidence !== undefined) {
|
|
5
|
+
return {
|
|
6
|
+
exitCode: 0,
|
|
7
|
+
stdout: JSON.stringify({
|
|
8
|
+
continue: true,
|
|
9
|
+
systemMessage: `agent-ops: ${result.code}`,
|
|
10
|
+
evidence: result.evidence
|
|
11
|
+
})
|
|
12
|
+
};
|
|
13
|
+
}
|
|
4
14
|
if (result.action === "continue" && result.status === "PASS") {
|
|
5
15
|
return { exitCode: 0, stdout: "" };
|
|
6
16
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { AgentOpsError } from "../../fs/paths.js";
|
|
4
|
+
export const OPENCODE_MANAGED_MARKER = "--managed-by=agent-ops";
|
|
5
|
+
export const OPENCODE_PLUGIN_PATH = ".opencode/plugins/agent-ops.js";
|
|
6
|
+
export const OPENCODE_USER_PLUGIN_PATH = ".config/opencode/plugins/agent-ops.js";
|
|
7
|
+
function userPluginPath(root, configuredHome = process.env.XDG_CONFIG_HOME, configuredDirectory = process.env.OPENCODE_CONFIG_DIR) {
|
|
8
|
+
const installationRoot = resolve(root ?? process.env.AGENT_OPS_HOME ?? homedir());
|
|
9
|
+
const customConfigDirectory = configuredDirectory?.trim();
|
|
10
|
+
const configDirectory = customConfigDirectory !== undefined && customConfigDirectory.length > 0
|
|
11
|
+
? customConfigDirectory
|
|
12
|
+
: configuredHome === undefined || configuredHome.trim().length === 0
|
|
13
|
+
? join(installationRoot, ".config", "opencode")
|
|
14
|
+
: join(configuredHome, "opencode");
|
|
15
|
+
if (!isAbsolute(configDirectory)) {
|
|
16
|
+
throw new AgentOpsError("OPENCODE_CONFIG_PATH_INVALID", "OpenCode's config directory must be an absolute path for user-scope installation.");
|
|
17
|
+
}
|
|
18
|
+
const target = resolve(configDirectory, "plugins", "agent-ops.js");
|
|
19
|
+
const relativeTarget = relative(installationRoot, target);
|
|
20
|
+
if (relativeTarget.length === 0 ||
|
|
21
|
+
relativeTarget === ".." ||
|
|
22
|
+
relativeTarget.startsWith(`..${sep}`) ||
|
|
23
|
+
isAbsolute(relativeTarget)) {
|
|
24
|
+
throw new AgentOpsError("OPENCODE_CONFIG_PATH_INVALID", "OpenCode's config directory must stay inside the managed user-scope root.");
|
|
25
|
+
}
|
|
26
|
+
return relativeTarget.split(sep).join("/");
|
|
27
|
+
}
|
|
28
|
+
export function opencodePluginTarget(scope, root, configuredHome, configuredDirectory) {
|
|
29
|
+
return {
|
|
30
|
+
path: scope === "project"
|
|
31
|
+
? OPENCODE_PLUGIN_PATH
|
|
32
|
+
: root === undefined && configuredHome === undefined &&
|
|
33
|
+
configuredDirectory === undefined &&
|
|
34
|
+
process.env.XDG_CONFIG_HOME === undefined &&
|
|
35
|
+
process.env.OPENCODE_CONFIG_DIR === undefined
|
|
36
|
+
? OPENCODE_USER_PLUGIN_PATH
|
|
37
|
+
: userPluginPath(root, configuredHome, configuredDirectory),
|
|
38
|
+
representation: "javascript",
|
|
39
|
+
requiresProjectTrust: scope === "project"
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validates the persisted plugin shape without consulting the current
|
|
44
|
+
* environment. User-scope installations may use a custom config directory,
|
|
45
|
+
* so their manifest path is allowed to have any safe prefix but must still
|
|
46
|
+
* name OpenCode's plugin entrypoint.
|
|
47
|
+
*/
|
|
48
|
+
export function isOpencodePluginPath(scope, path) {
|
|
49
|
+
if (scope === "project") {
|
|
50
|
+
return path === OPENCODE_PLUGIN_PATH;
|
|
51
|
+
}
|
|
52
|
+
return /^(?:[A-Za-z0-9._-]+\/)*plugins\/agent-ops\.js$/u.test(path);
|
|
53
|
+
}
|
|
54
|
+
function isAbsoluteRuntimePath(value) {
|
|
55
|
+
return (value.startsWith("/") ||
|
|
56
|
+
/^[A-Za-z]:[\\/]/u.test(value) ||
|
|
57
|
+
value.startsWith("\\\\"));
|
|
58
|
+
}
|
|
59
|
+
function assertRuntimePath(runtimePath) {
|
|
60
|
+
if (!isAbsoluteRuntimePath(runtimePath) ||
|
|
61
|
+
runtimePath.length > 4096 ||
|
|
62
|
+
runtimePath.includes("\0") ||
|
|
63
|
+
runtimePath.includes("\n") ||
|
|
64
|
+
runtimePath.includes("\r")) {
|
|
65
|
+
throw new AgentOpsError("OPENCODE_PLUGIN_PATH_INVALID", "opencode plugin runtime path must be absolute and valid.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function opencodePluginEvents(capabilities) {
|
|
69
|
+
const events = [];
|
|
70
|
+
if (capabilities.includes("lifecycle-summary")) {
|
|
71
|
+
events.push("SessionStart");
|
|
72
|
+
}
|
|
73
|
+
if (capabilities.includes("command-policy")) {
|
|
74
|
+
events.push("PreToolUse");
|
|
75
|
+
}
|
|
76
|
+
if (capabilities.includes("optional-stop-verify")) {
|
|
77
|
+
events.push("Stop");
|
|
78
|
+
}
|
|
79
|
+
return events;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Generates the dependency-free plugin installed under the selected local
|
|
83
|
+
* or user plugin directory.
|
|
84
|
+
* The only host dependency is opencode's Bun `$` shell tag, supplied by the
|
|
85
|
+
* plugin context. Keeping the shell function in the context makes the source
|
|
86
|
+
* importable under Node tests with a small fake.
|
|
87
|
+
*/
|
|
88
|
+
export function buildOpencodePlugin(capabilities, runtimePath) {
|
|
89
|
+
const events = opencodePluginEvents(capabilities);
|
|
90
|
+
if (events.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
assertRuntimePath(runtimePath);
|
|
94
|
+
const lines = [
|
|
95
|
+
"// Managed by agent-ops. Do not edit: `agent-ops update` rewrites this file.",
|
|
96
|
+
`const RUNTIME_PATH = ${JSON.stringify(runtimePath)};`,
|
|
97
|
+
`const MARKER = ${JSON.stringify(OPENCODE_MANAGED_MARKER)};`,
|
|
98
|
+
"",
|
|
99
|
+
"function outputText(result) {",
|
|
100
|
+
" if (typeof result?.stdout === \"string\") return result.stdout;",
|
|
101
|
+
" return new TextDecoder().decode(result?.stdout ?? new Uint8Array());",
|
|
102
|
+
"}",
|
|
103
|
+
"",
|
|
104
|
+
"async function invokeRuntime($, event, payload, projectRoot) {",
|
|
105
|
+
" try {",
|
|
106
|
+
" const result = await $`node ${RUNTIME_PATH} opencode ${event} ${MARKER} < ${new Response(JSON.stringify(payload))}`.cwd(projectRoot).quiet();",
|
|
107
|
+
" const decision = JSON.parse(outputText(result).trim());",
|
|
108
|
+
" if (decision?.decision !== \"allow\" && decision?.decision !== \"deny\") return null;",
|
|
109
|
+
" return decision;",
|
|
110
|
+
" } catch {",
|
|
111
|
+
" return null;",
|
|
112
|
+
" }",
|
|
113
|
+
"}",
|
|
114
|
+
"",
|
|
115
|
+
"async function runManagedHook($, event, payload) {",
|
|
116
|
+
" const decision = await invokeRuntime($, event, payload, payload.projectRoot);",
|
|
117
|
+
" if (decision === null) {",
|
|
118
|
+
" if (event === \"PreToolUse\") throw new Error(\"agent-ops: command policy is unavailable\");",
|
|
119
|
+
" return;",
|
|
120
|
+
" }",
|
|
121
|
+
" if (decision.decision === \"deny\") {",
|
|
122
|
+
" throw new Error(`agent-ops: ${decision.reason ?? \"denied\"}`);",
|
|
123
|
+
" }",
|
|
124
|
+
"}",
|
|
125
|
+
"",
|
|
126
|
+
"export const AgentOps = async ({ $, directory, worktree } = {}) => {",
|
|
127
|
+
" const projectRoot = directory ?? worktree ?? process.cwd();",
|
|
128
|
+
" const hooks = {};"
|
|
129
|
+
];
|
|
130
|
+
if (events.includes("SessionStart")) {
|
|
131
|
+
lines.push(" // opencode initializes plugins once per app, not once per session.", " await runManagedHook($, \"SessionStart\", {", " event: \"SessionStart\",", " projectRoot", " });");
|
|
132
|
+
}
|
|
133
|
+
if (events.includes("PreToolUse")) {
|
|
134
|
+
lines.push(' hooks["tool.execute.before"] = async (input, output) => {', ' if (input?.tool !== "bash") return;', " await runManagedHook($, \"PreToolUse\", {", " event: \"PreToolUse\",", " projectRoot,", " input,", " output", " });", " };");
|
|
135
|
+
}
|
|
136
|
+
if (events.includes("Stop")) {
|
|
137
|
+
lines.push(" hooks.event = async ({ event }) => {", ' if (event?.type !== "session.idle") return;', " await runManagedHook($, \"Stop\", { event: \"Stop\", projectRoot });", " };");
|
|
138
|
+
}
|
|
139
|
+
lines.push(" return hooks;", "};", "");
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|
|
142
|
+
export function isOpencodeManagedPlugin(source) {
|
|
143
|
+
return (source !== null &&
|
|
144
|
+
source.includes("Managed by agent-ops") &&
|
|
145
|
+
source.includes(OPENCODE_MANAGED_MARKER));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Registration probing checks the generated source's managed marker and the
|
|
149
|
+
* capability-implied hook registrations. The artifact hash check remains the
|
|
150
|
+
* exact-content check; this probe answers whether the relevant hooks exist.
|
|
151
|
+
*/
|
|
152
|
+
export function isOpencodePluginRegistered(source, capabilities) {
|
|
153
|
+
if (typeof source !== "string") {
|
|
154
|
+
return opencodePluginEvents(capabilities).length === 0;
|
|
155
|
+
}
|
|
156
|
+
if (!isOpencodeManagedPlugin(source)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return opencodePluginEvents(capabilities).every((event) => {
|
|
160
|
+
if (event === "SessionStart") {
|
|
161
|
+
return source.includes('runManagedHook($, "SessionStart"');
|
|
162
|
+
}
|
|
163
|
+
if (event === "PreToolUse") {
|
|
164
|
+
return source.includes('"tool.execute.before"') &&
|
|
165
|
+
source.includes('runManagedHook($, "PreToolUse"');
|
|
166
|
+
}
|
|
167
|
+
return source.includes('event?.type !== "session.idle"') &&
|
|
168
|
+
source.includes('runManagedHook($, "Stop"');
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const OPENCODE_SUPPORTED_EVENTS = [
|
|
2
|
+
"SessionStart",
|
|
3
|
+
"PreToolUse",
|
|
4
|
+
"Stop"
|
|
5
|
+
];
|
|
6
|
+
/**
|
|
7
|
+
* opencode exposes plugin hooks rather than named lifecycle events. Only
|
|
8
|
+
* `tool.execute.before` is documented as able to stop an action, so blocking
|
|
9
|
+
* outcomes for the other two stay UNKNOWN the way the Codex adapter records
|
|
10
|
+
* unconfirmed native behavior.
|
|
11
|
+
*/
|
|
12
|
+
export const OPENCODE_PLUGIN_HOOKS = {
|
|
13
|
+
SessionStart: "plugin-init",
|
|
14
|
+
PreToolUse: "tool.execute.before",
|
|
15
|
+
Stop: "event:session.idle"
|
|
16
|
+
};
|
|
17
|
+
export const OPENCODE_PRE_TOOL_BLOCKING = "throws";
|
|
18
|
+
export const OPENCODE_STOP_BLOCKING = "UNKNOWN";
|
|
19
|
+
/**
|
|
20
|
+
* A plugin's factory runs once when opencode starts, not once per session, so
|
|
21
|
+
* SessionStart is a degraded approximation rather than an equivalent.
|
|
22
|
+
*/
|
|
23
|
+
export const OPENCODE_SESSION_START_FIDELITY = "app-init";
|
|
24
|
+
export const OPENCODE_CAPABILITY_REGISTRATIONS = [
|
|
25
|
+
{
|
|
26
|
+
capability: "lifecycle-summary",
|
|
27
|
+
normalizedEvent: "session-start",
|
|
28
|
+
nativeEvent: "SessionStart",
|
|
29
|
+
surfaceId: "opencode-plugin",
|
|
30
|
+
support: "degraded",
|
|
31
|
+
runtimeFailure: "fail-open"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
capability: "command-policy",
|
|
35
|
+
normalizedEvent: "command",
|
|
36
|
+
nativeEvent: "PreToolUse",
|
|
37
|
+
surfaceId: "opencode-plugin",
|
|
38
|
+
support: "supported",
|
|
39
|
+
runtimeFailure: "fail-closed"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
capability: "optional-stop-verify",
|
|
43
|
+
normalizedEvent: "stop",
|
|
44
|
+
nativeEvent: "Stop",
|
|
45
|
+
surfaceId: "opencode-plugin",
|
|
46
|
+
support: "degraded",
|
|
47
|
+
runtimeFailure: "fail-open"
|
|
48
|
+
}
|
|
49
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { normalizeHookEvent } from "../../hooks/normalize.js";
|
|
2
|
+
import { normalizeShellHookEvent } from "../../hooks/shell.js";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The managed plugin forwards opencode's own hook payload untouched under
|
|
8
|
+
* `input`, so every field this adapter reads is documented plugin input rather
|
|
9
|
+
* than a shape the plugin invented.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeOpencodeHookInput(input) {
|
|
12
|
+
if (!isRecord(input)) {
|
|
13
|
+
return normalizeHookEvent(input);
|
|
14
|
+
}
|
|
15
|
+
const projectRoot = input.projectRoot;
|
|
16
|
+
if (input.event === "SessionStart") {
|
|
17
|
+
return normalizeHookEvent({ event: "session-start", projectRoot });
|
|
18
|
+
}
|
|
19
|
+
if (input.event === "Stop") {
|
|
20
|
+
return normalizeHookEvent({ event: "stop", projectRoot });
|
|
21
|
+
}
|
|
22
|
+
if (input.event === "PreToolUse" &&
|
|
23
|
+
isRecord(input.input) &&
|
|
24
|
+
input.input.tool === "bash" &&
|
|
25
|
+
isRecord(input.output) &&
|
|
26
|
+
isRecord(input.output.args) &&
|
|
27
|
+
typeof input.output.args.command === "string" &&
|
|
28
|
+
typeof projectRoot === "string") {
|
|
29
|
+
return normalizeShellHookEvent(input.output.args.command, projectRoot);
|
|
30
|
+
}
|
|
31
|
+
return normalizeHookEvent({ event: "unsupported", projectRoot });
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consumer is the managed plugin, so the wire format is ours to define: one
|
|
3
|
+
* JSON decision on stdout. Only `tool.execute.before` can act on a denial, so
|
|
4
|
+
* the other events always report `allow` and carry the code for logging.
|
|
5
|
+
*/
|
|
6
|
+
export function opencodeHookOutput(event, result) {
|
|
7
|
+
const decision = event === "PreToolUse" && result.action === "block"
|
|
8
|
+
? { decision: "deny", reason: result.code }
|
|
9
|
+
: {
|
|
10
|
+
decision: "allow",
|
|
11
|
+
...(result.status === "PASS" && event !== "Stop"
|
|
12
|
+
? {}
|
|
13
|
+
: { reason: result.code }),
|
|
14
|
+
...(event === "Stop" && result.evidence !== undefined
|
|
15
|
+
? { evidence: result.evidence }
|
|
16
|
+
: {})
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
exitCode: 0,
|
|
20
|
+
stdout: JSON.stringify(decision),
|
|
21
|
+
stderr: ""
|
|
22
|
+
};
|
|
23
|
+
}
|