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
@@ -0,0 +1,204 @@
1
+ // `install` / `uninstall` / `status` — wiring the automatic review into an agent.
2
+ //
3
+ // Every function here is pure of console and process.exit: they take explicit paths and
4
+ // return report objects. That is what lets the whole surface be driven from vitest against
5
+ // a temporary HOME without spawning a process, and it is why the CLI layer above owns all
6
+ // the printing.
7
+ import { claudeCodeWired, installClaudeCode, uninstallClaudeCode } from "./claude-code.js";
8
+ import { agentsMdPath, agentsMdWired, installAgentsMd, uninstallAgentsMd } from "./agents-md.js";
9
+ import { codexHooksEnabled, codexWired, installCodex, uninstallCodex } from "./codex.js";
10
+ import { installOpencode, opencodeWired, uninstallOpencode } from "./opencode.js";
11
+ import {
12
+ bundledOpencodePlugin,
13
+ bundledSkillsDir,
14
+ claudeSettingsPath,
15
+ codexHome,
16
+ installedCliCommand,
17
+ opencodeConfigDir,
18
+ packageRoot,
19
+ pinnedEnginePath,
20
+ } from "./paths.js";
21
+ import { type EditReport, SettingsParseError } from "./json-edit.js";
22
+ import { repoRoot } from "../init.js";
23
+ import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ export type Target = "claude-code" | "codex" | "opencode" | "agents-md";
27
+
28
+ /** The harnesses `--all` covers.
29
+ *
30
+ * `agents-md` is deliberately NOT in this list. It is the only target that writes a
31
+ * TRACKED FILE INTO THE USER'S REPOSITORY; every other one edits a dotfile under their
32
+ * home. `--all` must never hand someone an unexpected new file to review in `git status`,
33
+ * so AGENTS.md is always an explicit ask. */
34
+ export const ALL_TARGETS: readonly Target[] = ["claude-code", "codex", "opencode"];
35
+
36
+ export interface TargetResult {
37
+ target: Target;
38
+ /** What was written, when anything was. */
39
+ reports: EditReport[];
40
+ /** Things the user has to know: a restart, a trust prompt, a limitation. */
41
+ guidance: string[];
42
+ error?: string;
43
+ }
44
+
45
+ export interface InstallOptions {
46
+ targets: readonly Target[];
47
+ /** Scope the install to the current project instead of the user's home, where supported. */
48
+ project?: boolean;
49
+ cwd?: string;
50
+ /** Render what would be written and touch nothing. */
51
+ dryRun?: boolean;
52
+ /** Copy the bundled skills into the harness's skills directory too (default: yes). */
53
+ skills?: boolean;
54
+ }
55
+
56
+ /** Parse `--claude-code --codex --opencode --agents-md --all` into a target list.
57
+ * Returns `null` when nothing was selected, which the CLI turns into a usage error. */
58
+ export function parseTargets(flags: Record<string, unknown>): Target[] | null {
59
+ const picked = new Set<Target>();
60
+ if (flags.all === true) for (const t of ALL_TARGETS) picked.add(t);
61
+ if (flags["claude-code"] === true) picked.add("claude-code");
62
+ if (flags.codex === true) picked.add("codex");
63
+ if (flags.opencode === true) picked.add("opencode");
64
+ if (flags["agents-md"] === true) picked.add("agents-md");
65
+ return picked.size ? [...picked] : null;
66
+ }
67
+
68
+ /** The repository the project-scoped targets act on. */
69
+ function projectRoot(cwd: string): string {
70
+ return repoRoot() ?? cwd;
71
+ }
72
+
73
+ export function installForTargets(opts: InstallOptions): TargetResult[] {
74
+ const cwd = opts.cwd ?? process.cwd();
75
+ const out: TargetResult[] = [];
76
+ // Resolved once: it may pin a copy of the engine, which should happen at most once per run.
77
+ let command: string | null = null;
78
+ const cmd = () => (command ??= installedCliCommand());
79
+
80
+ for (const target of opts.targets) {
81
+ const r: TargetResult = { target, reports: [], guidance: [] };
82
+ try {
83
+ switch (target) {
84
+ case "claude-code": {
85
+ const settingsPath = claudeSettingsPath(opts.project === true, cwd);
86
+ if (!opts.dryRun) r.reports.push(installClaudeCode({ settingsPath, command: cmd() }));
87
+ else r.reports.push({ path: settingsPath, changed: false });
88
+ r.guidance.push(
89
+ "claude-code: the plugin route (`/plugin marketplace add maxgfr/ultra11y` then `/plugin install ultra11y@ultra11y`) ships the skills too — this settings hook only wires the gate.",
90
+ "claude-code: restart the session — hooks load at startup.",
91
+ );
92
+ break;
93
+ }
94
+ case "codex": {
95
+ // Codex reads hooks only from its own home; there is no project scope to honour.
96
+ if (opts.project === true) r.guidance.push("codex: --project is not supported (Codex reads hooks from its home only) — wiring the user scope.");
97
+ if (!opts.dryRun) {
98
+ const out = installCodex({ codexDir: codexHome(), command: cmd(), skillsSource: opts.skills === false ? null : bundledSkillsDir() });
99
+ r.reports.push(...out.reports);
100
+ r.guidance.push(...out.guidance);
101
+ } else r.reports.push({ path: join(codexHome(), "hooks.json"), changed: false });
102
+ break;
103
+ }
104
+ case "opencode": {
105
+ const source = bundledOpencodePlugin();
106
+ if (!source) {
107
+ r.error = "opencode: this engine was not run from a package carrying .opencode/plugins/ultra11y.js — install ultra11y from npm and retry.";
108
+ break;
109
+ }
110
+ const configDir = opts.project === true ? join(cwd, ".opencode") : opencodeConfigDir();
111
+ if (!opts.dryRun) {
112
+ const engine = existsSync(pinnedEnginePath()) ? pinnedEnginePath() : join(packageRoot() ?? "", "scripts", "ultra11y.mjs");
113
+ const out = installOpencode({
114
+ configDir,
115
+ pluginSource: source,
116
+ skillsSource: opts.skills === false ? null : bundledSkillsDir(),
117
+ enginePath: existsSync(engine) ? engine : null,
118
+ });
119
+ r.reports.push(...out.reports);
120
+ r.guidance.push(...out.guidance);
121
+ } else r.reports.push({ path: join(configDir, "plugin", "ultra11y.js"), changed: false });
122
+ break;
123
+ }
124
+ case "agents-md": {
125
+ const root = projectRoot(cwd);
126
+ if (!opts.dryRun) r.reports.push(installAgentsMd(root));
127
+ else r.reports.push({ path: agentsMdPath(root), changed: false });
128
+ r.guidance.push(
129
+ "agents-md: this is a tracked file in your repository — review it and commit it.",
130
+ "agents-md: it cannot make anything automatic (no hook API); `ultra11y init --hook` is what actually enforces the gate there.",
131
+ );
132
+ break;
133
+ }
134
+ }
135
+ } catch (e) {
136
+ r.error = e instanceof SettingsParseError ? e.message : `${target}: ${(e as Error).message}`;
137
+ }
138
+ out.push(r);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ export function uninstallForTargets(opts: InstallOptions): TargetResult[] {
144
+ const cwd = opts.cwd ?? process.cwd();
145
+ const out: TargetResult[] = [];
146
+ for (const target of opts.targets) {
147
+ const r: TargetResult = { target, reports: [], guidance: [] };
148
+ try {
149
+ switch (target) {
150
+ case "claude-code":
151
+ r.reports.push(uninstallClaudeCode({ settingsPath: claudeSettingsPath(opts.project === true, cwd) }));
152
+ break;
153
+ case "codex": {
154
+ const out = uninstallCodex({ codexDir: codexHome() });
155
+ r.reports.push(...out.reports);
156
+ r.guidance.push(...out.guidance);
157
+ break;
158
+ }
159
+ case "opencode": {
160
+ const out = uninstallOpencode({ configDir: opts.project === true ? join(cwd, ".opencode") : opencodeConfigDir() });
161
+ r.reports.push(...out.reports);
162
+ r.guidance.push(...out.guidance);
163
+ break;
164
+ }
165
+ case "agents-md":
166
+ r.reports.push(uninstallAgentsMd(projectRoot(cwd)));
167
+ break;
168
+ }
169
+ } catch (e) {
170
+ r.error = e instanceof SettingsParseError ? e.message : `${target}: ${(e as Error).message}`;
171
+ }
172
+ out.push(r);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ export interface StatusRow {
178
+ target: Target;
179
+ wired: boolean;
180
+ path: string;
181
+ note?: string;
182
+ }
183
+
184
+ /** The doctor. Never throws — a broken settings file is a row to report, not a crash. */
185
+ export function statusReport(opts: { project?: boolean; cwd?: string } = {}): StatusRow[] {
186
+ const cwd = opts.cwd ?? process.cwd();
187
+ const claudePath = claudeSettingsPath(opts.project === true, cwd);
188
+ const root = projectRoot(cwd);
189
+ const codexDir = codexHome();
190
+ const ocDir = opts.project === true ? join(cwd, ".opencode") : opencodeConfigDir();
191
+ const codexOn = codexWired(codexDir) > 0;
192
+ return [
193
+ { target: "claude-code", wired: claudeCodeWired(claudePath) > 0, path: claudePath, note: "the plugin route wires this separately and does not show here" },
194
+ {
195
+ target: "codex",
196
+ wired: codexOn,
197
+ path: join(codexDir, "hooks.json"),
198
+ // A wired hook behind a disabled feature flag never fires and looks fine. Say so.
199
+ note: codexOn && !codexHooksEnabled(codexDir) ? "WIRED BUT INERT: `[features] hooks = true` is not set in config.toml" : undefined,
200
+ },
201
+ { target: "opencode", wired: opencodeWired(ocDir), path: join(ocDir, "plugin", "ultra11y.js") },
202
+ { target: "agents-md", wired: agentsMdWired(root), path: agentsMdPath(root) },
203
+ ];
204
+ }
@@ -0,0 +1,97 @@
1
+ // Editing files that belong to the USER — their ~/.claude/settings.json, their
2
+ // ~/.codex/hooks.json, their AGENTS.md. Three rules govern everything here, and they are
3
+ // the reason this module exists instead of a few writeFileSync calls:
4
+ //
5
+ // 1. NEVER lose data. Anything pre-existing is backed up under a timestamped name before
6
+ // it is touched, and writes are atomic (tmp + rename) so an interrupted install cannot
7
+ // leave a half-written settings file that breaks the user's agent on next launch.
8
+ // 2. NEVER surprise. A no-op edit writes nothing and takes no backup, so running `install`
9
+ // twice does not litter the directory with copies of an unchanged file.
10
+ // 3. NEVER guess. A settings file that does not parse is reported, not overwritten — the
11
+ // user has hand-edited JSON in there and silently replacing it would be data loss.
12
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
13
+ import { dirname } from "node:path";
14
+
15
+ /** What an edit did. `changed: false` means the file already said what we wanted. */
16
+ export interface EditReport {
17
+ path: string;
18
+ changed: boolean;
19
+ /** Path of the backup taken before overwriting, when one was needed. */
20
+ backup?: string;
21
+ }
22
+
23
+ /** A user file we refuse to touch because we cannot read it. Carries the path so the CLI
24
+ * can tell the user exactly what to fix rather than "install failed". */
25
+ export class SettingsParseError extends Error {
26
+ constructor(
27
+ readonly path: string,
28
+ cause: string,
29
+ ) {
30
+ super(`${path} is not valid JSON (${cause}) — fix or move it, then run install again. It has NOT been modified.`);
31
+ this.name = "SettingsParseError";
32
+ }
33
+ }
34
+
35
+ /** A timestamp safe for a filename on every platform. */
36
+ function stamp(): string {
37
+ return new Date().toISOString().replaceAll(/[:.]/g, "-");
38
+ }
39
+
40
+ /** Write `content` to `path` atomically, backing up any existing file first. The tmp file
41
+ * is created in the SAME directory so the rename is a same-filesystem move (an atomic
42
+ * operation) rather than a copy that can be observed half-done. */
43
+ export function writeTextWithBackup(path: string, content: string, marker = "ultra11y"): EditReport {
44
+ if (existsSync(path) && readFileSync(path, "utf8") === content) return { path, changed: false };
45
+ mkdirSync(dirname(path), { recursive: true });
46
+ let backup: string | undefined;
47
+ if (existsSync(path)) {
48
+ backup = `${path}.${marker}-backup-${stamp()}`;
49
+ copyFileSync(path, backup);
50
+ }
51
+ const tmp = `${path}.${process.pid}.tmp`;
52
+ try {
53
+ writeFileSync(tmp, content, { mode: 0o644 });
54
+ renameSync(tmp, path);
55
+ } catch (e) {
56
+ rmSync(tmp, { force: true });
57
+ throw e;
58
+ }
59
+ return { path, changed: true, backup };
60
+ }
61
+
62
+ /** Read a JSON object, let `mutate` change it in place, write it back only if that
63
+ * actually changed something. A missing file starts from `{}`; a malformed one throws
64
+ * SettingsParseError rather than being clobbered. */
65
+ export function editJsonFile(path: string, mutate: (root: Record<string, unknown>) => void): EditReport {
66
+ let root: Record<string, unknown> = {};
67
+ if (existsSync(path)) {
68
+ const raw = readFileSync(path, "utf8");
69
+ if (raw.trim()) {
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch (e) {
74
+ throw new SettingsParseError(path, (e as Error).message);
75
+ }
76
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
77
+ throw new SettingsParseError(path, "the top level is not an object");
78
+ }
79
+ root = parsed as Record<string, unknown>;
80
+ }
81
+ }
82
+ const before = JSON.stringify(root);
83
+ mutate(root);
84
+ if (JSON.stringify(root) === before && existsSync(path)) return { path, changed: false };
85
+ return writeTextWithBackup(path, `${JSON.stringify(root, null, 2)}\n`);
86
+ }
87
+
88
+ /** Read a JSON object, or `null` when it is missing or unreadable. For `status`, which must
89
+ * report on a broken file rather than throw on it. */
90
+ export function readJsonSafe(path: string): Record<string, unknown> | null {
91
+ try {
92
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
93
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
@@ -0,0 +1,90 @@
1
+ // Wiring the gate into OpenCode.
2
+ //
3
+ // OpenCode loads plugins as JS modules, so unlike the hook-based harnesses there is no
4
+ // config to edit: the install IS the file at ~/.config/opencode/plugin/ultra11y.js. The
5
+ // alternative route — pinning `"plugin": ["ultra11y@latest"]` in opencode.json — resolves
6
+ // the npm package's `main`, which points at the same source file. Both end up running
7
+ // .opencode/plugins/ultra11y.js; this one just copies it somewhere OpenCode already looks.
8
+ //
9
+ // The copy is refused if a file is already there without our ownership marker: a user's own
10
+ // ultra11y.js is theirs, and silently replacing it would be data loss.
11
+ import { existsSync, readFileSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { type EditReport, editJsonFile, writeTextWithBackup } from "./json-edit.js";
14
+ import { copySkillsInto } from "./paths.js";
15
+
16
+ export const OPENCODE_MARKER = "ULTRA11Y_OPENCODE_PLUGIN";
17
+
18
+ export interface OpencodeInstall {
19
+ configDir: string;
20
+ /** The plugin source to copy (.opencode/plugins/ultra11y.js in this package). */
21
+ pluginSource: string;
22
+ /** Copy the two skills into ~/.config/opencode/skills/ as well. */
23
+ skillsSource?: string | null;
24
+ /** The engine the copied plugin should resolve. Written beside it so the plugin's
25
+ * `<here>/ultra11y.mjs` candidate hits — the copy sits far from any package root, so
26
+ * its npm-pin candidate cannot resolve. */
27
+ enginePath?: string | null;
28
+ }
29
+
30
+ function pluginTarget(configDir: string): string {
31
+ return join(configDir, "plugin", "ultra11y.js");
32
+ }
33
+
34
+ export function installOpencode({ configDir, pluginSource, skillsSource, enginePath }: OpencodeInstall): { reports: EditReport[]; guidance: string[] } {
35
+ const target = pluginTarget(configDir);
36
+ if (existsSync(target) && !readFileSync(target, "utf8").includes(OPENCODE_MARKER)) {
37
+ throw new Error(`refusing to overwrite ${target}: it is not a file ultra11y wrote. Remove it manually if you want the plugin here.`);
38
+ }
39
+ const reports: EditReport[] = [writeTextWithBackup(target, readFileSync(pluginSource, "utf8"))];
40
+
41
+ // The plugin's own resolution order looks here first after $ULTRA11Y_BIN.
42
+ if (enginePath && existsSync(enginePath)) {
43
+ reports.push(writeTextWithBackup(join(configDir, "plugin", "ultra11y.mjs"), readFileSync(enginePath, "utf8")));
44
+ }
45
+ if (skillsSource) reports.push(...copySkillsInto(skillsSource, join(configDir, "skills")));
46
+
47
+ return {
48
+ reports,
49
+ guidance: [
50
+ "opencode: restart the session — plugins load at startup.",
51
+ 'opencode: the alternative is an npm pin — add "plugin": ["ultra11y@latest"] to ~/.config/opencode/opencode.json and drop this file.',
52
+ "opencode: the gate surfaces as a blocked tool call whose error message carries the findings (OpenCode has no permission-decision channel).",
53
+ ],
54
+ };
55
+ }
56
+
57
+ export function uninstallOpencode({ configDir }: { configDir: string }): { reports: EditReport[]; guidance: string[] } {
58
+ const reports: EditReport[] = [];
59
+ const target = pluginTarget(configDir);
60
+ if (existsSync(target) && readFileSync(target, "utf8").includes(OPENCODE_MARKER)) {
61
+ rmSync(target, { force: true });
62
+ rmSync(join(configDir, "plugin", "ultra11y.mjs"), { force: true });
63
+ reports.push({ path: target, changed: true });
64
+ }
65
+ // Also drop an npm pin, if the user wired it that way instead.
66
+ const configPath = join(configDir, "opencode.json");
67
+ if (existsSync(configPath)) {
68
+ reports.push(
69
+ editJsonFile(configPath, (root) => {
70
+ if (!Array.isArray(root.plugin)) return;
71
+ const kept = (root.plugin as unknown[]).filter((p) => !/^ultra11y(@|$)/.test(String(p)));
72
+ if (kept.length > 0) root.plugin = kept;
73
+ else delete root.plugin;
74
+ }),
75
+ );
76
+ }
77
+ return { reports, guidance: [] };
78
+ }
79
+
80
+ export function opencodeWired(configDir: string): boolean {
81
+ const target = pluginTarget(configDir);
82
+ if (existsSync(target) && readFileSync(target, "utf8").includes(OPENCODE_MARKER)) return true;
83
+ // The npm-pin route leaves no file of ours, so check the config too.
84
+ try {
85
+ const cfg = JSON.parse(readFileSync(join(configDir, "opencode.json"), "utf8"));
86
+ return Array.isArray(cfg.plugin) && cfg.plugin.some((p: unknown) => /^ultra11y(@|$)/.test(String(p)));
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
@@ -0,0 +1,132 @@
1
+ // Where each harness keeps its configuration, and where the engine should be invoked from.
2
+ //
3
+ // Every path is computed from the environment rather than hard-coded, so the whole install
4
+ // surface can be driven against a temporary HOME in tests without spawning anything.
5
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+
9
+ /** `~/.codex`, or `$CODEX_HOME` when set. */
10
+ export function codexHome(): string {
11
+ return process.env.CODEX_HOME || join(homedir(), ".codex");
12
+ }
13
+
14
+ /** `~/.config/opencode`, honouring `$XDG_CONFIG_HOME`. */
15
+ export function opencodeConfigDir(): string {
16
+ const xdg = process.env.XDG_CONFIG_HOME;
17
+ return join(xdg && xdg !== "" ? xdg : join(homedir(), ".config"), "opencode");
18
+ }
19
+
20
+ /** Claude Code's settings file: the user's, or the project's under `--project`. */
21
+ export function claudeSettingsPath(project: boolean, cwd = process.cwd()): string {
22
+ return project ? join(cwd, ".claude", "settings.json") : join(homedir(), ".claude", "settings.json");
23
+ }
24
+
25
+ /** Where the pinned copy of the engine lives once installed. */
26
+ export function pinnedEnginePath(): string {
27
+ return join(homedir(), ".ultra11y", "bin", "ultra11y.mjs");
28
+ }
29
+
30
+ /** The `node <path>` command to bake into a harness's hook config.
31
+ *
32
+ * A hook command has to keep working long after `install` returned, from a process whose
33
+ * cwd is the user's repo and whose PATH we do not control. That rules out two tempting
34
+ * options: a relative path (wrong cwd) and `npx -y ultra11y` (an npx resolution on every
35
+ * shell call, inside a PreToolUse hook, is a tax nobody would accept).
36
+ *
37
+ * So: use the running bundle where it is stable, and otherwise pin a copy under
38
+ * `~/.ultra11y/bin/`. The pin is what makes `npx ultra11y install` work at all — its own
39
+ * argv[1] lives in a temp directory npm will delete. */
40
+ export function installedCliCommand(): string {
41
+ const override = process.env.ULTRA11Y_BIN;
42
+ if (override) return `node ${JSON.stringify(override)}`;
43
+
44
+ const argv1 = process.argv[1];
45
+ let source: string | null = null;
46
+ try {
47
+ if (argv1) source = realpathSync(argv1);
48
+ } catch {
49
+ /* argv[1] is gone or unreadable — fall through to the pin */
50
+ }
51
+ // An npx cache path is temporary by construction; anything under it must be copied out.
52
+ const ephemeral = !source || /[/\\](?:_npx|\.npm[/\\]_npx|npm-cache|Temp|tmp)[/\\]/.test(source);
53
+ if (source && !ephemeral) return `node ${JSON.stringify(source)}`;
54
+
55
+ const pin = pinnedEnginePath();
56
+ if (source) {
57
+ mkdirSync(join(pin, ".."), { recursive: true });
58
+ copyFileSync(source, pin);
59
+ }
60
+ return `node ${JSON.stringify(pin)}`;
61
+ }
62
+
63
+ /** True when a pinned engine is already in place. */
64
+ export function hasPinnedEngine(): boolean {
65
+ return existsSync(pinnedEnginePath());
66
+ }
67
+
68
+ /** The root of the ultra11y package this engine was run from — where `skills/` and
69
+ * `.opencode/plugins/` live. Found by walking up from the running bundle looking for the
70
+ * marker directories rather than from `import.meta.url`, because the bundle is a single
71
+ * file that may sit at the package root (`scripts/ultra11y.mjs`) or inside a skill
72
+ * (`skills/review-a11y/scripts/ultra11y.mjs`). Returns null when nothing looks right,
73
+ * which the callers turn into "skip the optional copies" rather than an error. */
74
+ export function packageRoot(): string | null {
75
+ let dir: string;
76
+ try {
77
+ dir = dirname(realpathSync(process.argv[1] ?? ""));
78
+ } catch {
79
+ return null;
80
+ }
81
+ for (let i = 0; i < 6 && dir && dir !== "/"; i++) {
82
+ if (existsSync(join(dir, "skills", "review-a11y", "SKILL.md"))) return dir;
83
+ dir = dirname(dir);
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** The two skills, as a directory to copy from — or null when this engine was not run from
89
+ * a package that carries them (a bare `scripts/ultra11y.mjs` downloaded on its own). */
90
+ export function bundledSkillsDir(): string | null {
91
+ const root = packageRoot();
92
+ const p = root ? join(root, "skills") : null;
93
+ return p && existsSync(p) ? p : null;
94
+ }
95
+
96
+ /** The OpenCode plugin source shipped in this package. */
97
+ export function bundledOpencodePlugin(): string | null {
98
+ const root = packageRoot();
99
+ const p = root ? join(root, ".opencode", "plugins", "ultra11y.js") : null;
100
+ return p && existsSync(p) ? p : null;
101
+ }
102
+
103
+ /** Copy each skill directory into `dest`, reporting a change only when there actually was
104
+ * one — so a second `install` says "already wired" instead of claiming work it did not do.
105
+ *
106
+ * Freshness is decided on SKILL.md and the embedded engine: the engine is what goes stale
107
+ * on an upgrade, and SKILL.md is what goes stale on a re-word. Copying is `cpSync` rather
108
+ * than a symlink so the skill survives the source being moved or an npx cache pruned. */
109
+ export function copySkillsInto(source: string, dest: string): Array<{ path: string; changed: boolean }> {
110
+ const out: Array<{ path: string; changed: boolean }> = [];
111
+ for (const name of ["ultra11y", "review-a11y"]) {
112
+ const from = join(source, name);
113
+ if (!existsSync(from)) continue;
114
+ const to = join(dest, name);
115
+ const same = ["SKILL.md", join("scripts", "ultra11y.mjs")].every((f) => {
116
+ const a = join(from, f);
117
+ const b = join(to, f);
118
+ try {
119
+ return existsSync(b) && statSync(a).size === statSync(b).size && readFileSync(a).equals(readFileSync(b));
120
+ } catch {
121
+ return false;
122
+ }
123
+ });
124
+ if (same) {
125
+ out.push({ path: to, changed: false });
126
+ continue;
127
+ }
128
+ cpSync(from, to, { recursive: true });
129
+ out.push({ path: to, changed: true });
130
+ }
131
+ return out;
132
+ }
@@ -0,0 +1,54 @@
1
+ // Managed blocks in files the user also writes in — their AGENTS.md, their config.toml.
2
+ //
3
+ // The contract is the marker pair, not the content: whatever sits between the markers is
4
+ // ours to rewrite, and everything outside them is theirs and is never touched. That is what
5
+ // lets `install` be idempotent against a file a human keeps editing, and what lets
6
+ // `uninstall` remove exactly what was added rather than the whole file.
7
+ import { existsSync, readFileSync, rmSync } from "node:fs";
8
+ import { type EditReport, writeTextWithBackup } from "./json-edit.js";
9
+
10
+ export const BLOCK_BEGIN = "<!-- BEGIN ultra11y (managed by `ultra11y install --agents-md`; edit outside this block) -->";
11
+ export const BLOCK_END = "<!-- END ultra11y -->";
12
+
13
+ /** Everything from the begin marker to the end marker, inclusive. Non-greedy so a file that
14
+ * somehow carries two blocks loses only the first, rather than everything between them. */
15
+ const blockRe = () => new RegExp(`${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`);
16
+
17
+ function escapeRe(s: string): string {
18
+ return s.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
+ }
20
+
21
+ /** Put `body` in the file's managed block: replace it if the markers are there, append it
22
+ * after a blank line otherwise, create the file if it is missing. The user's own prose is
23
+ * never rewritten — only the region between the markers. */
24
+ export function upsertManagedBlock(path: string, body: string): EditReport {
25
+ const block = `${BLOCK_BEGIN}\n${body.trim()}\n${BLOCK_END}\n`;
26
+ if (!existsSync(path)) return writeTextWithBackup(path, block);
27
+ const current = readFileSync(path, "utf8");
28
+ if (blockRe().test(current)) return writeTextWithBackup(path, current.replace(blockRe(), block));
29
+ // Appending, not rewriting: whatever the user wrote stays byte-identical above.
30
+ const sep = current.endsWith("\n\n") ? "" : current.endsWith("\n") ? "\n" : "\n\n";
31
+ return writeTextWithBackup(path, `${current}${sep}${block}`);
32
+ }
33
+
34
+ /** Remove the managed block. Deletes the file only when nothing but whitespace is left —
35
+ * i.e. when the block was all it ever held. */
36
+ export function removeManagedBlock(path: string): EditReport {
37
+ if (!existsSync(path)) return { path, changed: false };
38
+ const current = readFileSync(path, "utf8");
39
+ if (!blockRe().test(current)) return { path, changed: false };
40
+ const rest = current.replace(blockRe(), "");
41
+ if (rest.trim() === "") {
42
+ rmSync(path, { force: true });
43
+ return { path, changed: true };
44
+ }
45
+ // Collapse the trailing run of newlines to exactly one, so removing a block that was
46
+ // APPENDED (and therefore carries the blank-line separator we inserted) restores the
47
+ // user's file byte-for-byte rather than leaving it slowly growing blank lines.
48
+ return writeTextWithBackup(path, rest.replace(/\n+$/, "\n"));
49
+ }
50
+
51
+ /** True when the file currently carries our block. */
52
+ export function hasManagedBlock(path: string): boolean {
53
+ return existsSync(path) && blockRe().test(readFileSync(path, "utf8"));
54
+ }
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Single source of truth for shared types + the embedded version. sync-version.mjs
2
2
  // rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
3
- export const VERSION = "2.26.0";
3
+ export const VERSION = "2.28.0";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,
@@ -1,6 +1,6 @@
1
- declare const ENGINE_VERSION = "2.22.0";
1
+ declare const ENGINE_VERSION = "2.24.2";
2
2
  declare const SCHEMA_VERSION = 5;
3
- declare const EXTRACTOR_VERSION = 11;
3
+ declare const EXTRACTOR_VERSION = 12;
4
4
  type FileKind = "code" | "doc" | "config" | "asset" | "other";
5
5
  type EdgeKind = "contains" | "doc-link" | "import" | "call" | "extends" | "implements" | "use" | "mention";
6
6
  type Tier = 0 | 1 | 2;
@@ -397,7 +397,7 @@ declare function tagsQueryStatus(key: string): TagsQueryStatus;
397
397
  */
398
398
  declare function extractTags(ext: string, content: string): TagDefinition[];
399
399
 
400
- declare const DEFAULT_GRAMMARS_URL = "https://github.com/maxgfr/codeindex/releases/download/v2.22.0/grammars-2.22.0.tar.gz";
400
+ declare const DEFAULT_GRAMMARS_URL = "https://github.com/maxgfr/codeindex/releases/download/v2.24.2/grammars-2.24.2.tar.gz";
401
401
  interface GrammarsPullTarget {
402
402
  url: string;
403
403
  sha256Url?: string;
@@ -14,9 +14,9 @@ var ENGINE_VERSION, SCHEMA_VERSION, EXTRACTOR_VERSION;
14
14
  var init_types = __esm({
15
15
  "src/types.ts"() {
16
16
  "use strict";
17
- ENGINE_VERSION = "2.22.0";
17
+ ENGINE_VERSION = "2.24.2";
18
18
  SCHEMA_VERSION = 5;
19
- EXTRACTOR_VERSION = 11;
19
+ EXTRACTOR_VERSION = 12;
20
20
  }
21
21
  });
22
22
 
@@ -993,6 +993,18 @@ var init_js_ts = __esm({
993
993
  { re: /^\s*module\.exports\.(?<name>[\w$]+)\s*=/, kind: "const", exported: true },
994
994
  // top-level const arrow function (not exported)
995
995
  { re: /^\s*(?:const|let)\s+(?<name>[\w$]+)\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=]+)?=>/, kind: "const", exported: false },
996
+ // A module constant bound to a VALUE, not exported: `const RE = /href=/g`.
997
+ // The AST tier indexes these (a top-level binding is a declaration, only an
998
+ // in-function one is a local), and a fallback tier that answers "no such
999
+ // symbol" for every module constant diverges from the engine it stands in for.
1000
+ //
1001
+ // Anchored at column 0, unlike every rule above it: a line scanner cannot see
1002
+ // scope, so `^\s*` here would sweep in each `const x = 1` inside every function
1003
+ // body — the locals-flood the AST tier's inFunctionBody filter exists to
1004
+ // prevent, and the surplus this project criticises in a flat tags file. Column
1005
+ // 0 is the one module-scope proxy a line can carry. Must stay AFTER the arrow
1006
+ // and export rules: `scan()` keeps the first rule that matches a line.
1007
+ { re: /^(?:const|let|var)\s+(?<name>[\w$]+)\s*[:=]/, kind: "const", exported: false },
996
1008
  // `export default Foo;` — a class/const declared above and exported by reference.
997
1009
  { re: /^\s*export\s+default\s+(?<name>[A-Za-z_$][\w$]*)\s*;?\s*$/, kind: "default", exported: true }
998
1010
  ];
@@ -6069,7 +6081,15 @@ var init_specs = __esm({
6069
6081
  "arguments",
6070
6082
  "arrow_function",
6071
6083
  "function_expression",
6072
- "function"
6084
+ "function",
6085
+ // The IIFE's own link: `(function () { … })()` parses as
6086
+ // expression_statement → call_expression → parenthesized_expression →
6087
+ // function_expression, and without the parens listed the descent stopped one
6088
+ // node short — the same break the statement containers above were added for.
6089
+ // Whole categories of file are written inside one (browser scripts, UMD and
6090
+ // loader bundles), so for those the index held no functions at all. Named by
6091
+ // the ctags differential on a real loader script.
6092
+ "parenthesized_expression"
6073
6093
  ]),
6074
6094
  exported: neverExport,
6075
6095
  // export is tracked structurally; see LangSpec.exportMarkers
@@ -1,9 +1,9 @@
1
1
  {
2
- "tag": "v2.22.0",
3
- "engineVersion": "2.22.0",
2
+ "tag": "v2.24.2",
3
+ "engineVersion": "2.24.2",
4
4
  "sha256": {
5
- "codeindex-engine.mjs": "97cf34f1fcee485a9163fab4f6357b2d8871a9bfa199f4527d28e02a3f8889cb",
6
- "codeindex-engine.d.mts": "ee9589e36691679c854553e6f4e3fa158052da92c85501f34a8911834801f210"
5
+ "codeindex-engine.mjs": "209509d1e757d15cb0ac5c6b52a2e47c3f1cb17630c895a72140e0b3a8193ee6",
6
+ "codeindex-engine.d.mts": "0c744308646ec98c78afdbfcd9febf48532394101ace541a5c1f432549ad2397"
7
7
  },
8
- "syncedAt": "2026-07-31T09:30:22.297Z"
8
+ "syncedAt": "2026-08-01T08:55:39.105Z"
9
9
  }