ultra11y 2.26.0 → 2.28.0

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 (36) hide show
  1. package/.agents/plugins/marketplace.json +12 -0
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +21 -0
  5. package/.opencode/plugins/ultra11y.js +102 -0
  6. package/README.md +34 -16
  7. package/hooks/hooks.json +1 -1
  8. package/hooks/pre-tool-use.mjs +19 -8
  9. package/package.json +6 -2
  10. package/scripts/sync-version.mjs +12 -4
  11. package/scripts/ultra11y.mjs +831 -150
  12. package/scripts/vendor/wcag-2.2-fr.json +1 -1
  13. package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
  14. package/scripts/verify-skill-bundle.mjs +134 -1
  15. package/skills/review-a11y/SKILL.md +5 -1
  16. package/skills/review-a11y/scripts/ultra11y.mjs +831 -150
  17. package/skills/ultra11y/SKILL.md +2 -1
  18. package/skills/ultra11y/references/automation.md +9 -4
  19. package/skills/ultra11y/references/harnesses.md +117 -0
  20. package/skills/ultra11y/scripts/ultra11y.mjs +831 -150
  21. package/src/cli.ts +116 -17
  22. package/src/data/wcag-universe.json +1 -1
  23. package/src/hook.ts +0 -0
  24. package/src/init.ts +26 -2
  25. package/src/install/agents-md.ts +78 -0
  26. package/src/install/claude-code.ts +61 -0
  27. package/src/install/codex.ts +166 -0
  28. package/src/install/index.ts +204 -0
  29. package/src/install/json-edit.ts +97 -0
  30. package/src/install/opencode.ts +90 -0
  31. package/src/install/paths.ts +132 -0
  32. package/src/install/text-edit.ts +54 -0
  33. package/src/types.ts +1 -1
  34. package/src/vendor/codeindex-engine.d.mts +3 -3
  35. package/src/vendor/codeindex-engine.mjs +23 -3
  36. package/src/vendor/engine.meta.json +5 -5
