@fiodos/cli 0.1.28 → 0.1.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "Fiodos CLI — analyzes your app's source code and generates the in-app voice-agent manifest, then wires the orb. Powers `npx @fiodos/cli analyze`.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/aiAnalyze.js CHANGED
@@ -326,6 +326,34 @@ async function analyzeViaProxy({ apiKey, apiUrl, model, user, task = 'analysis',
326
326
  'Or pass --own-ai-key to analyze with your own AI key (code never reaches Fiodos).',
327
327
  );
328
328
  }
329
+ // PRE-FLIGHT platform check (analysis only, non-web platforms): the deployed
330
+ // backend may be OLDER than this CLI and not know the platform yet — its
331
+ // prompt builder then silently falls back to the generic web prompt and the
332
+ // resulting manifest misses the platform spec entirely (e.g. Shopify's
333
+ // execution blocks). The system-prompt endpoint echoes the platform it
334
+ // resolved, so a stale backend is detectable BEFORE spending any AI tokens.
335
+ // Fail-open on network/endpoint errors: the post-call echo guard still runs.
336
+ if (task === 'analysis' && platform && platform !== 'web') {
337
+ try {
338
+ const probe = await fetch(
339
+ `${apiUrl}/v1/developer/analysis/system-prompt?task=analysis&platform=${encodeURIComponent(platform)}`,
340
+ { headers: { 'x-api-key': apiKey } },
341
+ );
342
+ if (probe.ok) {
343
+ const meta = await probe.json();
344
+ if (meta && meta.platform && meta.platform !== platform) {
345
+ throw new Error(
346
+ `The Fiodos backend at ${apiUrl} does not support --platform ${platform} yet ` +
347
+ `(it would analyze as "${meta.platform}", missing the ${platform} platform spec). ` +
348
+ 'Update/redeploy the backend and re-run the analysis.',
349
+ );
350
+ }
351
+ }
352
+ } catch (e) {
353
+ if (String(e && e.message).includes('does not support --platform')) throw e;
354
+ /* preflight is best-effort — the post-call echo guard still protects */
355
+ }
356
+ }
329
357
  const res = await fetch(`${apiUrl}/v1/developer/analyze`, {
330
358
  method: 'POST',
331
359
  headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
@@ -351,6 +379,18 @@ async function analyzeViaProxy({ apiKey, apiUrl, model, user, task = 'analysis',
351
379
  throw new Error(`Hosted analysis failed: ${res.status} ${detail}`);
352
380
  }
353
381
  const data = await res.json();
382
+ // Platform degradation guard: an outdated backend that does not know the
383
+ // requested platform silently builds the generic WEB prompt instead — the
384
+ // manifest then LOOKS fine but misses the platform's spec (e.g. Shopify's
385
+ // declarative execution blocks, without which no commerce action can run).
386
+ // New backends echo the platform they actually used; a mismatch is fatal.
387
+ if (data.platform && platform && data.platform !== platform) {
388
+ throw new Error(
389
+ `The Fiodos backend at ${apiUrl} does not support --platform ${platform} yet ` +
390
+ `(it analyzed as "${data.platform}"). The manifest it would produce is missing ` +
391
+ `the ${platform} platform spec. Update/redeploy the backend and re-run the analysis.`,
392
+ );
393
+ }
354
394
  return {
355
395
  parsed: parseJsonContent(data.text || '{}'),
356
396
  usage: {
package/src/verify.js CHANGED
@@ -122,6 +122,79 @@ function sanitizeBubblePrompt(raw) {
122
122
  return s || undefined;
123
123
  }
124
124
 
125
+ /** "es-ES" / "ES" → "es" (base language code, lowercased); undefined when unusable. */
126
+ function sanitizeLanguageCode(raw) {
127
+ const code = String(raw || '').split(/[-_]/)[0].trim().toLowerCase();
128
+ return /^[a-z]{2,3}$/.test(code) ? code : undefined;
129
+ }
130
+
131
+ /**
132
+ * Sanitize a route's `bubblePrompts` translation map (fail-open, item never
133
+ * dropped): keeps only valid base-language keys whose values survive the same
134
+ * bubblePrompt hygiene. Returns undefined when nothing usable remains, so the
135
+ * SDK falls back to the single `bubblePrompt`.
136
+ */
137
+ function sanitizeBubblePrompts(raw) {
138
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
139
+ const out = {};
140
+ for (const [key, value] of Object.entries(raw)) {
141
+ const lang = sanitizeLanguageCode(key);
142
+ const prompt = sanitizeBubblePrompt(value);
143
+ if (lang && prompt && !(lang in out)) out[lang] = prompt;
144
+ }
145
+ return Object.keys(out).length > 0 ? out : undefined;
146
+ }
147
+
148
+ /**
149
+ * Max length (chars) of a dashboard `labels` translation — it renders as a
150
+ * short UI label (route/action name), never a sentence.
151
+ */
152
+ const MAX_LABEL = 60;
153
+
154
+ /** Sanitize one dashboard label translation string (see sanitizeBubblePrompt). */
155
+ function sanitizeLabelText(raw) {
156
+ if (typeof raw !== 'string') return undefined;
157
+ let s = raw.replace(/\s+/g, ' ').trim();
158
+ s = s.replace(/^["'«»“”]+|["'«»“”]+$/g, '').trim();
159
+ if (!s) return undefined;
160
+ if (s.length > MAX_LABEL) s = s.slice(0, MAX_LABEL).trim();
161
+ return s || undefined;
162
+ }
163
+
164
+ /**
165
+ * Sanitize a route/action's `labels` translation map (fail-open, item never
166
+ * dropped): keeps only valid base-language keys whose values survive label
167
+ * hygiene. Returns undefined when nothing usable remains, so the dashboard
168
+ * falls back to the primary `label`. Unlike `bubblePrompts`, this map is
169
+ * REQUIRED by the analyzer prompt (es/en, unconditional) — but the CLI still
170
+ * never blocks a manifest over a missing/malformed translation.
171
+ */
172
+ function sanitizeLabels(raw) {
173
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
174
+ const out = {};
175
+ for (const [key, value] of Object.entries(raw)) {
176
+ const lang = sanitizeLanguageCode(key);
177
+ const label = sanitizeLabelText(value);
178
+ if (lang && label && !(lang in out)) out[lang] = label;
179
+ }
180
+ return Object.keys(out).length > 0 ? out : undefined;
181
+ }
182
+
183
+ /**
184
+ * Sanitize the manifest-level `appLanguages` list (fail-open): dedupes and
185
+ * normalizes to base codes, preserving the model's order (primary first).
186
+ * Returns undefined when nothing usable remains (monolingual apps).
187
+ */
188
+ function sanitizeAppLanguages(raw) {
189
+ if (!Array.isArray(raw)) return undefined;
190
+ const out = [];
191
+ for (const entry of raw) {
192
+ const lang = sanitizeLanguageCode(entry);
193
+ if (lang && !out.includes(lang)) out.push(lang);
194
+ }
195
+ return out.length > 0 ? out : undefined;
196
+ }
197
+
125
198
  /** Normalize an expo-router href for comparison: strip (groups), trailing /index. */
126
199
  function normalizeHref(href) {
127
200
  return ('/' + String(href || '')
@@ -399,6 +472,15 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
399
472
  }
400
473
  if (bubble) route.bubblePrompt = bubble;
401
474
  else delete route.bubblePrompt;
475
+ // Multilingual bubble translations (fail-open): keep only clean entries so
476
+ // the orb can follow the app's active language; drop the map when empty.
477
+ const bubbles = sanitizeBubblePrompts(route.bubblePrompts);
478
+ if (bubbles) route.bubblePrompts = bubbles;
479
+ else delete route.bubblePrompts;
480
+ // Dashboard label translations (es/en) — see sanitizeLabels doc.
481
+ const routeLabels = sanitizeLabels(route.labels);
482
+ if (routeLabels) route.labels = routeLabels;
483
+ else delete route.labels;
402
484
  }
403
485
 
404
486
  // Schema coercion the model sometimes needs (same as the old engine).
@@ -414,10 +496,21 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
414
496
  action.confirmationMessageTemplate = `Confirm: ${action.label || action.intent}?`;
415
497
  provenance.notes.push(`action '${action.intent}': confirmation template filled mechanically`);
416
498
  }
499
+ // Dashboard label translations (es/en) — see sanitizeLabels doc.
500
+ const actionLabels = sanitizeLabels(action.labels);
501
+ if (actionLabels) action.labels = actionLabels;
502
+ else delete action.labels;
417
503
  }
418
504
 
505
+ // App-level supported languages (fail-open): normalized base codes, primary
506
+ // first; dropped entirely when the analysis found no i18n evidence.
507
+ const appLanguages = sanitizeAppLanguages(manifest.appLanguages);
508
+ const cleanManifest = { ...manifest, routes: dedupedRoutes, actions: dedupedActions };
509
+ if (appLanguages) cleanManifest.appLanguages = appLanguages;
510
+ else delete cleanManifest.appLanguages;
511
+
419
512
  return {
420
- manifest: { ...manifest, routes: dedupedRoutes, actions: dedupedActions },
513
+ manifest: cleanManifest,
421
514
  provenance,
422
515
  };
423
516
  }
package/src/verifyWire.js CHANGED
@@ -29,7 +29,14 @@ function normRel(p) {
29
29
  /** Extract "path(line,col): error" / "ERROR ... path:line" style lines. */
30
30
  function extractErrorLines(output) {
31
31
  const lines = String(output || '').split('\n');
32
- return lines.filter((l) => /error\s+TS\d+|: error|ERROR\b|is not assignable|Cannot find|does not exist/i.test(l));
32
+ return lines.filter((l) => {
33
+ // npm's failure banner ("npm error path …", "npm ERR! command failed") is
34
+ // the WRAPPER around a failed script, not a diagnostic. Counting it as an
35
+ // error made an action fail with "compilation: npm error path <dir>" even
36
+ // when every real tsc line had been classified as style-only.
37
+ if (/^\s*npm\s+(error|ERR!)/i.test(l)) return false;
38
+ return /error\s+TS\d+|: error|ERROR\b|is not assignable|Cannot find|does not exist/i.test(l);
39
+ });
33
40
  }
34
41
 
35
42
  /**
@@ -576,6 +576,36 @@ function deriveBridgeAnchor(content, invoke) {
576
576
  return null;
577
577
  }
578
578
 
579
+ /**
580
+ * "Once wired, never regressed": when the file ALREADY contains a generated
581
+ * block registering this intent (from a previous verified run), reuse that
582
+ * position instead of re-rolling the AI's anchor. Re-running the wiring is
583
+ * otherwise nondeterministic — a bad anchor + an unlucky AI correction can
584
+ * un-wire an action that worked yesterday. Returns the nearest line ABOVE the
585
+ * existing block that survives the strip and is unique in the stripped
586
+ * content (a deterministic anchor for the same spot), or null.
587
+ */
588
+ function reuseExistingBlockAnchor(rawContent, strippedContent, intent) {
589
+ const blockRe = new RegExp(
590
+ `(${esc(EDIT_START_PREFIX)}[^\\n]*)\\n[\\s\\S]*?${esc(EDIT_END)}`,
591
+ 'g',
592
+ );
593
+ let m;
594
+ while ((m = blockRe.exec(rawContent))) {
595
+ if (!GENERATED_HEADER_RE.test(m[1])) continue;
596
+ if (!new RegExp(`"${esc(intent)}"\\s*:`).test(m[0])) continue;
597
+ const lines = rawContent.slice(0, m.index).split('\n');
598
+ for (let i = lines.length - 1; i >= 0; i--) {
599
+ const line = lines[i];
600
+ const t = line.trim();
601
+ if (!t || t.startsWith('//') || t.startsWith('/*') || t.startsWith('*')) continue;
602
+ if (line.includes('FYODOS:')) continue;
603
+ if (countOccurrences(strippedContent, line) === 1) return line;
604
+ }
605
+ }
606
+ return null;
607
+ }
608
+
579
609
  /**
580
610
  * True when an anchor that appears several times is HARMLESS: every occurrence
581
611
  * resolves to the same actual insertion point (the nearest JSX `return`), so
@@ -634,7 +664,13 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
634
664
  const occ = anchor ? countOccurrences(content, anchor) : 0;
635
665
  if (occ !== 1) {
636
666
  const scope = b.scope === 'class-field' ? 'class-field' : 'statement';
637
- if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
667
+ const reused = reuseExistingBlockAnchor(rawContent, content, base.intent);
668
+ if (reused) {
669
+ // A previous run already wired this intent here and verified it; the
670
+ // proven position beats re-rolling an ambiguous AI anchor.
671
+ anchor = reused;
672
+ anchorDerived = true;
673
+ } else if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
638
674
  // Keep the AI anchor: every occurrence lands on the same insertion point.
639
675
  } else {
640
676
  const derived = deriveBridgeAnchor(content, invoke);
@@ -675,6 +711,13 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
675
711
  const rawFrom = normRel(imp && imp.from);
676
712
  const kind = (imp && imp.kind) || 'named';
677
713
  if (!name || !rawFrom) return { reason: 'a bridge import is incomplete (missing name/from)' };
714
+ // The component often ALREADY imports the symbol (useApp, a store hook…).
715
+ // Re-importing it produces "Duplicate identifier" and fails the candidate,
716
+ // so an already-in-scope name is accepted without adding a second import.
717
+ if (new RegExp(`^\\s*import\\b[^;\\n]*[^\\w$]${esc(name)}[^\\w$][^;\\n]*from`, 'm').test(content)) {
718
+ importedNames.add(name);
719
+ continue;
720
+ }
678
721
  const from = resolveSourceFile(appRoot, rawFrom);
679
722
  if (!from) return { reason: `the bridge import points to a non-existent file: '${rawFrom}'` };
680
723
  if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) return { reason: `named import '${name}' does not exist in '${from}'` };
@@ -1351,6 +1394,26 @@ function insertRegisterBlock(content, file, anchor, block, scope) {
1351
1394
  return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
1352
1395
  }
1353
1396
 
1397
+ // When the anchor's own line is unmistakably a body STATEMENT (a declaration
1398
+ // or a bare call ending in `;`), insert right AFTER it. Snapping to the
1399
+ // nearest `return` instead can land the registration inside a conditional
1400
+ // early-return branch (`if (authLoading) { … return <Spinner/> }`): it
1401
+ // compiles, but in normal use that branch never runs and the bridge is never
1402
+ // registered — the action silently dies at runtime.
1403
+ const anchorLineStart = content.lastIndexOf('\n', at) + 1;
1404
+ const anchorLineEnd = content.indexOf('\n', at);
1405
+ const anchorLine = content.slice(anchorLineStart, anchorLineEnd === -1 ? content.length : anchorLineEnd).trim();
1406
+ const isStatementLine =
1407
+ /^(const|let|var)\b.*;\s*$/.test(anchorLine) || /^[\w$][\w$.]*\((?:[^()]|\([^()]*\))*\);\s*$/.test(anchorLine);
1408
+ if (isStatementLine) {
1409
+ // A multi-line declaration (`const { a, b } =\n useApp();`) ends at the
1410
+ // first `;` at/after the anchor, not at the anchor's own line end.
1411
+ const semi = content.indexOf(';', at);
1412
+ const stmtEnd = semi === -1 ? (anchorLineEnd === -1 ? content.length : anchorLineEnd) : content.indexOf('\n', semi);
1413
+ const endIdx = stmtEnd === -1 ? content.length : stmtEnd;
1414
+ return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
1415
+ }
1416
+
1354
1417
  const ret = nearestJsxReturn(content, at);
1355
1418
  if (ret !== -1) {
1356
1419
  const lineStart = content.lastIndexOf('\n', ret) + 1;
@@ -1500,6 +1563,34 @@ function pickRelevantFiles(files, evidence, candidate, intent) {
1500
1563
  for (const imp of (candidate.bridge && candidate.bridge.imports) || []) if (imp && imp.from) wanted.add(normRel(imp.from));
1501
1564
  for (const r of candidate.requiredSymbols || []) if (r && r.file) wanted.add(normRel(r.file));
1502
1565
  }
1566
+ // The most common unfixable failure is "symbol X is not available in the
1567
+ // proposed file": the fix lives in ANOTHER component that consumes X. Give
1568
+ // the corrector every file that mentions the invoked symbols (capped) so it
1569
+ // can retarget the bridge instead of blindly retrying the same file.
1570
+ const invokeSrc = String(
1571
+ (candidate && ((candidate.bridge && candidate.bridge.invoke) || candidate.call)) || '',
1572
+ );
1573
+ const symbols = new Set();
1574
+ let sm;
1575
+ const symRe = /(?:^|[^.\w$])([A-Za-z_$][\w$]{2,})\s*\(/g;
1576
+ while ((sm = symRe.exec(invokeSrc))) {
1577
+ if (!JS_KEYWORDS.has(sm[1]) && !SAFE_GLOBALS.has(sm[1])) symbols.add(sm[1]);
1578
+ }
1579
+ if (symbols.size) {
1580
+ let extra = 0;
1581
+ for (const f of files) {
1582
+ if (extra >= 4) break;
1583
+ if (wanted.has(normRel(f.rel))) continue;
1584
+ const content = String(f.content || '');
1585
+ for (const sym of symbols) {
1586
+ if (new RegExp(`(^|[^\\w$])${esc(sym)}([^\\w$]|$)`).test(content)) {
1587
+ wanted.add(normRel(f.rel));
1588
+ extra += 1;
1589
+ break;
1590
+ }
1591
+ }
1592
+ }
1593
+ }
1503
1594
  const picked = files.filter((f) => wanted.has(normRel(f.rel)));
1504
1595
  return picked.length ? picked : files.slice(0, 4);
1505
1596
  }
package/src/wireWeb.js CHANGED
@@ -134,7 +134,7 @@ function agentSnippetVue() {
134
134
  `</script>\n\n` +
135
135
  `<template>\n` +
136
136
  ` <!-- ...your app... -->\n` +
137
- ` <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" />\n` +
137
+ ` <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" locale-detection="document" />\n` +
138
138
  `</template>`
139
139
  );
140
140
  }
@@ -144,7 +144,7 @@ function agentSnippetSvelte() {
144
144
  `<script lang="ts">\n` +
145
145
  ` import FiodosAgent from '@fiodos/svelte/FiodosAgent.svelte';\n` +
146
146
  `</script>\n\n` +
147
- `<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} />`
147
+ `<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} localeDetection="document" />`
148
148
  );
149
149
  }
150
150
 
@@ -122,6 +122,10 @@ function buildEntryBootstrapBlock(opts = {}) {
122
122
  }
123
123
  }
124
124
  if (screenImportPath) lines.push(` getCurrentRoute: ${SCREEN_ROUTE_EXPORT},`);
125
+ // Language link (never left disconnected): the orb follows the page's live
126
+ // <html lang>, so analyzer bubble translations track the app's language
127
+ // switcher automatically.
128
+ lines.push(" localeDetection: 'document',");
125
129
  lines.push(' mount: true,');
126
130
  lines.push('});');
127
131
  lines.push(ORB_SCRIPT_END);
@@ -243,6 +247,10 @@ function agentJsx(framework, ts, indent = ' ') {
243
247
  `${indent}<FiodosAgent\n` +
244
248
  `${indent} apiKey={${keyExpr}}\n` +
245
249
  `${indent} apiUrl={${urlExpr}}\n` +
250
+ // Language link (never left disconnected): the orb follows the page's live
251
+ // <html lang>, so analyzer bubble translations track the app's language
252
+ // switcher automatically.
253
+ `${indent} localeDetection="document"\n` +
246
254
  `${indent}/>`
247
255
  );
248
256
  }
@@ -644,7 +652,7 @@ function injectVueSfc(plan) {
644
652
  }
645
653
  const block = [
646
654
  ORB_HTML_START,
647
- ' <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" />',
655
+ ' <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" locale-detection="document" />',
648
656
  ORB_HTML_END,
649
657
  ].join('\n');
650
658
  source = source.replace('</template>', `${block}\n</template>`);
@@ -662,7 +670,7 @@ function injectSvelteSfc(plan) {
662
670
  }
663
671
  const block = [
664
672
  ORB_HTML_START,
665
- '<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} />',
673
+ '<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} localeDetection="document" />',
666
674
  ORB_HTML_END,
667
675
  ].join('\n');
668
676
  if (source.includes('</main>')) {
@@ -699,7 +707,7 @@ function injectAngularComponent(plan) {
699
707
  );
700
708
  }
701
709
 
702
- const block = [ORB_HTML_START, '<fyodos-agent [apiKey]="fyodosApiKey" [baseUrl]="fyodosApiUrl" />', ORB_HTML_END].join('\n');
710
+ const block = [ORB_HTML_START, '<fyodos-agent [apiKey]="fyodosApiKey" [baseUrl]="fyodosApiUrl" localeDetection="document" />', ORB_HTML_END].join('\n');
703
711
  html = `${html.trimEnd()}\n${block}\n`;
704
712
  writeFile(target.file, ts);
705
713
  writeFile(templatePath, html);