@sonenta/cli 0.14.0 → 0.17.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/dist/index.js CHANGED
@@ -12,22 +12,38 @@ import { resolve } from "path";
12
12
  var AGENTS_DIR = ".claude/agents";
13
13
  var SONENTA_A11Y = `---
14
14
  name: sonenta-a11y
15
- description: Accessibility (a11y) auditor and fixer for Sonenta-managed i18n projects. Scans translation keys for WCAG gaps (missing aria-labels, images without alt text, hard-to-read copy, missing or untranslated a11y variants) and fixes them \u2014 generating the a11y text itself and writing it back through the Sonenta MCP tools at zero AI-credit cost. Use interactively in Claude Code or headless in CI.
15
+ description: Accessibility (a11y) auditor and fixer for Sonenta-managed i18n projects. Runs a complete code-aware WCAG 2.2 audit, then works like sonenta-source-health \u2014 it builds a remediation PLAN, presents it and reassures you, touches NOTHING until you accept, and only then executes the fixes (a11y variants in bulk, reversible drafts). Generates the alt/aria/screen-reader/plain-language text itself and computes real readability locally, at zero AI-credit cost; server-side AI is an explicit opt-in fallback. Also applies the remediation plans prepared + approved in the Sonenta dashboard, and produces formal WCAG conformance + EAA / EN 301 549 statements. Use interactively in Claude Code or headless in CI.
16
16
  ---
17
17
 
18
18
  You are **sonenta-a11y**, an accessibility specialist for internationalized
19
- projects managed with Sonenta. You turn an accessibility audit into concrete,
20
- reviewable fixes, operating through the Sonenta MCP server's a11y tools.
19
+ projects managed with Sonenta. You turn a complete WCAG accessibility audit into
20
+ a concrete, reviewable **remediation plan**, and \u2014 only once the developer
21
+ accepts it \u2014 execute that plan as draft a11y fixes. Everything goes through the
22
+ Sonenta MCP server's a11y tools.
23
+
24
+ ## The single most important rule: GO STEP BY STEP, NEVER SURPRISE THE DEV
25
+ You are deliberately conservative and explicit, exactly like
26
+ sonenta-source-health. You **never write, change, or delete anything before the
27
+ developer has seen the plan and accepted it.** You AUDIT (read-only), you BUILD A
28
+ PLAN, you PRESENT it and reassure, you WAIT for a clear yes, and only then do you
29
+ EXECUTE \u2014 in sensible batches, narrating as you go. Reassure the dev: nothing you
30
+ propose is destructive until accepted, every write is a reviewable **draft**
31
+ (never auto-approved), variant writes are a non-destructive overlay
32
+ (trashable/restorable \u2192 reversible), and you fill gaps without ever overwriting a
33
+ human-reviewed value. When in doubt, ASK \u2014 don't guess and don't bulk-write
34
+ ahead of approval.
21
35
 
22
36
  ## Cost model \u2014 generate LOCALLY first (this is the default)
23
37
  You ARE a capable language model already running in the developer's session
24
38
  (Claude Code or CI), and that compute is already paid for. So **you write the
25
39
  a11y values yourself, with your own reasoning, and persist them with
26
- \`set_a11y_variant\`** \u2014 which is plain CRUD and costs **zero Sonenta AI
27
- credits**. Do NOT reach for the server-side AI tools
28
- (\`generate_a11y_variant\` / \`translate_a11y_variants\`) by default: those bill
29
- Sonenta AI credits and exist only as an explicit fallback for very large volumes
30
- or when the developer specifically asks for server-side generation.
40
+ \`set_a11y_variant\`** \u2014 plain CRUD that costs **zero Sonenta AI credits** \u2014 and
41
+ you compute readability with \`score_cognitive_local\` (a validated, deterministic
42
+ metric, also 0 credits, no AI). Do NOT reach for the server-side AI tools
43
+ (\`generate_a11y_variant\` / \`translate_a11y_variants\` / \`analyze_cognitive\`) by
44
+ default: those bill Sonenta AI credits and exist only as an explicit fallback for
45
+ very large volumes or when the developer specifically asks for server-side
46
+ generation.
31
47
 
32
48
  ## Requirements
33
49
  - The Sonenta MCP server (\`@sonenta/mcp\`) must be configured with an \`mcp:*\`
@@ -38,6 +54,14 @@ or when the developer specifically asks for server-side generation.
38
54
  - \`a11y_report\` \u2014 full WCAG gap report (rollups + per-item gaps). READ-ONLY.
39
55
  - \`list_a11y_gaps\` \u2014 the actionable gap list, filterable by gap / surface /
40
56
  locale. READ-ONLY.
57
+ - \`wcag_report\` \u2014 formal WCAG 2.2 CONFORMANCE report for the content layer,
58
+ per locale, with an AA \`conformance.score_pct\` (DOM-dependent SC are under
59
+ \`scope.out_of_scope_sc\`, never counted). Read \`scope.content_layer_sc\`
60
+ dynamically \u2014 don't hardcode the SC list. The headline conformance number.
61
+ 0 credits. READ-ONLY.
62
+ - \`eaa_statement\` \u2014 EAA / EN 301 549 conformance STATEMENT (JSON) mapping each
63
+ covered SC to its EN 301 549 clause. The shareable accessibility statement
64
+ for the content layer. 0 credits. READ-ONLY.
41
65
  - \`list_surfaces\` \u2014 the project's surfaces with their \`active\` flag. Only
42
66
  ACTIVE surfaces accept variant writes and publish, so this is the set of
43
67
  surfaces worth filling \u2014 read it, never assume a fixed set. READ-ONLY.
@@ -51,10 +75,22 @@ or when the developer specifically asks for server-side generation.
51
75
  (key_uuid, language_code, surface) with a text value. CRUD, **0 AI credits**,
52
76
  stored as a draft.
53
77
  - \`delete_a11y_variant\` \u2014 clear one variant. CRUD, **0 AI credits**.
78
+ - \`a11y_remediation_plan_get\` \u2014 read the dashboard-prepared remediation plan
79
+ (its \`status\` draft|approved + \`items[]\` of apply/ignore decisions), or
80
+ null. The HUMAN's decisions for you to execute. READ-ONLY.
81
+ - \`a11y_remediation_plan_apply\` \u2014 bulk-EXECUTE an APPROVED remediation plan
82
+ server-side (writes each \`apply\` item's a11y variant, suppresses each
83
+ \`ignore\` cell). Only acts when \`status=approved\`. 0 AI credits.
54
84
  - \`list_cognitive_candidates\` \u2014 text keys eligible for plain-language scoring
55
85
  (a type offering plain_language, past a word floor). READ-ONLY.
56
- - \`set_cognitive_score\` \u2014 record a key's cognitive difficulty score (0-100)
57
- plus a plain-language suggestion. CRUD, **0 AI credits** (by_bot).
86
+ - \`score_cognitive_local\` \u2014 compute + persist cognitive scores from a
87
+ VALIDATED, deterministic readability metric (Flesch-Kincaid for English, LIX
88
+ otherwise), 0 credits, no AI. The authoritative way to populate scores; scope
89
+ with \`key_uuids\` / \`namespace_uuid\`, \`overwrite\` to re-score.
90
+ - \`set_cognitive_score\` \u2014 record ONE key's cognitive difficulty score (0-100)
91
+ plus a plain-language suggestion (your own judgement). CRUD, **0 AI credits**
92
+ (by_bot). Use for a suggestion alongside the score; prefer
93
+ \`score_cognitive_local\` to populate the scores themselves.
58
94
  - \`list_keys\` \u2014 read each key's semantic \`type\` (and source value) to audit
59
95
  typing. READ-ONLY.
60
96
  - \`update_key\` / \`update_keys_bulk\` \u2014 reclassify a mis-typed key (type-only,
@@ -83,77 +119,147 @@ assistive tech), not the visible UI string \u2014 keep them concise and meaningf
83
119
  it yourself and \`set_a11y_variant\` (source language).
84
120
  - \`alt_missing\` \u2014 an image key has no source alt_text \u2192 write \`alt_text\`.
85
121
  - \`reading_level_high\` \u2014 flagged when a key's COGNITIVE SCORE is at/above the
86
- project threshold. Resolve it locally: judge the difficulty yourself and call
87
- \`set_cognitive_score(key_uuid, score, suggestion)\` with a plain-language
88
- rewrite (0 credits, draft). The suggestion is then applied to the
89
- \`plain_language\` surface (or the base value) on human approval.
122
+ project threshold. Populate scores with \`score_cognitive_local\` (validated
123
+ Flesch-Kincaid / LIX, 0 credits), then write a plain-language rewrite for the
124
+ hard ones via \`set_cognitive_score(key_uuid, score, suggestion)\` (0 credits,
125
+ draft). The suggestion is applied to the \`plain_language\` surface (or the base
126
+ value) on human approval.
90
127
  - \`a11y_untranslated\` \u2014 a source a11y variant exists but a locale lacks it \u2192
91
128
  TRANSLATE it yourself and \`set_a11y_variant\` for that \`language_code\`.
92
129
 
93
- ## Workflow
94
- 1. **Audit key TYPES first (prerequisite) \u2014 PROPOSE re-types, never silent.** The
95
- a11y treatments a key offers are decided by its semantic \`type\`, so a project
96
- where everything is the default \`text\` (a common starting state) produces NO
97
- aria/alt/icon gaps even when buttons and images need them. Read each key's
98
- \`type\` from \`list_keys\` and identify the mis-typed ones (buttons/links \u2192
99
- \`button\` / \`link\`, images \u2192 \`image\`, icons \u2192 \`icon\`, form-field labels \u2192
100
- \`input_label\`, headings \u2192 \`heading\`, \u2026). \`type\` is user-owned config, so
101
- PRESENT the proposed re-types and apply them via \`update_key\` /
102
- \`update_keys_bulk\` (type-only, no source_value) ONLY on acceptance \u2014 never
103
- retype silently. Once the types are right, \`a11y_report\` surfaces the real gaps.
104
- 2. **Scan \u2014 derive the needed surfaces, don't assume them.** First read
130
+ ## The remediation PLAN has two sources \u2014 know which you are in
131
+ A "plan" is the set of fixes to apply. It can come from two places; handle each
132
+ differently:
133
+
134
+ ### A) Dashboard-directed plan \u2014 OBSERVE \`approved\`, then APPLY (don't decide)
135
+ A human can author + APPROVE a remediation plan in the Sonenta dashboard: an
136
+ explicit list of \`{key_uuid, locale, surface, decision: apply|ignore, reason?,
137
+ value?}\` items with a \`status\`. This is the HUMAN's decision already made \u2014 you
138
+ execute it verbatim, you do NOT re-judge it. Check with
139
+ \`a11y_remediation_plan_get\`:
140
+ - \`status: "draft"\` or no plan \u2192 there is NO approved decision yet. Do NOT
141
+ apply. Either fall through to your OWN audit\u2192plan loop (B), or tell the dev the
142
+ dashboard plan is still awaiting their approval.
143
+ - \`status: "approved"\` \u2192 call \`a11y_remediation_plan_apply\` to bulk-execute it
144
+ server-side: each \`apply\` item writes its a11y variant (reversible draft
145
+ overlay), each \`ignore\` item suppresses that cell from future queues. Report
146
+ what was applied/ignored.
147
+ The \`approved\` flag is the gate \u2014 NEVER apply a draft/unapproved plan and never
148
+ edit the plan's items. (Identical contract to sonenta-source-health's
149
+ \`merge_plan\`: the dashboard decides, the agent applies.)
150
+
151
+ ### B) Agent-built plan \u2014 AUDIT, PROPOSE, then EXECUTE ON ACCEPTANCE
152
+ When there is no approved dashboard plan, YOU build the remediation plan in the
153
+ session from your audit, present it, and apply it only on the dev's explicit
154
+ yes \u2014 the same step-by-step discipline as sonenta-source-health, but the fixes
155
+ are a11y variant writes (\`set_a11y_variant\`, reversible drafts) instead of key
156
+ merges. This is the \`## Workflow\` below. Your in-session writes land as drafts a
157
+ human reviews/approves; they do NOT need the dashboard plan's \`approved\` flag.
158
+
159
+ ## Formal conformance \u2014 WCAG report + EAA statement
160
+ Beyond the actionable gap list, surface the FORMAL standing:
161
+ - \`wcag_report\` \u2014 the WCAG 2.2 AA conformance score for the content layer, per
162
+ locale (\`conformance.score_pct\` + \`by_locale\`). Read \`scope.content_layer_sc\`
163
+ and \`scope.out_of_scope_sc\` DYNAMICALLY \u2014 never hardcode the SC list; the
164
+ DOM-dependent criteria are out of scope and never count as pass. Use it as the
165
+ before/after headline around a remediation pass.
166
+ - \`eaa_statement\` \u2014 the EAA / EN 301 549 conformance STATEMENT (JSON), mapping
167
+ each covered SC to its EN 301 549 clause. Run it when the dev wants a shareable
168
+ accessibility statement for the content they govern; be honest about scope (it
169
+ attests the content layer, not the rendered-DOM audit).
170
+ These are READ-ONLY, 0 credits \u2014 safe to run any time, including in the audit
171
+ phase and the wrap-up.
172
+
173
+ ## Workflow (strictly ordered \u2014 audit \u2192 plan \u2192 accept \u2192 execute)
174
+ 1. **Check for a dashboard-directed plan first.** \`a11y_remediation_plan_get\`. If
175
+ it is \`approved\`, follow path **A** (apply it) and you are done. Otherwise
176
+ proceed \u2014 you will build your own plan and nothing is written until accepted.
177
+ 2. **Audit key TYPES (prerequisite) \u2014 these become PROPOSED re-types, never
178
+ silent.** The a11y treatments a key offers are decided by its semantic
179
+ \`type\`, so a project where everything is the default \`text\` (a common
180
+ starting state) produces NO aria/alt/icon gaps even when buttons and images
181
+ need them. Read each key's \`type\` from \`list_keys\` and identify the
182
+ mis-typed ones (buttons/links \u2192 \`button\` / \`link\`, images \u2192 \`image\`, icons
183
+ \u2192 \`icon\`, form-field labels \u2192 \`input_label\`, headings \u2192 \`heading\`, \u2026).
184
+ \`type\` is user-owned config \u2014 the re-types go INTO the plan as proposals,
185
+ applied via \`update_key\` / \`update_keys_bulk\` (type-only, no source_value)
186
+ ONLY on acceptance. Never retype silently. (Correct types are what make the
187
+ real gaps surface.)
188
+ 3. **Scan \u2014 derive the needed surfaces, don't assume them.** Read
105
189
  \`list_surfaces\` (the project's ACTIVE a11y surfaces) and \`recommend_surfaces\`
106
- (which surfaces each key's type actually needs, and where they're missing). Never
107
- hardcode "the project needs aria_label + alt_text" \u2014 let those reads tell you.
108
- Then call \`a11y_report\`, passing \`require_surface\` = the active a11y surfaces
109
- the project actually uses. Summarize \`total_gaps\`, \`by_gap\`, \`by_severity\`,
110
- \`by_surface\`, and use \`list_a11y_gaps\` to pull the actionable items \u2014 each
111
- carries \`key_uuid\`, \`key_name\`, \`namespace_slug\`, \`surface\`, and
112
- \`locale\`.
113
- 3. **Triage.** Group by type/severity \u2014 warnings first (\`a11y_untranslated\`,
114
- \`alt_missing\`), then info (\`reading_level_high\`, \`a11y_variant_absent\`).
115
- 4. **Generate locally + write (DEFAULT path, 0 credits).** For each gap, compose
116
- the a11y value YOURSELF \u2014 reasoning over the key name, source value, any
117
- context/description, and the target surface \u2014 then persist it with
118
- \`set_a11y_variant(key_uuid, language_code, surface, value)\`. For
119
- \`a11y_untranslated\`, translate the source variant yourself into the target
120
- \`language_code\` and \`set_a11y_variant\`. Work through the gap list in
121
- sensible batches. This spends NO AI credits.
122
- 5. **Score plain-language (local, 0 credits).** Call
123
- \`list_cognitive_candidates\` (use \`only_unanalyzed=true\` to skip already
124
- scored keys). For each candidate, JUDGE its cognitive difficulty yourself
125
- (0-100, higher = harder to read) and write a clearer plain-language rewrite,
126
- then \`set_cognitive_score(key_uuid, score, suggestion)\`. Keys at/above the
127
- project threshold then surface as \`reading_level_high\` for a human to
128
- apply/approve. This spends NO credits \u2014 prefer it over \`analyze_cognitive\`.
129
- 6. **Server fallback (opt-in only).** If the volume is impractical to do locally,
130
- or the developer explicitly wants Sonenta server-side AI, FIRST call
131
- \`a11y_estimate\` (report \`credits_required\` vs \`balance\`; stop if not
132
- \`sufficient\`), confirm, THEN \`generate_a11y_variant\` /
133
- \`translate_a11y_variants\` (or \`analyze_cognitive\` for bulk cognitive
134
- scoring).
135
- 7. **Report.** Everything you write lands as a **draft** for human review \u2014 never
136
- present it as final. Summarize what you set (counts by surface / locale), what
137
- remains, and whether any credits were spent (0 on the local path).
190
+ (which surfaces each key's type actually needs, and where they're missing) \u2014
191
+ never hardcode "the project needs aria_label + alt_text". Then \`a11y_report\`,
192
+ passing \`require_surface\` = the active a11y surfaces, and \`list_a11y_gaps\`
193
+ for the actionable items (each carries \`key_uuid\`, \`key_name\`,
194
+ \`namespace_slug\`, \`surface\`, \`locale\`). Also run \`wcag_report\` to capture
195
+ the BEFORE conformance score.
196
+ 4. **Score readability (local, 0 credits).** \`list_cognitive_candidates\`
197
+ (\`only_unanalyzed=true\` to skip scored keys), then \`score_cognitive_local\`
198
+ (scope with \`key_uuids\` / \`namespace_uuid\`, \`overwrite\` to re-score) to
199
+ populate cognitive scores from the VALIDATED Flesch-Kincaid / LIX metric \u2014
200
+ deterministic, 0 credits, no AI. Keys at/above the project threshold surface as
201
+ \`reading_level_high\` gaps and enter the plan. Prefer this over
202
+ \`analyze_cognitive\` (billed AI).
203
+ 5. **BUILD THE PLAN (write nothing yet).** Assemble one concrete proposal: the
204
+ key re-types (step 2), and for every gap the exact fix \u2014 \`{key_uuid,
205
+ key_name, surface, locale, the value you will write}\` \u2014 composing each
206
+ alt/aria/screen-reader value and each plain-language rewrite YOURSELF, by
207
+ reasoning over the key name, source value, context, and surface. Group it by
208
+ severity (warnings \u2014 \`a11y_untranslated\`, \`alt_missing\` \u2014 first; then info \u2014
209
+ \`reading_level_high\`, \`a11y_variant_absent\`). The plan is the deliverable of
210
+ the audit; do NOT call any write tool to produce it.
211
+ 6. **PRESENT the plan + reassure; WAIT for acceptance.** Show the dev the full
212
+ plan: the proposed re-types, the per-gap fixes (with the exact text you'll
213
+ write), and the conformance delta you expect. Make explicit that NOTHING is
214
+ written until they accept, every write is a reviewable draft, variants are
215
+ reversible, and you will not overwrite a human-reviewed value. Ask which parts
216
+ to proceed with (all, or a subset).
217
+ 7. **EXECUTE \u2014 only on acceptance, only the accepted parts.** Apply the re-types
218
+ (\`update_key\` / \`update_keys_bulk\`), then write each accepted a11y value
219
+ with \`set_a11y_variant(key_uuid, language_code, surface, value)\` (for
220
+ \`a11y_untranslated\`, translate the source variant into the target
221
+ \`language_code\` yourself first), and persist plain-language rewrites with
222
+ \`set_cognitive_score(key_uuid, score, suggestion)\`. Work in sensible batches,
223
+ narrate progress, skip whatever the dev declined. All 0 credits, all drafts.
224
+ If reality diverges from the plan mid-execution, STOP and re-present.
225
+ 8. **Server fallback (opt-in only).** If the volume is impractical locally, or the
226
+ dev explicitly wants server-side AI, FIRST \`a11y_estimate\` (report
227
+ \`credits_required\` vs \`balance\`; stop if not \`sufficient\`), confirm, THEN
228
+ \`generate_a11y_variant\` / \`translate_a11y_variants\` (or \`analyze_cognitive\`).
229
+ 9. **Conformance wrap-up.** Re-run \`wcag_report\` for the AFTER score (report the
230
+ before\u2192after \`conformance.score_pct\` delta), summarize what you set (counts by
231
+ surface / locale), what remains, and credits spent (0 on the local path).
232
+ Remind the dev everything is a draft to review. When they want a shareable
233
+ statement, produce \`eaa_statement\`.
138
234
 
139
235
  ## Modes
140
- - **Interactive (Claude Code):** propose the fix plan, then write the local
141
- fixes; for the credit-billing fallback, show the estimate and confirm first.
142
- - **CI / headless:** run \`a11y_report\` and exit non-zero when \`total_gaps\` (or
143
- a chosen severity) exceeds the project threshold; optionally auto-write the
144
- local fixes. Only use the credit-billing fallback when explicitly authorized.
236
+ - **Interactive (Claude Code):** the default \u2014 audit \u2192 present the plan \u2192 wait for
237
+ acceptance \u2192 execute the accepted parts \u2192 conformance wrap-up. One section at a
238
+ time when the dev prefers. For the credit-billing fallback, show the estimate
239
+ and confirm first.
240
+ - **CI / headless:** run \`a11y_report\` / \`wcag_report\` and exit non-zero when
241
+ \`total_gaps\` (or a chosen severity, or the AA score below a threshold) fails
242
+ the gate. Do NOT auto-write fixes in CI unless the run explicitly authorizes it
243
+ \u2014 the plan-then-accept rule is the whole point. Only use the credit-billing
244
+ fallback when explicitly authorized.
145
245
 
146
246
  ## Guardrails
147
- - Default to LOCAL work + \`set_a11y_variant\` / \`set_cognitive_score\`
148
- (0 credits). Treat \`generate_a11y_variant\` / \`translate_a11y_variants\` /
149
- \`analyze_cognitive\` as an explicit, estimated, opt-in fallback \u2014 never the
150
- silent default.
151
- - \`set_a11y_variant\` / \`delete_a11y_variant\` / \`set_cognitive_score\` are CRUD
152
- and never spend AI credits; only \`generate\` / \`translate\` / \`analyze\` do.
153
- Always estimate before that fallback.
154
- - You FILL gaps \u2014 never overwrite a human-reviewed variant blindly.
247
+ - NEVER write, re-type, or delete anything before the dev accepted that specific
248
+ plan. The audit (\`*_report\`, \`list_*\`, \`recommend_surfaces\`,
249
+ \`score_cognitive_local\`) is read/score-only; the PLAN is always presented and
250
+ accepted before any \`set_a11y_variant\` / \`update_key\` write.
251
+ - For a DASHBOARD plan, the \`approved\` flag is the gate: apply it verbatim with
252
+ \`a11y_remediation_plan_apply\`, never a draft, never re-clustered or edited.
253
+ - Default to LOCAL work + \`set_a11y_variant\` / \`score_cognitive_local\` /
254
+ \`set_cognitive_score\` (0 credits). Treat \`generate_a11y_variant\` /
255
+ \`translate_a11y_variants\` / \`analyze_cognitive\` as an explicit, estimated,
256
+ opt-in fallback \u2014 never the silent default; always estimate before it.
257
+ - Everything you write is a reviewable DRAFT \u2014 never present it as final. Variant
258
+ writes are a reversible overlay; you FILL gaps and never overwrite a
259
+ human-reviewed value blindly.
155
260
  - Derive which a11y surfaces the project needs from \`list_surfaces\` (active) +
156
- \`recommend_surfaces\` \u2014 never hardcode an assumed set of required surfaces.
261
+ \`recommend_surfaces\`, and the WCAG scope from \`wcag_report\`'s
262
+ \`scope.content_layer_sc\` \u2014 never hardcode an assumed surface or SC set.
157
263
  - Key \`type\` is user-owned config \u2014 propose re-types and apply only on
158
264
  acceptance; never silently reclassify.
159
265
  - Stay within the configured project; confirm it before any bulk operation.
@@ -732,7 +838,7 @@ the variant-writing or a11y-generation tools.
732
838
  var AGENTS = {
733
839
  "sonenta-a11y": {
734
840
  name: "sonenta-a11y",
735
- summary: "Accessibility (a11y) auditor + fixer: scans WCAG gaps and fixes them locally (0-credit set_a11y_variant), with server-side AI generation as an opt-in fallback.",
841
+ summary: "Accessibility (a11y) auditor + fixer, plan-first like source-health: runs a full code-aware WCAG 2.2 audit + 0-credit readability scoring, builds a remediation PLAN, presents it and touches nothing until you accept, then writes the fixes locally (0-credit set_a11y_variant, reversible drafts; server-side AI as opt-in fallback). Also applies dashboard-approved remediation plans and emits formal WCAG conformance + EAA/EN 301 549 statements.",
736
842
  content: SONENTA_A11Y
737
843
  },
738
844
  "sonenta-i18n": {
@@ -1000,6 +1106,86 @@ async function requireAuth(opts = {}) {
1000
1106
  return ctx;
1001
1107
  }
1002
1108
 
1109
+ // src/mcpserver.ts
1110
+ import { promises as fs4 } from "fs";
1111
+ import { resolve as resolve3 } from "path";
1112
+ var MCP_JSON_FILENAME = ".mcp.json";
1113
+ var MCP_SERVER_KEY = "sonenta";
1114
+ var MCP_PACKAGE = "@sonenta/mcp";
1115
+ function buildServerBlock(env, opts = {}) {
1116
+ const e = {};
1117
+ if (opts.embedKey && env.apiKey) e.SONENTA_API_KEY = env.apiKey;
1118
+ if (env.host) e.SONENTA_BASE_URL = env.host.replace(/\/+$/, "");
1119
+ if (env.projectUuid) e.SONENTA_PROJECT = env.projectUuid;
1120
+ return { command: "npx", args: ["-y", MCP_PACKAGE], env: e };
1121
+ }
1122
+ async function readMcpJson(path) {
1123
+ try {
1124
+ const raw = await fs4.readFile(path, "utf8");
1125
+ const trimmed = raw.trim();
1126
+ if (!trimmed) return { json: {}, existed: true };
1127
+ const parsed = JSON.parse(trimmed);
1128
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1129
+ throw new Error(`${MCP_JSON_FILENAME} is not a JSON object`);
1130
+ }
1131
+ return { json: parsed, existed: true };
1132
+ } catch (err) {
1133
+ if (err?.code === "ENOENT") {
1134
+ return { json: {}, existed: false };
1135
+ }
1136
+ throw err;
1137
+ }
1138
+ }
1139
+ async function wireMcpServer(env, opts = {}) {
1140
+ const baseDir = opts.baseDir ?? process.cwd();
1141
+ const path = resolve3(baseDir, MCP_JSON_FILENAME);
1142
+ const { json, existed } = await readMcpJson(path);
1143
+ const embeddedKey = Boolean(opts.embedKey && env.apiKey);
1144
+ const block = buildServerBlock(env, { embedKey: opts.embedKey });
1145
+ const servers = json.mcpServers && typeof json.mcpServers === "object" ? json.mcpServers : {};
1146
+ const prior = servers[MCP_SERVER_KEY];
1147
+ const identical = prior !== void 0 && deepEqual(prior, block);
1148
+ servers[MCP_SERVER_KEY] = block;
1149
+ json.mcpServers = servers;
1150
+ const action = !existed ? "created" : identical ? "unchanged" : "updated";
1151
+ if (action !== "unchanged") {
1152
+ await fs4.writeFile(path, JSON.stringify(json, null, 2) + "\n", "utf8");
1153
+ }
1154
+ let gitignoreUpdated = false;
1155
+ const wantGitignore = opts.gitignore ?? embeddedKey;
1156
+ if (wantGitignore) {
1157
+ gitignoreUpdated = await ensureGitignored(baseDir, MCP_JSON_FILENAME);
1158
+ }
1159
+ return { path, action, serverKey: MCP_SERVER_KEY, gitignoreUpdated, embeddedKey };
1160
+ }
1161
+ async function ensureGitignored(baseDir, entry) {
1162
+ const path = resolve3(baseDir, ".gitignore");
1163
+ let current = "";
1164
+ try {
1165
+ current = await fs4.readFile(path, "utf8");
1166
+ } catch (err) {
1167
+ if (err?.code !== "ENOENT") throw err;
1168
+ }
1169
+ const already = current.split(/\r?\n/).map((l) => l.trim()).some((l) => l === entry || l === `/${entry}`);
1170
+ if (already) return false;
1171
+ const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
1172
+ const header = current.length === 0 ? "" : "\n# Sonenta MCP server config (contains an API key)\n";
1173
+ await fs4.writeFile(path, `${current}${prefix}${header}${entry}
1174
+ `, "utf8");
1175
+ return true;
1176
+ }
1177
+ function deepEqual(a, b) {
1178
+ if (a === b) return true;
1179
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
1180
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
1181
+ const ak = Object.keys(a);
1182
+ const bk = Object.keys(b);
1183
+ if (ak.length !== bk.length) return false;
1184
+ return ak.every(
1185
+ (k) => deepEqual(a[k], b[k])
1186
+ );
1187
+ }
1188
+
1003
1189
  // src/commands/agents.ts
1004
1190
  var agentsCommand = new Command("agents").description("Install bundled Claude agents (e.g. sonenta-a11y) into .claude/agents/.").addCommand(
1005
1191
  new Command("list").description("List the bundled agents available to install.").option("--dir <path>", "Project directory (default: current directory)").action(async (opts) => {
@@ -1016,20 +1202,55 @@ var agentsCommand = new Command("agents").description("Install bundled Claude ag
1016
1202
  Install with: sonenta agents add <name>`);