package/src/cli.ts CHANGED
@@ -32,7 +32,9 @@ import { runScanLocal, runScanManyLocal, runCrawlScanLocal, runSampleScanLocal,
32
32
  import { validateSample, lintSample, kindLabel } from "./sample.js";
33
33
  import { runFix, fixSummary } from "./fix.js";
34
34
  import { diffAgainstBaseline, baselineSummary, parseFailOn, findingsAtOrAbove } from "./baseline.js";
35
- import { repoRoot, writeHook, writeCi } from "./init.js";
35
+ import { repoRoot, resolveEnginePath, writeHook, writeCi } from "./init.js";
36
+ import { installForTargets, parseTargets, statusReport, uninstallForTargets } from "./install/index.js";
37
+ import { agentsMdBlock } from "./install/agents-md.js";
36
38
  import { auditSummary, captureCoverageSummary } from "./output.js";
37
39
  import { toSarif } from "./sarif.js";
38
40
  import { annotations, stepSummary } from "./annotate.js";
@@ -82,7 +84,10 @@ Usage:
82
84
  ultra11y snapshot list [--root <dir>] [--json]
83
85
  ultra11y pages --in <audit.json> [--standard <pack>] [--json] [--lang auto|en|fr] (the per-page criterion grid)
84
86
  ultra11y mcp [--transport stdio|http] [--cwd <dir>] [--allow-write] [--port <n>] [--bind <addr>] [--allow-remote] [--allow-origin <o>] [--max-response-bytes <n>]
85
- ultra11y hook --claude-code (internal: the plugin's PreToolUse hook; payload on stdin)
87
+ ultra11y hook --claude-code|--codex|--opencode (internal: the PreToolUse hook; payload on stdin)
88
+ ultra11y install --claude-code | --codex | --opencode | --agents-md | --all [--project] [--dry-run] [--no-skills]
89
+ ultra11y uninstall --claude-code | --codex | --opencode | --agents-md | --all [--project]
90
+ ultra11y status [--json] [--project] (doctor: which agents will run the review by themselves)
86
91
  ultra11y dev [--port <n>] [--root <dir>] [--standard <pack>] [--lang auto|en|fr] (dev side-car: live overlay + per-page dashboard)
87
92
  ultra11y dev --next [--port <n>] (write the Next overlay component, then wire one line into your layout)
88
93
 
@@ -386,6 +391,9 @@ const COMMANDS = [
386
391
  "pack",
387
392
  "orchestrate",
388
393
  "hook",
394
+ "install",
395
+ "uninstall",
396
+ "status",
389
397
  ] as const;
390
398
  type Command = (typeof COMMANDS)[number];
391
399
 
@@ -494,6 +502,15 @@ const BOOLEAN_FLAGS = new Set([
494
502
  "version",
495
503
  "allow-remote",
496
504
  "allow-write",
505
+ // Harness selectors, shared by `hook` and `install`/`uninstall`/`status`. Without these
506
+ // the parser warned "unknown flag --claude-code" on stderr on EVERY guarded shell call.
507
+ "claude-code",
508
+ "codex",
509
+ "opencode",
510
+ "agents-md",
511
+ "all",
512
+ "project",
513
+ "no-skills",
497
514
  ]);
498
515
  const KNOWN_FLAGS: ReadonlySet<string> = new Set<string>([...VALUE_FLAGS, ...BOOLEAN_FLAGS]);
499
516
  // Long flags whose repetition should accumulate (comma-joined) rather than last-wins,
@@ -1053,13 +1070,7 @@ async function cmdSnapshot(p: ParsedArgs): Promise<number> {
1053
1070
 
1054
1071
  function cmdInit(p: ParsedArgs): number {
1055
1072
  const root = repoRoot() ?? process.cwd();
1056
- let engineRel = process.argv[1] ?? "scripts/ultra11y.mjs";
1057
- try {
1058
- const abs = realpathSync(engineRel);
1059
- engineRel = abs.startsWith(root + sep) ? relative(root, abs) : abs;
1060
- } catch {
1061
- /* keep as-is */
1062
- }
1073
+ const engineRel = resolveEnginePath(root);
1063
1074
  const failOnParsed = parseFailOn(p.flags["fail-on"]);
1064
1075
  if (p.flags["fail-on"] !== undefined && failOnParsed === null) {
1065
1076
  console.error(`ultra11y init: --fail-on must be blocking|major|minor (got "${String(p.flags["fail-on"])}").`);
@@ -1089,25 +1100,107 @@ function cmdInit(p: ParsedArgs): number {
1089
1100
  return 0;
1090
1101
  }
1091
1102
 
1092
- /** `hook --claude-code` — the decision half of the plugin's PreToolUse hook. Reads the
1093
- * payload on stdin, prints the hook JSON when the pending commit/push/PR should get an
1094
- * accessibility review first, and prints NOTHING otherwise. Not meant to be run by hand:
1095
- * `hooks/pre-tool-use.mjs` is what Claude Code calls, and it only reaches here once its
1096
- * cheap prefilter has passed. Always exits 0 — see src/hook.ts for why. */
1103
+ /** `hook --claude-code|--codex|--opencode` — the decision half of the PreToolUse hook.
1104
+ * Reads the payload on stdin, emits when the pending commit/push/PR should get an
1105
+ * accessibility review first, and emits NOTHING otherwise. Not meant to be run by hand:
1106
+ * `hooks/pre-tool-use.mjs` (Claude Code, Codex) and `.opencode/plugins/ultra11y.js`
1107
+ * (OpenCode) are what call it, and only once their cheap prefilter has passed. Always
1108
+ * exits 0 — see src/hook.ts for why.
1109
+ *
1110
+ * Two output shapes:
1111
+ * - `--claude-code` / `--codex`: the `hookSpecificOutput` JSON envelope. Codex's PreToolUse
1112
+ * is a clone of Claude Code's and accepts exactly the one decision this emits (`deny`),
1113
+ * so the two are the same code path today. The flag is kept distinct anyway: it gives
1114
+ * `status` a marker to grep for, and it is where the branch goes the day they diverge.
1115
+ * - `--opencode`: plain text. OpenCode has no permission-decision channel — its plugin
1116
+ * blocks by throwing, with the message shown to the agent — so the message is assembled
1117
+ * here, in typed code, rather than in a committed .js file no type-checker covers. */
1097
1118
  async function cmdHook(p: ParsedArgs): Promise<number> {
1098
- if (p.flags["claude-code"] !== true) {
1099
- console.error("ultra11y hook: expected `hook --claude-code` (the payload is read on stdin).");
1119
+ const opencode = p.flags.opencode === true;
1120
+ if (!opencode && p.flags["claude-code"] !== true && p.flags.codex !== true) {
1121
+ console.error("ultra11y hook: expected `hook --claude-code`, `--codex` or `--opencode` (the payload is read on stdin).");
1100
1122
  return 2;
1101
1123
  }
1102
1124
  try {
1103
1125
  const decision = decide(JSON.parse(await readStdin()) as PreToolUsePayload);
1104
- if (decision) process.stdout.write(JSON.stringify(decision));
1126
+ if (!decision) return 0;
1127
+ const out = decision.hookSpecificOutput;
1128
+ process.stdout.write(opencode ? `${out.permissionDecisionReason}\n\n${out.additionalContext}` : JSON.stringify(decision));
1105
1129
  } catch {
1106
1130
  /* unreadable payload, engine failure — stay silent rather than break a git flow */
1107
1131
  }
1108
1132
  return 0;
1109
1133
  }
1110
1134
 
1135
+ const TARGET_FLAGS = "--claude-code | --codex | --opencode | --agents-md | --all";
1136
+
1137
+ /** `install --<harness>` — wire the automatic review into an agent on this machine.
1138
+ *
1139
+ * The native route is better where it exists (`/plugin install` on Claude Code,
1140
+ * `codex plugin add` on Codex): a plugin carries the skills and the hook together and
1141
+ * updates as one thing. This exists for what a plugin cannot do — wiring the gate for an
1142
+ * npm install, and the AGENTS.md fallback on harnesses with no plugin system at all. */
1143
+ function cmdInstall(p: ParsedArgs): number {
1144
+ const targets = parseTargets(p.flags);
1145
+ if (!targets) {
1146
+ console.error(`ultra11y install: pick at least one target: ${TARGET_FLAGS}`);
1147
+ console.error(" --all covers the agent harnesses; --agents-md is separate because it writes a tracked file into your repository.");
1148
+ return 2;
1149
+ }
1150
+ const dryRun = p.flags["dry-run"] === true;
1151
+ // --dry-run on the AGENTS.md target prints the block itself: it is the one output a user
1152
+ // may want to paste elsewhere (CLAUDE.md, GEMINI.md, .cursor/rules) by hand.
1153
+ if (dryRun && targets.includes("agents-md")) {
1154
+ console.log(agentsMdBlock(repoRoot() ?? process.cwd()));
1155
+ if (targets.length === 1) return 0;
1156
+ }
1157
+ const results = installForTargets({ targets, project: p.flags.project === true, dryRun, skills: p.flags["no-skills"] !== true });
1158
+ return reportInstall(results, dryRun ? "would wire" : "wired");
1159
+ }
1160
+
1161
+ function cmdUninstall(p: ParsedArgs): number {
1162
+ const targets = parseTargets(p.flags);
1163
+ if (!targets) {
1164
+ console.error(`ultra11y uninstall: pick at least one target: ${TARGET_FLAGS}`);
1165
+ return 2;
1166
+ }
1167
+ return reportInstall(uninstallForTargets({ targets, project: p.flags.project === true }), "removed");
1168
+ }
1169
+
1170
+ /** Shared printer. Exit 0 when every target succeeded, 1 when some did not — so a script
1171
+ * can tell "wired three of three" from "wired two and one is broken". */
1172
+ function reportInstall(results: ReturnType<typeof installForTargets>, verb: string): number {
1173
+ let failed = 0;
1174
+ for (const r of results) {
1175
+ if (r.error) {
1176
+ failed++;
1177
+ console.error(`ultra11y: ${r.error}`);
1178
+ continue;
1179
+ }
1180
+ const changed = r.reports.filter((x) => x.changed);
1181
+ if (changed.length === 0) console.log(`ultra11y ${r.target}: already ${verb} (nothing to do)`);
1182
+ for (const x of changed) console.log(`ultra11y ${r.target}: ${verb} ${x.path}${x.backup ? ` (backed up to ${x.backup})` : ""}`);
1183
+ for (const g of r.guidance) console.log(` ${g}`);
1184
+ }
1185
+ return failed ? 1 : 0;
1186
+ }
1187
+
1188
+ /** `status` — the doctor. Answers "is the automatic review actually going to fire?", which
1189
+ * is otherwise invisible until a commit that should have been stopped goes through. */
1190
+ function cmdStatus(p: ParsedArgs): number {
1191
+ const rows = statusReport({ project: p.flags.project === true });
1192
+ if (p.flags.json === true) {
1193
+ console.log(JSON.stringify({ version: VERSION, targets: rows }, null, 2));
1194
+ return 0;
1195
+ }
1196
+ console.log(`ultra11y ${VERSION}`);
1197
+ for (const r of rows) {
1198
+ console.log(` ${r.target.padEnd(12)} ${r.wired ? "wired " : "not wired"} ${r.path}`);
1199
+ if (r.note) console.log(` ${"".padEnd(12)} ${r.note}`);
1200
+ }
1201
+ return 0;
1202
+ }
1203
+
1111
1204
  function cmdCriteria(p: ParsedArgs): number {
1112
1205
  // --generate: emit the bundled WCAG references/criteria.md (no trailing newline; the
1113
1206
  // shell redirect / committed file owns that). Used by `pnpm run build:criteria`.
@@ -2390,6 +2483,12 @@ export async function main(argv: string[]): Promise<number> {
2390
2483
  return cmdMcp(p);
2391
2484
  case "hook":
2392
2485
  return cmdHook(p);
2486
+ case "install":
2487
+ return cmdInstall(p);
2488
+ case "uninstall":
2489
+ return cmdUninstall(p);
2490
+ case "status":
2491
+ return cmdStatus(p);
2393
2492
  default:
2394
2493
  console.error(`ultra11y: "${p.command}" is not implemented yet`);
2395
2494
  return 1;
@@ -2,7 +2,7 @@
2
2
  "wcagVersion": "2.2",
3
3
  "source": "https://www.w3.org/TR/WCAG22/",
4
4
  "criteriaSource": "https://github.com/w3c/wcag",
5
- "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-07-31 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
5
+ "provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-01 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
6
6
  "criteria": [
7
7
  {
8
8
  "id": "1.1.1",
package/src/hook.ts CHANGED
Binary file
package/src/init.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  // --write --safe`) and re-stages them, blocking only on issues that need judgment. The
6
6
  // legacy baseline regression gate (`audit --changed --baseline … --fail-on …`, blocking
7
7
  // only NEW findings) stays available via `init --baseline` and the CI workflow.
8
- import { writeFileSync, mkdirSync, chmodSync } from "node:fs";
8
+ import { writeFileSync, mkdirSync, chmodSync, realpathSync } from "node:fs";
9
9
  import { execFileSync } from "node:child_process";
10
- import { join } from "node:path";
10
+ import { join, relative, sep } from "node:path";
11
11
  import { VERSION, type Severity } from "./types.js";
12
12
 
13
13
  // English --fail-on token written into generated hooks/CI (the tool is English-first;
@@ -22,6 +22,30 @@ export function repoRoot(): string | null {
22
22
  }
23
23
  }
24
24
 
25
+ /** Where the running engine lives, as a path to bake into a generated file. Relative to
26
+ * `root` when the bundle sits inside it (so a committed git hook keeps working after a
27
+ * clone), absolute otherwise. Callers that need a runnable COMMAND, not a path, should
28
+ * use `engineInvocation`. */
29
+ export function resolveEnginePath(root: string): string {
30
+ const argv1 = process.argv[1] ?? "scripts/ultra11y.mjs";
31
+ try {
32
+ const abs = realpathSync(argv1);
33
+ return abs.startsWith(root + sep) ? relative(root, abs) : abs;
34
+ } catch {
35
+ return argv1;
36
+ }
37
+ }
38
+
39
+ /** The command a human or an agent can paste to run the engine from `root`.
40
+ * `node <relpath>` when the bundle is vendored in the repo (no install, no network),
41
+ * `npx -y ultra11y` when it lives outside — where a bare path would break the moment the
42
+ * reader is on another machine. Used by the AGENTS.md block, which is read by agents that
43
+ * have no skill system and so cannot be handed a resolved path. */
44
+ export function engineInvocation(root: string): string {
45
+ const p = resolveEnginePath(root);
46
+ return p.startsWith("/") || p.startsWith("..") ? "npx -y ultra11y" : `node ${p}`;
47
+ }
48
+
25
49
  /** POSIX `sh` pre-commit hook (default, strict staged snapshot). Operates on EXACTLY the
26
50
  * staged index blobs (what the commit records, not the working tree): first auto-applies
27
51
  * SAFE deterministic fixes and re-stages them, then blocks the commit only if issues >=
@@ -0,0 +1,78 @@
1
+ // The AGENTS.md fallback — for harnesses with no skill system AND no hook system
2
+ // (Cursor, Amp, Zed, Gemini CLI, Windsurf, aider…).
3
+ //
4
+ // Be honest about what this is: it CANNOT make anything automatic. There is no channel to
5
+ // deny a command on, so nothing stops a commit. What it can do is two things the agent
6
+ // cannot do for itself — make the engine discoverable, and hand over the adjudication
7
+ // protocol so the findings are ruled on instead of pasted at the user. The real enforcement
8
+ // on such a harness is the repo's own git hook (`init --hook`), which is why the block
9
+ // closes by saying so.
10
+ //
11
+ // The block is GENERATED rather than committed as a static .md so `--dry-run` and the
12
+ // writer emit the same bytes, and so the engine invocation is resolved for the reader's
13
+ // actual checkout rather than guessed.
14
+ import type { EditReport } from "./json-edit.js";
15
+ import { engineInvocation } from "../init.js";
16
+ import { hasManagedBlock, removeManagedBlock, upsertManagedBlock } from "./text-edit.js";
17
+ import { join } from "node:path";
18
+
19
+ /** Where the block goes: the repository root, next to the agent instructions already there. */
20
+ export function agentsMdPath(root: string): string {
21
+ return join(root, "AGENTS.md");
22
+ }
23
+
24
+ /** The managed block's body, with the engine invocation resolved for `root`. */
25
+ export function agentsMdBlock(root: string): string {
26
+ const e = engineInvocation(root);
27
+ return `## Accessibility (ultra11y)
28
+
29
+ This repository is checked against **WCAG 2.2 AA** with \`ultra11y\`: a zero-dependency
30
+ static engine — no install, no API key, no network.
31
+
32
+ **Before you commit, push, or open a pull request**, whenever you changed HTML, CSS,
33
+ JSX/TSX, Vue, Svelte or Astro:
34
+
35
+ \`\`\`sh
36
+ ${e} audit --staged --graph # exactly what the commit would record
37
+ ${e} audit --since origin/main --graph # the whole branch, for a pull request
38
+ \`\`\`
39
+
40
+ \`--graph\` is not optional on JSX/TSX: it turns on the real cross-file analysis (an
41
+ icon-only component used without a name, a label defined in another file) and it suppresses
42
+ a whole class of single-file false positives.
43
+
44
+ Then **adjudicate**. Do not paste the output at the user.
45
+
46
+ 1. Read every finding at its \`file:line\` in the real code. Refute the ones the code
47
+ disproves, and say what disproves them. Findings inside library sources or single-file
48
+ components are **preliminary**: the audited artifact is the RENDERED HTML.
49
+ 2. Rule on the judgment criteria yourself, from the code — is this \`alt\` relevant, is this
50
+ link's purpose clear in context, is the reading order right.
51
+ \`${e} verify --report <md> --in <audit.json> --manual\` emits the worklist and the
52
+ per-criterion decision protocol.
53
+ 3. Name what source cannot decide — computed contrast, visible focus, zoom and reflow,
54
+ content on hover — as **residual risks**. Never call them conforming.
55
+ 4. **Never invent a non-conformity.** \`${e} check --report <file>\` fails the report when a
56
+ cited element does not resolve. Run it before claiming anything.
57
+ 5. Apply the safe deterministic fixes with \`${e} fix --staged --write --safe\` and re-stage.
58
+ What remains needs judgment and the user's agreement.
59
+
60
+ Severity vocabulary: \`blocking\` > \`major\` > \`minor\`. Block the work on \`blocking\`.
61
+
62
+ To make this automatic without relying on an agent, wire the repo itself — \`${e} init --hook\`
63
+ (git pre-commit) and \`${e} init --ci\` (GitHub Actions) are harness-independent and are the
64
+ real safety net here. Full audits, dated conformance reports, RGAA packs and remediation
65
+ backlogs: \`${e} --help\`.`;
66
+ }
67
+
68
+ export function installAgentsMd(root: string): EditReport {
69
+ return upsertManagedBlock(agentsMdPath(root), agentsMdBlock(root));
70
+ }
71
+
72
+ export function uninstallAgentsMd(root: string): EditReport {
73
+ return removeManagedBlock(agentsMdPath(root));
74
+ }
75
+
76
+ export function agentsMdWired(root: string): boolean {
77
+ return hasManagedBlock(agentsMdPath(root));
78
+ }
@@ -0,0 +1,61 @@
1
+ // Wiring the gate into Claude Code's settings.json.
2
+ //
3
+ // This is NOT the recommended route — `/plugin install ultra11y@ultra11y` is, because a
4
+ // plugin carries the skills and the hook together and updates as one thing. This exists for
5
+ // the case the plugin cannot serve: ultra11y installed from npm as a dev dependency, where
6
+ // the user wants the automatic review without adopting a marketplace.
7
+ //
8
+ // Ownership is tracked by the MARKER inside the command string, so uninstall removes
9
+ // exactly the hook entries this wrote and leaves a user's own PreToolUse hooks untouched.
10
+ import { type EditReport, editJsonFile, readJsonSafe } from "./json-edit.js";
11
+
12
+ /** Present in every command we write, and in nothing else. */
13
+ export const CLAUDE_MARKER = "hook --claude-code";
14
+
15
+ /** The tool names the gate must see. Same set as hooks/hooks.json — Claude Code names its
16
+ * shell `Bash`; the rest are here so one settings entry also covers a harness that reuses
17
+ * this file format with a differently-named shell. */
18
+ const MATCHER = "Bash|bash|shell|local_shell|exec_command|run_command|exec";
19
+
20
+ interface HookGroup {
21
+ matcher?: string;
22
+ hooks?: Array<{ type?: string; command?: string; timeout?: number }>;
23
+ }
24
+
25
+ /** Drop every group whose hooks are all ours, and prune ours out of mixed groups. Written
26
+ * so a user who added their own PreToolUse hook next to ours keeps it. */
27
+ function withoutOurs(groups: unknown): HookGroup[] {
28
+ if (!Array.isArray(groups)) return [];
29
+ return (groups as HookGroup[])
30
+ .map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !String(h.command ?? "").includes(CLAUDE_MARKER)) }))
31
+ .filter((g) => (g.hooks ?? []).length > 0);
32
+ }
33
+
34
+ export function installClaudeCode({ settingsPath, command }: { settingsPath: string; command: string }): EditReport {
35
+ return editJsonFile(settingsPath, (root) => {
36
+ const hooks = (root.hooks ??= {}) as Record<string, unknown>;
37
+ hooks.PreToolUse = [
38
+ ...withoutOurs(hooks.PreToolUse),
39
+ { matcher: MATCHER, hooks: [{ type: "command", command: `${command} ${CLAUDE_MARKER}`, timeout: 30 }] },
40
+ ];
41
+ });
42
+ }
43
+
44
+ export function uninstallClaudeCode({ settingsPath }: { settingsPath: string }): EditReport {
45
+ return editJsonFile(settingsPath, (root) => {
46
+ const hooks = root.hooks as Record<string, unknown> | undefined;
47
+ if (!hooks || typeof hooks !== "object") return;
48
+ const kept = withoutOurs(hooks.PreToolUse);
49
+ if (kept.length > 0) hooks.PreToolUse = kept;
50
+ else delete hooks.PreToolUse;
51
+ if (Object.keys(hooks).length === 0) delete root.hooks;
52
+ });
53
+ }
54
+
55
+ /** How many of our hook entries this settings file carries. */
56
+ export function claudeCodeWired(settingsPath: string): number {
57
+ const root = readJsonSafe(settingsPath);
58
+ const groups = (root?.hooks as Record<string, unknown> | undefined)?.PreToolUse;
59
+ if (!Array.isArray(groups)) return 0;
60
+ return (groups as HookGroup[]).reduce((n, g) => n + (g.hooks ?? []).filter((h) => String(h.command ?? "").includes(CLAUDE_MARKER)).length, 0);
61
+ }
@@ -0,0 +1,166 @@
1
+ // Wiring the gate into the OpenAI Codex CLI.
2
+ //
3
+ // Codex's hook engine is a near-clone of Claude Code's: the same event names, the same
4
+ // payload fields, the same `hookSpecificOutput` envelope, and it exports ${CLAUDE_PLUGIN_ROOT}
5
+ // alongside its own ${PLUGIN_ROOT}. On PreToolUse it accepts exactly ONE decision — `deny`,
6
+ // with a non-empty reason — which is precisely and only what this engine emits. So the
7
+ // decision half needs no Codex-specific branch; `hook --codex` is the same code path.
8
+ //
9
+ // Two things ARE Codex-specific and are why this file exists:
10
+ // 1. hooks are behind a feature flag: `[features] hooks = true` in config.toml;
11
+ // 2. Codex asks the user to trust a hook command before running it, recording the answer
12
+ // as a sha256 in `[hooks.state]`. That hash is NOT forged here — see below.
13
+ //
14
+ // The native route (`codex plugin marketplace add maxgfr/ultra11y` then `codex plugin add`)
15
+ // is better where the user wants it: it carries the skills and the hook together. This
16
+ // exists for an npm install, and for users who do not want a marketplace.
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { type EditReport, editJsonFile, readJsonSafe, writeTextWithBackup } from "./json-edit.js";
20
+ import { copySkillsInto } from "./paths.js";
21
+
22
+ /** Present in every command we write, and in nothing else. */
23
+ export const CODEX_MARKER = "hook --codex";
24
+
25
+ /** Codex's shell tool is `shell`; the alternation also covers its variants and Claude
26
+ * Code's `Bash`, so one entry is right whichever name a given Codex build uses. */
27
+ const MATCHER = "Bash|bash|shell|local_shell|exec_command|run_command|exec";
28
+
29
+ interface HookGroup {
30
+ matcher?: string;
31
+ hooks?: Array<{ type?: string; command?: string; timeout?: number; statusMessage?: string }>;
32
+ }
33
+
34
+ function withoutOurs(groups: unknown): HookGroup[] {
35
+ if (!Array.isArray(groups)) return [];
36
+ return (groups as HookGroup[])
37
+ .map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !String(h.command ?? "").includes(CODEX_MARKER)) }))
38
+ .filter((g) => (g.hooks ?? []).length > 0);
39
+ }
40
+
41
+ /** Turn on `[features] hooks = true`, whatever shape the user's config.toml is already in.
42
+ *
43
+ * Hand-rolled rather than a TOML library because the whole job is one key, and because a
44
+ * round-trip through a parser would reformat a file full of the user's own comments and
45
+ * ordering. Three cases: the key is already there (possibly `false`), the `[features]`
46
+ * table exists without it, or neither exists. */
47
+ export function enableHooksFeature(content: string): { content: string; changed: boolean } {
48
+ if (HOOKS_ON.test(featuresBody(content))) return { content, changed: false };
49
+
50
+ const body = featuresBody(content);
51
+ if (HOOKS_ANY.test(body)) {
52
+ // Flip it in place, without disturbing anything else in the table.
53
+ return { content: content.replace(body, body.replace(HOOKS_ANY, "hooks = true")), changed: true };
54
+ }
55
+ if (FEATURES_HEADER.test(content)) {
56
+ return { content: content.replace(FEATURES_HEADER, "$&\nhooks = true"), changed: true };
57
+ }
58
+ // No [features] table at all. Prepend it: a top-level table has to precede any other
59
+ // table header, or its keys would be swallowed by whichever table came before.
60
+ const prefix = content.trim() === "" ? "" : "\n";
61
+ return { content: `[features]\nhooks = true\n${prefix}${content}`, changed: true };
62
+ }
63
+
64
+ /** Remove the key we added, leaving an empty `[features]` table rather than guessing
65
+ * whether the user wanted it. */
66
+ export function disableHooksFeature(content: string): { content: string; changed: boolean } {
67
+ const body = featuresBody(content);
68
+ if (!HOOKS_ON.test(body)) return { content, changed: false };
69
+ return { content: content.replace(body, body.replace(HOOKS_ON_LINE, "")), changed: true };
70
+ }
71
+
72
+ // Every whitespace class here is HORIZONTAL ONLY (`[ \t]`, never `\s`). A TOML key lives on
73
+ // its own line, and `\s*` at either end of the pattern happily swallows the surrounding
74
+ // newlines — which collapses `[features]\nhooks = false\n` into `[features]hooks = true`,
75
+ // silently corrupting the user's config into a file Codex can no longer read.
76
+ const HOOKS_ON = /^[ \t]*hooks[ \t]*=[ \t]*true[ \t]*$/m;
77
+ const HOOKS_ON_LINE = /^[ \t]*hooks[ \t]*=[ \t]*true[ \t]*\n?/m;
78
+ const HOOKS_ANY = /^[ \t]*hooks[ \t]*=[ \t]*(?:false|true)[ \t]*$/m;
79
+ const FEATURES_HEADER = /^[ \t]*\[features\][ \t]*$/m;
80
+ const ANY_TABLE_HEADER = /^[ \t]*\[/m;
81
+
82
+ /** The text between `[features]` and the next table header — the only region where a bare
83
+ * `hooks = true` means what we want. Without this, a `hooks = true` under some other table
84
+ * would be mistaken for ours. */
85
+ function featuresBody(content: string): string {
86
+ const m = FEATURES_HEADER.exec(content);
87
+ if (!m) return "";
88
+ const rest = content.slice(m.index + m[0].length);
89
+ const next = ANY_TABLE_HEADER.exec(rest);
90
+ return rest.slice(0, next ? next.index : undefined);
91
+ }
92
+
93
+ export interface CodexInstall {
94
+ codexDir: string;
95
+ command: string;
96
+ /** Copy the two skills into ~/.codex/skills/ as well. */
97
+ skillsSource?: string | null;
98
+ }
99
+
100
+ export function installCodex({ codexDir, command, skillsSource }: CodexInstall): { reports: EditReport[]; guidance: string[] } {
101
+ const reports: EditReport[] = [];
102
+
103
+ reports.push(
104
+ editJsonFile(join(codexDir, "hooks.json"), (root) => {
105
+ const hooks = (root.hooks ??= {}) as Record<string, unknown>;
106
+ hooks.PreToolUse = [
107
+ ...withoutOurs(hooks.PreToolUse),
108
+ { matcher: MATCHER, hooks: [{ type: "command", command: `${command} ${CODEX_MARKER}`, timeout: 30, statusMessage: "ultra11y" }] },
109
+ ];
110
+ }),
111
+ );
112
+
113
+ const configPath = join(codexDir, "config.toml");
114
+ const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
115
+ const edit = enableHooksFeature(current);
116
+ if (edit.changed) reports.push(writeTextWithBackup(configPath, edit.content));
117
+
118
+ if (skillsSource) reports.push(...copySkillsInto(skillsSource, join(codexDir, "skills")));
119
+
120
+ return {
121
+ reports,
122
+ guidance: [
123
+ // Deliberately NOT forging the trusted_hash. Reproducing Codex's canonicalisation
124
+ // exactly is a bet that breaks silently, in the user's home directory, the next time
125
+ // Codex changes it — and the failure mode of a wrong hash is a gate that never fires.
126
+ "codex: Codex will ask you to trust this hook the first time it fires — accept it, or review it with /hooks.",
127
+ "codex: restart the session — hooks load at startup.",
128
+ "codex: `codex plugin marketplace add maxgfr/ultra11y` then `codex plugin add ultra11y@ultra11y` is the native route; it ships the skills and the hook together.",
129
+ ],
130
+ };
131
+ }
132
+
133
+ export function uninstallCodex({ codexDir }: { codexDir: string }): { reports: EditReport[]; guidance: string[] } {
134
+ const reports: EditReport[] = [];
135
+ const hooksPath = join(codexDir, "hooks.json");
136
+ if (existsSync(hooksPath)) {
137
+ reports.push(
138
+ editJsonFile(hooksPath, (root) => {
139
+ const hooks = root.hooks as Record<string, unknown> | undefined;
140
+ if (!hooks || typeof hooks !== "object") return;
141
+ const kept = withoutOurs(hooks.PreToolUse);
142
+ if (kept.length > 0) hooks.PreToolUse = kept;
143
+ else delete hooks.PreToolUse;
144
+ if (Object.keys(hooks).length === 0) delete root.hooks;
145
+ }),
146
+ );
147
+ }
148
+ // `[features] hooks = true` is deliberately LEFT ALONE: other tools (and the user) may
149
+ // rely on it, and turning it off would silently disable their hooks too.
150
+ return { reports, guidance: ["codex: `[features] hooks = true` was left enabled — other hooks may depend on it."] };
151
+ }
152
+
153
+ /** How many of our hook entries this Codex home carries. */
154
+ export function codexWired(codexDir: string): number {
155
+ const root = readJsonSafe(join(codexDir, "hooks.json"));
156
+ const groups = (root?.hooks as Record<string, unknown> | undefined)?.PreToolUse;
157
+ if (!Array.isArray(groups)) return 0;
158
+ return (groups as HookGroup[]).reduce((n, g) => n + (g.hooks ?? []).filter((h) => String(h.command ?? "").includes(CODEX_MARKER)).length, 0);
159
+ }
160
+
161
+ /** True when Codex would actually run hooks at all. A wired hook behind a disabled feature
162
+ * flag is the silent failure this exists to surface. */
163
+ export function codexHooksEnabled(codexDir: string): boolean {
164
+ const p = join(codexDir, "config.toml");
165
+ return existsSync(p) && HOOKS_ON.test(featuresBody(readFileSync(p, "utf8")));
166
+ }