@kylecheng3146/agent-ops 0.1.2 → 0.1.4
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 +184 -269
- package/dist/packages/cli/src/cli.js +2 -7
- package/dist/packages/cli/src/commands/hook.js +42 -0
- package/dist/packages/cli/src/commands/init.js +24 -3
- 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 +373 -0
- package/dist/packages/cli/src/version.js +3 -0
- package/dist/packages/cli/src/wizard.js +50 -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/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/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 +5 -3
- package/postinstall.cjs +102 -0
- 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,373 @@
|
|
|
1
|
+
// figlet "agent-ops" -f Standard, trimmed of trailing blank lines/columns.
|
|
2
|
+
export 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
|
+
async function typedChoice(question, choices, io, defaultIndex) {
|
|
79
|
+
const { createInterface } = await import("node:readline/promises");
|
|
80
|
+
const readline = createInterface({ input: io.input, output: io.output });
|
|
81
|
+
try {
|
|
82
|
+
const options = choices
|
|
83
|
+
.map((choice, index) => `${index + 1}: ${choice.label}`)
|
|
84
|
+
.join(", ");
|
|
85
|
+
const answer = (await readline.question(`${question} [${options}]: `)).trim();
|
|
86
|
+
if (answer === "") {
|
|
87
|
+
return choices[defaultIndex].value;
|
|
88
|
+
}
|
|
89
|
+
const index = Number.parseInt(answer, 10) - 1;
|
|
90
|
+
if (Number.isInteger(index) && choices[index] !== undefined) {
|
|
91
|
+
return choices[index].value;
|
|
92
|
+
}
|
|
93
|
+
const match = choices.find((choice) => choice.label.toLowerCase() === answer.toLowerCase());
|
|
94
|
+
return match?.value ?? choices[defaultIndex].value;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
readline.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function renderChoices(outputLike, question, choices, selected, vertical = false, selectAll = false, selectAllLabel = "Select all", selectAllDescription, focusedIndex = 0) {
|
|
101
|
+
const renderChoice = (choice, active, focused) => {
|
|
102
|
+
const label = `${active ? DOT_ON : DOT_OFF} ${choice.label}`;
|
|
103
|
+
const cursor = focused ? "❯ " : " ";
|
|
104
|
+
const renderedLabel = active
|
|
105
|
+
? bold(outputLike, green(outputLike, label))
|
|
106
|
+
: dim(outputLike, label);
|
|
107
|
+
const lines = [
|
|
108
|
+
vertical
|
|
109
|
+
? `${dim(outputLike, RAIL)} ${cursor}${renderedLabel}`
|
|
110
|
+
: `${cursor}${renderedLabel}`
|
|
111
|
+
];
|
|
112
|
+
if (vertical && choice.description !== undefined) {
|
|
113
|
+
lines.push(`${dim(outputLike, RAIL)} ${dim(outputLike, choice.description)}`);
|
|
114
|
+
}
|
|
115
|
+
return lines;
|
|
116
|
+
};
|
|
117
|
+
const allActive = selectAll && selected.size === choices.length;
|
|
118
|
+
const renderedChoices = [
|
|
119
|
+
...(selectAll
|
|
120
|
+
? renderChoice({
|
|
121
|
+
label: selectAllLabel,
|
|
122
|
+
value: undefined,
|
|
123
|
+
description: selectAllDescription
|
|
124
|
+
}, allActive, selectAll && focusedIndex === 0)
|
|
125
|
+
: []),
|
|
126
|
+
...choices.flatMap((choice, index) => {
|
|
127
|
+
const active = selected.has(index);
|
|
128
|
+
const focused = (selectAll ? index + 1 : index) === focusedIndex;
|
|
129
|
+
return renderChoice(choice, active, focused);
|
|
130
|
+
})
|
|
131
|
+
];
|
|
132
|
+
const rendered = vertical
|
|
133
|
+
? renderedChoices
|
|
134
|
+
: [`${dim(outputLike, RAIL)} ${renderedChoices.join(" / ")}`];
|
|
135
|
+
return [
|
|
136
|
+
`${bold(outputLike, DIAMOND)} ${question}`,
|
|
137
|
+
...rendered
|
|
138
|
+
].join("\n");
|
|
139
|
+
}
|
|
140
|
+
function assertChoices(choices) {
|
|
141
|
+
if (choices.length === 0) {
|
|
142
|
+
throw new Error("At least one selector choice is required.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Single-value selector for interactive wizard choices. */
|
|
146
|
+
export async function selectOption(question, choices, io, defaultIndex = 0) {
|
|
147
|
+
assertChoices(choices);
|
|
148
|
+
const initialIndex = Math.min(Math.max(defaultIndex, 0), choices.length - 1);
|
|
149
|
+
if (typeof io.input.setRawMode !== "function" ||
|
|
150
|
+
io.input.isTTY !== true) {
|
|
151
|
+
return await typedChoice(question, choices, io, initialIndex);
|
|
152
|
+
}
|
|
153
|
+
const outputLike = {
|
|
154
|
+
isTTY: true,
|
|
155
|
+
columns: io.output.columns,
|
|
156
|
+
write: (value) => io.output.write(value)
|
|
157
|
+
};
|
|
158
|
+
const { emitKeypressEvents } = await import("node:readline");
|
|
159
|
+
emitKeypressEvents(io.input);
|
|
160
|
+
io.input.setRawMode(true);
|
|
161
|
+
io.input.resume();
|
|
162
|
+
let index = initialIndex;
|
|
163
|
+
let rendered = renderChoices(outputLike, question, choices, new Set([index]), false, false, "Select all", undefined, index);
|
|
164
|
+
io.output.write(`${rendered}\n`);
|
|
165
|
+
return await new Promise((resolve) => {
|
|
166
|
+
const cleanup = () => {
|
|
167
|
+
io.input.setRawMode?.(false);
|
|
168
|
+
io.input.pause();
|
|
169
|
+
io.input.removeListener("keypress", onKeypress);
|
|
170
|
+
};
|
|
171
|
+
const redraw = () => {
|
|
172
|
+
eraseLines((value) => io.output.write(value), rendered.split("\n").length);
|
|
173
|
+
rendered = renderChoices(outputLike, question, choices, new Set([index]), false, false, "Select all", undefined, index);
|
|
174
|
+
io.output.write(`${rendered}\n`);
|
|
175
|
+
};
|
|
176
|
+
const move = (delta) => {
|
|
177
|
+
index = (index + delta + choices.length) % choices.length;
|
|
178
|
+
redraw();
|
|
179
|
+
};
|
|
180
|
+
const onKeypress = (_chunk, key) => {
|
|
181
|
+
if (key?.ctrl === true && key.name === "c") {
|
|
182
|
+
cleanup();
|
|
183
|
+
process.exit(130);
|
|
184
|
+
}
|
|
185
|
+
if (key?.name === "up" ||
|
|
186
|
+
key?.name === "left" ||
|
|
187
|
+
key?.name === "h") {
|
|
188
|
+
move(-1);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (key?.name === "down" ||
|
|
192
|
+
key?.name === "right" ||
|
|
193
|
+
key?.name === "tab" ||
|
|
194
|
+
key?.name === "l") {
|
|
195
|
+
move(1);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (key?.name === "home") {
|
|
199
|
+
index = 0;
|
|
200
|
+
redraw();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (key?.name === "end") {
|
|
204
|
+
index = choices.length - 1;
|
|
205
|
+
redraw();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (key?.name === "return" || key?.name === "space") {
|
|
209
|
+
cleanup();
|
|
210
|
+
resolve(choices[index].value);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
io.input.on("keypress", onKeypress);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/** Multi-value selector. Space toggles a choice; Enter confirms. */
|
|
217
|
+
export async function selectOptions(question, choices, io, defaultValues = [], options = {}) {
|
|
218
|
+
assertChoices(choices);
|
|
219
|
+
const defaultIndexes = new Set(choices.flatMap((choice, index) => defaultValues.includes(choice.value) ? [index] : []));
|
|
220
|
+
if (defaultIndexes.size === 0 && options.selectAll !== true) {
|
|
221
|
+
defaultIndexes.add(0);
|
|
222
|
+
}
|
|
223
|
+
if (typeof io.input.setRawMode !== "function" ||
|
|
224
|
+
io.input.isTTY !== true) {
|
|
225
|
+
const value = await typedChoice(question, choices, io, 0);
|
|
226
|
+
return [value];
|
|
227
|
+
}
|
|
228
|
+
const selectAll = options.selectAll === true;
|
|
229
|
+
const choiceCount = choices.length + (selectAll ? 1 : 0);
|
|
230
|
+
const outputLike = {
|
|
231
|
+
isTTY: true,
|
|
232
|
+
columns: io.output.columns,
|
|
233
|
+
write: (value) => io.output.write(value)
|
|
234
|
+
};
|
|
235
|
+
const { emitKeypressEvents } = await import("node:readline");
|
|
236
|
+
emitKeypressEvents(io.input);
|
|
237
|
+
io.input.setRawMode(true);
|
|
238
|
+
io.input.resume();
|
|
239
|
+
let index = 0;
|
|
240
|
+
const selected = new Set(defaultIndexes);
|
|
241
|
+
let selectionHintShown = false;
|
|
242
|
+
let rendered = renderChoices(outputLike, question, choices, selected, true, selectAll, options.selectAllLabel, options.selectAllDescription, index);
|
|
243
|
+
io.output.write(`${rendered}\n`);
|
|
244
|
+
return await new Promise((resolve) => {
|
|
245
|
+
const cleanup = () => {
|
|
246
|
+
io.input.setRawMode?.(false);
|
|
247
|
+
io.input.pause();
|
|
248
|
+
io.input.removeListener("keypress", onKeypress);
|
|
249
|
+
};
|
|
250
|
+
const redraw = () => {
|
|
251
|
+
eraseLines((value) => io.output.write(value), rendered.split("\n").length);
|
|
252
|
+
selectionHintShown = false;
|
|
253
|
+
rendered = renderChoices(outputLike, question, choices, selected, true, selectAll, options.selectAllLabel, options.selectAllDescription, index);
|
|
254
|
+
io.output.write(`${rendered}\n`);
|
|
255
|
+
};
|
|
256
|
+
const move = (delta) => {
|
|
257
|
+
index = (index + delta + choiceCount) % choiceCount;
|
|
258
|
+
redraw();
|
|
259
|
+
};
|
|
260
|
+
const onKeypress = (_chunk, key) => {
|
|
261
|
+
if (key?.ctrl === true && key.name === "c") {
|
|
262
|
+
cleanup();
|
|
263
|
+
process.exit(130);
|
|
264
|
+
}
|
|
265
|
+
if (key?.name === "up" || key?.name === "left") {
|
|
266
|
+
move(-1);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (key?.name === "down" ||
|
|
270
|
+
key?.name === "right" ||
|
|
271
|
+
key?.name === "tab") {
|
|
272
|
+
move(1);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (key?.name === "space") {
|
|
276
|
+
if (selectAll && index === 0) {
|
|
277
|
+
if (selected.size === choices.length) {
|
|
278
|
+
selected.clear();
|
|
279
|
+
selected.add(0);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
choices.forEach((_choice, choiceIndex) => {
|
|
283
|
+
selected.add(choiceIndex);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const choiceIndex = selectAll ? index - 1 : index;
|
|
289
|
+
if (selected.has(choiceIndex)) {
|
|
290
|
+
if (selected.size > 1) {
|
|
291
|
+
selected.delete(choiceIndex);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
selected.add(choiceIndex);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
redraw();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (key?.name === "return") {
|
|
302
|
+
if (selected.size === 0) {
|
|
303
|
+
if (!selectionHintShown) {
|
|
304
|
+
rendered = `${rendered}\n${dim(outputLike, `${RAIL} Choose at least one option with Space.`)}`;
|
|
305
|
+
selectionHintShown = true;
|
|
306
|
+
io.output.write(`${rendered}\n`);
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
cleanup();
|
|
311
|
+
resolve(choices
|
|
312
|
+
.filter((_choice, choiceIndex) => selected.has(choiceIndex))
|
|
313
|
+
.map((choice) => choice.value));
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
io.input.on("keypress", onKeypress);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Arrow-key Yes/No selector. Falls back to a typed y/N prompt when stdin
|
|
321
|
+
* cannot enter raw mode (piped input, or a stub stream in tests), so the
|
|
322
|
+
* same call works under both a real terminal and test harnesses.
|
|
323
|
+
*/
|
|
324
|
+
export async function selectYesNo(question, io, defaultValue = false) {
|
|
325
|
+
if (typeof io.input.setRawMode !== "function" ||
|
|
326
|
+
io.input.isTTY !== true) {
|
|
327
|
+
return await typedFallback(question, io, defaultValue);
|
|
328
|
+
}
|
|
329
|
+
const outputLike = {
|
|
330
|
+
isTTY: true,
|
|
331
|
+
columns: io.output.columns,
|
|
332
|
+
write: (value) => io.output.write(value)
|
|
333
|
+
};
|
|
334
|
+
const { emitKeypressEvents } = await import("node:readline");
|
|
335
|
+
emitKeypressEvents(io.input);
|
|
336
|
+
io.input.setRawMode(true);
|
|
337
|
+
io.input.resume();
|
|
338
|
+
let value = defaultValue;
|
|
339
|
+
let rendered = renderChoice(outputLike, question, value);
|
|
340
|
+
io.output.write(`${rendered}\n`);
|
|
341
|
+
return await new Promise((resolve) => {
|
|
342
|
+
const cleanup = () => {
|
|
343
|
+
io.input.setRawMode?.(false);
|
|
344
|
+
io.input.pause();
|
|
345
|
+
io.input.removeListener("keypress", onKeypress);
|
|
346
|
+
};
|
|
347
|
+
const redraw = () => {
|
|
348
|
+
eraseLines((value_) => io.output.write(value_), rendered.split("\n").length);
|
|
349
|
+
rendered = renderChoice(outputLike, question, value);
|
|
350
|
+
io.output.write(`${rendered}\n`);
|
|
351
|
+
};
|
|
352
|
+
const onKeypress = (_chunk, key) => {
|
|
353
|
+
if (key?.ctrl === true && key.name === "c") {
|
|
354
|
+
cleanup();
|
|
355
|
+
process.exit(130);
|
|
356
|
+
}
|
|
357
|
+
if (key?.name === "left" ||
|
|
358
|
+
key?.name === "right" ||
|
|
359
|
+
key?.name === "tab" ||
|
|
360
|
+
key?.name === "h" ||
|
|
361
|
+
key?.name === "l") {
|
|
362
|
+
value = !value;
|
|
363
|
+
redraw();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (key?.name === "return" || key?.name === "space") {
|
|
367
|
+
cleanup();
|
|
368
|
+
resolve(value);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
io.input.on("keypress", onKeypress);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
import { CliArgumentError } from "./args.js";
|
|
2
|
+
import { selectOption, selectOptions } from "./ui.js";
|
|
2
3
|
const SCOPES = new Set(["project", "user"]);
|
|
3
4
|
const HARNESSES = new Set(["both", "claude", "codex"]);
|
|
4
5
|
const PROFILES = new Set(["advisory", "core", "guardrails"]);
|
|
6
|
+
const SCOPE_CHOICES = [
|
|
7
|
+
{ label: "project", value: "project" },
|
|
8
|
+
{ label: "user", value: "user" }
|
|
9
|
+
];
|
|
10
|
+
const HARNESS_CHOICES = [
|
|
11
|
+
{ label: "both", value: "both" },
|
|
12
|
+
{ label: "claude", value: "claude" },
|
|
13
|
+
{ label: "codex", value: "codex" }
|
|
14
|
+
];
|
|
15
|
+
const PROFILE_CHOICES = [
|
|
16
|
+
{
|
|
17
|
+
label: "core",
|
|
18
|
+
value: "core",
|
|
19
|
+
description: "Base rules, task tracking, verification, and review guidance."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
label: "advisory",
|
|
23
|
+
value: "advisory",
|
|
24
|
+
description: "Adds informational SessionStart summaries and local logs; never blocks."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: "guardrails",
|
|
28
|
+
value: "guardrails",
|
|
29
|
+
description: "Blocks high-confidence unsafe commands and enables optional Stop verification."
|
|
30
|
+
}
|
|
31
|
+
];
|
|
32
|
+
const WIZARD_SUBTITLE = "Safe setup for Codex + Claude Code with profile-driven rules, verification, and hooks.";
|
|
5
33
|
async function createPromptSession(io) {
|
|
6
34
|
if (!io.isTTY) {
|
|
7
35
|
throw new CliArgumentError("CLI_INTERACTIVE_REQUIRED", "Missing init choices require an interactive terminal.");
|
|
@@ -53,6 +81,28 @@ export async function completeInitChoices(args, io) {
|
|
|
53
81
|
if (!io.isTTY) {
|
|
54
82
|
throw new CliArgumentError("CLI_INTERACTIVE_REQUIRED", "Non-interactive init requires --scope, --harness, and at least one --profile.");
|
|
55
83
|
}
|
|
84
|
+
if (io.input !== undefined && io.output !== undefined) {
|
|
85
|
+
const selectorIo = {
|
|
86
|
+
input: io.input,
|
|
87
|
+
output: io.output
|
|
88
|
+
};
|
|
89
|
+
selectorIo.output.write(`${WIZARD_SUBTITLE}\n\n`);
|
|
90
|
+
const scope = args.scope ?? await selectOption("Scope", SCOPE_CHOICES, selectorIo);
|
|
91
|
+
const harness = args.harness ?? await selectOption("Harness", HARNESS_CHOICES, selectorIo);
|
|
92
|
+
const profiles = args.profiles.length > 0
|
|
93
|
+
? args.profiles
|
|
94
|
+
: await selectOptions("Profiles (multi-select: ↑↓ move, Space toggle, Enter confirm)", PROFILE_CHOICES, selectorIo, [], {
|
|
95
|
+
selectAll: true,
|
|
96
|
+
selectAllLabel: "Select all",
|
|
97
|
+
selectAllDescription: "Enable core, advisory, and guardrails together."
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
...args,
|
|
101
|
+
scope,
|
|
102
|
+
harness,
|
|
103
|
+
profiles
|
|
104
|
+
};
|
|
105
|
+
}
|
|
56
106
|
const session = await createPromptSession(io);
|
|
57
107
|
try {
|
|
58
108
|
const scope = args.scope ??
|
|
@@ -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.");
|
|
@@ -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) {
|