@kylecheng3146/agent-ops 0.1.3 → 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.
@@ -1,17 +1,12 @@
1
1
  import { CliArgumentError, parseArgs } from "./args.js";
2
2
  import { errorEnvelope, okEnvelope, writeEnvelope } from "./output.js";
3
3
  import { completeInitChoices } from "./wizard.js";
4
- const WELCOME_BANNER = [
5
- "+--------------------------------------------------+",
6
- "| LOOP ENGINEERING TOOLKIT |",
7
- "| Safe setup for Codex + Claude Code |",
8
- "+--------------------------------------------------+"
9
- ].join("\n");
4
+ import { BANNER } from "./ui.js";
10
5
  export function renderWelcome(color) {
11
6
  const cyan = color ? "\u001b[36m" : "";
12
7
  const bold = color ? "\u001b[1m" : "";
13
8
  const reset = color ? "\u001b[0m" : "";
14
- return `${cyan}${bold}${WELCOME_BANNER}${reset}\n\n`;
9
+ return `${cyan}${bold}${BANNER}${reset}\n\n`;
15
10
  }
16
11
  export const HELP_TEXT = `Usage: agent-ops <command> [options]
17
12
 
@@ -4,16 +4,34 @@ import { applyInstallPlan } from "../../../../runtime/src/install/apply.js";
4
4
  import { okEnvelope } from "../output.js";
5
5
  import { formatOperationPlan } from "../plan-output.js";
6
6
  export function formatInstallPlan(plan) {
7
+ const hooks = plan.manifest.hooks ?? [];
7
8
  return formatOperationPlan({
8
9
  title: "Installation plan",
9
10
  metadata: [
10
11
  `Scope: ${plan.scope}`,
11
12
  `Harness: ${plan.harness}`,
12
- `Profiles: ${plan.profiles.join(", ")}`
13
+ `Profiles: ${plan.profiles.join(", ")}`,
14
+ ...(hooks.length === 0
15
+ ? ["Hooks: none selected"]
16
+ : [
17
+ "Hooks:",
18
+ ...hooks.map((hook) => ` - ${hook.harness}: ${hook.path} (${hook.events.join(", ")})`)
19
+ ])
13
20
  ],
14
21
  operations: plan.operations
15
22
  });
16
23
  }
24
+ function appliedMessage(plan) {
25
+ const hooks = plan.manifest.hooks ?? [];
26
+ if (hooks.length === 0) {
27
+ return "Loop Engineering Toolkit installation applied.\nHooks: none selected.";
28
+ }
29
+ return [
30
+ "Loop Engineering Toolkit installation applied.",
31
+ "Hooks configured:",
32
+ ...hooks.map((hook) => `- ${hook.harness}: ${hook.path} (${hook.events.join(", ")})`)
33
+ ].join("\n");
34
+ }
17
35
  function initError(code, message, plan) {
18
36
  return {
19
37
  code,
@@ -63,6 +81,6 @@ export async function runInitCommand(options) {
63
81
  return okEnvelope("INIT_APPLIED", {
64
82
  applied: true,
65
83
  plan,
66
- message: "Loop Engineering Toolkit installation applied."
84
+ message: appliedMessage(plan)
67
85
  });
68
86
  }
@@ -1,5 +1,5 @@
1
1
  // figlet "agent-ops" -f Standard, trimmed of trailing blank lines/columns.
2
- const BANNER = [
2
+ export const BANNER = [
3
3
  " _",
4
4
  " __ _ __ _ ___ _ __ | |_ ___ _ __ ___ ",
5
5
  " / _\` |/ _\` |/ _ \\ '_ \\| __|____ / _ \\| '_ \\/ __|",
@@ -75,6 +75,247 @@ async function typedFallback(question, io, defaultValue) {
75
75
  readline.close();
76
76
  }
77
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
+ }
78
319
  /**
79
320
  * Arrow-key Yes/No selector. Falls back to a typed y/N prompt when stdin
80
321
  * cannot enter raw mode (piped input, or a stub stream in tests), so the
@@ -1,3 +1,3 @@
1
1
  // Single source of the published CLI version. Release preparation updates
2
2
  // this constant; bin.ts and the hook entry both read it from here.
3
- export const CLI_VERSION = "0.1.3";
3
+ export const CLI_VERSION = "0.1.4";
@@ -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 ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kylecheng3146/agent-ops",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Evidence-driven development loops for Codex and Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,7 +19,8 @@
19
19
  "docs/zh-TW/spec/",
20
20
  "README.md",
21
21
  "LICENSE",
22
- "SECURITY.md"
22
+ "SECURITY.md",
23
+ "postinstall.cjs"
23
24
  ],
24
25
  "publishConfig": {
25
26
  "access": "public"
@@ -30,7 +31,8 @@
30
31
  "test:compile": "node scripts/clean.mjs .tmp && tsc -p tsconfig.test.json",
31
32
  "test": "npm run test:compile && node scripts/run-tests.mjs .tmp/test-dist/tests",
32
33
  "build": "node scripts/clean.mjs dist && tsc -p tsconfig.build.json",
33
- "package:check": "node scripts/package-check.mjs"
34
+ "package:check": "node scripts/package-check.mjs",
35
+ "postinstall": "node postinstall.cjs"
34
36
  },
35
37
  "devDependencies": {
36
38
  "@types/node": "26.1.1",
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+
3
+ const { closeSync, existsSync, openSync, readFileSync } = require("node:fs");
4
+ const { join, resolve } = require("node:path");
5
+ const { spawnSync } = require("node:child_process");
6
+ const { isatty } = require("node:tty");
7
+
8
+ const PACKAGE_NAME = "@kylecheng3146/agent-ops";
9
+ const packageRoot = __dirname;
10
+ const installRoot = resolve(process.env.INIT_CWD ?? process.cwd());
11
+
12
+ function isDirectDependency() {
13
+ try {
14
+ const packageJson = JSON.parse(
15
+ readFileSync(join(installRoot, "package.json"), "utf8")
16
+ );
17
+ return [
18
+ packageJson.dependencies,
19
+ packageJson.devDependencies,
20
+ packageJson.optionalDependencies,
21
+ packageJson.peerDependencies
22
+ ].some(
23
+ (dependencies) =>
24
+ dependencies !== null &&
25
+ typeof dependencies === "object" &&
26
+ dependencies[PACKAGE_NAME] !== undefined
27
+ );
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function openTerminal() {
34
+ if (process.stdin.isTTY === true && process.stdout.isTTY === true) {
35
+ return { stdio: "inherit", close() {} };
36
+ }
37
+
38
+ const inputPath = process.platform === "win32" ? "CONIN$" : "/dev/tty";
39
+ const outputPath = process.platform === "win32" ? "CONOUT$" : "/dev/tty";
40
+ let inputFd;
41
+ let outputFd;
42
+ try {
43
+ inputFd = openSync(inputPath, "r");
44
+ outputFd = process.platform === "win32" ? openSync(outputPath, "a") : inputFd;
45
+ if (!isatty(inputFd) || !isatty(outputFd)) {
46
+ throw new Error("Interactive terminal is unavailable.");
47
+ }
48
+ return {
49
+ stdio: [inputFd, outputFd, outputFd],
50
+ close() {
51
+ if (outputFd !== inputFd) {
52
+ closeSync(outputFd);
53
+ }
54
+ closeSync(inputFd);
55
+ }
56
+ };
57
+ } catch {
58
+ if (outputFd !== undefined && outputFd !== inputFd) {
59
+ closeSync(outputFd);
60
+ }
61
+ if (inputFd !== undefined) {
62
+ closeSync(inputFd);
63
+ }
64
+ return null;
65
+ }
66
+ }
67
+
68
+ if (
69
+ installRoot === resolve(packageRoot) ||
70
+ process.env.CI !== undefined ||
71
+ !isDirectDependency()
72
+ ) {
73
+ process.exit(0);
74
+ }
75
+
76
+ const terminal = openTerminal();
77
+ if (terminal === null) {
78
+ process.exit(0);
79
+ }
80
+
81
+ const cliPath = join(packageRoot, "dist", "packages", "cli", "src", "bin.js");
82
+ if (!existsSync(cliPath)) {
83
+ process.stderr.write(
84
+ "agent-ops init was skipped because the installed CLI is unavailable.\n"
85
+ );
86
+ terminal.close();
87
+ process.exit(0);
88
+ }
89
+
90
+ const result = spawnSync(process.execPath, [cliPath], {
91
+ cwd: installRoot,
92
+ env: process.env,
93
+ stdio: terminal.stdio
94
+ });
95
+ terminal.close();
96
+
97
+ if (result.error !== undefined || result.status !== 0) {
98
+ process.stderr.write(
99
+ "agent-ops init was not applied; package installation completed. " +
100
+ "Run `agent-ops` to retry.\n"
101
+ );
102
+ }