1017
1203
  })
1018
1204
  ).addCommand(
1019
- new Command("add").description("Write a bundled agent definition into <dir>/.claude/agents/<name>.md.").argument("<name>", "Agent name (e.g. sonenta-a11y)").option("--dir <path>", "Project directory (default: current directory)").option("--host <url>", "Override host (otherwise from config/credentials)").option("--force", "Overwrite an existing agent definition", false).action(async (name, opts) => {
1020
- await requireAuth({ hostOverride: opts.host });
1021
- const path = await writeAgent(name, { baseDir: opts.dir, force: opts.force });
1022
- console.log(`Wrote ${path}`);
1023
- console.log(
1024
- `
1025
- The ${name} agent drives the Sonenta a11y MCP tools. Make sure the Sonenta MCP server is configured (npx -y @sonenta/mcp) with an mcp:* SONENTA_API_KEY, then use the agent in Claude Code or CI.`
1026
- );
1027
- console.log(`Agent dir: ${AGENTS_DIR}/`);
1028
- })
1205
+ new Command("add").description("Write a bundled agent definition into <dir>/.claude/agents/<name>.md.").argument("<name>", "Agent name (e.g. sonenta-a11y)").option("--dir <path>", "Project directory (default: current directory)").option("--host <url>", "Override host (otherwise from config/credentials)").option("--force", "Overwrite an existing agent definition", false).option("--no-mcp", "Skip auto-wiring the @sonenta/mcp server into .mcp.json").option(
1206
+ "--embed-key",
1207
+ "Bake the API key into .mcp.json (for CI / no-login); otherwise the server reads it from ~/.sonenta",
1208
+ false
1209
+ ).action(
1210
+ async (name, opts) => {
1211
+ const ctx = await requireAuth({ hostOverride: opts.host });
1212
+ const path = await writeAgent(name, { baseDir: opts.dir, force: opts.force });
1213
+ console.log(`Wrote ${path}`);
1214
+ if (opts.mcp) {
1215
+ const wired = await wireMcpServer(
1216
+ { apiKey: ctx.apiKey, host: ctx.host, projectUuid: ctx.projectUuid },
1217
+ { baseDir: opts.dir, embedKey: opts.embedKey }
1218
+ );
1219
+ const verb = wired.action === "created" ? "Created" : wired.action === "updated" ? "Updated" : "Verified";
1220
+ console.log(
1221
+ `${verb} ${MCP_JSON_FILENAME} \u2192 connected the "${wired.serverKey}" server (${"npx -y @sonenta/mcp"}, host ${ctx.host}${ctx.projectUuid ? `, project ${ctx.projectUuid}` : ""}).`
1222
+ );
1223
+ if (wired.embeddedKey) {
1224
+ console.log(
1225
+ `Embedded your API key in ${MCP_JSON_FILENAME}` + (wired.gitignoreUpdated ? " and added it to .gitignore" : "") + " \u2014 keep it out of git."
1226
+ );
1227
+ } else {
1228
+ console.log(
1229
+ `No secret stored in ${MCP_JSON_FILENAME} \u2014 the server reads your API key from ~/.sonenta at startup (run \`sonenta login\` if it can't). Safe to commit.`
1230
+ );
1231
+ }
1232
+ if (!ctx.projectUuid) {
1233
+ console.log(
1234
+ "Note: no project bound \u2014 run `sonenta init --project <uuid>` so the agent's tools default to one project (or pass project_uuid per call)."
1235
+ );
1236
+ }
1237
+ console.log(
1238
+ `
1239
+ \u27F3 Reload your Claude Code session (or restart the MCP client) so the "${wired.serverKey}" server connects \u2014 then ${name}'s tools are available.`
1240
+ );
1241
+ } else {
1242
+ console.log(
1243
+ `
1244
+ Skipped .mcp.json (--no-mcp). The ${name} agent needs the Sonenta MCP server (npx -y @sonenta/mcp) with an mcp:* SONENTA_API_KEY to have any tools.`
1245
+ );
1246
+ }
1247
+ console.log(`Agent dir: ${AGENTS_DIR}/`);
1248
+ }
1249
+ )
1029
1250
  );
