@fiodos/cli 0.1.25 → 0.1.26

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.25",
3
+ "version": "0.1.26",
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
@@ -141,7 +141,9 @@ function buildUserPrompt(appMeta, files, omitted, staticRoutes, surface = null,
141
141
  : '';
142
142
  const routeBlock = staticRoutes.length
143
143
  ? `Statically-scanned routes (existence is verified — use these hrefs):\n${routeHint}\n`
144
- : `No static route list (web app): infer routes/views from the router and navigation calls in the code below.\n`;
144
+ : opts.platform === 'shopify'
145
+ ? 'Shopify storefront: derive routes from the PUBLIC STOREFRONT SNAPSHOT files under _fyodos/storefront/ (universal pages + one route per real collection/page handle) and from the theme templates below.\n'
146
+ : `No static route list (web app): infer routes/views from the router and navigation calls in the code below.\n`;
145
147
 
146
148
  // Detected interactive link elements on the product surface — concrete hrefs
147
149
  // the model should turn into LINK actions (verified later against the file).
@@ -171,7 +173,7 @@ function buildUserPrompt(appMeta, files, omitted, staticRoutes, surface = null,
171
173
  routeBlock +
172
174
  linkBlock +
173
175
  omittedNote +
174
- (includeSchema && LOCAL_PROMPTS ? `\nManifest schema:\n${LOCAL_PROMPTS.manifestSchemaDoc()}\n\n` : '\n') +
176
+ (includeSchema && LOCAL_PROMPTS ? `\nManifest schema:\n${LOCAL_PROMPTS.manifestSchemaDoc(opts.platform === 'shopify')}\n\n` : '\n') +
175
177
  `FULL SOURCE CODE (${files.length} files):\n\n${codeBlob}`
176
178
  );
177
179
  }
