@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.
Files changed (80) hide show
  1. package/README.md +88 -13
  2. package/dist/packages/cli/src/args.js +39 -6
  3. package/dist/packages/cli/src/bin.js +33 -11
  4. package/dist/packages/cli/src/cli.js +8 -8
  5. package/dist/packages/cli/src/commands/doctor.js +31 -9
  6. package/dist/packages/cli/src/commands/hook.js +18 -16
  7. package/dist/packages/cli/src/commands/init.js +29 -7
  8. package/dist/packages/cli/src/commands/review.js +5 -1
  9. package/dist/packages/cli/src/commands/uninstall.js +6 -5
  10. package/dist/packages/cli/src/commands/update.js +13 -5
  11. package/dist/packages/cli/src/context.js +9 -3
  12. package/dist/packages/cli/src/hook-process.js +109 -7
  13. package/dist/packages/cli/src/plan-output.js +9 -4
  14. package/dist/packages/cli/src/public-plan.js +62 -0
  15. package/dist/packages/cli/src/ui.js +242 -1
  16. package/dist/packages/cli/src/version.js +1 -1
  17. package/dist/packages/cli/src/wizard.js +68 -2
  18. package/dist/runtime/src/adapters/claude/config.js +0 -8
  19. package/dist/runtime/src/adapters/claude/events.js +26 -0
  20. package/dist/runtime/src/adapters/claude/output.js +6 -6
  21. package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
  22. package/dist/runtime/src/adapters/codex/config.js +29 -11
  23. package/dist/runtime/src/adapters/codex/events.js +26 -0
  24. package/dist/runtime/src/adapters/codex/output.js +10 -0
  25. package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
  26. package/dist/runtime/src/adapters/opencode/config.js +170 -0
  27. package/dist/runtime/src/adapters/opencode/events.js +49 -0
  28. package/dist/runtime/src/adapters/opencode/input.js +32 -0
  29. package/dist/runtime/src/adapters/opencode/output.js +23 -0
  30. package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
  31. package/dist/runtime/src/config/explain.js +7 -0
  32. package/dist/runtime/src/config/hash.js +24 -0
  33. package/dist/runtime/src/config/merge.js +6 -2
  34. package/dist/runtime/src/config/migrate.js +19 -4
  35. package/dist/runtime/src/contracts.js +10 -1
  36. package/dist/runtime/src/fs/manifest.js +32 -1
  37. package/dist/runtime/src/fs/transaction.js +14 -2
  38. package/dist/runtime/src/hooks/advisory.js +16 -0
  39. package/dist/runtime/src/hooks/stop-service.js +70 -0
  40. package/dist/runtime/src/hooks/stop-verify.js +4 -1
  41. package/dist/runtime/src/install/doctor.js +119 -9
  42. package/dist/runtime/src/install/harness.js +296 -35
  43. package/dist/runtime/src/install/hooks.js +22 -17
  44. package/dist/runtime/src/install/ownership.js +110 -34
  45. package/dist/runtime/src/install/plan.js +207 -28
  46. package/dist/runtime/src/install/probes.js +9 -43
  47. package/dist/runtime/src/install/profiles.js +8 -1
  48. package/dist/runtime/src/install/surface-inspection.js +296 -0
  49. package/dist/runtime/src/install/surfaces.js +11 -0
  50. package/dist/runtime/src/install/uninstall.js +10 -3
  51. package/dist/runtime/src/install/update.js +8 -2
  52. package/dist/runtime/src/schema/validate.js +37 -18
  53. package/dist/runtime/src/task/service.js +3 -3
  54. package/dist/runtime/src/task/store.js +12 -3
  55. package/dist/runtime/src/verify/command-executor.js +113 -0
  56. package/dist/runtime/src/verify/evidence.js +4 -24
  57. package/dist/runtime/src/verify/service.js +20 -92
  58. package/dist/runtime/src/verify/spawn.js +6 -1
  59. package/docs/en/guides/configuration.md +83 -0
  60. package/docs/en/guides/quickstart.md +9 -0
  61. package/docs/en/guides/security.md +5 -0
  62. package/docs/en/spec/README.md +9 -0
  63. package/docs/en/spec/harness-adapters.md +66 -2
  64. package/docs/en/spec/maintenance.md +11 -0
  65. package/docs/en/spec/review.md +11 -0
  66. package/docs/zh-TW/guides/configuration.md +77 -0
  67. package/docs/zh-TW/guides/quickstart.md +9 -0
  68. package/docs/zh-TW/guides/security.md +5 -0
  69. package/docs/zh-TW/spec/README.md +9 -0
  70. package/docs/zh-TW/spec/harness-adapters.md +57 -3
  71. package/docs/zh-TW/spec/maintenance.md +11 -1
  72. package/docs/zh-TW/spec/review.md +10 -0
  73. package/package.json +8 -4
  74. package/postinstall.cjs +102 -0
  75. package/schemas/config.schema.json +55 -1
  76. package/schemas/manifest.schema.json +7 -2
  77. package/templates/common/AGENTS.block.md +2 -1
  78. package/templates/common/CLAUDE.block.md +2 -1
  79. package/dist/runtime/src/review/claude-runner.js +0 -4
  80. package/dist/runtime/src/review/codex-runner.js +0 -4
