@sonenta/cli 0.17.0 → 0.18.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
@@ -1,10 +1,73 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command16 } from "commander";
4
+ import { Command as Command17 } from "commander";
5
+
6
+ // package.json
7
+ var package_default = {
8
+ name: "@sonenta/cli",
9
+ version: "0.18.0",
10
+ description: "Command-line interface for Sonenta translation management.",
11
+ license: "MIT",
12
+ homepage: "https://sonenta.com",
13
+ repository: {
14
+ type: "git",
15
+ url: "https://github.com/verbumia/verbumia-tools.git",
16
+ directory: "cli"
17
+ },
18
+ bugs: {
19
+ url: "https://github.com/verbumia/verbumia-tools/issues"
20
+ },
21
+ keywords: [
22
+ "sonenta",
23
+ "verbumia",
24
+ "cli",
25
+ "i18n",
26
+ "translations",
27
+ "localization"
28
+ ],
29
+ author: "Sonenta",
30
+ type: "module",
31
+ bin: {
32
+ sonenta: "bin/sonenta.js",
33
+ verbumia: "bin/verbumia.js"
34
+ },
35
+ main: "./dist/index.js",
36
+ types: "./dist/index.d.ts",
37
+ files: [
38
+ "bin/",
39
+ "dist/",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ engines: {
44
+ node: ">=18.0.0"
45
+ },
46
+ scripts: {
47
+ build: "tsup",
48
+ test: "vitest run",
49
+ typecheck: "tsc --noEmit",
50
+ dev: "tsx src/index.ts",
51
+ prepublishOnly: "npm run typecheck && npm run test && npm run build"
52
+ },
53
+ dependencies: {
54
+ commander: "^12.1.0"
55
+ },
56
+ devDependencies: {
57
+ "@types/node": "^20.0.0",
58
+ tsup: "^8.3.0",
59
+ tsx: "^4.19.0",
60
+ typescript: "^5.5.0",
61
+ vitest: "^2.1.0"
62
+ },
63
+ publishConfig: {
64
+ access: "public",
65
+ registry: "https://registry.npmjs.org/"
66
+ }
67
+ };
5
68
 
6
69
  // src/commands/agents.ts
7
- import { Command } from "commander";
70
+ import { Command as Command2 } from "commander";
8
71
 
9
72
  // src/agents.ts
10
73
  import { promises as fs } from "fs";
@@ -295,9 +358,27 @@ is an explicit, estimate-first, opt-in fallback for very large volumes.
295
358
  \`update_keys_bulk\`, \`acknowledge_missing_keys\`.
296
359
  - **Translate:** \`list_untranslated_keys\`, \`propose_translations_bulk\`
297
360
  (your primary write \u2014 CRUD, 0 credits), \`validate_translations\`.
298
- - **Consistency:** \`glossary_list\`, \`project_context_get\`.
361
+ - **Consistency:** \`glossary_list\`, \`project_context_get\`,
362
+ \`list_placeholder_mismatches\` (audit: translations whose interpolation
363
+ variables drifted from the source \u2014 see **Placeholders**).
299
364
  - **Ship:** \`publish_cdn\`.
300
365
 
366
+ ## Placeholders \u2014 NEVER ship a translation that breaks interpolation
367
+ A translation must carry the SAME interpolation variables as its source value \u2014
368
+ by NAME, brace-agnostic (i18next \`{{name}}\`, ICU \`{name}\`, Ruby \`%{name}\`).
369
+ Dropping \`{{count}}\` or inventing \`{{total}}\` is a runtime bug (missing data or
370
+ a crash), so:
371
+ - When you translate, COPY the source's variables verbatim into your output (keep
372
+ the same names; you may reorder them for grammar). Don't translate, rename, or
373
+ drop a variable token.
374
+ - The project may set \`placeholder_enforcement = strict\`. Then
375
+ \`propose_translations_bulk\` REFUSES an offending item: that item comes back
376
+ \`{status:"error", error:{code:"PLACEHOLDER_MISMATCH"}, placeholder_mismatch:
377
+ {missing, extra, expected, got}}\` (the envelope also carries
378
+ \`placeholder_mismatch_count\`); good items still apply. In \`warn\` mode the item
379
+ applies but carries \`variable_warnings:{missing, extra}\`. Treat BOTH as a defect
380
+ to fix \u2014 see the workflow.
381
+
301
382
  ## Workflow
302
383
  1. **Assess.** \`get_project_info\` (source + target languages, namespaces);
303
384
  \`coverage_report\` (per-language completeness); \`health_report\`
@@ -318,14 +399,25 @@ is an explicit, estimate-first, opt-in fallback for very large volumes.
318
399
  language, \`list_untranslated_keys\`. BEFORE translating, read
319
400
  \`glossary_list\` (respect \`forbidden\` / \`do_not_translate\`, apply
320
401
  \`translation\` rules) and \`project_context_get\` (brand voice, domain, tone).
321
- Translate each value YOURSELF, then write a batch with
402
+ Translate each value YOURSELF \u2014 **carrying the source's interpolation
403
+ variables verbatim** (see **Placeholders**) \u2014 then write a batch with
322
404
  \`propose_translations_bulk\` (status draft/proposed). Work through the
323
405
  languages and namespaces in sensible batches.
324
- 4. **Validate (optional).** \`validate_translations\` lints an i18next blob
406
+ 4. **Honor strict placeholders \u2014 fix and resubmit, never leave a mismatch.**
407
+ Inspect the \`propose_translations_bulk\` result: for any item with
408
+ \`error.code == "PLACEHOLDER_MISMATCH"\` (strict) OR \`variable_warnings\`
409
+ (warn), read \`placeholder_mismatch.{missing, extra, expected, got}\` (each
410
+ item is ONE language, so this is already per-language), REWRITE that value so
411
+ its variables are exactly \`expected\` \u2014 add every \`missing\`, drop every
412
+ \`extra\`, keep the names \u2014 and RESUBMIT the corrected item. Repeat until the
413
+ batch returns no \`PLACEHOLDER_MISMATCH\` and \`placeholder_mismatch_count == 0\`.
414
+ A strict-refused item was NOT written, so it is not done until it re-submits
415
+ clean. (Optionally \`list_placeholder_mismatches\` to audit the whole project.)
416
+ 5. **Validate (optional).** \`validate_translations\` lints an i18next blob
325
417
  server-side; fix anything it flags.
326
- 5. **Publish.** \`publish_cdn\` to release the bundles \u2014 with confirmation in
418
+ 6. **Publish.** \`publish_cdn\` to release the bundles \u2014 with confirmation in
327
419
  interactive mode, only on explicit authorization in CI.
328
- 6. **Report.** Show the coverage delta (before/after), counts of keys created and
420
+ 7. **Report.** Show the coverage delta (before/after), counts of keys created and
329
421
  strings translated, what remains, and that translations are drafts to review.
330
422
 
331
423
  ## Modes
@@ -346,12 +438,15 @@ is an explicit, estimate-first, opt-in fallback for very large volumes.
346
438
  - Local translation + \`propose_translations_bulk\` is the default and costs 0
347
439
  credits; any server-side AI translation is an explicit, estimated, opt-in
348
440
  fallback.
441
+ - A translation must preserve the source's interpolation variables (by name).
442
+ Never leave a \`PLACEHOLDER_MISMATCH\` (strict) or \`variable_warnings\` (warn)
443
+ unresolved \u2014 fix the value and resubmit before considering the key done.
349
444
  - Never \`publish_cdn\` without confirmation/authorization; stay within the
350
445
  configured project.
351
446
  `;
352
447
  var SONENTA_SOURCE_HEALTH = `---
353
448
  name: sonenta-source-health
354
- description: Source-health repairer for Sonenta-managed i18n projects. Finds DUPLICATE source strings (the same source value spread across many keys \u2014 which inflates translation cost and lets the same string drift into N different translations) and fixes them, working STRICTLY step by step: it lists the affected files first, presents a plan and reassures you before touching anything, edits ONLY on your acceptance, and marks each group resolved through the Sonenta MCP tools. When the Sonenta dashboard has prepared a merge plan, it applies that plan verbatim \u2014 merging the clustered keys (value-safe), then differentiating or allowing whatever still shares text. Use interactively in Claude Code or headless in CI.
449
+ description: Source-health repairer for Sonenta-managed i18n projects. Finds DUPLICATE source strings (the same source value spread across many keys \u2014 which inflates translation cost and lets the same string drift into N different translations) and fixes them, working STRICTLY step by step: it lists the affected files first, presents a plan and reassures you before touching anything, edits ONLY on your acceptance, and marks each group resolved through the Sonenta MCP tools. When the Sonenta dashboard has prepared a merge plan, it applies that plan verbatim \u2014 merging the clustered keys (value-safe), then differentiating or allowing whatever still shares text. It also repairs PLACEHOLDER MISMATCHES \u2014 translations whose interpolation variables ({{name}}, {count}, %{n}) drifted from the source \u2014 by rewriting the target value to match and re-checking. Use interactively in Claude Code or headless in CI.
355
450
  ---
356
451
 
357
452
  You are **sonenta-source-health**, a careful repair specialist for
@@ -394,6 +489,14 @@ every change is a reviewable draft.
394
489
  optional \`note\` recording why. CRUD. Only on the dev's EXPLICIT acceptance \u2014
395
490
  \`allowed\` in particular is the user's business decision, never an agent
396
491
  default; never auto-mark a group the dev hasn't decided.
492
+ - \`list_placeholder_mismatches\` \u2014 your SECOND worklist (PM2): translations
493
+ whose interpolation variables drifted from the source. Each item =
494
+ {key_uuid, key, namespace_slug, language, source_value, source_vars[],
495
+ target_value, target_vars[], missing[], extra[]}. READ-ONLY \u2014 see
496
+ **Placeholder mismatch repair**.
497
+ - \`propose_translation\` \u2014 write ONE corrected target translation
498
+ (\`{namespace, key, language_code, value}\`, draft). CRUD, 0 credits. Your
499
+ write tool for placeholder repairs (the source value is untouched).
397
500
 
398
501
  ## Repair strategies (pick per group, propose explicitly)
399
502
  For a group of keys sharing one source value, the right fix is usually one of:
@@ -453,6 +556,30 @@ decision to apply, so you PROPOSE a strategy (consolidate / disambiguate / allow
453
556
  from the section above and act ONLY on the dev's explicit acceptance \u2014 never
454
557
  auto-resolve or auto-allow a group the dev hasn't decided.
455
558
 
559
+ ## Placeholder mismatch repair (PM2) \u2014 list, rewrite, RE-CHECK
560
+ Your second job: translations whose interpolation VARIABLES drifted from the
561
+ source (a broken \`{{name}}\` / \`{count}\` is a runtime bug \u2014 missing data or a
562
+ crash). Detection is by variable NAME, brace-agnostic (i18next \`{{}}\`, ICU
563
+ \`{}\`, Ruby \`%{}\`). This repair edits a TARGET TRANSLATION value, never the
564
+ source \u2014 so it's low-risk and reversible (a draft), but you still go step by step.
565
+ 1. **List.** \`list_placeholder_mismatches\` (optionally scoped by \`key_id\` /
566
+ \`language_code\`). Each item gives \`source_vars\`, the offending
567
+ \`target_value\` / \`target_vars\`, and the \`missing\` / \`extra\` variables for
568
+ that language. Present the worklist (key, language, what's missing/extra).
569
+ 2. **Rewrite \u2014 yourself, 0 credits.** For each item, rewrite \`target_value\` so
570
+ its variables are exactly \`source_vars\`: ADD every \`missing\` token, REMOVE
571
+ every \`extra\` one, keep the names identical (you may reposition them for
572
+ grammar), and preserve the translation's meaning. Don't translate or rename a
573
+ variable token.
574
+ 3. **Write on acceptance.** Persist each corrected value with
575
+ \`propose_translation(namespace, key, language_code, value)\` (a draft; the
576
+ source value is untouched). In interactive mode propose the rewrites and apply
577
+ on the dev's yes; in CI apply only when the run authorizes auto-fix.
578
+ 4. **RE-CHECK (mandatory).** Re-call \`list_placeholder_mismatches\` \u2014 there is NO
579
+ server-side auto-fix, so the repair is only confirmed when \`total == 0\` (or
580
+ the item is gone). If something still mismatches, you mis-rewrote it \u2014 fix and
581
+ re-check again.
582
+
456
583
  ## Workflow (strictly ordered)
457
584
  1. **List the affected files first.** Call \`list_source_duplicates(status=to_fix)\`.
458
585
  For each group, resolve the keys to their human locations with \`list_keys\`
@@ -843,12 +970,12 @@ var AGENTS = {
843
970
  },
844
971
  "sonenta-i18n": {
845
972
  name: "sonenta-i18n",
846
- summary: "i18n automation: audits coverage, creates missing keys, translates the untranslated locally (0-credit propose_translations_bulk), and publishes \u2014 server-side AI translation as an opt-in fallback.",
973
+ summary: "i18n automation: audits coverage, creates missing keys, translates the untranslated locally (0-credit propose_translations_bulk), and publishes \u2014 server-side AI translation as an opt-in fallback. Preserves interpolation variables and honors strict placeholder mode: self-corrects + resubmits on a PLACEHOLDER_MISMATCH so it never writes a translation that breaks placeholders.",
847
974
  content: SONENTA_I18N
848
975
  },
849
976
  "sonenta-source-health": {
850
977
  name: "sonenta-source-health",
851
- summary: "Duplicate-source repairer. Applies the merge plans prepared in the Sonenta dashboard \u2014 repoints t() usages onto the canonical key and trashes the redundant ones (value-safe soft-delete, never edits a source value), then differentiates or allows whatever still shares text. Strictly step-by-step and ONLY on your acceptance; also repairs without a plan (auto consolidate/disambiguate/allow).",
978
+ summary: "Duplicate-source repairer. Applies the merge plans prepared in the Sonenta dashboard \u2014 repoints t() usages onto the canonical key and trashes the redundant ones (value-safe soft-delete, never edits a source value), then differentiates or allows whatever still shares text. Strictly step-by-step and ONLY on your acceptance; also repairs without a plan (auto consolidate/disambiguate/allow). Plus PLACEHOLDER MISMATCH repair: lists translations whose interpolation vars drifted from the source, rewrites the target to match (propose_translation), and re-checks until clean.",
852
979
  content: SONENTA_SOURCE_HEALTH
853
980
  },
854
981
  "sonenta-knowledge": {
@@ -1119,6 +1246,14 @@ function buildServerBlock(env, opts = {}) {
1119
1246
  if (env.projectUuid) e.SONENTA_PROJECT = env.projectUuid;
1120
1247
  return { command: "npx", args: ["-y", MCP_PACKAGE], env: e };
1121
1248
  }
1249
+ async function readWiredServer(baseDir = process.cwd()) {
1250
+ const { json } = await readMcpJson(resolve3(baseDir, MCP_JSON_FILENAME));
1251
+ const servers = json.mcpServers;
1252
+ if (!servers || typeof servers !== "object") return null;
1253
+ const block = servers[MCP_SERVER_KEY];
1254
+ if (!block || typeof block !== "object") return null;
1255
+ return block;
1256
+ }
1122
1257
  async function readMcpJson(path) {
1123
1258
  try {
1124
1259
  const raw = await fs4.readFile(path, "utf8");
@@ -1186,9 +1321,239 @@ function deepEqual(a, b) {
1186
1321
  );
1187
1322
  }
1188
1323
 
1324
+ // src/doctor.ts
1325
+ var CANON_HOST = "https://api.sonenta.dev";
1326
+ var KEYS_HINT = "create one in the dashboard \u2192 Org Settings \u2192 API Keys (scope mcp:*), then run `sonenta login`";
1327
+ function hasMcpScope(scopes) {
1328
+ if (!scopes) return false;
1329
+ return scopes.some((s) => s === "*" || s === "mcp:*" || s.startsWith("mcp:"));
1330
+ }
1331
+ async function probe(fetchImpl, host, apiKey, path) {
1332
+ const url = `${host.replace(/\/+$/, "")}${path}`;
1333
+ try {
1334
+ const res = await fetchImpl(url, { headers: { Authorization: `ApiKey ${apiKey}` } });
1335
+ let json;
1336
+ try {
1337
+ json = await res.json();
1338
+ } catch {
1339
+ json = void 0;
1340
+ }
1341
+ return { status: res.status, ok: res.ok, json };
1342
+ } catch (e) {
1343
+ return { status: 0, ok: false, networkError: e.message };
1344
+ }
1345
+ }
1346
+ async function runDoctor(opts = {}) {
1347
+ const fetchImpl = opts.fetchImpl ?? fetch;
1348
+ const dir = opts.dir ?? process.cwd();
1349
+ const checks = [];
1350
+ const { path: cfgPath, config } = await readConfig(dir).catch(() => ({
1351
+ path: null,
1352
+ config: {}
1353
+ }));
1354
+ checks.push({
1355
+ id: "config",
1356
+ title: "Project config",
1357
+ status: cfgPath ? "pass" : "warn",
1358
+ detail: cfgPath ? `sonenta.config.json found${config.project_uuid ? ` (project ${config.project_uuid})` : ""}` : "no sonenta.config.json in this directory",
1359
+ fix: cfgPath ? void 0 : "run `sonenta init --project <uuid>` to scaffold it and bind a project"
1360
+ });
1361
+ let ctx = null;
1362
+ try {
1363
+ ctx = await resolveContext({ hostOverride: opts.hostOverride, cwd: dir });
1364
+ checks.push({
1365
+ id: "login",
1366
+ title: "Login",
1367
+ status: "pass",
1368
+ detail: `credentials resolved for ${ctx.host}`
1369
+ });
1370
+ } catch (e) {
1371
+ checks.push({
1372
+ id: "login",
1373
+ title: "Login",
1374
+ status: "fail",
1375
+ detail: e.message,
1376
+ fix: `run \`sonenta login --host ${opts.hostOverride ?? CANON_HOST}\``
1377
+ });
1378
+ }
1379
+ const wired = await readWiredServer(dir).catch(() => null);
1380
+ checks.push({
1381
+ id: "mcp_wired",
1382
+ title: "MCP server wired",
1383
+ status: wired ? "pass" : "fail",
1384
+ detail: wired ? `.mcp.json declares the "sonenta" server (${MCP_PACKAGE})` : 'no "sonenta" server in .mcp.json',
1385
+ fix: wired ? void 0 : "run `sonenta agents add <name>` (or `sonenta init`) to wire it, then reload your Claude session"
1386
+ });
1387
+ if (!ctx) {
1388
+ return finalize(checks);
1389
+ }
1390
+ const me = await probe(fetchImpl, ctx.host, ctx.apiKey, "/v1/me");
1391
+ if (me.networkError) {
1392
+ checks.push({
1393
+ id: "api",
1394
+ title: "API reachable",
1395
+ status: "fail",
1396
+ detail: `could not reach ${ctx.host}: ${me.networkError}`,
1397
+ fix: `check your connection and the host \u2014 the canonical host is ${CANON_HOST} (\`sonenta login --host ${CANON_HOST}\`)`
1398
+ });
1399
+ return finalize(checks);
1400
+ }
1401
+ if (me.status === 404) {
1402
+ checks.push({
1403
+ id: "api",
1404
+ title: "API reachable",
1405
+ status: "fail",
1406
+ detail: `${ctx.host} returned 404 for /v1/me \u2014 almost certainly the wrong host`,
1407
+ fix: `use the canonical host: \`sonenta login --host ${CANON_HOST}\``
1408
+ });
1409
+ return finalize(checks);
1410
+ }
1411
+ if (me.status === 401) {
1412
+ checks.push({
1413
+ id: "api",
1414
+ title: "API key valid",
1415
+ status: "fail",
1416
+ detail: "the API returned 401 \u2014 your key is invalid or revoked",
1417
+ fix: `run \`sonenta login\` with a current key (${KEYS_HINT})`
1418
+ });
1419
+ return finalize(checks);
1420
+ }
1421
+ if (!me.ok) {
1422
+ checks.push({
1423
+ id: "api",
1424
+ title: "API reachable",
1425
+ status: "fail",
1426
+ detail: `unexpected ${me.status} from ${ctx.host}/v1/me`,
1427
+ fix: `verify the host (${CANON_HOST}) and try again`
1428
+ });
1429
+ return finalize(checks);
1430
+ }
1431
+ checks.push({
1432
+ id: "api",
1433
+ title: "API reachable",
1434
+ status: "pass",
1435
+ detail: `${ctx.host} responded 200 to /v1/me`
1436
+ });
1437
+ const accountActive = me.json?.account_active !== false;
1438
+ if (!accountActive) {
1439
+ checks.push({
1440
+ id: "account",
1441
+ title: "Account active",
1442
+ status: "fail",
1443
+ detail: "your account/subscription is inactive",
1444
+ fix: "reactivate your subscription in the Sonenta dashboard"
1445
+ });
1446
+ } else {
1447
+ checks.push({ id: "account", title: "Account active", status: "pass", detail: "account active" });
1448
+ }
1449
+ const scopes = Array.isArray(me.json?.scopes) ? me.json.scopes : void 0;
1450
+ const mcpOk = hasMcpScope(scopes);
1451
+ checks.push({
1452
+ id: "mcp_scope",
1453
+ title: "Key has mcp:* scope",
1454
+ status: mcpOk ? "pass" : "fail",
1455
+ detail: mcpOk ? "the key carries the mcp:* scope" : `the key lacks the mcp:* scope${scopes ? ` (has: ${scopes.join(", ") || "none"})` : ""}`,
1456
+ fix: mcpOk ? void 0 : `the agents drive the MCP tools, which need an mcp:* key \u2014 ${KEYS_HINT}`
1457
+ });
1458
+ if (!ctx.projectUuid) {
1459
+ checks.push({
1460
+ id: "a11y",
1461
+ title: "a11y tools respond",
1462
+ status: "skip",
1463
+ detail: "no project bound, so the per-project a11y surface can't be probed",
1464
+ fix: "bind a project: `sonenta init --project <uuid>`"
1465
+ });
1466
+ return finalize(checks);
1467
+ }
1468
+ const surf = await probe(
1469
+ fetchImpl,
1470
+ ctx.host,
1471
+ ctx.apiKey,
1472
+ `/v1/mcp/projects/${ctx.projectUuid}/surfaces`
1473
+ );
1474
+ if (surf.ok) {
1475
+ checks.push({
1476
+ id: "a11y",
1477
+ title: "a11y tools respond",
1478
+ status: "pass",
1479
+ detail: "the project's a11y/surface MCP endpoint responded"
1480
+ });
1481
+ } else if (surf.status === 403) {
1482
+ const detail = surf.json?.detail ?? surf.json;
1483
+ const isMissingScope = detail?.code === "MISSING_SCOPE";
1484
+ const howTo = typeof detail?.how_to_get === "string" ? detail.how_to_get : void 0;
1485
+ const msg = typeof detail?.message === "string" ? detail.message : void 0;
1486
+ checks.push({
1487
+ id: "a11y",
1488
+ title: "a11y tools respond",
1489
+ status: "fail",
1490
+ detail: isMissingScope ? `the a11y MCP surface returned 403: this key lacks the ${detail?.required_scope ?? "mcp:*"} scope for this project` : "the a11y MCP surface returned 403 \u2014 the key can't act on this project",
1491
+ fix: msg ?? (howTo ? `get a scoped key: ${howTo}` : `the key needs the mcp:* scope for this project \u2014 ${KEYS_HINT}`)
1492
+ });
1493
+ } else if (surf.status === 404) {
1494
+ checks.push({
1495
+ id: "a11y",
1496
+ title: "a11y tools respond",
1497
+ status: "fail",
1498
+ detail: `project ${ctx.projectUuid} not found on ${ctx.host} (404)`,
1499
+ fix: "check project_uuid in sonenta.config.json and the host (`sonenta init --project <uuid>`)"
1500
+ });
1501
+ } else {
1502
+ checks.push({
1503
+ id: "a11y",
1504
+ title: "a11y tools respond",
1505
+ status: "fail",
1506
+ detail: surf.networkError ? `could not reach the a11y surface: ${surf.networkError}` : `unexpected ${surf.status} from the a11y MCP surface`,
1507
+ fix: `verify the host (${CANON_HOST}) and that the project is reachable`
1508
+ });
1509
+ }
1510
+ return finalize(checks);
1511
+ }
1512
+ function finalize(checks) {
1513
+ return { checks, ok: !checks.some((c) => c.status === "fail") };
1514
+ }
1515
+
1516
+ // src/commands/doctor.ts
1517
+ import { Command } from "commander";
1518
+ var MARK = {
1519
+ pass: "\u2713",
1520
+ fail: "\u2717",
1521
+ warn: "!",
1522
+ skip: "\xB7"
1523
+ };
1524
+ function formatReport(report) {
1525
+ const lines = ["sonenta doctor \u2014 agent preflight", ""];
1526
+ const width = Math.max(...report.checks.map((c) => c.title.length));
1527
+ for (const c of report.checks) {
1528
+ lines.push(`${MARK[c.status]} ${c.title.padEnd(width)} ${c.detail}`);
1529
+ if (c.fix && (c.status === "fail" || c.status === "warn" || c.status === "skip")) {
1530
+ lines.push(`${" ".repeat(width + 4)}\u2192 Fix: ${c.fix}`);
1531
+ }
1532
+ }
1533
+ lines.push("");
1534
+ if (report.ok) {
1535
+ lines.push(
1536
+ "All checks passed. If your agent still shows no tools, reload your Claude Code session \u2014 the MCP server connects at session start."
1537
+ );
1538
+ } else {
1539
+ const failed = report.checks.filter((c) => c.status === "fail").length;
1540
+ lines.push(
1541
+ `${failed} check${failed === 1 ? "" : "s"} failed \u2014 fix the step(s) above, then re-run \`sonenta doctor\`.`
1542
+ );
1543
+ }
1544
+ return lines;
1545
+ }
1546
+ var doctorCommand = new Command("doctor").description(
1547
+ "Preflight the agent setup: verifies the @sonenta/mcp server is wired + reachable, the key has the mcp:* scope, and the project's a11y tools respond \u2014 with an exact fix for anything that's off."
1548
+ ).option("--dir <path>", "Project directory (default: current directory)").option("--host <url>", "Override host (otherwise from config/credentials)").action(async (opts) => {
1549
+ const report = await runDoctor({ dir: opts.dir, hostOverride: opts.host });
1550
+ for (const line of formatReport(report)) console.log(line);
1551
+ if (!report.ok) process.exit(1);
1552
+ });
1553
+
1189
1554
  // src/commands/agents.ts
