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.
- package/.agents/plugins/marketplace.json +12 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +21 -0
- package/.opencode/plugins/ultra11y.js +102 -0
- package/README.md +34 -16
- package/hooks/hooks.json +1 -1
- package/hooks/pre-tool-use.mjs +19 -8
- package/package.json +6 -2
- package/scripts/sync-version.mjs +12 -4
- package/scripts/ultra11y.mjs +831 -150
- package/scripts/vendor/wcag-2.2-fr.json +1 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/scripts/verify-skill-bundle.mjs +134 -1
- package/skills/review-a11y/SKILL.md +5 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +831 -150
- package/skills/ultra11y/SKILL.md +2 -1
- package/skills/ultra11y/references/automation.md +9 -4
- package/skills/ultra11y/references/harnesses.md +117 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +831 -150
- package/src/cli.ts +116 -17
- package/src/data/wcag-universe.json +1 -1
- package/src/hook.ts +0 -0
- package/src/init.ts +26 -2
- package/src/install/agents-md.ts +78 -0
- package/src/install/claude-code.ts +61 -0
- package/src/install/codex.ts +166 -0
- package/src/install/index.ts +204 -0
- package/src/install/json-edit.ts +97 -0
- package/src/install/opencode.ts +90 -0
- package/src/install/paths.ts +132 -0
- package/src/install/text-edit.ts +54 -0
- package/src/types.ts +1 -1
- package/src/vendor/codeindex-engine.d.mts +3 -3
- package/src/vendor/codeindex-engine.mjs +23 -3
- 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
|
|
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
|
-
|
|
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
|
|
1093
|
-
* payload on stdin,
|
|
1094
|
-
* accessibility review first, and
|
|
1095
|
-
* `hooks/pre-tool-use.mjs`
|
|
1096
|
-
*
|
|
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
|
-
|
|
1099
|
-
|
|
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)
|
|
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-
|
|
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
|
+
}
|