@@ -383,7 +385,7 @@ async function analyzeWithAI({
383
385
  if (useProxy) {
384
386
  // Server prompt mode: the backend builds the system prompt (with the
385
387
  // manifest schema embedded), so the user payload carries only the inputs.
386
- user = buildUserPrompt(appMeta, files, omitted, staticRoutes, surface, spec, { includeSchema: false });
388
+ user = buildUserPrompt(appMeta, files, omitted, staticRoutes, surface, spec, { includeSchema: false, platform });
387
389
  ({ parsed, usage, serverModel, analysisToken } = await analyzeViaProxy({
388
390
  apiKey, apiUrl, model: resolved, user, task: 'analysis', platform, hasUserSpec: Boolean(spec),
389
391
  }));
@@ -391,7 +393,7 @@ async function analyzeWithAI({
391
393
  const sp = await resolveOwnKeySystemPrompt({
392
394
  task: 'analysis', platform, hasUserSpec: Boolean(spec), apiKey, apiUrl,
393
395
  });
394
- user = buildUserPrompt(appMeta, files, omitted, staticRoutes, surface, spec, { includeSchema: sp.schemaInUser });
396
+ user = buildUserPrompt(appMeta, files, omitted, staticRoutes, surface, spec, { includeSchema: sp.schemaInUser, platform });
395
397
  ({ parsed, usage } = isAnthropicModel(resolved)
396
398
  ? await completeAnthropic({ model: resolved, system: sp.system, user })
397
399
  : await completeOpenAI({ model: resolved, system: sp.system, user }));
@@ -237,8 +237,31 @@ function summarizeSession(result) {
237
237
  }
238
238
  }
239
239
 
240
+ function summarizeScreen(result) {
241
+ if (!result) return { status: 'skipped', applied: false };
242
+ const base = { status: result.status };
243
+ switch (result.status) {
244
+ case 'applied':
245
+ case 'applied-untested':
246
+ return {
247
+ ...base,
248
+ applied: true,
249
+ what: 'Connected the app\'s ACTIVE screen state to the orb (state-driven screens, no URL change).',
250
+ revert: 'Delete screen.generated.* and remove the FYODOS:SCREEN blocks + mount prop.',
251
+ };
252
+ case 'review':
253
+ return { ...base, applied: false, note: `Screen wiring left for review: ${result.reason || ''}` };
254
+ case 'reverted':
255
+ return { ...base, applied: false, note: `Screen wiring reverted: ${result.reason || ''}` };
256
+ case 'url-screens':
257
+ return { ...base, applied: false, note: 'Screens are URL-driven — no screen wiring needed.' };
258
+ default:
259
+ return { ...base, applied: false };
260
+ }
261
+ }
262
+
240
263
  function collectFileEntries(appRoot, ctx) {
241
- const { orbResult, handlerResult, registryResult, sessionResult, envResult } = ctx;
264
+ const { orbResult, handlerResult, registryResult, sessionResult, screenResult, envResult } = ctx;
242
265
  const created = [];
243
266
  const modified = [];
244
267
  const docs = [];
@@ -344,6 +367,36 @@ function collectFileEntries(appRoot, ctx) {
344
367
  }
345
368
  }
346
369
 
370
+ // Screen-truth wiring (state-driven screens — automatic)
371
+ if (
372
+ screenResult &&
373
+ (screenResult.status === 'applied' || screenResult.status === 'applied-untested')
374
+ ) {
375
+ if (screenResult.screenFile) {
376
+ created.push({
377
+ path: normRel(appRoot, screenResult.screenFile),
378
+ phase: 'screen',
379
+ what: 'Generated screen module (`screen.generated.*`): reports the ACTIVE screen to the orb.',
380
+ revert: 'Safe to delete; re-run the installer to regenerate.',
381
+ });
382
+ }
383
+ if (screenResult.editedFile) {
384
+ modified.push({
385
+ path: screenResult.editedFile,
386
+ phase: 'screen',
387
+ what: 'Registered the live active-screen state in the bridge (reversible FYODOS:SCREEN block).',
388
+ revert: 'Remove the FYODOS:SCREEN blocks.',
389
+ });
390
+ }
391
+ if (screenResult.docPath) {
392
+ docs.push({
393
+ path: normRel(appRoot, screenResult.docPath),
394
+ phase: 'screen',
395
+ what: 'Screen-truth wiring summary: why it was needed, honesty contract.',
396
+ });
397
+ }
398
+ }
399
+
347
400
  // Env (never record values)
348
401
  if (envResult?.status === 'written' && envResult.writePlan?.targetRel) {
349
402
  modified.push({
@@ -433,6 +486,7 @@ function buildRunRecord(appRoot, ctx) {
433
486
  handlers: summarizeHandlers(ctx.handlerResult, appRoot),
434
487
  registries: summarizeRegistries(ctx.registryResult),
435
488
  session: summarizeSession(ctx.sessionResult),
489
+ screen: summarizeScreen(ctx.screenResult),
436
490
  env: summarizeEnv(ctx.envResult),
437
491
  },
438
492
  files: collectFileEntries(appRoot, ctx),
package/src/collect.js CHANGED
@@ -47,6 +47,12 @@ const PROFILES = {
47
47
  exts: ['.kt'],
48
48
  skipDirs: ['.git', '.gradle', 'build', '.idea'],
49
49
  },
50
+ // Shopify theme repo (GitHub-synced Liquid theme). The "code" is the theme's
51
+ // Liquid templates + JSON templates/settings; heavy static assets are noise.
52
+ shopify: {
53
+ exts: ['.liquid', '.json'],
54
+ skipDirs: ['.git', 'node_modules', 'assets', 'dist', 'build'],
55
+ },
50
56
  };
51
57
 
52
58
  const SKIP_FILES = /\.(d\.ts|test\.|spec\.)|babel\.config|metro\.config|tailwind\.config|postcss\.config|jest\.config|eslint/;
@@ -64,8 +70,16 @@ const CHARS_PER_TOKEN = 3.0;
64
70
 
65
71
  /** Lower tier = higher priority when the budget forces choices. Generalized so
66
72
  * it ranks screens/state/services first across web, RN AND native naming. */
67
- function tierFor(rel) {
73
+ function tierFor(rel, platform) {
68
74
  const p = rel.replace(/\\/g, '/');
75
+ if (platform === 'shopify') {
76
+ // Shopify theme layout: templates define the store's pages; layout + locales
77
+ // carry structure and language; sections/snippets are secondary UI.
78
+ if (/^(layout|templates)\//.test(p)) return 0;
79
+ if (/^locales\//.test(p)) return 1;
80
+ if (/^sections\//.test(p)) return 2;
81
+ return 3; // snippets/, config/ (settings_data.json can be huge), rest
82
+ }
69
83
  if (/^(src\/)?app\//.test(p)) return 0; // expo-router / Next screens
70
84
  if (/(^|\/)(screens?|pages?|views?|routes?|activities|fragments)\//i.test(p)) return 0;
71
85
  if (/(screen|page|view|activity|fragment)\.(dart|kt|swift)$/i.test(p)) return 0;
@@ -95,7 +109,7 @@ function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' }
95
109
  if (SKIP_FILES.test(rel)) continue;
96
110
  if (isNative && SKIP_NATIVE_FILES.test(rel)) continue;
97
111
  const content = fs.readFileSync(full, 'utf8');
98
- files.push({ rel, content, tier: tierFor(rel), chars: content.length });
112
+ files.push({ rel, content, tier: tierFor(rel, platform), chars: content.length });
99
113
  }
100
114
  };
101
115
  walk(appRoot);
package/src/index.js CHANGED
@@ -97,11 +97,16 @@ const { verifyManifest } = require('./verify');
97
97
  const {
98
98
  wireWebOrb, reportWireResult, connectOrbRegistries, reportRegistriesResult,
99
99
  connectOrbSession, reportSessionConnectResult,
100
+ connectOrbScreen, reportScreenConnectResult,
100
101
  } = require('./wireWeb');
101
102
  const { wireReactNativeOrb, reportReactNativeWire, connectRnSession } = require('./wireReactNative');
103
+ const {
104
+ isShopifyThemeRoot, fetchStorefrontSnapshot, wireShopifyTheme, reportShopifyWire,
105
+ } = require('./shopifyTheme');
102
106
  const { wireFlutterOrb, writeFlutterEnv, reportFlutterWire } = require('./wireFlutter');
103
107
  const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
104
108
  const { wireSession, reportSessionResult } = require('./wireSession');
109
+ const { wireScreen, reportScreenResult } = require('./wireScreen');
105
110
  const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
106
111
  const { scoreSurface, computeSurface } = require('./scoreSurface');
107
112
  const { buildRunRecord, writeChangeRegistry } = require('./changeRegistry');
@@ -117,6 +122,8 @@ function arg(flag, fallback) {
117
122
  const VALUE_FLAGS = new Set([
118
123
  '--out', '--model', '--max-input-tokens', '--platform', '--api-key', '--api-url',
119
124
  '--spec', '--analysis-root',
125
+ // shopify (theme repo mode): the store's public domain for the catalog snapshot
126
+ '--store',
120
127
  // from-odata (closed-software mode)
121
128
  '--app-id', '--app-name', '--app-description', '--auth-ref', '--entities',
122
129
  ]);
@@ -192,9 +199,11 @@ function askProceed() {
192
199
  });
193
200
  }
194
201
 
195
- /** Spinner copy: "website" for web targets, "app" for mobile/native. */
202
+ /** Spinner copy: "website" for web targets, "store" for Shopify, "app" for mobile/native. */
196
203
  function surfaceNoun(platform) {
197
- return platform === 'web' ? 'website' : 'app';
204
+ if (platform === 'web') return 'website';
205
+ if (platform === 'shopify') return 'store';
206
+ return 'app';
198
207
  }
199
208
 
200
209
  function orbMountSpinnerLabel(platform) {
@@ -293,6 +302,20 @@ async function main() {
293
302
  }
294
303
  }
295
304
 
305
+ // ── Rewire-only mode: `fiodos rewire <dir>` or `fiodos <dir> --wire-only` ──
306
+ // Re-runs ONLY the handler wiring (verify → correct → retry, same loop as the
307
+ // install) from the saved artifacts of the LAST analysis. No re-analysis, no
308
+ // quota, no manifest publish — the repair path when some action ended up
309
+ // "manual" (Context/dashboard point developers here).
310
+ {
311
+ const pos = positionalArgs();
312
+ if (pos[0] === 'rewire' || process.argv.includes('--wire-only')) {
313
+ const target = pos[0] === 'rewire' ? pos[1] || '.' : pos[0] || '.';
314
+ await runRewireOnly(target);
315
+ return;
316
+ }
317
+ }
318
+
296
319
  // Consent gate: confirm before doing anything. `yes` continues; `no` (or any
297
320
  // other answer) cancels instantly — no analysis is run, the command just ends.
298
321
  const proceed = await askProceed();
@@ -329,7 +352,7 @@ async function main() {
329
352
  fs.mkdirSync(outDir, { recursive: true });
330
353
 
331
354
  const platformArg = (arg('--platform', '') || '').toLowerCase();
332
- const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android'];
355
+ const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android', 'shopify'];
333
356
  const forcedPlatform = KNOWN_PLATFORMS.includes(platformArg) ? platformArg : null;
334
357
 
335
358
  // ── 1. Static route scan (cheap facts; also the route verifier's ground truth)
@@ -369,6 +392,24 @@ async function main() {
369
392
 
370
393
  // ── 2. Collect source files (rule-based, budget-aware) ────────────────────
371
394
  const { included, omitted, estimatedTokens } = collectFiles(analysisRoot, { maxInputTokens, platform });
395
+
396
+ // Shopify: the theme alone doesn't say WHAT the store sells. Fetch a PUBLIC
397
+ // storefront snapshot (collections + product sample) from the store's own
398
+ // endpoints and include it as synthetic FILE blocks (_fyodos/storefront/*)
399
+ // so the analysis personalizes routes/bubbles/examples to the REAL catalog
400
+ // — and so evidence verification has concrete files to point at. Fail-open:
401
+ // an unreachable store still gets the universal surface file.
402
+ if (platform === 'shopify') {
403
+ const storeDomain = arg('--store', process.env.FYODOS_SHOPIFY_STORE || '');
404
+ if (!storeDomain) {
405
+ logUser('Tip: add --store your-store.com so the analysis reads your live catalog (collections & products).');
406
+ }
407
+ const snapshot = await withSpinner('Reading your store\u2019s public catalog', () =>
408
+ fetchStorefrontSnapshot(storeDomain, { log: logDev }),
409
+ );
410
+ included.push(...snapshot.files);
411
+ }
412
+
372
413
  fs.writeFileSync(path.join(outDir, 'files-sent.json'), JSON.stringify({
373
414
  included: included.map((f) => f.rel),
374
415
  omitted,
@@ -398,6 +439,10 @@ async function main() {
398
439
  // The AI's "session" block: WHERE the app's auth/session state lives, so the
399
440
  // orb's isAuthenticated/getUserId are wired automatically (no manual step).
400
441
  let sessionWiring = null;
442
+ // The AI's "screen" block: whether screens are URL-driven or CLIENT STATE,
443
+ // and where that state lives — so the orb knows which screen the user is on
444
+ // even when the URL never changes (see wireScreen.js).
445
+ let screenWiring = null;
401
446
  let analysisMeta = { engine: 'auto-manifest-v3-ai-first', model: null, costUSD: 0 };
402
447
  // Signed proof returned by the hosted-analysis proxy: it tells the publish
403
448
  // step the quota was already consumed at /v1/developer/analyze (no double
@@ -461,6 +506,7 @@ async function main() {
461
506
  evidence = parsed.evidence || {};
462
507
  wiring = parsed.wiring || {};
463
508
  sessionWiring = parsed.session || null;
509
+ screenWiring = parsed.screen || null;
464
510
  // Only meaningful when the developer passed --spec; the backend sanitizes
465
511
  // (context fields only, real intents only) before touching any override.
466
512
  specResets = (userSpec && parsed.specResets && typeof parsed.specResets === 'object')
@@ -475,7 +521,7 @@ async function main() {
475
521
  // Actions are always verified against real code. Routes are verified only
476
522
  // when there is filesystem ground truth (mobile); for web they are kept as
477
523
  // AI-proposed and flagged unverified in provenance.
478
- ({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes }));
524
+ ({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes, platform }));
479
525
  ensureBackRoute(manifest, provenance);
480
526
  const droppedActions = provenance.actions.filter((a) => !a.included).length;
481
527
  const droppedRoutes = provenance.routes.filter((r) => !r.included).length;
@@ -484,7 +530,7 @@ async function main() {
484
530
  logUser('Analysis complete');
485
531
 
486
532
  fs.writeFileSync(path.join(outDir, 'evidence.json'), JSON.stringify({
487
- evidence, wiring, session: sessionWiring, uncertain: parsed.uncertain || [],
533
+ evidence, wiring, session: sessionWiring, screen: screenWiring, uncertain: parsed.uncertain || [],
488
534
  }, null, 2));
489
535
  fs.writeFileSync(path.join(outDir, 'usage.json'), JSON.stringify({
490
536
  model, elapsedMs, usage,
@@ -618,6 +664,7 @@ async function main() {
618
664
  let handlerWireResult = null;
619
665
  let registryWireResult = null;
620
666
  let sessionWireResult = null;
667
+ let screenWireResult = null;
621
668
  const { runPostWireTest } = require('./postWireTest');
622
669
  const selfCorrect = !process.argv.includes('--no-self-correct');
623
670
  // Corrections follow the same key model as the analysis: hosted (proxy)
@@ -765,6 +812,46 @@ async function main() {
765
812
  }
766
813
  }
767
814
 
815
+ // Fourth pass — SCREEN-TRUTH wiring. For apps whose screens are CLIENT
816
+ // STATE (a section switcher) instead of URLs, connect the active-screen
817
+ // state to the orb so it truly knows where the user is: per-screen
818
+ // bubble, "you are already there" detection and screen-scoped context.
819
+ // Same consent/verification/revert model as session wiring.
820
+ if (screenWiring && !process.argv.includes('--no-orb-wire')) {
821
+ const handlerDeclined =
822
+ handlerResult &&
823
+ (handlerResult.status === 'declined' || handlerResult.status === 'declined-noninteractive');
824
+ if (!handlerDeclined) {
825
+ const handlerConsented =
826
+ handlerResult &&
827
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
828
+ const screenResult = await wireScreen(analysisRoot, {
829
+ screen: screenWiring,
830
+ colors: fyodosTermColors(),
831
+ quiet: !isVerbose(),
832
+ assumeYes: assumeYes || Boolean(handlerConsented),
833
+ noWire,
834
+ framework: 'web',
835
+ appName: manifest.appName,
836
+ runTest: !process.argv.includes('--no-wire-test'),
837
+ testRunner: runPostWireTest,
838
+ });
839
+ screenWireResult = screenResult;
840
+ report(() => reportScreenResult(screenResult, fyodosTermColors(), { quiet: !isVerbose() }));
841
+ if (
842
+ orbMounted &&
843
+ (screenResult.status === 'applied' || screenResult.status === 'applied-untested')
844
+ ) {
845
+ const connectedScreen = connectOrbScreen(analysisRoot, {
846
+ screenFileAbs: screenResult.screenFile,
847
+ });
848
+ report(() =>
849
+ reportScreenConnectResult(connectedScreen, fyodosTermColors(), { quiet: !isVerbose() }),
850
+ );
851
+ }
852
+ }
853
+ }
854
+
768
855
  // Finalize the install proof: now that the (slow) action wiring is done,
769
856
  // tell the dashboard the terminal status so its "actions connected" tick
770
857
  // turns green for real — never before wiring actually finished. Only do
@@ -779,6 +866,7 @@ async function main() {
779
866
  actionsStatus: status,
780
867
  actionsTotal: total,
781
868
  actionsDone: done,
869
+ actions: actionsDetailFromHandlerResult(manifest, handlerResult),
782
870
  });
783
871
  }
784
872
  };
@@ -833,6 +921,7 @@ async function main() {
833
921
  handlerResult: handlerWireResult,
834
922
  registryResult: registryWireResult,
835
923
  sessionResult: sessionWireResult,
924
+ screenResult: screenWireResult,
836
925
  envResult: envWriteResult,
837
926
  });
838
927
  const paths = writeChangeRegistry(analysisRoot, record);
@@ -841,6 +930,66 @@ async function main() {
841
930
  logDev(`[change-registry] could not write: ${e.message}`);
842
931
  }
843
932
  }
933
+ } else if (platform === 'shopify' && publishing) {
934
+ // ── Shopify: publish + embed the orb in layout/theme.liquid ───────────────
935
+ // No code wiring: the manifest's commerce actions carry declarative
936
+ // `execution` blocks that the embed turns into handlers at runtime
937
+ // (buildApiActionRegistries, {baseUrl} → the store's own origin). The one
938
+ // edit is the theme.liquid snippet (idempotent markers); Shopify's GitHub
939
+ // sync deploys it when the developer pushes.
940
+ loadAppEnv(analysisRoot);
941
+ const { apiKey, apiUrl } = resolveApiTarget();
942
+ const noWire = process.argv.includes('--no-wire');
943
+ let orbWireResult = null;
944
+
945
+ await withSpinner('Publishing to your project', async ({ setLabel, pause, resume }) => {
946
+ await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
947
+ if (!noWire && !process.argv.includes('--no-orb-wire')) {
948
+ setLabel(orbMountSpinnerLabel(platform));
949
+ orbWireResult = wireShopifyTheme(analysisRoot, { apiKey, apiUrl, noWire });
950
+ pause();
951
+ reportShopifyWire(orbWireResult, fyodosTermColors(), { quiet: !isVerbose() });
952
+ resume();
953
+ // Install proof only when the snippet is genuinely in the theme. All
954
+ // actions are execution-declared (nothing left to wire), so the
955
+ // terminal status is 'done' immediately — the dashboard tick then
956
+ // waits only for the runtime heartbeat once the theme is pushed.
957
+ if (
958
+ apiKey &&
959
+ (orbWireResult.status === 'added' || orbWireResult.status === 'already')
960
+ ) {
961
+ const proof = await postOrbWired({
962
+ apiKey,
963
+ apiUrl,
964
+ file: orbWireResult.file || '',
965
+ platform: 'shopify',
966
+ actionsStatus: 'done',
967
+ });
968
+ if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
969
+ logDev(`[orb-wired] shopify install proof not recorded (${proof.status || proof.reason}); dashboard will rely on the runtime ping`);
970
+ }
971
+ }
972
+ }
973
+ });
974
+ logUser('Published to your project');
975
+ if (orbWireResult && (orbWireResult.status === 'added' || orbWireResult.status === 'already')) {
976
+ logUser('Commit & push this theme repo — Shopify\u2019s GitHub sync deploys the orb to your live store.');
977
+ }
978
+
979
+ if (!noWire) {
980
+ try {
981
+ const record = buildRunRecord(analysisRoot, {
982
+ appName: manifest.appName,
983
+ manifest,
984
+ publishMeta,
985
+ orbResult: orbWireResult,
986
+ });
987
+ const paths = writeChangeRegistry(analysisRoot, record);
988
+ logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
989
+ } catch (e) {
990
+ logDev(`[change-registry] could not write: ${e.message}`);
991
+ }
992
+ }
844
993
  } else if (platform === 'mobile' && publishing) {
845
994
  // ── Mobile (Expo / React Native): mirror the web pipeline, adapted ─────────
846
995
  // Same single command that publishes also mounts the orb (via the SDK's
@@ -1131,6 +1280,9 @@ function detectPlatform(appRoot, appDir) {
1131
1280
  return found;
1132
1281
  };
1133
1282
 
1283
+ // Shopify theme repos (GitHub-synced Liquid themes) are unmistakable and
1284
+ // have no package.json framework signal — check them first.
1285
+ if (isShopifyThemeRoot(appRoot)) return 'shopify';
1134
1286
  if (has('pubspec.yaml')) return 'flutter';
1135
1287
  if (has('Package.swift') || hasAnyExt('.xcodeproj') || (has('Sources') && hasAnyExt('.swift'))) return 'ios';
1136
1288
  if ((has('settings.gradle.kts') || has('settings.gradle') || has('build.gradle.kts') || has('build.gradle')) && hasAnyExt('.kt')) {
@@ -1563,6 +1715,7 @@ async function postOrbWired({
1563
1715
  actionsStatus = 'pending',
1564
1716
  actionsTotal = 0,
1565
1717
  actionsDone = 0,
1718
+ actions = null,
1566
1719
  }) {
1567
1720
  if (!apiKey) return { ok: false, reason: 'no-api-key' };
1568
1721
  try {
@@ -1575,6 +1728,11 @@ async function postOrbWired({
1575
1728
  actionsStatus,
1576
1729
  actionsTotal,
1577
1730
  actionsDone,
1731
+ // Per-action wiring detail (intent/status/reason). Powers the backend's
1732
+ // "wired or nonexistent" invariant (unwired actions never reach the
1733
+ // agent prompt) and the dashboard/Context wiring diagnosis. Older
1734
+ // backends ignore the extra field.
1735
+ ...(Array.isArray(actions) ? { actions } : {}),
1578
1736
  }),
1579
1737
  });
1580
1738
  if (res.ok) {
@@ -1589,6 +1747,210 @@ async function postOrbWired({
1589
1747
  }
1590
1748
  }
1591
1749
 
1750
+ /**
1751
+ * Rewire-only mode (`fiodos rewire <dir>` / `--wire-only`): re-run JUST the
1752
+ * handler wiring from the saved artifacts of the last analysis (manifest.json +
1753
+ * evidence.json in the analysis output dir). The full verify→diagnose→correct→
1754
+ * retry loop runs again — including AI corrections — so an action that fell to
1755
+ * "manual" in the install gets fresh attempts WITHOUT a re-analysis (no quota,
1756
+ * no manifest publish, no orb re-mount). Ends by re-posting the per-action
1757
+ * wiring status so the dashboard/Context see the repaired state.
1758
+ */
1759
+ async function runRewireOnly(target) {
1760
+ const inputRoot = path.resolve(target || '.');
1761
+ const safeName = path.basename(inputRoot).replace(/[^a-z0-9_-]/gi, '_') || 'app';
1762
+ const outDir = arg('--out', path.join(os.tmpdir(), 'fiodos', 'analysis', safeName));
1763
+
1764
+ const manifestPath = path.join(outDir, 'manifest.json');
1765
+ const evidencePath = path.join(outDir, 'evidence.json');
1766
+ if (!fs.existsSync(manifestPath) || !fs.existsSync(evidencePath)) {
1767
+ logUser('No saved analysis found for this project — run a full analysis first:');
1768
+ logUser(` npx fiodos analyze ${target || '.'} --publish`);
1769
+ logUser(`(looked in ${outDir}; pass --out <dir> if you analyzed with a custom output dir)`);
1770
+ process.exitCode = 1;
1771
+ return;
1772
+ }
1773
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1774
+ const ev = JSON.parse(fs.readFileSync(evidencePath, 'utf8'));
1775
+ const evidence = ev.evidence || {};
1776
+ const wiring = ev.wiring || {};
1777
+
1778
+ const scope = resolveAnalysisRoot(inputRoot, 'web');
1779
+ const analysisRoot = scope.root;
1780
+ loadAppEnv(analysisRoot);
1781
+ const { apiKey, apiUrl } = resolveApiTarget();
1782
+ const model = resolveModel(arg('--model', '') || DEFAULT_MODEL);
1783
+ const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
1784
+ const { runPostWireTest } = require('./postWireTest');
1785
+ const { included } = collectFiles(analysisRoot, { maxInputTokens: Number(arg('--max-input-tokens', '150000')), platform: 'web' });
1786
+
1787
+ const selfCorrect = !process.argv.includes('--no-self-correct');
1788
+ const correctorUseProxy = !process.argv.includes('--own-ai-key');
1789
+ const corrector = selfCorrect
1790
+ ? async (args) => {
1791
+ const { wiring: fixed } = await correctActionWiring({
1792
+ ...args, model, platform: 'web', useProxy: correctorUseProxy, apiKey, apiUrl,
1793
+ });
1794
+ return fixed;
1795
+ }
1796
+ : null;
1797
+
1798
+ logUser(`Rewiring actions from the saved analysis (${manifest.actions.length} action(s), no re-analysis)…`);
1799
+ let handlerResult = null;
1800
+ await withSpinner(wiringSpinnerLabel('web'), async ({ pause, resume }) => {
1801
+ if (!assumeYes) pause();
1802
+ handlerResult = await wireHandlers(analysisRoot, {
1803
+ manifest,
1804
+ evidence,
1805
+ wiring,
1806
+ appName: manifest.appName,
1807
+ colors: fyodosTermColors(),
1808
+ assumeYes,
1809
+ noWire: false,
1810
+ quiet: !isVerbose(),
1811
+ runTest: !process.argv.includes('--no-wire-test'),
1812
+ revertOnFailure: true,
1813
+ testRunner: runPostWireTest,
1814
+ corrector,
1815
+ files: included,
1816
+ maxAttempts: Number(process.env.FYODOS_WIRE_MAX_ATTEMPTS || 3),
1817
+ });
1818
+ if (!assumeYes) resume();
1819
+ pause();
1820
+ reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
1821
+ resume();
1822
+
1823
+ const handlerApplied =
1824
+ handlerResult &&
1825
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested') &&
1826
+ (handlerResult.autoCount || 0) > 0 &&
1827
+ handlerResult.registryFile;
1828
+ if (handlerApplied) {
1829
+ const connected = connectOrbRegistries(analysisRoot, {
1830
+ registryFileAbs: handlerResult.registryFile,
1831
+ });
1832
+ pause();
1833
+ reportRegistriesResult(connected, fyodosTermColors(), { quiet: !isVerbose() });
1834
+ resume();
1835
+ }
1836
+ });
1837
+
1838
+ // Session + screen wiring also replay from the saved analysis (same consent
1839
+ // model as the main flow: an APPLIED handler wiring proves the "yes").
1840
+ const handlerConsented =
1841
+ handlerResult &&
1842
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
1843
+ if (ev.session) {
1844
+ const sessionResult = await wireSession(analysisRoot, {
1845
+ session: ev.session,
1846
+ colors: fyodosTermColors(),
1847
+ quiet: !isVerbose(),
1848
+ assumeYes: assumeYes || Boolean(handlerConsented),
1849
+ framework: 'web',
1850
+ appName: manifest.appName,
1851
+ runTest: !process.argv.includes('--no-wire-test'),
1852
+ testRunner: runPostWireTest,
1853
+ });
1854
+ reportSessionResult(sessionResult, fyodosTermColors(), { quiet: !isVerbose() });
1855
+ if (sessionResult.status === 'applied' || sessionResult.status === 'applied-untested') {
1856
+ const connectedSession = connectOrbSession(analysisRoot, {
1857
+ sessionFileAbs: sessionResult.sessionFile,
1858
+ });
1859
+ reportSessionConnectResult(connectedSession, fyodosTermColors(), { quiet: !isVerbose() });
1860
+ }
1861
+ }
1862
+ if (ev.screen) {
1863
+ const screenResult = await wireScreen(analysisRoot, {
1864
+ screen: ev.screen,
1865
+ colors: fyodosTermColors(),
1866
+ quiet: !isVerbose(),
1867
+ assumeYes: assumeYes || Boolean(handlerConsented),
1868
+ framework: 'web',
1869
+ appName: manifest.appName,
1870
+ runTest: !process.argv.includes('--no-wire-test'),
1871
+ testRunner: runPostWireTest,
1872
+ });
1873
+ reportScreenResult(screenResult, fyodosTermColors(), { quiet: !isVerbose() });
1874
+ if (screenResult.status === 'applied' || screenResult.status === 'applied-untested') {
1875
+ const connectedScreen = connectOrbScreen(analysisRoot, {
1876
+ screenFileAbs: screenResult.screenFile,
1877
+ });
1878
+ reportScreenConnectResult(connectedScreen, fyodosTermColors(), { quiet: !isVerbose() });
1879
+ }
1880
+ }
1881
+
1882
+ if (apiKey && handlerResult) {
1883
+ const { status, total, done } = actionsWiredFromHandlerResult(handlerResult);
1884
+ await postOrbWired({
1885
+ apiKey,
1886
+ apiUrl,
1887
+ file: (handlerResult.registryRel || ''),
1888
+ actionsStatus: status,
1889
+ actionsTotal: total,
1890
+ actionsDone: done,
1891
+ actions: actionsDetailFromHandlerResult(manifest, handlerResult),
1892
+ });
1893
+ }
1894
+ const wired = (handlerResult && handlerResult.autoCount) || 0;
1895
+ const manual = (handlerResult && handlerResult.manualCount) || 0;
1896
+ logUser(`Rewire finished: ${wired} action(s) wired, ${manual} still need attention.`);
1897
+ }
1898
+
1899
+ /**
1900
+ * Per-action wiring detail for the backend (POST /v1/developer/orb-wired).
1901
+ * One entry per manifest action:
1902
+ * { intent, handler, status: 'wired'|'manual'|'link', reason?, attempts? }
1903
+ * 'wired' → verified and installed (the action really runs);
1904
+ * 'manual' → could not be wired/verified — the backend EXCLUDES it from the
1905
+ * agent surface ("wired or nonexistent" invariant) and the
1906
+ * dashboard/Context can show and repair it;
1907
+ * 'link' → navigation/link action, needs no handler (always available).
1908
+ * Returns null when wiring did not actually run (declined/skipped): with no
1909
+ * verdict the backend must not filter anything.
1910
+ */
1911
+ function actionsDetailFromHandlerResult(manifest, handlerResult) {
1912
+ const r = handlerResult || {};
1913
+ const ranStatuses = new Set(['applied', 'applied-untested', 'manual-only', 'reverted', 'failed']);
1914
+ if (!ranStatuses.has(r.status)) return null;
1915
+ const plan = r.plan || {};
1916
+ const autoByIntent = new Map((plan.auto || []).map((e) => [e.intent, e]));
1917
+ const reviewByIntent = new Map((plan.review || plan.manual || []).map((e) => [e.intent, e]));
1918
+ const verifByIntent = new Map((r.verification || []).map((v) => [v.intent, v]));
1919
+ const wholesaleFailed = r.status === 'reverted' || r.status === 'failed';
1920
+
1921
+ const detail = [];
1922
+ for (const a of (manifest && manifest.actions) || []) {
1923
+ const intent = a.intent;
1924
+ if (a.kind === 'link') {
1925
+ detail.push({ intent, handler: '', status: 'link' });
1926
+ continue;
1927
+ }
1928
+ const verif = verifByIntent.get(intent);
1929
+ if (!wholesaleFailed && autoByIntent.has(intent)) {
1930
+ detail.push({
1931
+ intent,
1932
+ handler: a.handler || intent,
1933
+ status: 'wired',
1934
+ ...(verif && verif.level ? { level: verif.level } : {}),
1935
+ ...(verif && verif.attempts ? { attempts: verif.attempts } : {}),
1936
+ });
1937
+ continue;
1938
+ }
1939
+ const rev = reviewByIntent.get(intent);
1940
+ const reason = wholesaleFailed
1941
+ ? `wiring ${r.status}: the combined install did not pass the build safety net`
1942
+ : String((rev && rev.reason) || (verif && verif.error) || 'could not be wired automatically');
1943
+ detail.push({
1944
+ intent,
1945
+ handler: a.handler || intent,
1946
+ status: 'manual',
1947
+ reason: reason.split('|')[0].replace(/\s+/g, ' ').trim().slice(0, 300),
1948
+ ...(verif && verif.attempts ? { attempts: verif.attempts } : {}),
1949
+ });
1950
+ }
1951
+ return detail;
1952
+ }
1953
+
1592
1954
  // Map a wireHandlers() result to the terminal action-wiring status the backend
1593
1955
  // records, plus how many actions ended up wired. Drives the onboarding's third
1594
1956
  // "actions connected" tick so it only goes green once wiring truly finished.