@@ -1,6 +1,7 @@
1
1
  import { applyUninstallPlan, createUninstallPlan } from "../../../../runtime/src/install/uninstall.js";
2
2
  import { okEnvelope } from "../output.js";
3
3
  import { formatOperationPlan } from "../plan-output.js";
4
+ import { toPublicUninstallPlan } from "../public-plan.js";
4
5
  function formatUninstallPlan(plan) {
5
6
  return formatOperationPlan({
6
7
  title: "Uninstall plan",
@@ -13,14 +14,14 @@ function formatUninstallPlan(plan) {
13
14
  `Harness: ${plan.manifest.harness}`
14
15
  ])
15
16
  ],
16
- operations: plan.operations
17
+ operations: toPublicUninstallPlan(plan).operations
17
18
  });
18
19
  }
19
20
  function uninstallError(code, message, plan) {
20
21
  return {
21
22
  code,
22
23
  status: "error",
23
- data: { applied: false, plan, message },
24
+ data: { applied: false, plan: toPublicUninstallPlan(plan), message },
24
25
  errors: [{ code, message }]
25
26
  };
26
27
  }
@@ -29,14 +30,14 @@ export async function runUninstallCommand(options) {
29
30
  if (!plan.installed) {
30
31
  return okEnvelope("UNINSTALL_NOT_INSTALLED", {
31
32
  applied: false,
32
- plan,
33
+ plan: toPublicUninstallPlan(plan),
33
34
  message: "No managed installation exists."
34
35
  });
35
36
  }
36
37
  if (options.args.dryRun) {
37
38
  return okEnvelope("UNINSTALL_PLAN_READY", {
38
39
  applied: false,
39
- plan,
40
+ plan: toPublicUninstallPlan(plan),
40
41
  message: "Uninstall plan calculated; no files were changed.",
41
42
  text: formatUninstallPlan(plan)
42
43
  });
@@ -51,7 +52,7 @@ export async function runUninstallCommand(options) {
51
52
  await applyUninstallPlan(options.root, plan);
52
53
  return okEnvelope("UNINSTALL_APPLIED", {
53
54
  applied: true,
54
- plan,
55
+ plan: toPublicUninstallPlan(plan),
55
56
  message: "Managed installation content was removed."
56
57
  });
57
58
  }
@@ -1,25 +1,27 @@
1
1
  import { applyUpdatePlan, createUpdatePlan } from "../../../../runtime/src/install/update.js";
2
2
  import { okEnvelope } from "../output.js";
3
3
  import { formatOperationPlan } from "../plan-output.js";
4
+ import { toPublicUpdatePlan } from "../public-plan.js";
4
5
  export function formatUpdatePlan(plan) {
5
6
  return formatOperationPlan({
6
7
  title: "Update plan",
7
8
  metadata: [
8
9
  `Target version: ${plan.targetVersion}`,
10
+ `Harness: ${plan.installation.harness.join(", ")}`,
9
11
  `Schema migrations: ${plan.migrationSteps.length === 0
10
12
  ? "none"
11
13
  : plan.migrationSteps
12
14
  .map(({ fromVersion, toVersion }) => `${fromVersion} -> ${toVersion}`)
13
15
  .join(", ")}`
14
16
  ],
15
- operations: plan.installation.operations
17
+ operations: toPublicUpdatePlan(plan).installation.operations
16
18
  });
17
19
  }
18
20
  function updateError(code, message, plan) {
19
21
  return {
20
22
  code,
21
23
  status: "error",
22
- data: { applied: false, plan, message },
24
+ data: { applied: false, plan: toPublicUpdatePlan(plan), message },
23
25
  errors: [{ code, message }]
24
26
  };
25
27
  }
@@ -27,6 +29,9 @@ export async function runUpdateCommand(options) {
27
29
  const plan = await createUpdatePlan({
28
30
  root: options.root,
29
31
  adapters: options.adapters,
32
+ ...(options.args.harness === undefined
33
+ ? {}
34
+ : { harness: options.args.harness }),
30
35
  ...(options.registry === undefined
31
36
  ? {}
32
37
  : { registry: options.registry }),
@@ -35,12 +40,15 @@ export async function runUpdateCommand(options) {
35
40
  : { targetVersion: options.targetVersion }),
36
41
  ...(options.hookRuntimePath === undefined
37
42
  ? {}
38
- : { hookRuntimePath: options.hookRuntimePath })
43
+ : { hookRuntimePath: options.hookRuntimePath }),
44
+ ...((options.hookTargets ?? options.args.hookTargets) === undefined
45
+ ? {}
46
+ : { hookTargets: options.hookTargets ?? options.args.hookTargets })
39
47
  });
40
48
  if (options.args.dryRun) {
41
49
  return okEnvelope("UPDATE_PLAN_READY", {
42
50
  applied: false,
43
- plan,
51
+ plan: toPublicUpdatePlan(plan),
44
52
  message: "Update plan calculated; no files were changed.",
45
53
  text: formatUpdatePlan(plan)
46
54
  });
@@ -55,7 +63,7 @@ export async function runUpdateCommand(options) {
55
63
  await applyUpdatePlan(options.root, plan);
56
64
  return okEnvelope("UPDATE_APPLIED", {
57
65
  applied: true,
58
- plan,
66
+ plan: toPublicUpdatePlan(plan),
59
67
  message: `Managed installation updated to ${plan.targetVersion}.`
60
68
  });
61
69
  }
@@ -1,16 +1,22 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
5
+ import { sha256 } from "../../../runtime/src/fs/hash.js";
4
6
  import { loadConfigFile } from "../../../runtime/src/config/load.js";
5
7
  import { mergeConfigLayers } from "../../../runtime/src/config/merge.js";
6
- import { sha256 } from "../../../runtime/src/fs/hash.js";
7
8
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
8
9
  import { localStatePaths } from "../../../runtime/src/security/permissions.js";
9
10
  import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
10
11
  export const DEFAULT_CONFIG = {
11
- schemaVersion: 1,
12
+ schemaVersion: 2,
12
13
  profiles: [],
13
14
  verification: { commands: [] },
15
+ features: {
16
+ stopVerification: {
17
+ enabled: false
18
+ }
19
+ },
14
20
  pathMappings: [],
15
21
  securityExceptions: []
16
22
  };
@@ -91,7 +97,7 @@ export async function repositoryTrust(root, config, cliVersion) {
91
97
  const binding = await calculateTrustBinding({
92
98
  repositoryPath: root,
93
99
  remoteUrl: repositoryRemoteUrl(root),
94
- configHash: sha256(JSON.stringify(config)),
100
+ configHash: calculateConfigHash(config),
95
101
  runtimeHash: sha256(cliVersion)
96
102
  });
97
103
  return (await new FileTrustStore(state.trustStore, state.anchorDirectory).status(binding)).status;
@@ -1,7 +1,15 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { calculateConfigHash } from "../../../runtime/src/config/hash.js";
4
+ import { claudeStopRecursionMarker } from "../../../runtime/src/adapters/claude/input.js";
5
+ import { HARNESS_IDS, harnessDescriptor } from "../../../runtime/src/install/harness.js";
6
+ import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
7
+ import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
1
8
  import { runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
2
9
  import { loadEffectiveConfig, repositoryTrust } from "./context.js";
3
- const HARNESSES = new Set(["codex", "claude"]);
10
+ const HARNESSES = new Set(HARNESS_IDS);
4
11
  const MAX_HOOK_INPUT_BYTES = 1024 * 1024;
12
+ const execFile = promisify(execFileCallback);
5
13
  async function readStdin(stream) {
6
14
  const chunks = [];
7
15
  let total = 0;
@@ -17,28 +25,122 @@ async function readStdin(stream) {
17
25
  }
18
26
  return Buffer.concat(chunks, total).toString("utf8");
19
27
  }
28
+ function parseInput(source) {
29
+ try {
30
+ return JSON.parse(source);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function defaultGitRunner(root) {
37
+ return {
38
+ run: async (args) => {
39
+ try {
40
+ const result = await execFile("git", [...args], {
41
+ cwd: root,
42
+ encoding: "buffer"
43
+ });
44
+ return {
45
+ exitCode: 0,
46
+ stdout: result.stdout
47
+ };
48
+ }
49
+ catch (error) {
50
+ const failure = error;
51
+ return {
52
+ exitCode: failure.status ?? 1,
53
+ stdout: failure.stdout ?? new Uint8Array()
54
+ };
55
+ }
56
+ }
57
+ };
58
+ }
59
+ function stopVerificationOptions(input) {
60
+ const nativeRecursion = input.harness === "claude" &&
61
+ claudeStopRecursionMarker(input.rawInput);
62
+ const recursionActive = nativeRecursion || process.env[STOP_VERIFICATION_ENV] === "1";
63
+ const configHash = calculateConfigHash(input.config);
64
+ return {
65
+ confirmedConfig: true,
66
+ trusted: input.trusted,
67
+ scopeMapped: true,
68
+ recursionMarker: recursionActive,
69
+ configHash,
70
+ verify: async () => await new StopVerificationService({
71
+ root: input.root,
72
+ config: input.config,
73
+ trusted: input.trusted,
74
+ gitRunner: input.gitRunner,
75
+ processRunner: input.processRunner,
76
+ recursionActive,
77
+ configHash
78
+ }).verify()
79
+ };
80
+ }
81
+ function shouldBuildStopVerification(harness, event, config, rawInput) {
82
+ if (event !== "Stop" || !config.features.stopVerification.enabled) {
83
+ return false;
84
+ }
85
+ let normalized;
86
+ try {
87
+ normalized = harnessDescriptor(harness).runtime.normalizeInput(rawInput);
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ if (normalized.event !== "stop") {
93
+ return false;
94
+ }
95
+ return harnessDescriptor(harness).control.registrations.some(({ capability, support }) => capability === "optional-stop-verify" && support !== "unsupported");
96
+ }
20
97
  /**
21
98
  * Runs one hook invocation. Always resolves to exit code 0: a hook that
22
99
  * cannot answer must never block the harness it advises.
23
100
  */
24
- export async function runHookProcess(argv, io, cliVersion) {
101
+ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
25
102
  const [harness, event] = argv;
26
103
  if (harness === undefined ||
27
104
  !HARNESSES.has(harness) ||
28
105
  event === undefined ||
29
106
  !HOOK_EVENTS.includes(event)) {
30
- io.writeStderr("Usage: agent-ops hook <codex|claude> <SessionStart|PreToolUse|Stop>\n");
107
+ io.writeStderr(`Usage: agent-ops hook <${HARNESS_IDS.join("|")}> <SessionStart|PreToolUse|Stop>\n`);
31
108
  return 0;
32
109
  }
33
110
  try {
34
- const root = process.cwd();
35
- const config = (await loadEffectiveConfig(root, "project")).config;
111
+ const root = dependencies.root ?? process.cwd();
112
+ const config = dependencies.loadConfig === undefined
113
+ ? (await loadEffectiveConfig(root, "project")).config
114
+ : await dependencies.loadConfig(root);
115
+ const trustStatus = dependencies.trust === undefined
116
+ ? await repositoryTrust(root, config, cliVersion)
117
+ : await dependencies.trust(root, config, cliVersion);
118
+ const rawInput = await readStdin(io.stdin);
119
+ const parsedInput = parseInput(rawInput);
120
+ const trusted = trustStatus === "TRUSTED";
121
+ const gitRunner = dependencies.gitRunner ?? defaultGitRunner(root);
122
+ const processRunner = dependencies.processRunner ?? new NodeVerificationProcessRunner();
123
+ const stopVerification = shouldBuildStopVerification(harness, event, config, parsedInput)
124
+ ? stopVerificationOptions({
125
+ harness: harness,
126
+ rawInput: parsedInput,
127
+ root,
128
+ config,
129
+ trusted,
130
+ gitRunner,
131
+ processRunner
132
+ })
133
+ : undefined;
36
134
  const output = await runHookCommand({
37
135
  harness: harness,
38
136
  event: event,
39
- stdin: await readStdin(io.stdin),
137
+ stdin: rawInput,
40
138
  config,
41
- trusted: (await repositoryTrust(root, config, cliVersion)) === "TRUSTED"
139
+ trusted,
140
+ ...(dependencies.advisory === undefined
141
+ ? {}
142
+ : { advisory: dependencies.advisory }),
143
+ ...(stopVerification === undefined ? {} : { stopVerification })
42
144
  });
43
145
  if (output.stdout.length > 0) {
44
146
  io.writeStdout(output.stdout);
@@ -18,10 +18,15 @@ export function formatOperationPlan(options) {
18
18
  for (const operation of options.operations) {
19
19
  lines.push(`- ${operation.kind} ${escapeTerminalText(operation.path)}`, ` expected: ${operation.expectedHash ?? "<absent>"}`);
20
20
  if (operation.kind === "write") {
21
- lines.push(" content:", ...operation.content
22
- .replace(/\n$/u, "")
23
- .split("\n")
24
- .map((line) => ` ${escapeTerminalText(line)}`));
21
+ if ("contentHash" in operation) {
22
+ lines.push(` content: <opaque ${escapeTerminalText(operation.contentHash)}>`, ` summary: ${escapeTerminalText(operation.summary)}`);
23
+ }
24
+ else {
25
+ lines.push(" content:", ...operation.content
26
+ .replace(/\n$/u, "")
27
+ .split("\n")
28
+ .map((line) => ` ${escapeTerminalText(line)}`));
29
+ }
25
30
  }
26
31
  }
27
32
  return `${lines.join("\n")}\n`;
@@ -0,0 +1,62 @@
1
+ import { redactSecrets } from "../../../runtime/src/security/redact.js";
2
+ import { sha256 } from "../../../runtime/src/fs/hash.js";
3
+ const OPAQUE_SUMMARY = "Opaque managed settings content withheld.";
4
+ function publicPath(path) {
5
+ return redactSecrets(path);
6
+ }
7
+ export function disclosureOf(operation) {
8
+ return operation.disclosure ?? "full";
9
+ }
10
+ export function toPublicOperation(operation) {
11
+ const path = publicPath(operation.path);
12
+ if (operation.kind === "remove") {
13
+ return {
14
+ kind: "remove",
15
+ path,
16
+ expectedHash: operation.expectedHash
17
+ };
18
+ }
19
+ if (disclosureOf(operation) === "opaque") {
20
+ return {
21
+ kind: "write",
22
+ path,
23
+ expectedHash: operation.expectedHash,
24
+ contentHash: sha256(operation.content),
25
+ summary: redactSecrets(OPAQUE_SUMMARY)
26
+ };
27
+ }
28
+ return {
29
+ kind: "write",
30
+ path,
31
+ expectedHash: operation.expectedHash,
32
+ content: operation.content
33
+ };
34
+ }
35
+ export function toPublicOperations(operations) {
36
+ return operations.map(toPublicOperation);
37
+ }
38
+ export function toPublicInstallPlan(plan) {
39
+ return {
40
+ scope: plan.scope,
41
+ harness: plan.harness,
42
+ profiles: plan.profiles,
43
+ capabilities: plan.capabilities,
44
+ manifest: plan.manifest,
45
+ operations: toPublicOperations(plan.operations)
46
+ };
47
+ }
48
+ export function toPublicUpdatePlan(plan) {
49
+ return {
50
+ targetVersion: plan.targetVersion,
51
+ migrationSteps: plan.migrationSteps,
52
+ installation: toPublicInstallPlan(plan.installation)
53
+ };
54
+ }
55
+ export function toPublicUninstallPlan(plan) {
56
+ return {
57
+ installed: plan.installed,
58
+ manifest: plan.manifest,
59
+ manifestHash: plan.manifestHash,
60
+ operations: toPublicOperations(plan.operations)
61
+ };
62
+ }
@@ -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.5";