1190
- var agentsCommand = new Command("agents").description("Install bundled Claude agents (e.g. sonenta-a11y) into .claude/agents/.").addCommand(
1191
- new Command("list").description("List the bundled agents available to install.").option("--dir <path>", "Project directory (default: current directory)").action(async (opts) => {
1555
+ var agentsCommand = new Command2("agents").description("Install bundled Claude agents (e.g. sonenta-a11y) into .claude/agents/.").addCommand(
1556
+ new Command2("list").description("List the bundled agents available to install.").option("--dir <path>", "Project directory (default: current directory)").action(async (opts) => {
1192
1557
  const baseDir = opts.dir;
1193
1558
  const agents = listAgents();
1194
1559
  console.log(`Available agents (${agents.length}):`);
@@ -1202,7 +1567,7 @@ var agentsCommand = new Command("agents").description("Install bundled Claude ag
1202
1567
  Install with: sonenta agents add <name>`);
1203
1568
  })
1204
1569
  ).addCommand(
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(
1570
+ new Command2("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
1571
  "--embed-key",
1207
1572
  "Bake the API key into .mcp.json (for CI / no-login); otherwise the server reads it from ~/.sonenta",
1208
1573
  false
@@ -1245,6 +1610,9 @@ Skipped .mcp.json (--no-mcp). The ${name} agent needs the Sonenta MCP server (np
1245
1610
  );
1246
1611
  }
1247
1612
  console.log(`Agent dir: ${AGENTS_DIR}/`);
1613
+ console.log("");
1614
+ const report = await runDoctor({ dir: opts.dir, hostOverride: opts.host });
1615
+ for (const line of formatReport(report)) console.log(line);
1248
1616
  }
1249
1617
  )
1250
1618
  );
@@ -1252,7 +1620,7 @@ Skipped .mcp.json (--no-mcp). The ${name} agent needs the Sonenta MCP server (np
1252
1620
  // src/commands/export.ts
1253
1621
  import { promises as fs5 } from "fs";
1254
1622
  import { join as join2 } from "path";
1255
- import { Command as Command2 } from "commander";
1623
+ import { Command as Command3 } from "commander";
1256
1624
 
1257
1625
  // src/i18next_tree.ts
1258
1626
  function unflatten(flat, sep = ".") {
@@ -1377,7 +1745,7 @@ async function collect(ctx, languages, namespace) {
1377
1745
  }
1378
1746
  return out;
1379
1747
  }
1380
- var exportCommand = new Command2("export").description(
1748
+ var exportCommand = new Command3("export").description(
1381
1749
  "Export Verbumia translations as i18next JSON. Flat dot-notation by default (--nested for nested trees). Writes <out>/<lang>/<namespace>.json with --out, otherwise prints { locale: { namespace: tree } } to stdout."
1382
1750
  ).option("--language <code>", "Restrict to a single language code").option("--namespace <slug>", "Restrict to a single namespace slug").option("--nested", "Emit nested JSON instead of flat dot-notation", false).option("--out <dir>", "Write files instead of printing to stdout").option("--host <url>", "Override host (otherwise from config/credentials)").action(
1383
1751
  async (opts) => {
@@ -1412,7 +1780,7 @@ var exportCommand = new Command2("export").description(
1412
1780
  // src/commands/import.ts
1413
1781
  import { promises as fs6 } from "fs";
1414
1782
  import { basename, dirname as dirname3 } from "path";
1415
- import { Command as Command3 } from "commander";
1783
+ import { Command as Command4 } from "commander";
1416
1784
  function resolveLangNs(filePath, optLang, optNs) {
1417
1785
  const stem = basename(filePath).replace(/\.json$/i, "");
1418
1786
  const parent = basename(dirname3(filePath));
@@ -1442,7 +1810,7 @@ function countLeaves(tree) {
1442
1810
  }
1443
1811
  return n;
1444
1812
  }
1445
- var importCommand = new Command3("import").description(
1813
+ var importCommand = new Command4("import").description(
1446
1814
  "Import i18next JSON file(s) into Verbumia in ONE call \u2014 creates missing keys and upserts translations (idempotent). Accepts nested or flat JSON. Language/namespace are inferred from the path (<lang>/<namespace>.json) or forced with --language / --namespace."
1447
1815
  ).argument("<files...>", "i18next JSON file(s), e.g. locales/fr/common.json or fr.json").option("--language <code>", "Force the language code for every file").option("--namespace <slug>", "Force the namespace slug for every file").option("--status <status>", "draft | translated (default: translated)").option("--version <slug>", "Target version slug (default: production version)").option("--dry-run", "Print what would be imported without sending", false).option("--host <url>", "Override host (otherwise from config/credentials)").action(
1448
1816
  async (files, opts) => {
@@ -1486,7 +1854,7 @@ var importCommand = new Command3("import").description(
1486
1854
  // src/commands/init.ts
1487
1855
  import { existsSync } from "fs";
1488
1856
  import { resolve as resolve5 } from "path";
1489
- import { Command as Command4 } from "commander";
1857
+ import { Command as Command5 } from "commander";
1490
1858
 
1491
1859
  // src/repodoc.ts
1492
1860
  import { promises as fs7 } from "fs";
@@ -1652,7 +2020,7 @@ async function gatherRepoDocData(opts) {
1652
2020
  };
1653
2021
  }
1654
2022
  }
1655
- var initCommand = new Command4("init").description(
2023
+ var initCommand = new Command5("init").description(
1656
2024
  "Scaffold sonenta.config.json AND write a managed Sonenta block into CLAUDE.md / AGENTS.md so coding agents know how this repo uses Sonenta."
1657
2025
  ).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
2026
  "--embed-key",
@@ -1740,9 +2108,9 @@ var initCommand = new Command4("init").description(
1740
2108
  );
1741
2109
 
1742
2110
  // src/commands/keys.ts
1743
- import { Command as Command5 } from "commander";
1744
- var keysCommand = new Command5("keys").description("Inspect translation keys for the current project.").addCommand(
1745
- new Command5("list").description("List keys for the configured project (addressed by namespace slug + key name).").option("--namespace <slug>", "Filter by namespace slug").option("--host <url>", "Override host (otherwise from config/credentials)").action(async (opts) => {
2111
+ import { Command as Command6 } from "commander";
2112
+ var keysCommand = new Command6("keys").description("Inspect translation keys for the current project.").addCommand(
2113
+ new Command6("list").description("List keys for the configured project (addressed by namespace slug + key name).").option("--namespace <slug>", "Filter by namespace slug").option("--host <url>", "Override host (otherwise from config/credentials)").action(async (opts) => {
1746
2114
  const ctx = await requireAuth({ hostOverride: opts.host });
1747
2115
  const items = await listKeys(ctx, { namespace: opts.namespace });
1748
2116
  console.log(`total: ${items.length}`);
@@ -1751,7 +2119,7 @@ var keysCommand = new Command5("keys").description("Inspect translation keys for
1751
2119
  );
1752
2120
 
1753
2121
  // src/commands/login.ts
1754
- import { Command as Command6 } from "commander";
2122
+ import { Command as Command7 } from "commander";
1755
2123
 
1756
2124
  // src/prompt.ts
1757
2125
  import { createInterface } from "readline";
@@ -1812,7 +2180,7 @@ async function promptSecret(message) {
1812
2180
 
1813
2181
  // src/commands/login.ts
1814
2182
  var TOKEN_REGEX = /^vrb_[a-z]+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
1815
- var loginCommand = new Command6("login").description(
2183
+ var loginCommand = new Command7("login").description(
1816
2184
  "Store an API key for a host. Token resolution order: --token, SONENTA_TOKEN env, then interactive prompt (TTY only)."
1817
2185
  ).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) => {
1818
2186
  let host = opts.host;
@@ -1863,8 +2231,8 @@ var loginCommand = new Command6("login").description(
1863
2231
  });
1864
2232
 
1865
2233
  // src/commands/logout.ts
1866
- import { Command as Command7 } from "commander";
1867
- var logoutCommand = new Command7("logout").description("Remove stored credentials for a host (default: the current default host).").option("--host <url>", "Host to forget. Omit to forget the current default.").action(async (opts) => {
2234
+ import { Command as Command8 } from "commander";
2235
+ var logoutCommand = new Command8("logout").description("Remove stored credentials for a host (default: the current default host).").option("--host <url>", "Host to forget. Omit to forget the current default.").action(async (opts) => {
1868
2236
  const creds = await readCredentials();
1869
2237
  const target = opts.host ?? creds.default;
1870
2238
  if (!target) {
@@ -1880,8 +2248,8 @@ var logoutCommand = new Command7("logout").description("Remove stored credential
1880
2248
  });
1881
2249
 
1882
2250
  // src/commands/missing.ts
1883
- import { Command as Command8 } from "commander";
1884
- var missingCommand = new Command8("missing").description(
2251
+ import { Command as Command9 } from "commander";
2252
+ var missingCommand = new Command9("missing").description(
1885
2253
  "List runtime-detected missing keys for the configured project. Requires the API key to carry the `mcp:*` scope."
1886
2254
  ).option("--namespace <slug>", "Filter by namespace slug").option("--language <code>", "Filter by language code").option("--status <state>", "Filter by status (open|resolved|...)").option("--limit <n>", "Page size (1-200, default 50)", "50").option("--host <url>", "Override host (otherwise from config/credentials)").action(
1887
2255
  async (opts) => {
@@ -1915,9 +2283,9 @@ var missingCommand = new Command8("missing").description(
1915
2283
  );
1916
2284
 
1917
2285
  // src/commands/projects.ts
1918
- import { Command as Command9 } from "commander";
1919
- var projectsCommand = new Command9("projects").description("Inspect Verbumia projects accessible to your API key.").addCommand(
1920
- new Command9("list").description("List the projects this API key can reach.").option("--host <url>", "Override host (otherwise from config/credentials)").action(async (opts) => {
2286
+ import { Command as Command10 } from "commander";
2287
+ var projectsCommand = new Command10("projects").description("Inspect Verbumia projects accessible to your API key.").addCommand(
2288
+ new Command10("list").description("List the projects this API key can reach.").option("--host <url>", "Override host (otherwise from config/credentials)").action(async (opts) => {
1921
2289
  const ctx = await requireAuth({ hostOverride: opts.host });
1922
2290
  const items = await listProjects(ctx);
1923
2291
  if (items.length === 0) {
@@ -1933,7 +2301,7 @@ var projectsCommand = new Command9("projects").description("Inspect Verbumia pro
1933
2301
  );
1934
2302
 
1935
2303
  // src/commands/pull.ts
1936
- import { Command as Command10 } from "commander";
2304
+ import { Command as Command11 } from "commander";
1937
2305
 
1938
2306
  // src/locales.ts
1939
2307
  import { promises as fs8 } from "fs";
@@ -2011,7 +2379,7 @@ function diffFlat(local, remote) {
2011
2379
  }
2012
2380
 
2013
2381
  // src/commands/pull.ts
2014
- var pullCommand = new Command10("pull").description(
2382
+ var pullCommand = new Command11("pull").description(
2015
2383
  "Pull translations from Verbumia into locales/<lang>/<namespace>.json (flat dot-notation). Overwrites local files \u2014 pair with `git status`."
2016
2384
  ).option("--language <code>", "Restrict to a single language code").option("--namespace <slug>", "Restrict to a single namespace slug").option("--dest <dir>", "Output directory", DEFAULT_LOCALES_DIR).option("--host <url>", "Override host (otherwise from config/credentials)").action(
2017
2385
  async (opts) => {
@@ -2050,8 +2418,8 @@ var pullCommand = new Command10("pull").description(
2050
2418
  );
2051
2419
 
2052
2420
  // src/commands/push.ts
2053
- import { Command as Command11 } from "commander";
2054
- var pushCommand = new Command11("push").description(
2421
+ import { Command as Command12 } from "commander";
2422
+ var pushCommand = new Command12("push").description(
2055
2423
  "Push the whole local locales/ tree to Verbumia in ONE import call \u2014 creates missing keys and upserts translations (idempotent). Reads locales/<lang>/<namespace>.json (flat dot-notation)."
2056
2424
  ).option("--language <code>", "Restrict to a single language code").option("--namespace <slug>", "Restrict to a single namespace slug").option("--src <dir>", "Locales directory", DEFAULT_LOCALES_DIR).option("--status <status>", "draft | translated (default: translated)").option("--version <slug>", "Target version slug (default: production version)").option("--dry-run", "Print what would be pushed without sending", false).option("--host <url>", "Override host (otherwise from config/credentials)").action(
2057
2425
  async (opts) => {
@@ -2098,9 +2466,9 @@ var pushCommand = new Command11("push").description(
2098
2466
  );
2099
2467
 
2100
2468
  // src/commands/releases.ts
2101
- import { Command as Command12 } from "commander";
2102
- var releasesCommand = new Command12("releases").description("Manage CDN releases for the project.").addCommand(
2103
- new Command12("publish").description(
2469
+ import { Command as Command13 } from "commander";
2470
+ var releasesCommand = new Command13("releases").description("Manage CDN releases for the project.").addCommand(
2471
+ new Command13("publish").description(
2104
2472
  "Trigger a CDN release: build bundles for every (language, namespace) and push them to the public CDN. Idempotent \u2014 unchanged bundles are reused. Subscribed SDKs receive a live `translations_published` event."
2105
2473
  ).option("--language <code>", "Restrict to one language (default: all)").option("--namespace <slug>", "Restrict to one namespace (default: all)").option("--version <slug>", "Version slug (default: production version)").option("--dry-run", "Print the planned release without publishing", false).option("--host <url>", "Override host (otherwise from config/credentials)").action(
2106
2474
  async (opts) => {
@@ -2127,7 +2495,7 @@ var releasesCommand = new Command12("releases").description("Manage CDN releases
2127
2495
 
2128
2496
  // src/commands/snapshot.ts
2129
2497
  import { promises as fs9 } from "fs";
2130
- import { Command as Command13 } from "commander";
2498
+ import { Command as Command14 } from "commander";
2131
2499
  function bundleUrl(cdnBase, project, version, lang, ns) {
2132
2500
  return `${cdnBase.replace(/\/+$/, "")}/p/${project}/${version}/latest/${lang}/${ns}.json`;
2133
2501
  }
@@ -2151,7 +2519,7 @@ async function fetchBundle(url) {
2151
2519
  if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
2152
2520
  return await res.json();
2153
2521
  }
2154
- var snapshotCommand = new Command13("snapshot").description(
2522
+ var snapshotCommand = new Command14("snapshot").description(
2155
2523
  "Generate a build-time translations snapshot for @sonenta/react-i18next `initialBundles` (offline-first fallback). Fetches the PUBLIC CDN bundles (exactly what the SDK loads at runtime) and assembles Record<locale, Record<namespace, tree>>. Emits a .ts module (default) or .json."
2156
2524
  ).option("--language <code>", "Restrict to a single language code").option("--namespace <slug>", "Restrict to a single namespace slug").option("--version <slug>", "Version slug (default: the configured version_slug)").option("--format <fmt>", "ts | json (default: ts)", "ts").option("--cdn <base>", "CDN base URL", "https://cdn.sonenta.com").option("--out <file>", "Write to a file instead of stdout").option("--host <url>", "Override host (used to discover languages/namespaces)").action(
2157
2525
  async (opts) => {
@@ -2205,8 +2573,8 @@ var snapshotCommand = new Command13("snapshot").description(
2205
2573
  );
2206
2574
 
2207
2575
  // src/commands/status.ts
2208
- import { Command as Command14 } from "commander";
2209
- var statusCommand = new Command14("status").description("Diff local locales/ against the remote project state.").option("--language <code>", "Restrict to one language code").option("--namespace <slug>", "Restrict to one namespace slug").option("--src <dir>", "Locales directory", DEFAULT_LOCALES_DIR).option("--host <url>", "Override host (otherwise from config/credentials)").action(
2576
+ import { Command as Command15 } from "commander";
2577
+ var statusCommand = new Command15("status").description("Diff local locales/ against the remote project state.").option("--language <code>", "Restrict to one language code").option("--namespace <slug>", "Restrict to one namespace slug").option("--src <dir>", "Locales directory", DEFAULT_LOCALES_DIR).option("--host <url>", "Override host (otherwise from config/credentials)").action(
2210
2578
  async (opts) => {
2211
2579
  const ctx = await requireAuth({ hostOverride: opts.host });
2212
2580
  const knownLangs = new Set((await getProjectInfo(ctx)).languages);
@@ -2264,8 +2632,8 @@ var statusCommand = new Command14("status").description("Diff local locales/ aga
2264
2632
  );
2265
2633
 
2266
2634
  // src/commands/whoami.ts
2267
- import { Command as Command15 } from "commander";
2268
- var whoamiCommand = new Command15("whoami").description("Show the configured default host + which API key is in use.").option("--host <url>", "Inspect a specific host instead of the default").action(async (opts) => {
2635
+ import { Command as Command16 } from "commander";
2636
+ var whoamiCommand = new Command16("whoami").description("Show the configured default host + which API key is in use.").option("--host <url>", "Inspect a specific host instead of the default").action(async (opts) => {
2269
2637
  const creds = await readCredentials();
2270
2638
  if (!creds.default && Object.keys(creds.hosts).length === 0) {
2271
2639
  console.log("Not logged in. Run `sonenta login --host <url> --token <\u2026>`.");
@@ -2293,8 +2661,8 @@ var whoamiCommand = new Command15("whoami").description("Show the configured def
2293
2661
  });
2294
2662
 
2295
2663
  // src/index.ts
2296
- var program = new Command16();
2297
- program.name("sonenta").description("CLI for Sonenta translation management.").version("0.7.1");
2664
+ var program = new Command17();
2665
+ program.name("sonenta").description("CLI for Sonenta translation management.").version(package_default.version);
2298
2666
  program.addCommand(loginCommand);
2299
2667
  program.addCommand(logoutCommand);
2300
2668
  program.addCommand(whoamiCommand);
@@ -2310,6 +2678,7 @@ program.addCommand(releasesCommand);
2310
2678
  program.addCommand(snapshotCommand);
2311
2679
  program.addCommand(missingCommand);
2312
2680
  program.addCommand(agentsCommand);
2681
+ program.addCommand(doctorCommand);
2313
2682
  program.parseAsync(process.argv).catch((err) => {
2314
2683
  console.error(err instanceof Error ? err.message : err);
2315
2684
  process.exit(1);