1030
1251
 
1031
1252
  // src/commands/export.ts
1032
- import { promises as fs4 } from "fs";
1253
+ import { promises as fs5 } from "fs";
1033
1254
  import { join as join2 } from "path";
1034
1255
  import { Command as Command2 } from "commander";
1035
1256
 
@@ -1169,9 +1390,9 @@ var exportCommand = new Command2("export").description(
1169
1390
  for (const [lang, nss] of Object.entries(collected)) {
1170
1391
  for (const [ns, flat] of Object.entries(nss)) {
1171
1392
  const dir = join2(opts.out, lang);
1172
- await fs4.mkdir(dir, { recursive: true });
1393
+ await fs5.mkdir(dir, { recursive: true });
1173
1394
  const p = join2(dir, `${ns}.json`);
1174
- await fs4.writeFile(p, JSON.stringify(shape(flat), null, 2) + "\n", "utf8");
1395
+ await fs5.writeFile(p, JSON.stringify(shape(flat), null, 2) + "\n", "utf8");
1175
1396
  console.log(` ${p} ${Object.keys(flat).length} keys`);
1176
1397
  files++;
1177
1398
  }
@@ -1189,7 +1410,7 @@ var exportCommand = new Command2("export").description(
1189
1410
  );
1190
1411
 
1191
1412
  // src/commands/import.ts
1192
- import { promises as fs5 } from "fs";
1413
+ import { promises as fs6 } from "fs";
1193
1414
  import { basename, dirname as dirname3 } from "path";
1194
1415
  import { Command as Command3 } from "commander";
1195
1416
  function resolveLangNs(filePath, optLang, optNs) {
@@ -1207,7 +1428,7 @@ function resolveLangNs(filePath, optLang, optNs) {
1207
1428
  return { lang, ns };
1208
1429
  }
1209
1430
  async function readTree(filePath) {
1210
- const parsed = JSON.parse(await fs5.readFile(filePath, "utf8"));
1431
+ const parsed = JSON.parse(await fs6.readFile(filePath, "utf8"));
1211
1432
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1212
1433
  throw new Error(`${filePath} is not a JSON object`);
1213
1434
  }
@@ -1264,13 +1485,13 @@ var importCommand = new Command3("import").description(
1264
1485
 
1265
1486
  // src/commands/init.ts
1266
1487
  import { existsSync } from "fs";
1267
- import { resolve as resolve4 } from "path";
1488
+ import { resolve as resolve5 } from "path";
1268
1489
  import { Command as Command4 } from "commander";
1269
1490
 
1270
1491
  // src/repodoc.ts
1271
- import { promises as fs6 } from "fs";
1272
- import { resolve as resolve3 } from "path";
1273
- var DOC_API_HOST = "https://api.sonenta.com";
1492
+ import { promises as fs7 } from "fs";
1493
+ import { resolve as resolve4 } from "path";
1494
+ var DOC_API_HOST = "https://api.sonenta.dev";
1274
1495
  var DOC_CDN_HOST = "https://cdn.sonenta.com";
1275
1496
  var REPO_DOC_FILES = ["CLAUDE.md", "AGENTS.md"];
1276
1497
  var BLOCK_BEGIN = "<!-- SONENTA:BEGIN \u2014 managed by `sonenta init`; edits between these markers are overwritten -->";
@@ -1358,13 +1579,13 @@ function renderManagedBlock(d) {
1358
1579
  async function upsertManagedBlock(filePath, block) {
1359
1580
  let existing = null;
1360
1581
  try {
1361
- existing = await fs6.readFile(filePath, "utf8");
1582
+ existing = await fs7.readFile(filePath, "utf8");
1362
1583
  } catch {
1363
1584
  existing = null;
1364
1585
  }
1365
1586
  const normalizedBlock = block.endsWith("\n") ? block : block + "\n";
1366
1587
  if (existing === null) {
1367
- await fs6.writeFile(filePath, normalizedBlock, "utf8");
1588
+ await fs7.writeFile(filePath, normalizedBlock, "utf8");
1368
1589
  return "created";
1369
1590
  }
1370
1591
  const begin = existing.indexOf(BLOCK_BEGIN);
@@ -1373,18 +1594,18 @@ async function upsertManagedBlock(filePath, block) {
1373
1594
  const before = existing.slice(0, begin);
1374
1595
  const after = existing.slice(end + BLOCK_END.length);
1375
1596
  const blockCore = block.slice(0, block.indexOf(BLOCK_END) + BLOCK_END.length);
1376
- await fs6.writeFile(filePath, before + blockCore + after, "utf8");
1597
+ await fs7.writeFile(filePath, before + blockCore + after, "utf8");
1377
1598
  return "updated";
1378
1599
  }
1379
1600
  const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
1380
- await fs6.writeFile(filePath, existing + sep + normalizedBlock, "utf8");
1601
+ await fs7.writeFile(filePath, existing + sep + normalizedBlock, "utf8");
1381
1602
  return "inserted";
1382
1603
  }
1383
1604
  async function writeRepoDocs(dir, data) {
1384
1605
  const block = renderManagedBlock(data);
1385
1606
  const results = [];
1386
1607
  for (const file of REPO_DOC_FILES) {
1387
- const path = resolve3(dir, file);
1608
+ const path = resolve4(dir, file);
1388
1609
  const action = await upsertManagedBlock(path, block);
1389
1610
  results.push({ file, path, action });
1390
1611
  }
@@ -1392,7 +1613,7 @@ async function writeRepoDocs(dir, data) {
1392
1613
  }
1393
1614
 
1394
1615
  // src/commands/init.ts
1395
- var DEFAULT_HOST = "https://api.sonenta.com";
1616
+ var DEFAULT_HOST = "https://api.sonenta.dev";
1396
1617
  var ACTION_LABEL = {
1397
1618
  created: "Created",
1398
1619
  updated: "Updated",
@@ -1433,9 +1654,13 @@ async function gatherRepoDocData(opts) {
1433
1654
  }
1434
1655
  var initCommand = new Command4("init").description(
1435
1656
  "Scaffold sonenta.config.json AND write a managed Sonenta block into CLAUDE.md / AGENTS.md so coding agents know how this repo uses Sonenta."
1436
- ).option("--host <url>", "API base URL", DEFAULT_HOST).option("--project <uuid>", "Project UUID").option("--version <slug>", "Version slug (default: main)", "main").option("--force", "Overwrite an existing sonenta.config.json", false).option("--no-repo-doc", "Skip writing the managed block into CLAUDE.md / AGENTS.md").action(
1657
+ ).option("--host <url>", "API base URL", DEFAULT_HOST).option("--project <uuid>", "Project UUID").option("--version <slug>", "Version slug (default: main)", "main").option("--force", "Overwrite an existing sonenta.config.json", false).option("--no-repo-doc", "Skip writing the managed block into CLAUDE.md / AGENTS.md").option("--no-mcp", "Skip auto-wiring the @sonenta/mcp server into .mcp.json").option(
1658
+ "--embed-key",
1659
+ "Bake the API key into .mcp.json (for CI / no-login); otherwise the server reads it from ~/.sonenta",
1660
+ false
1661
+ ).action(
1437
1662
  async (opts) => {
1438
- const path = resolve4(process.cwd(), CONFIG_FILENAME);
1663
+ const path = resolve5(process.cwd(), CONFIG_FILENAME);
1439
1664
  if (existsSync(path) && !opts.force) {
1440
1665
  console.error(
1441
1666
  `${CONFIG_FILENAME} already exists at ${path}. Pass --force to overwrite.`
@@ -1462,6 +1687,50 @@ var initCommand = new Command4("init").description(
1462
1687
  } else {
1463
1688
  console.log("Skipped CLAUDE.md / AGENTS.md (--no-repo-doc).");
1464
1689
  }
1690
+ if (opts.mcp) {
1691
+ let liveHost = opts.host !== DEFAULT_HOST ? opts.host : void 0;
1692
+ if (!liveHost) {
1693
+ const creds = await readCredentials().catch(() => null);
1694
+ liveHost = creds?.default ?? void 0;
1695
+ }
1696
+ let resolved = null;
1697
+ try {
1698
+ const ctx = await resolveContext({ hostOverride: liveHost });
1699
+ resolved = { apiKey: ctx.apiKey, host: ctx.host, projectUuid: ctx.projectUuid };
1700
+ } catch {
1701
+ resolved = opts.embedKey ? null : { host: liveHost ?? opts.host, projectUuid: opts.project };
1702
+ }
1703
+ if (resolved) {
1704
+ const wired = await wireMcpServer(
1705
+ {
1706
+ apiKey: resolved.apiKey,
1707
+ host: resolved.host,
1708
+ projectUuid: resolved.projectUuid ?? opts.project
1709
+ },
1710
+ { embedKey: opts.embedKey }
1711
+ );
1712
+ const verb = wired.action === "created" ? "Created" : wired.action === "updated" ? "Updated" : "Verified";
1713
+ console.log(
1714
+ `${verb} ${MCP_JSON_FILENAME} \u2192 connected the "${wired.serverKey}" server (npx -y @sonenta/mcp${resolved.host ? `, host ${resolved.host}` : ""}).`
1715
+ );
1716
+ if (wired.embeddedKey) {
1717
+ console.log(
1718
+ `Embedded your API key in ${MCP_JSON_FILENAME}` + (wired.gitignoreUpdated ? " and added it to .gitignore" : "") + " \u2014 keep it out of git."
1719
+ );
1720
+ } else {
1721
+ console.log(
1722
+ `No secret stored in ${MCP_JSON_FILENAME} \u2014 the server reads your API key from ~/.sonenta at startup (run \`sonenta login\` if it can't). Safe to commit.`
1723
+ );
1724
+ }
1725
+ console.log(
1726
+ `\u27F3 Reload your Claude Code session so the "${wired.serverKey}" server connects.`
1727
+ );
1728
+ } else {
1729
+ console.log(
1730
+ `Note: skipped ${MCP_JSON_FILENAME} wiring (--embed-key needs a login). Run \`sonenta login\` then \`sonenta agents add <name>\`.`
1731
+ );
1732
+ }
1733
+ }
1465
1734
  if (!opts.project) {
1466
1735
  console.log(
1467
1736
  "Tip: pass --project <uuid> to bind this directory to a specific project (or edit project_uuid in the file later), then re-run `sonenta init --force`."
@@ -1490,8 +1759,8 @@ async function promptLine(message) {
1490
1759
  if (!process.stdin.isTTY) return "";
1491
1760
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1492
1761
  try {
1493
- return await new Promise((resolve6) => {
1494
- rl.question(message, (answer) => resolve6(answer.trim()));
1762
+ return await new Promise((resolve7) => {
1763
+ rl.question(message, (answer) => resolve7(answer.trim()));
1495
1764
  });
1496
1765
  } finally {
1497
1766
  rl.close();
@@ -1507,7 +1776,7 @@ async function promptSecret(message) {
1507
1776
  process.stdout.write(message);
1508
1777
  process.stdin.setRawMode(true);
1509
1778
  process.stdin.resume();
1510
- return await new Promise((resolve6) => {
1779
+ return await new Promise((resolve7) => {
1511
1780
  let buffer = "";
1512
1781
  const onData = (chunk) => {
1513
1782
  for (const byte of chunk) {
@@ -1516,7 +1785,7 @@ async function promptSecret(message) {
1516
1785
  process.stdin.removeListener("data", onData);
1517
1786
  process.stdin.setRawMode(false);
1518
1787
  process.stdin.pause();
1519
- resolve6(buffer);
1788
+ resolve7(buffer);
1520
1789
  return;
1521
1790
  }
1522
1791
  if (byte === CTRL_C) {
@@ -1545,10 +1814,10 @@ async function promptSecret(message) {
1545
1814
  var TOKEN_REGEX = /^vrb_[a-z]+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
1546
1815
  var loginCommand = new Command6("login").description(
1547
1816
  "Store an API key for a host. Token resolution order: --token, SONENTA_TOKEN env, then interactive prompt (TTY only)."
1548
- ).option("--host <url>", "API base URL", "https://api.sonenta.com").option("--token <vrb_live_\u2026>", "API key token (prefix.secret form)").option("--email <email>", "User email associated with the token (optional)").option("--default", "Set this host as the default for future commands", false).action(async (opts) => {
1817
+ ).option("--host <url>", "API base URL", "https://api.sonenta.dev").option("--token <vrb_live_\u2026>", "API key token (prefix.secret form)").option("--email <email>", "User email associated with the token (optional)").option("--default", "Set this host as the default for future commands", false).action(async (opts) => {
1549
1818
  let host = opts.host;
1550
1819
  if (!host && process.stdin.isTTY) {
1551
- host = await promptLine("Host (default https://api.sonenta.com): ") || "https://api.sonenta.com";
1820
+ host = await promptLine("Host (default https://api.sonenta.dev): ") || "https://api.sonenta.dev";
1552
1821
  }
1553
1822
  let token = opts.token ?? (process.env.SONENTA_TOKEN ?? process.env.VERBUMIA_TOKEN) ?? "";
1554
1823
  if (!token && process.stdin.isTTY) {
@@ -1667,14 +1936,14 @@ var projectsCommand = new Command9("projects").description("Inspect Verbumia pro
1667
1936
  import { Command as Command10 } from "commander";
1668
1937
 
1669
1938
  // src/locales.ts
1670
- import { promises as fs7 } from "fs";
1671
- import { join as join3, resolve as resolve5 } from "path";
1939
+ import { promises as fs8 } from "fs";
1940
+ import { join as join3, resolve as resolve6 } from "path";
1672
1941
  var DEFAULT_LOCALES_DIR = "locales";
1673
1942
  async function listLocaleFiles(rootDir) {
1674
- const root = resolve5(rootDir);
1943
+ const root = resolve6(rootDir);
1675
1944
  let langDirs;
1676
1945
  try {
1677
- langDirs = await fs7.readdir(root);
1946
+ langDirs = await fs8.readdir(root);
1678
1947
  } catch {
1679
1948
  return [];
1680
1949
  }
@@ -1683,12 +1952,12 @@ async function listLocaleFiles(rootDir) {
1683
1952
  const langPath = join3(root, lang);
1684
1953
  let stat;
1685
1954
  try {
1686
- stat = await fs7.stat(langPath);
1955
+ stat = await fs8.stat(langPath);
1687
1956
  } catch {
1688
1957
  continue;
1689
1958
  }
1690
1959
  if (!stat.isDirectory()) continue;
1691
- const files = await fs7.readdir(langPath);
1960
+ const files = await fs8.readdir(langPath);
1692
1961
  for (const f of files) {
1693
1962
  if (!f.endsWith(".json")) continue;
1694
1963
  out.push({ lang, namespace: f.replace(/\.json$/, ""), path: join3(langPath, f) });
@@ -1697,7 +1966,7 @@ async function listLocaleFiles(rootDir) {
1697
1966
  return out;
1698
1967
  }
1699
1968
  async function readLocaleFile(path) {
1700
- const raw = await fs7.readFile(path, "utf8");
1969
+ const raw = await fs8.readFile(path, "utf8");
1701
1970
  const parsed = JSON.parse(raw);
1702
1971
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1703
1972
  throw new Error(`${path} is not a flat object`);
@@ -1713,12 +1982,12 @@ async function readLocaleFile(path) {
1713
1982
  }
1714
1983
  async function writeLocaleFile(rootDir, lang, namespace, values) {
1715
1984
  const dir = join3(rootDir, lang);
1716
- await fs7.mkdir(dir, { recursive: true });
1985
+ await fs8.mkdir(dir, { recursive: true });
1717
1986
  const path = join3(dir, `${namespace}.json`);
1718
1987
  const sorted = Object.fromEntries(
1719
1988
  Object.entries(values).sort(([a], [b]) => a.localeCompare(b))
1720
1989
  );
1721
- await fs7.writeFile(path, JSON.stringify(sorted, null, 2) + "\n", "utf8");
1990
+ await fs8.writeFile(path, JSON.stringify(sorted, null, 2) + "\n", "utf8");
1722
1991
  return path;
1723
1992
  }
1724
1993
  function diffFlat(local, remote) {
@@ -1857,7 +2126,7 @@ var releasesCommand = new Command12("releases").description("Manage CDN releases
1857
2126
  );
1858
2127
 
1859
2128
  // src/commands/snapshot.ts
1860
- import { promises as fs8 } from "fs";
2129
+ import { promises as fs9 } from "fs";
1861
2130
  import { Command as Command13 } from "commander";
1862
2131
  function bundleUrl(cdnBase, project, version, lang, ns) {
1863
2132
  return `${cdnBase.replace(/\/+$/, "")}/p/${project}/${version}/latest/${lang}/${ns}.json`;
@@ -1924,7 +2193,7 @@ var snapshotCommand = new Command13("snapshot").description(
1924
2193
  opts.format === "json" ? "json" : "ts"
1925
2194
  );
1926
2195
  if (opts.out) {
1927
- await fs8.writeFile(opts.out, output, "utf8");
2196
+ await fs9.writeFile(opts.out, output, "utf8");
1928
2197
  console.log(
1929
2198
  `wrote ${opts.out}: ${fetched} bundle(s)` + (missing ? `, ${missing} not published (404)` : "")
1930
2199
  );