@frankieai/core 0.2.5 → 0.2.7

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.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  FIREWORKS_MODELS: () => FIREWORKS_MODELS,
24
24
  SKILL_TOOL_NAME: () => SKILL_TOOL_NAME,
25
+ STANDARD_AGENT_IDS: () => STANDARD_AGENT_IDS,
25
26
  STARTER_PROMPT_ICONS: () => STARTER_PROMPT_ICONS,
26
27
  activeSkills: () => activeSkills,
27
28
  agentConfigSchema: () => agentConfigSchema,
@@ -41,14 +42,33 @@ __export(index_exports, {
41
42
  evaluateToolPolicy: () => evaluateToolPolicy,
42
43
  featuresConfigSchema: () => featuresConfigSchema,
43
44
  frankieConfigSchema: () => frankieConfigSchema,
45
+ inferTheme: () => inferTheme,
46
+ insertImport: () => insertImport,
47
+ installAgentIds: () => installAgentIds,
48
+ installBranch: () => installBranch,
49
+ installTitle: () => installTitle,
50
+ knowledgeBaseSchema: () => knowledgeBaseSchema,
51
+ knowledgeSourceSchema: () => knowledgeSourceSchema,
44
52
  limitsConfigSchema: () => limitsConfigSchema,
45
53
  matchPath: () => matchPath,
54
+ matchesAnyPagePattern: () => matchesAnyPagePattern,
55
+ matchesInstallBranch: () => matchesInstallBranch,
56
+ matchesPagePattern: () => matchesPagePattern,
46
57
  mcpServerSchema: () => mcpServerSchema,
47
58
  modelChoiceSchema: () => modelChoiceSchema,
48
59
  modelConfigSchema: () => modelConfigSchema,
60
+ moduleImporter: () => moduleImporter,
61
+ mountWidget: () => mountWidget,
62
+ normalizeColor: () => normalizeColor,
49
63
  operationId: () => operationId,
50
64
  parseFrankieConfig: () => parseFrankieConfig,
65
+ personaEnvKey: () => personaEnvKey,
66
+ personaInstructions: () => personaInstructions,
67
+ personaProfileSchema: () => personaProfileSchema,
68
+ plainText: () => plainText,
51
69
  planHostedInstall: () => planHostedInstall,
70
+ renderJson: () => renderJson,
71
+ resolvePersonaConfig: () => resolvePersonaConfig,
52
72
  sdkUpdateWorkflow: () => sdkUpdateWorkflow,
53
73
  skillSchema: () => skillSchema,
54
74
  starterPromptSchema: () => starterPromptSchema,
@@ -57,7 +77,8 @@ __export(index_exports, {
57
77
  toolDescriptions: () => toolDescriptions,
58
78
  toolPolicySchema: () => toolPolicySchema,
59
79
  validateApiDocument: () => validateApiDocument,
60
- widgetThemeSchema: () => widgetThemeSchema
80
+ widgetThemeSchema: () => widgetThemeSchema,
81
+ withDependencies: () => withDependencies
61
82
  });
62
83
  module.exports = __toCommonJS(index_exports);
63
84
 
@@ -95,6 +116,13 @@ function describeTool(toolName, input) {
95
116
  const skill = typeof input === "object" && input !== null ? input["skill"] : void 0;
96
117
  return typeof skill === "string" && skill.trim() ? `Using skill: ${skill.trim()}` : "Using a skill";
97
118
  }
119
+ const builtIn = /^frankie__([a-z0-9]+)__(.+)$/.exec(toolName);
120
+ if (builtIn) {
121
+ const [, group, action] = builtIn;
122
+ if (group === "docs") return "Search the docs";
123
+ if (group === "escalate") return "Escalate to a person";
124
+ return humanise(action ?? toolName);
125
+ }
98
126
  const mcp = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(toolName);
99
127
  if (mcp) {
100
128
  const [, server, tool] = mcp;
@@ -146,16 +174,21 @@ function toolDescriptions(config, toolIds) {
146
174
  for (const source of config.apis) {
147
175
  for (const capability of source.capabilities) {
148
176
  const id = `api__${source.name}__${capability.operationId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48)}`;
149
- if (wanted.has(id)) found.set(id, `${capability.method} ${capability.path}` + (capability.summary ? ` \u2014 ${capability.summary}` : ""));
177
+ if (wanted.has(id)) {
178
+ found.set(id, {
179
+ description: `${capability.method} ${capability.path}` + (capability.summary ? ` \u2014 ${capability.summary}` : ""),
180
+ ...capability.inputs ? { inputs: capability.inputs } : {}
181
+ });
182
+ }
150
183
  }
151
184
  }
152
185
  for (const source of config.mcpServers) {
153
186
  for (const tool of source.discoveredTools) {
154
187
  const id = `mcp__${source.name}__${tool.name}`;
155
- if (wanted.has(id)) found.set(id, tool.description || tool.name);
188
+ if (wanted.has(id)) found.set(id, { description: tool.description || tool.name, ...tool.inputs ? { inputs: tool.inputs } : {} });
156
189
  }
157
190
  }
158
- return toolIds.map((id) => ({ id, description: found.get(id) ?? id }));
191
+ return toolIds.map((id) => ({ id, ...found.get(id) ?? { description: id } }));
159
192
  }
160
193
  function suggestedPromptsFromSkills(config) {
161
194
  const seen = /* @__PURE__ */ new Set();
@@ -193,6 +226,8 @@ function draftSkill(goal, tools) {
193
226
  "",
194
227
  "Required inputs: note what each tool below needs, and ask the user for anything missing before calling it.",
195
228
  "",
229
+ "Quality bar: what makes a good result, not just a valid request. List the optional fields a complete item has (a description, a level, a length\u2026) and what a good value looks like; draft them from the conversation, ask for facts only the user knows, and show the full item for confirmation before saving.",
230
+ "",
196
231
  "Steps:",
197
232
  ...tools.map((tool, index) => `${index + 1}. ${tool.id}${tool.label ? ` \u2014 ${tool.label}` : ""}`),
198
233
  "Call only the tool that matches the user's actual request; respect the signed-in user's own permissions and confirm before any consequential write.",
@@ -298,7 +333,19 @@ var featuresConfigSchema = import_zod2.z.object({
298
333
  * Internal dashboard mode: help administer existing workspaces, tools and
299
334
  * conversations without restarting the onboarding procedure.
300
335
  */
301
- configurationAssistant: import_zod2.z.boolean().default(false)
336
+ configurationAssistant: import_zod2.z.boolean().default(false),
337
+ /**
338
+ * Human escalation: give the agent a built-in tool to hand a conversation to
339
+ * the workspace's team when it can't resolve something or the visitor asks
340
+ * for a person. Each escalation is recorded on the conversation (a dashboard
341
+ * inbox) and, when `webhookUrl` is set, POSTed there (Slack, Zapier, a
342
+ * ticketing intake…). For support/landing-page agents.
343
+ */
344
+ escalation: import_zod2.z.object({
345
+ enabled: import_zod2.z.boolean().default(false),
346
+ /** Optional HTTPS endpoint that receives each escalation as JSON. */
347
+ webhookUrl: import_zod2.z.string().url().startsWith("https://").max(2e3).optional()
348
+ }).prefault({})
302
349
  }).prefault({});
303
350
  var mcpServerSchema = import_zod2.z.object({
304
351
  name: import_zod2.z.string().min(1).regex(/^[a-z0-9][a-z0-9_-]*$/i, "MCP server names must be alphanumeric with - or _"),
@@ -312,7 +359,8 @@ var mcpServerSchema = import_zod2.z.object({
312
359
  enabled: import_zod2.z.boolean().default(true),
313
360
  /** Explicit tool selection. Omitted keeps legacy all-tools behavior; [] disables all. */
314
361
  selectedTools: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
315
- discoveredTools: import_zod2.z.array(import_zod2.z.object({ name: import_zod2.z.string(), description: import_zod2.z.string().optional() })).default([]),
362
+ /** Tools the server listed at discovery; `inputs` summarizes each one's input schema for skill writing. */
363
+ discoveredTools: import_zod2.z.array(import_zod2.z.object({ name: import_zod2.z.string(), description: import_zod2.z.string().optional(), inputs: import_zod2.z.string().optional() })).default([]),
316
364
  /** `http` covers streamable HTTP; `sse` is the legacy transport. */
317
365
  transport: import_zod2.z.enum(["http", "sse"]).default("http"),
318
366
  url: import_zod2.z.string().url(),
@@ -332,6 +380,14 @@ var apiCapabilitySchema = import_zod2.z.object({
332
380
  method: import_zod2.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
333
381
  path: import_zod2.z.string().min(1),
334
382
  summary: import_zod2.z.string().optional(),
383
+ /**
384
+ * One line naming what the operation takes — its path/query parameters and
385
+ * the request body's top-level fields, each marked required where the spec
386
+ * says so, with its description or allowed values. This is what lets a
387
+ * skill say which inputs to collect before calling the tool (and which it
388
+ * must never invent), instead of "check the tool's own parameters".
389
+ */
390
+ inputs: import_zod2.z.string().optional(),
335
391
  /**
336
392
  * True when this is a write operation the spec requires auth for, but the
337
393
  * source has no way to authorize it as the specific calling user — no
@@ -532,6 +588,58 @@ var brandingConfigSchema = import_zod2.z.object({
532
588
  avatarUrl: import_zod2.z.string().url().optional(),
533
589
  launcherLabel: import_zod2.z.string().default("Need a hand?")
534
590
  });
591
+ var knowledgeSourceSchema = import_zod2.z.object({
592
+ url: import_zod2.z.string().url().startsWith("https://").max(2e3)
593
+ });
594
+ var knowledgeBaseSchema = import_zod2.z.object({
595
+ enabled: import_zod2.z.boolean().default(false),
596
+ sources: import_zod2.z.array(knowledgeSourceSchema).max(20).default([]),
597
+ /** Cap on pages fetched per source during a crawl. */
598
+ maxPages: import_zod2.z.number().int().positive().max(200).default(40)
599
+ }).prefault({});
600
+ var personaProfileSchema = import_zod2.z.object({
601
+ /** Stable lowercase slug, unique within the workspace. Baked into env var, branch and mount module. */
602
+ id: import_zod2.z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-]*$/, "Persona ids are lowercase slugs."),
603
+ /** Tab label and install-PR title suffix, e.g. "Knowledge base". */
604
+ label: import_zod2.z.string().trim().min(1).max(60),
605
+ /** This persona's own publishable key. One key maps to exactly one persona. */
606
+ publishableKey: import_zod2.z.string().min(8),
607
+ /** App-Router path globs this persona shows on. Empty = every page no other agent claims. */
608
+ pagePatterns: import_zod2.z.array(import_zod2.z.string().trim().min(1).max(200)).max(20).default([]),
609
+ /**
610
+ * Whether visitors may chat with this persona without signing in to the host
611
+ * app. Unset inherits the workspace's `auth.allowAnonymous`. A support or docs
612
+ * agent on public pages needs this on, or every logged-out visitor is told to
613
+ * sign in.
614
+ */
615
+ allowAnonymous: import_zod2.z.boolean().optional(),
616
+ /**
617
+ * What this agent helps with, in the team's own words ("pricing, plans,
618
+ * getting started"). The one thing a team needs to write; the agent's role,
619
+ * greeting and instructions come from defaults. It is added to the agent's
620
+ * instructions at runtime (see `personaInstructions`).
621
+ */
622
+ scope: import_zod2.z.string().trim().max(2e3).optional(),
623
+ /**
624
+ * Where to send people when the agent can't answer and it doesn't hand the
625
+ * chat to the team itself: a contact page (https) or an email (mailto:).
626
+ */
627
+ contactUrl: import_zod2.z.string().trim().max(500).regex(/^(?:https:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/, "Use an https:// link or a mailto: address.").optional(),
628
+ /**
629
+ * Shallow-merged over the workspace branding. Each field is optional with no
630
+ * default, so only the keys the persona sets override the workspace's — an
631
+ * absent field keeps the workspace value rather than resetting it.
632
+ */
633
+ branding: import_zod2.z.object({
634
+ name: import_zod2.z.string().optional(),
635
+ tagline: import_zod2.z.string().optional(),
636
+ avatarUrl: import_zod2.z.string().url().optional(),
637
+ launcherLabel: import_zod2.z.string().optional()
638
+ }).optional(),
639
+ agent: agentConfigSchema.prefault({}),
640
+ features: featuresConfigSchema,
641
+ knowledgeBase: knowledgeBaseSchema
642
+ });
535
643
  var frankieConfigSchema = import_zod2.z.object({
536
644
  tenantId: import_zod2.z.string().min(1),
537
645
  branding: brandingConfigSchema.prefault({}),
@@ -546,7 +654,18 @@ var frankieConfigSchema = import_zod2.z.object({
546
654
  skills: import_zod2.z.array(skillSchema).max(30).default([]),
547
655
  limits: limitsConfigSchema.prefault({}),
548
656
  /** Opt-in end-user chat capabilities (attachments, model picker). */
549
- features: featuresConfigSchema
657
+ features: featuresConfigSchema,
658
+ /** Documentation the agent answers from, indexed for semantic retrieval. */
659
+ knowledgeBase: knowledgeBaseSchema,
660
+ /** App-Router path globs the primary (product) agent shows on. Empty = every page no persona claims. */
661
+ pagePatterns: import_zod2.z.array(import_zod2.z.string().trim().min(1).max(200)).max(20).default([]),
662
+ /**
663
+ * Additional embedded personas on this workspace. Empty for a single-agent
664
+ * workspace — the top-level agent/features/knowledgeBase + `auth.publishableKey`
665
+ * is the primary persona. Each entry resolves to its own one-agent config via
666
+ * its own publishable key, so the data plane still sees one config per key.
667
+ */
668
+ personas: import_zod2.z.array(personaProfileSchema).max(8).default([])
550
669
  });
551
670
  var LEAKY_MODEL_MIGRATION = {
552
671
  "accounts/fireworks/models/kimi-k2p7-code": "accounts/fireworks/models/kimi-k3"
@@ -562,8 +681,73 @@ function parseFrankieConfig(input) {
562
681
  function defineFrankieConfig(config) {
563
682
  return config;
564
683
  }
684
+ function resolvePersonaConfig(config, publishableKey) {
685
+ if (publishableKey === config.auth.publishableKey) return config;
686
+ const persona = config.personas.find((entry) => entry.publishableKey === publishableKey);
687
+ if (!persona) return null;
688
+ return parseFrankieConfig({
689
+ ...config,
690
+ personas: [],
691
+ branding: { ...config.branding, ...persona.branding ?? {} },
692
+ auth: { ...config.auth, publishableKey: persona.publishableKey, allowAnonymous: persona.allowAnonymous ?? config.auth.allowAnonymous },
693
+ pagePatterns: persona.pagePatterns,
694
+ agent: { ...persona.agent, systemPrompt: personaInstructions(persona) },
695
+ features: persona.features,
696
+ knowledgeBase: persona.knowledgeBase
697
+ });
698
+ }
699
+ function personaInstructions(persona) {
700
+ const base = persona.agent.systemPrompt.trim();
701
+ const scope = persona.scope?.trim();
702
+ const handsOff = persona.features.escalation.enabled;
703
+ const contact = !handsOff ? persona.contactUrl?.trim() : void 0;
704
+ const fallback = handsOff ? "" : contact ? `When you can't answer from what you know, or someone asks for a person, say so briefly and point them to ${contact} to reach the team. Never guess.` : "When you can't answer from what you know, say so plainly. Never guess, and never promise that someone will follow up.";
705
+ const boundary = "Stay within this. If someone asks about something outside it, say briefly that it isn't something you can help with here" + (handsOff ? ", and offer to connect them with the team." : contact ? ", and share the contact link." : ".");
706
+ return [base, scope ? "What you help with, in the team's own words:\n" + scope + "\n\n" + boundary : "", fallback].filter(Boolean).join("\n\n");
707
+ }
708
+ function normalizeRoutePath(value) {
709
+ let path = (value || "/").split(/[?#]/)[0].trim();
710
+ if (!path.startsWith("/")) path = "/" + path;
711
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
712
+ return path;
713
+ }
714
+ function matchesPagePattern(pathname, pattern) {
715
+ const path = normalizeRoutePath(pathname);
716
+ const pat = normalizeRoutePath(pattern);
717
+ const trailingDeep = pat.endsWith("/**");
718
+ const core = trailingDeep ? pat.slice(0, -3) : pat;
719
+ const escaped = core.replace(/[.+^${}()|[\]\\]/g, "\\$&");
720
+ const body = escaped.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*");
721
+ return new RegExp("^" + body + (trailingDeep ? "(?:/.*)?" : "") + "$").test(path);
722
+ }
723
+ function matchesAnyPagePattern(pathname, patterns) {
724
+ return patterns.length === 0 || patterns.some((pattern) => matchesPagePattern(pathname, pattern));
725
+ }
565
726
 
566
727
  // src/install.ts
728
+ function personaEnvKey(personaId) {
729
+ return "NEXT_PUBLIC_FRANKIE_KEY_" + personaId.toUpperCase().replace(/[^A-Z0-9]/g, "_");
730
+ }
731
+ var STANDARD_AGENT_IDS = ["support", "docs"];
732
+ function installAgentIds(personaIds = []) {
733
+ const custom = [...new Set(personaIds)].filter((id) => /^[a-z0-9][a-z0-9-]*$/.test(id) && !STANDARD_AGENT_IDS.includes(id)).sort();
734
+ return [...STANDARD_AGENT_IDS, ...custom];
735
+ }
736
+ function installBranch(workspaceId, baseSha, personaId) {
737
+ return "frankie/install-" + workspaceId + (personaId ? "--" + personaId : "") + "-" + baseSha.slice(0, 8);
738
+ }
739
+ function plainText(value, max = 80) {
740
+ const text = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/[`<>[\]()*_#|!\\]/g, "").replace(/\s+/g, " ").trim();
741
+ return text.length > max ? text.slice(0, max - 1).trimEnd() + "\u2026" : text;
742
+ }
743
+ function installTitle(personaLabel) {
744
+ return personaLabel ? "Install Frankie: " + plainText(personaLabel, 60) : "Install Frankie";
745
+ }
746
+ function matchesInstallBranch(ref, workspaceId, personaId) {
747
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
748
+ const mid = personaId ? escape(workspaceId) + "--?" + escape(personaId) : escape(workspaceId);
749
+ return new RegExp("^frankie/install-" + mid + "-[0-9a-f]{8}$").test(ref);
750
+ }
567
751
  function assertNoCommittedFrankieKeys(files) {
568
752
  for (const file of files) {
569
753
  if (/\bpk_live_[0-9a-f]{24}\b/i.test(file.content) || /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(file.content)) {
@@ -571,8 +755,17 @@ function assertNoCommittedFrankieKeys(files) {
571
755
  }
572
756
  }
573
757
  }
758
+ var ENV_BLOCK_START = "# --- Frankie (managed) ---";
759
+ var ENV_BLOCK_END = "# --- end Frankie ---";
760
+ var LEGACY_ENV_COMMENTS = [
761
+ "# Public at runtime, but inject it through your deployment environment instead of committing a workspace value.",
762
+ "# Server only: use the secret from this workspace. Never commit its value.",
763
+ "# This persona's publishable key. Public at runtime; set it in your deployment environment, don't commit a value."
764
+ ];
574
765
  function planHostedInstall(input) {
575
766
  const { files, workspace } = input;
767
+ const product = !input.persona;
768
+ const anonymous = workspace.allowAnonymous;
576
769
  const layouts = Object.keys(files).filter((p) => /(?:^|\/)(?:src\/)?app(?:\/\([^/]+\))?\/layout\.tsx$/.test(p));
577
770
  const roots = new Set(layouts.map((path) => path.replace(/app\/(?:\([^/]+\)\/)?layout\.tsx$/, "app/")));
578
771
  if (roots.size > 1) throw new Error("Multiple Next.js applications found. Run the installer from the intended app with the CLI; Frankie will not guess which app to modify.");
@@ -586,34 +779,70 @@ function planHostedInstall(input) {
586
779
  const pkg = JSON.parse(files[pkgPath] ?? "{}");
587
780
  if (!pkg.dependencies?.next) throw new Error("The selected app must have Next.js in dependencies.");
588
781
  const out = /* @__PURE__ */ new Map();
782
+ const deletions = [];
589
783
  const notes = [];
590
- const existingMount = files[layoutPath].includes("FrankieWidgetMount");
591
784
  const sourceRoot = appRoot.endsWith("src/app/") ? root + "src/" : root;
592
785
  const tokenPath = appRoot + "api/frankie/token/route.ts";
593
786
  const healthPath = appRoot + "api/frankie/health/route.ts";
594
- const auth = detectAuth(files, sourceRoot, pkg.dependencies);
595
- const authStrategy = workspace.allowAnonymous ? "anonymous" : files[tokenPath] ? "existing" : auth?.strategy ?? "unconfigured";
787
+ const agentsPath = appRoot + "frankie/FrankieAgents.tsx";
788
+ const chatModulePath = appRoot + "frankie/FrankieChatPage.tsx";
789
+ const importer = moduleImporter(files, root, sourceRoot);
790
+ const auth = detectAuth(files, sourceRoot, pkg.dependencies, (module2) => importer(tokenPath, module2));
791
+ const existingToken = files[tokenPath];
792
+ const authStrategy = anonymous ? "anonymous" : auth?.strategy ?? (existingToken && !existingToken.includes("AUTH_NOT_CONFIGURED") ? "existing" : "unconfigured");
596
793
  const needsAuthSetup = authStrategy === "unconfigured";
597
- if (needsAuthSetup) notes.push("No session lookup was detected. Export getCurrentUser from lib/frankie-auth.ts (see the generated token endpoint); until then it answers 501 and chat reports that sign-in is not wired.");
598
- if (authStrategy === "supabase") notes.push("Detected Supabase auth: the token endpoint verifies the user's Supabase access token and the widget forwards it automatically. Users must be signed in through Supabase.");
599
- pkg.dependencies = { ...pkg.dependencies, "@frankieai/react": pkg.dependencies["@frankieai/react"] ?? "^0.2.1", ...!workspace.allowAnonymous ? { jose: pkg.dependencies.jose ?? "^6.0.11" } : {} };
600
- out.set(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
601
- const authProp = workspace.allowAnonymous ? "" : " getUserToken={getUserToken}";
602
- const shared = `import { FrankieProvider, FrankieWidget, FrankieChatPage } from "@frankieai/react";
603
- import "@frankieai/react/styles.css";
604
- `;
605
- const clientAuthImport = auth?.client && !workspace.allowAnonymous ? `import { ${auth.client.name} } from "@/lib/supabase";
794
+ if (product && needsAuthSetup) notes.push("No session lookup was detected. Export getCurrentUser from lib/frankie-auth.ts (see the generated token endpoint); until then it answers 501 and chat reports that sign-in is not wired.");
795
+ if (product && authStrategy === "supabase") notes.push("Detected Supabase auth: the token endpoint verifies the user's Supabase access token and the widget forwards it automatically. Users must be signed in through Supabase.");
796
+ pkg.dependencies = withDependencies(pkg.dependencies, { "@frankieai/react": "^0.2.1", ...!anonymous ? { jose: "^6.0.11" } : {} });
797
+ out.set(pkgPath, renderJson(pkg, files[pkgPath]));
798
+ let manifest = {};
799
+ try {
800
+ const parsed = JSON.parse(files[root + "frankie.install.json"] ?? "{}");
801
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) manifest = parsed;
802
+ } catch {
803
+ }
804
+ const priorChat = manifest.workspaceId === workspace.id && (manifest.chatPath === "/chat" || manifest.chatPath === "/frankie/chat") ? manifest.chatPath : void 0;
805
+ const chatPath = priorChat ?? (files[integrationAppRoot + "chat/page.tsx"] ? "/frankie/chat" : "/chat");
806
+ let legacyChat = false;
807
+ for (const path of Object.keys(files).sort()) {
808
+ if (!path.startsWith(appRoot)) continue;
809
+ const legacy = path.match(/(?:^|\/)frankie\/(?:FrankieWidgetMount|FrankieSetupChat)\.tsx$/);
810
+ if (legacy) {
811
+ const exportsChat = /\bWorkspaceChatPage\b/.test(files[path]);
812
+ legacyChat ||= exportsChat;
813
+ out.set(path, `// Frankie now mounts every agent from FrankieAgents in the root layout.
814
+ // This module stays so existing imports keep working; it renders nothing.
815
+ export function FrankieWidgetMount() {
816
+ return null;
817
+ }
818
+ ${exportsChat ? `export { WorkspaceChatPage } from "${relativeImport(path, chatModulePath.slice(0, -4))}";
819
+ ` : ""}`);
820
+ }
821
+ if (/(?:^|\/)frankie\/FrankieWidget\.[a-z0-9][a-z0-9-]*\.tsx$/.test(path)) deletions.push(path);
822
+ }
823
+ const clientAuth = anonymous ? void 0 : auth?.client;
824
+ const clientAuthImport = clientAuth ? `import { ${clientAuth.name} } from "${importer(agentsPath, "lib/supabase")}";
606
825
  ` : "";
607
- const getToken = workspace.allowAnonymous ? "" : auth?.client ? `
608
- async function getUserToken() {
609
- const session = (await ${auth.client.name}?.auth.getSession())?.data.session;
610
- if (!session?.access_token) return undefined;
611
- const response = await fetch("/api/frankie/token", { method: "POST", headers: { Authorization: \`Bearer \${session.access_token}\` } });
826
+ const tokenHelpers = anonymous ? "" : clientAuth ? `
827
+ async function supabaseToken() {
828
+ const session = (await ${clientAuth.name}?.auth.getSession())?.data.session;
829
+ return session?.access_token;
830
+ }
831
+ /** The signed-in user's Frankie token, or undefined when nobody is signed in. */
832
+ export async function getUserToken() {
833
+ const accessToken = await supabaseToken();
834
+ if (!accessToken) return undefined;
835
+ const response = await fetch("/api/frankie/token", { method: "POST", headers: { Authorization: \`Bearer \${accessToken}\` } });
612
836
  if (!response.ok) return undefined;
613
837
  return ((await response.json()) as { token?: string }).token;
614
838
  }
839
+ const optionalUserToken = getUserToken;
840
+ async function productAvailable() {
841
+ return Boolean(await supabaseToken());
842
+ }
615
843
  ` : `
616
- async function getUserToken() {
844
+ /** The signed-in user's Frankie token; throws the reason chat can't start. */
845
+ export async function getUserToken() {
617
846
  const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
618
847
  if (response.ok) return ((await response.json()) as { token?: string }).token;
619
848
  if (response.status === 401) throw new Error("Sign in to this app to use chat.");
@@ -621,65 +850,153 @@ async function getUserToken() {
621
850
  if (response.status === 503) throw new Error("Chat is not configured on the server yet (FRANKIE_JWT_SECRET is missing).");
622
851
  throw new Error("The chat sign-in endpoint returned " + response.status + ".");
623
852
  }
853
+ /** Support and docs agents chat with visitors too: who is signed in, if anyone. */
854
+ async function optionalUserToken() {
855
+ try {
856
+ const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
857
+ return response.ok ? ((await response.json()) as { token?: string }).token : undefined;
858
+ } catch {
859
+ return undefined;
860
+ }
861
+ }
862
+ /** The product agent is for signed-in users. 501/503 still show it, so the widget can say what to fix. */
863
+ async function productAvailable() {
864
+ try {
865
+ const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
866
+ return response.ok || response.status === 501 || response.status === 503;
867
+ } catch {
868
+ return false;
869
+ }
870
+ }
624
871
  `;
625
- const hostApiHeaders = auth?.client && !workspace.allowAnonymous ? `
626
- headers: async () => {
627
- const session = (await ${auth.client.name}?.auth.getSession())?.data.session;
872
+ const hostApiHeaders = clientAuth ? `
873
+ headers: async (): Promise<Record<string, string>> => {
874
+ const session = (await ${clientAuth.name}?.auth.getSession())?.data.session;
628
875
  return session?.access_token ? { Authorization: \`Bearer \${session.access_token}\` } : {};
629
876
  },` : "";
630
- const hostApi = `const hostApi = {
877
+ const hostApi = `export const hostApi = {
631
878
  baseUrl: typeof window !== "undefined" ? window.location.origin : "",
632
- credentials: "${auth?.client || workspace.allowAnonymous ? "same-origin" : "include"}" as const,${hostApiHeaders}
879
+ credentials: "${clientAuth || anonymous ? "same-origin" : "include"}" as const,${hostApiHeaders}
633
880
  };
634
881
  `;
635
- const apiProp = " api={hostApi}";
636
- let priorChat;
637
- try {
638
- const previous = JSON.parse(files[root + "frankie.install.json"] ?? "{}");
639
- if (previous.workspaceId === workspace.id && ["/chat", "/frankie/chat"].includes(previous.chatPath)) priorChat = previous.chatPath;
640
- } catch {
641
- }
642
- const chatPath = priorChat ?? (files[integrationAppRoot + "chat/page.tsx"] ? "/frankie/chat" : "/chat");
643
- if (!priorChat && files[integrationAppRoot + chatPath.slice(1) + "/page.tsx"]) throw new Error("Both /chat and /frankie/chat already exist. Choose a chat route manually.");
644
- out.set(root + "frankie.install.json", JSON.stringify({ workspaceId: workspace.id, chatPath }, null, 2) + "\n");
645
- if (chatPath !== "/chat") notes.push("Existing /chat preserved. Frankie chat is available at /frankie/chat.");
646
- const mount = `"use client";
882
+ const personaIds = installAgentIds([...input.agents ?? [], ...input.persona ? [input.persona.id] : []]);
883
+ const agentsModule = `"use client";
884
+ // Generated by Frankie. Every Frankie agent in this app mounts here, once, from
885
+ // the root layout. An agent turns on when its publishable key is set in the
886
+ // deployment environment, and shows on the pages chosen for it in Frankie.
887
+ import { useEffect, useState } from "react";
647
888
  import { usePathname } from "next/navigation";
648
- ${shared}${clientAuthImport}${getToken}${hostApi}
649
- export function FrankieWidgetMount() {
650
- const pathname = usePathname();
651
- const key = process.env.NEXT_PUBLIC_FRANKIE_KEY;
652
- if (!key || pathname === ${JSON.stringify(chatPath)}) return null;
653
- return <FrankieProvider publishableKey={key}${authProp}${apiProp}><FrankieWidget /></FrankieProvider>;
889
+ import { FrankieClient, FrankieProvider, FrankieWidget, type PublicWidgetConfig } from "@frankieai/react";
890
+ import "@frankieai/react/styles.css";
891
+ ${clientAuthImport}${tokenHelpers}${hostApi}
892
+ type Agent = { id: string; key: string | undefined };
893
+ // Literal process.env reads, so Next.js inlines each key at build time. Where
894
+ // two agents' pages overlap the earlier one wins; the product agent takes
895
+ // every page no other agent claims.
896
+ const PERSONAS: Agent[] = [
897
+ ${personaIds.map((id) => ` { id: ${JSON.stringify(id)}, key: process.env.${personaEnvKey(id)} },`).join("\n")}
898
+ ];
899
+ const PRODUCT: Agent = { id: "product", key: process.env.NEXT_PUBLIC_FRANKIE_KEY };
900
+ // The full-page chat never gets a floating launcher on top of it.
901
+ const CHAT_PATHS = ["/chat", "/frankie/chat"];
902
+
903
+ type Loaded = { config: PublicWidgetConfig | undefined; pages: string[] };
904
+ const loading = new Map<string, Promise<Loaded>>();
905
+ function load(key: string): Promise<Loaded> {
906
+ let pending = loading.get(key);
907
+ if (!pending) {
908
+ pending = new FrankieClient({ publishableKey: key })
909
+ .getConfig()
910
+ .then((config) => ({ config, pages: (config as { pages?: string[] }).pages ?? [] }))
911
+ .catch(() => ({ config: undefined, pages: [] }));
912
+ loading.set(key, pending);
913
+ }
914
+ return pending;
654
915
  }
916
+
917
+ function matchesAny(pathname: string, patterns: string[]): boolean {
918
+ if (!patterns.length) return true;
919
+ const path = (pathname || "/").split(/[?#]/)[0]!.replace(/(.)\\/+$/, "$1");
920
+ return patterns.some((pattern) => {
921
+ const pat = (pattern || "/").replace(/(.)\\/+$/, "$1");
922
+ const deep = pat.endsWith("/**");
923
+ const core = (deep ? pat.slice(0, -3) : pat).replace(/[.+^\${}()|[\\]\\\\]/g, "\\\\$&");
924
+ const body = core.split("**").map((chunk) => chunk.replace(/\\*/g, "[^/]*")).join(".*");
925
+ return new RegExp("^" + body + (deep ? "(?:/.*)?" : "") + "$").test(path);
926
+ });
927
+ }
928
+
929
+ function pick(pathname: string, loaded: Record<string, Loaded>, signedIn: boolean): { agent: Agent; key: string } | undefined {
930
+ if (CHAT_PATHS.includes(pathname)) return undefined;
931
+ const live = [...PERSONAS, PRODUCT].flatMap((agent) => (agent.key ? [{ agent, key: agent.key }] : []));
932
+ // Decide once every agent's pages are known, so one never flashes in before another.
933
+ if (live.some(({ key }) => !loaded[key])) return undefined;
934
+ const personas = live.filter(({ agent, key }) => agent !== PRODUCT && loaded[key]!.config);
935
+ const claimed = personas.find(({ key }) => loaded[key]!.pages.length > 0 && matchesAny(pathname, loaded[key]!.pages));
936
+ if (claimed) return claimed;
937
+ const product = live.find(({ agent }) => agent === PRODUCT);
938
+ if (product && signedIn && matchesAny(pathname, loaded[product.key]!.pages)) return product;
939
+ return personas.find(({ key }) => loaded[key]!.pages.length === 0);
940
+ }
941
+
942
+ export function FrankieAgents() {
943
+ const pathname = usePathname() || "/";
944
+ const [loaded, setLoaded] = useState<Record<string, Loaded>>({});
945
+ useEffect(() => {
946
+ let live = true;
947
+ for (const { key } of [...PERSONAS, PRODUCT]) {
948
+ if (key) void load(key).then((result) => { if (live) setLoaded((prev) => ({ ...prev, [key]: result })); });
949
+ }
950
+ return () => { live = false; };
951
+ }, []);
952
+ ${anonymous ? ` const signedIn = true;
953
+ ` : ` const [signedIn, setSignedIn] = useState(false);
954
+ useEffect(() => {
955
+ if (!PRODUCT.key) return;
956
+ let live = true;
957
+ void productAvailable().then((ok) => { if (live) setSignedIn(ok); });
958
+ return () => { live = false; };
959
+ }, [pathname]);
960
+ `} const active = pick(pathname, loaded, signedIn);
961
+ if (!active) return null;
962
+ return (
963
+ <FrankieProvider key={active.key} publishableKey={active.key} config={loaded[active.key]?.config}${anonymous ? "" : ` getUserToken={active.agent === PRODUCT ? getUserToken : optionalUserToken}`} api={hostApi}>
964
+ <FrankieWidget />
965
+ </FrankieProvider>
966
+ );
967
+ }
968
+ `;
969
+ out.set(agentsPath, agentsModule);
970
+ const rootLayout = files[rootLayoutPath];
971
+ if (!rootLayout.includes("FrankieAgents")) {
972
+ const mounted = mountWidget(rootLayout, "FrankieAgents");
973
+ if (!mounted) throw new Error("The root layout has no safe mount point. Add <FrankieAgents /> inside <body> manually.");
974
+ out.set(rootLayoutPath, insertImport(mounted, `import { FrankieAgents } from "./frankie/FrankieAgents";`));
975
+ }
976
+ if (product || legacyChat) {
977
+ out.set(chatModulePath, `"use client";
978
+ import { FrankieChatPage } from "@frankieai/react";
979
+ import "@frankieai/react/styles.css";
980
+ import { ${anonymous ? "" : "getUserToken, "}hostApi } from "./FrankieAgents";
981
+
655
982
  export function WorkspaceChatPage() {
656
983
  const key = process.env.NEXT_PUBLIC_FRANKIE_KEY;
657
984
  if (!key) return <p>Chat is not configured. Set NEXT_PUBLIC_FRANKIE_KEY in your deployment.</p>;
658
- return <FrankieChatPage publishableKey={key}${authProp}${apiProp} />;
985
+ return <FrankieChatPage publishableKey={key}${anonymous ? "" : " getUserToken={getUserToken}"} api={hostApi} />;
659
986
  }
660
- `;
661
- const moduleName = existingMount ? "FrankieSetupChat" : "FrankieWidgetMount";
662
- out.set(integrationAppRoot + "frankie/" + moduleName + ".tsx", mount);
663
- if (!existingMount) {
664
- const layout = files[layoutPath];
665
- const importLine = `import { FrankieWidgetMount } from "./frankie/FrankieWidgetMount";
666
- `;
667
- const withImport = /^["']use client["'];/.test(layout) ? layout.replace(/^(['"]use client['"];)/, "$1\n" + importLine) : importLine + layout;
668
- if (withImport.includes("</body>")) {
669
- out.set(layoutPath, withImport.replace("</body>", " <FrankieWidgetMount />\n</body>"));
670
- } else if (withImport.includes("</main>")) {
671
- out.set(layoutPath, withImport.replace("</main>", " <FrankieWidgetMount />\n</main>"));
672
- } else if (withImport.includes("{children}")) {
673
- out.set(layoutPath, withImport.replace("{children}", "{children}<FrankieWidgetMount />"));
674
- } else {
675
- throw new Error("The selected layout has no safe widget mount point. Add FrankieWidgetMount manually.");
676
- }
987
+ `);
677
988
  }
678
- const pageImport = chatPath === "/chat" ? "../frankie/" : "../../frankie/";
679
- out.set(integrationAppRoot + chatPath.slice(1) + "/page.tsx", `import { WorkspaceChatPage } from "${pageImport}${moduleName}";
989
+ if (product) {
990
+ if (!priorChat && files[integrationAppRoot + chatPath.slice(1) + "/page.tsx"]) throw new Error("Both /chat and /frankie/chat already exist. Choose a chat route manually.");
991
+ const pagePath = integrationAppRoot + chatPath.slice(1) + "/page.tsx";
992
+ out.set(pagePath, `import { WorkspaceChatPage } from "${relativeImport(pagePath, chatModulePath.slice(0, -4))}";
680
993
  export default WorkspaceChatPage;
681
994
  `);
682
- if (!workspace.allowAnonymous && !files[tokenPath]) {
995
+ const { personas: _legacyPersonas, ...kept2 } = manifest;
996
+ out.set(root + "frankie.install.json", JSON.stringify({ ...kept2, workspaceId: workspace.id, chatPath }, null, 2) + "\n");
997
+ if (chatPath !== "/chat") notes.push("Existing /chat preserved. Frankie chat is available at /frankie/chat.");
998
+ }
999
+ if (product && !anonymous && !files[tokenPath]) {
683
1000
  const lookup = auth?.lookup ?? `// Frankie could not find your session lookup. Export it from lib/frankie-auth.ts:
684
1001
  // export async function getCurrentUser(): Promise<{ id: string; name?: string; email?: string } | null>
685
1002
  // then import it here in place of this stub.
@@ -707,8 +1024,7 @@ export async function POST(${auth?.handlerParam ?? ""}) {
707
1024
  }
708
1025
  `);
709
1026
  }
710
- if (!files[healthPath]) {
711
- out.set(healthPath, `import { NextResponse } from "next/server";
1027
+ out.set(healthPath, `import { NextResponse } from "next/server";
712
1028
 
713
1029
  export const dynamic = "force-dynamic";
714
1030
 
@@ -717,88 +1033,128 @@ export async function GET() {
717
1033
  {
718
1034
  frankie: true,
719
1035
  publishableKey: Boolean(process.env.NEXT_PUBLIC_FRANKIE_KEY),
1036
+ agents: {
1037
+ ${personaIds.map((id) => ` ${JSON.stringify(id)}: Boolean(process.env.${personaEnvKey(id)}),`).join("\n")}
1038
+ },
720
1039
  jwtSecret: Boolean(process.env.FRANKIE_JWT_SECRET),
721
1040
  auth: ${JSON.stringify(authStrategy)},
722
- chatPath: ${JSON.stringify(chatPath)},
723
1041
  },
724
1042
  { headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } },
725
1043
  );
726
1044
  }
727
1045
  `);
728
- }
729
- let doc;
730
- for (const [path, content] of Object.entries(files)) {
731
- if (!/(?:openapi|swagger)[^/]*\.json$/i.test(path)) continue;
732
- try {
733
- const parsed = JSON.parse(content);
734
- if (parsed.openapi && parsed.paths) {
735
- doc = parsed;
736
- break;
1046
+ let operationCount = 0;
1047
+ const specPath = root + "public/frankie.openapi.json";
1048
+ if (product) {
1049
+ let doc;
1050
+ for (const [path, content] of Object.entries(files)) {
1051
+ if (!/(?:openapi|swagger)[^/]*\.json$/i.test(path)) continue;
1052
+ try {
1053
+ const parsed = JSON.parse(content);
1054
+ if (parsed.openapi && parsed.paths) {
1055
+ doc = parsed;
1056
+ break;
1057
+ }
1058
+ } catch {
737
1059
  }
738
- } catch {
739
1060
  }
740
- }
741
- const middlewareMatchers = nextMiddlewareMatchers(files, sourceRoot, root);
742
- const paths = {};
743
- let withheld = 0;
744
- for (const [file, content] of Object.entries(files)) {
745
- if (/\.(test|spec)\.[tj]sx?$/.test(file)) continue;
746
- const nextRoute = file.match(/(?:^|\/)app(\/.*)\/route\.[tj]s$/);
747
- if (nextRoute) {
748
- const path = nextRoute[1].replace(/\/\([^/]+\)/g, "").replace(/\[([^\]]+)\]/g, "{$1}");
749
- if (path.includes("...") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) continue;
750
- const guarded = matchesAnyMatcher(middlewareMatchers, path) || IN_HANDLER_AUTH_GUARD.test(content);
751
- for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
752
- if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content)) {
753
- if (!addInstallOperation(paths, method, path, file, guarded)) withheld += 1;
1061
+ const middlewareMatchers = nextMiddlewareMatchers(files, sourceRoot, root);
1062
+ const paths = {};
1063
+ let withheld = 0;
1064
+ for (const [file, content] of Object.entries(files)) {
1065
+ if (/\.(test|spec)\.[tj]sx?$/.test(file)) continue;
1066
+ const nextRoute = file.match(/(?:^|\/)app(\/.*)\/route\.[tj]s$/);
1067
+ if (nextRoute) {
1068
+ const path = nextRoute[1].replace(/\/\([^/]+\)/g, "").replace(/\[([^\]]+)\]/g, "{$1}");
1069
+ if (path.includes("...") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) continue;
1070
+ const guarded = matchesAnyMatcher(middlewareMatchers, path) || IN_HANDLER_AUTH_GUARD.test(content);
1071
+ for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
1072
+ if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content)) {
1073
+ if (!addInstallOperation(paths, method, path, file, guarded)) withheld += 1;
1074
+ }
754
1075
  }
1076
+ } else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
1077
+ for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["'](\s*,\s*[A-Za-z_$][\w$]*\s*,)?/g))
1078
+ if (!addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file, Boolean(match[3]))) withheld += 1;
755
1079
  }
756
- } else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
757
- for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["'](\s*,\s*[A-Za-z_$][\w$]*\s*,)?/g))
758
- if (!addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file, Boolean(match[3]))) withheld += 1;
759
1080
  }
1081
+ doc ??= { openapi: "3.0.3", info: { title: workspace.name + " API", version: "1.0.0", description: "Generated route inventory. Review schemas, authorization, and the deployed origin before enabling tools." }, paths };
1082
+ for (const [path, item] of Object.entries(paths)) doc.paths[path] = { ...item, ...doc.paths[path] };
1083
+ operationCount = Object.values(doc.paths).reduce((n, item) => n + Object.keys(item).filter((m) => /^(get|post|put|patch|delete)$/.test(m)).length, 0);
1084
+ out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
1085
+ if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
1086
+ if (withheld) notes.push(`Withheld ${withheld} newly discovered write operation(s) with no detected authorization guard (no Express/Fastify middleware argument, Next.js middleware.ts matcher covering the route, or in-handler auth check). Add them to the spec yourself once they're protected.`);
1087
+ notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
1088
+ notes.push("No public spec endpoint is required: Frankie connects this committed spec on install and reads later changes from your repository through the GitHub connection.");
1089
+ notes.push("The token endpoint issues a signed user JWT, but your product API must validate it and enforce user/resource permissions before exposing tools.");
1090
+ } else {
1091
+ const envKey = personaEnvKey(input.persona.id);
1092
+ notes.push(`${plainText(input.persona.label, 60)} turns on when ${envKey} is set in each frontend environment. Its pages come from Frankie at runtime, so changing them later needs no new pull request.`);
760
1093
  }
761
- doc ??= { openapi: "3.0.3", info: { title: workspace.name + " API", version: "1.0.0", description: "Generated route inventory. Review schemas, authorization, and the deployed origin before enabling tools." }, paths };
762
- for (const [path, item] of Object.entries(paths)) doc.paths[path] = { ...item, ...doc.paths[path] };
763
- const specPath = root + "public/frankie.openapi.json";
764
- const operationCount = Object.values(doc.paths).reduce((n, item) => n + Object.keys(item).filter((m) => /^(get|post|put|patch|delete)$/.test(m)).length, 0);
765
- out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
766
- if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
767
- if (withheld) notes.push(`Withheld ${withheld} newly discovered write operation(s) with no detected authorization guard (no Express/Fastify middleware argument, Next.js middleware.ts matcher covering the route, or in-handler auth check). Add them to the spec yourself once they're protected.`);
768
- notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
769
- notes.push("No public spec endpoint is required: Frankie connects this committed spec on install and reads later changes from your repository through the GitHub connection.");
770
- notes.push("The token endpoint issues a signed user JWT, but your product API must validate it and enforce user/resource permissions before exposing tools.");
771
1094
  const envPath = root + ".env.example";
772
- const env = (files[envPath] ?? "").replace(/^NEXT_PUBLIC_FRANKIE_KEY=.*\n?/gm, "").replace(/^FRANKIE_JWT_SECRET=.*\n?/gm, "").trimEnd();
773
- out.set(envPath, env + "\n# Public at runtime, but inject it through your deployment environment instead of committing a workspace value.\nNEXT_PUBLIC_FRANKIE_KEY=\n" + (workspace.allowAnonymous ? "" : "# Server only: use the secret from this workspace. Never commit its value.\nFRANKIE_JWT_SECRET=\n"));
774
- out.set(root + "FRANKIE_SETUP.md", `# Frankie setup
1095
+ const keyLine = /^(?:NEXT_PUBLIC_FRANKIE_KEY(?:_[A-Z0-9_]+)?|FRANKIE_JWT_SECRET)=.*$/;
1096
+ const kept = [];
1097
+ let inBlock = false;
1098
+ for (const line of (files[envPath] ?? "").split("\n")) {
1099
+ if (line === ENV_BLOCK_START) {
1100
+ inBlock = true;
1101
+ continue;
1102
+ }
1103
+ if (line === ENV_BLOCK_END) {
1104
+ inBlock = false;
1105
+ continue;
1106
+ }
1107
+ if (inBlock || keyLine.test(line) || LEGACY_ENV_COMMENTS.includes(line)) continue;
1108
+ kept.push(line);
1109
+ }
1110
+ const before = kept.join("\n").trimEnd();
1111
+ const block = [
1112
+ ENV_BLOCK_START,
1113
+ "# Publishable keys are public at runtime, but set them in your deployment environment instead of committing values. An agent stays off until its key is set.",
1114
+ "NEXT_PUBLIC_FRANKIE_KEY=",
1115
+ ...personaIds.map((id) => personaEnvKey(id) + "="),
1116
+ ...anonymous ? [] : ["# Server only: the secret from your Frankie workspace. Never commit its value or expose it to the browser.", "FRANKIE_JWT_SECRET="],
1117
+ ENV_BLOCK_END
1118
+ ].join("\n");
1119
+ out.set(envPath, (before ? before + "\n\n" : "") + block + "\n");
1120
+ if (product) {
1121
+ const keys = ["NEXT_PUBLIC_FRANKIE_KEY", ...personaIds.map(personaEnvKey)];
1122
+ out.set(root + "FRANKIE_SETUP.md", `# Frankie setup
775
1123
 
776
- Workspace: ${workspace.name}
1124
+ Workspace: ${plainText(workspace.name)}
777
1125
 
778
- 1. Copy NEXT_PUBLIC_FRANKIE_KEY from Frankie \u2192 Workspace \u2192 Developer details into the frontend deployment environment. Frankie deliberately leaves the value blank in this PR.
779
- ${workspace.allowAnonymous ? "" : "2. Add FRANKIE_JWT_SECRET from this workspace to the server hosting /api/frankie/token. Never commit the value.\n"}3. Run your package manager and commit the updated lockfile (for pnpm: pnpm install --lockfile-only). The remote GitHub installer cannot safely synthesize a lockfile.
780
- 4. Nothing to expose: this PR commits your spec to the repository, Frankie connects it for you on install, and later spec changes are read from your repository through the GitHub connection \u2014 no public spec endpoint or middleware bypass is required.
1126
+ 1. Copy NEXT_PUBLIC_FRANKIE_KEY from Frankie \u2192 Workspace \u2192 Developer details into every frontend deployment environment (Preview and Production). Frankie deliberately leaves the value blank in this PR.
1127
+ ${anonymous ? "" : "2. Add FRANKIE_JWT_SECRET from this workspace to the server hosting /api/frankie/token only. Never commit the value or expose it to the browser.\n"}3. Run your package manager and commit the updated lockfile (for pnpm: pnpm install --lockfile-only). The remote GitHub installer cannot safely synthesize a lockfile.
1128
+ 4. Nothing to expose: this PR commits your spec to the repository, Frankie connects it for you on install, and later spec changes are read from your repository through the GitHub connection.
781
1129
  5. Open ${chatPath}. It is created inside the detected authenticated dashboard group when one exists; add its link to the host sidebar during review.
782
- 6. In Frankie \u2192 Tools & MCP, approve the operations you want and choose access limits (the spec is already connected from this PR; it includes read and write routes). After merging later spec changes, use GitHub integration \u2192 Refresh Frankie from merged spec.
1130
+ 6. In Frankie \u2192 Tools & MCP, approve the operations you want and choose access limits. After merging later spec changes, use GitHub integration \u2192 Refresh Frankie from merged spec.
783
1131
  7. Forwarding sends the Frankie-signed JWT. Your API must validate it and enforce user/resource permissions. It is not automatically interchangeable with a Firebase or OAuth access token.
784
- 8. Add NEXT_PUBLIC_FRANKIE_KEY to every frontend deployment environment (Preview and Production). ${workspace.allowAnonymous ? "" : "Add FRANKIE_JWT_SECRET only to the server environment running /api/frankie/token; never expose it to the browser.\n"}9. Review conversations in Workspace \u2192 Chat sessions.
1132
+
1133
+ ## More agents
1134
+
1135
+ Every Frankie agent mounts from \`frankie/FrankieAgents.tsx\` in the root layout. To turn on another agent (Support, Docs), set its key in the deployment \u2014 no code change:
1136
+
1137
+ ${keys.map((key) => "- `" + key + "`").join("\n")}
1138
+
1139
+ Each agent shows on the pages chosen for it in Frankie; those are read at runtime.
785
1140
 
786
1141
  ## Remaining setup
787
1142
 
788
1143
  ${notes.map((n) => "- " + n).join("\n")}
789
1144
  `);
790
- const planned = [...out].map(([path, content]) => ({ path, content }));
1145
+ }
1146
+ const planned = [...out].filter(([path, content]) => files[path] !== content).map(([path, content]) => ({ path, content }));
791
1147
  assertNoCommittedFrankieKeys(planned);
792
- return { files: planned, root, chatPath, specPath, operationCount, needsAuthSetup, authStrategy, notes };
1148
+ return { files: planned, deletions, root, chatPath, specPath, operationCount, needsAuthSetup, authStrategy, notes };
793
1149
  }
794
1150
  var GET_CURRENT_USER = /export\s+(?:async\s+function\s+getCurrentUser\b|(?:const|let)\s+getCurrentUser\b|\{[^}]*\bgetCurrentUser\b[^}]*\})/;
795
1151
  var CUSTOM_AUTH_MODULES = ["lib/frankie-auth", "frankie/auth", "lib/auth", "lib/session", "lib/current-user", "lib/user", "server/auth", "auth"];
796
- function detectAuth(files, sourceRoot, deps) {
1152
+ function detectAuth(files, sourceRoot, deps, ref) {
797
1153
  for (const module2 of CUSTOM_AUTH_MODULES) {
798
1154
  for (const ext of [".ts", ".tsx", ".js"]) {
799
1155
  const source = files[sourceRoot + module2 + ext];
800
1156
  if (source && GET_CURRENT_USER.test(source)) {
801
- return { strategy: "custom", lookup: `import { getCurrentUser } from "@/${module2}";
1157
+ return { strategy: "custom", lookup: `import { getCurrentUser } from "${ref(module2)}";
802
1158
  ` };
803
1159
  }
804
1160
  }
@@ -807,8 +1163,8 @@ function detectAuth(files, sourceRoot, deps) {
807
1163
  return {
808
1164
  strategy: "firebase",
809
1165
  lookup: `import { cookies } from "next/headers";
810
- import { getAdminAuth } from "@/lib/firebase-admin";
811
- import { SESSION_COOKIE } from "@/lib/session";
1166
+ import { getAdminAuth } from "${ref("lib/firebase-admin")}";
1167
+ import { SESSION_COOKIE } from "${ref("lib/session")}";
812
1168
  async function getCurrentUser() {
813
1169
  const cookie = (await cookies()).get(SESSION_COOKIE)?.value;
814
1170
  if (!cookie) return null;
@@ -822,7 +1178,7 @@ async function getCurrentUser() {
822
1178
  if (authFile?.includes("NextAuth") && /export\s+(?:const|let)\s*\{[^}]*\bauth\b/.test(authFile)) {
823
1179
  return {
824
1180
  strategy: "nextauth",
825
- lookup: `import { auth } from "@/auth";
1181
+ lookup: `import { auth } from "${ref("auth")}";
826
1182
  async function getCurrentUser() {
827
1183
  const session = await auth();
828
1184
  const user = session?.user as { id?: string; name?: string | null; email?: string | null } | undefined;
@@ -900,6 +1256,107 @@ function matchesAnyMatcher(matchers, path) {
900
1256
  return new RegExp(`^${regexBody}$`).test(path);
901
1257
  });
902
1258
  }
1259
+ function insertImport(source, importLine) {
1260
+ const skip = [/^\s+/, /^\/\/[^\n]*/, /^\/\*[\s\S]*?\*\//];
1261
+ const keep = [/^["'][^"'\n]*["'][ \t]*;?/, /^import\s+(?:type\s+)?(?:[^"'`;]*?\sfrom\s*)?["'][^"'\n]+["'][ \t]*;?/];
1262
+ let at = 0;
1263
+ let afterPrologue = 0;
1264
+ let afterComments = 0;
1265
+ for (; ; ) {
1266
+ const rest = source.slice(at);
1267
+ const skipped = skip.map((pattern) => rest.match(pattern)?.[0]).find((hit) => hit);
1268
+ if (skipped) {
1269
+ at += skipped.length;
1270
+ if (!/^\s+$/.test(skipped) && afterPrologue === 0) afterComments = at;
1271
+ continue;
1272
+ }
1273
+ const kept = keep.map((pattern) => rest.match(pattern)?.[0]).find((hit) => hit);
1274
+ if (!kept) break;
1275
+ at += kept.length;
1276
+ afterPrologue = at;
1277
+ }
1278
+ const end = afterPrologue || afterComments;
1279
+ if (end === 0) return `${importLine}
1280
+
1281
+ ${source}`;
1282
+ return `${source.slice(0, end)}
1283
+ ${importLine}${source.slice(end)}`;
1284
+ }
1285
+ function mountWidget(layout, component = "FrankieWidgetMount") {
1286
+ const mount = `<${component} />`;
1287
+ for (const tag of ["</body>", "</main>"]) {
1288
+ const at = layout.indexOf(tag);
1289
+ if (at === -1) continue;
1290
+ const lineStart = layout.lastIndexOf("\n", at) + 1;
1291
+ const lead = layout.slice(lineStart, at);
1292
+ if (!/^[ \t]*$/.test(lead)) return `${layout.slice(0, at)}${mount}${layout.slice(at)}`;
1293
+ const unit = lead.includes(" ") ? " " : " ";
1294
+ return `${layout.slice(0, lineStart)}${lead}${unit}${mount}
1295
+ ${layout.slice(lineStart)}`;
1296
+ }
1297
+ return layout.includes("{children}") ? layout.replace("{children}", `{children}${mount}`) : void 0;
1298
+ }
1299
+ function withDependencies(current, added) {
1300
+ const existing = current ?? {};
1301
+ const merged = { ...existing };
1302
+ for (const [name, range] of Object.entries(added)) merged[name] ??= range;
1303
+ const names = Object.keys(existing);
1304
+ const sorted = [...names].sort((a, b) => a.localeCompare(b, "en"));
1305
+ if (names.some((name, index) => name !== sorted[index])) return merged;
1306
+ return Object.fromEntries(Object.keys(merged).sort((a, b) => a.localeCompare(b, "en")).map((name) => [name, merged[name]]));
1307
+ }
1308
+ function renderJson(value, original) {
1309
+ const indent = original?.match(/^([ \t]+)"/m)?.[1] ?? " ";
1310
+ const text = JSON.stringify(value, null, indent);
1311
+ return original === void 0 || original.endsWith("\n") ? `${text}
1312
+ ` : text;
1313
+ }
1314
+ function moduleImporter(files, root, sourceRoot) {
1315
+ const tsconfig = files[root + "tsconfig.json"];
1316
+ if (tsconfig === void 0) return (_from, module2) => `@/${module2}`;
1317
+ const alias = aliasFor(tsconfig, root, sourceRoot);
1318
+ if (alias !== void 0) return (_from, module2) => `${alias}${module2}`;
1319
+ return (from, module2) => relativeImport(from, sourceRoot + module2);
1320
+ }
1321
+ function aliasFor(tsconfig, root, sourceRoot) {
1322
+ let options;
1323
+ try {
1324
+ const parsed = JSON.parse(tsconfig.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
1325
+ options = parsed.compilerOptions;
1326
+ } catch {
1327
+ return void 0;
1328
+ }
1329
+ const paths = options?.paths;
1330
+ if (!paths || typeof paths !== "object") return void 0;
1331
+ const baseUrl = typeof options?.baseUrl === "string" ? options.baseUrl : ".";
1332
+ const target = normalizePath(sourceRoot);
1333
+ for (const [pattern, targets] of Object.entries(paths)) {
1334
+ if (!pattern.endsWith("/*") || !Array.isArray(targets)) continue;
1335
+ for (const entry of targets) {
1336
+ if (typeof entry !== "string" || !entry.endsWith("/*")) continue;
1337
+ if (normalizePath(`${root}${baseUrl}/${entry.slice(0, -1)}`) === target) return pattern.slice(0, -1);
1338
+ }
1339
+ }
1340
+ return void 0;
1341
+ }
1342
+ function normalizePath(path) {
1343
+ const out = [];
1344
+ for (const part of path.split("/")) {
1345
+ if (part === "" || part === ".") continue;
1346
+ if (part === "..") out.pop();
1347
+ else out.push(part);
1348
+ }
1349
+ return out.join("/") + "/";
1350
+ }
1351
+ function relativeImport(fromFile, module2) {
1352
+ const from = normalizePath(fromFile.slice(0, fromFile.lastIndexOf("/") + 1)).split("/").filter(Boolean);
1353
+ const to = module2.split("/").filter((part) => part !== "" && part !== ".");
1354
+ let common = 0;
1355
+ while (common < from.length && common < to.length && from[common] === to[common]) common += 1;
1356
+ const up = from.length - common;
1357
+ const rel = [...Array(up).fill(".."), ...to.slice(common)].join("/");
1358
+ return up === 0 ? `./${rel}` : rel;
1359
+ }
903
1360
 
904
1361
  // src/sdk-updates.ts
905
1362
  function sdkUpdateWorkflow(mode) {
@@ -1139,10 +1596,185 @@ function compareApiDocuments(before, after) {
1139
1596
  unchangedIds: [...next].filter(([key, value]) => old.get(key)?.fingerprint === value.fingerprint).map(([, value]) => value.id)
1140
1597
  };
1141
1598
  }
1599
+
1600
+ // src/theme-infer.ts
1601
+ var hex2 = (value) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
1602
+ var toHex = (r, g, b) => "#" + hex2(r) + hex2(g) + hex2(b);
1603
+ function hslToHex(h, s, l) {
1604
+ const sat = s / 100, light = l / 100;
1605
+ const k = (n) => (n + h / 30) % 12;
1606
+ const a = sat * Math.min(light, 1 - light);
1607
+ const f = (n) => light - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1608
+ return toHex(f(0) * 255, f(8) * 255, f(4) * 255);
1609
+ }
1610
+ function oklchToHex(l, c, hDeg) {
1611
+ const h = hDeg * Math.PI / 180;
1612
+ const a = c * Math.cos(h), b = c * Math.sin(h);
1613
+ const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
1614
+ const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
1615
+ const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
1616
+ const linear = [
1617
+ 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
1618
+ -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
1619
+ -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_
1620
+ ];
1621
+ const gamma = (x) => (x <= 31308e-7 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055) * 255;
1622
+ return toHex(gamma(linear[0]), gamma(linear[1]), gamma(linear[2]));
1623
+ }
1624
+ var NUMBER = String.raw`-?\d*\.?\d+`;
1625
+ var parts = (body) => body.replace(/\/.*$/, "").split(/[\s,]+/).filter(Boolean);
1626
+ function normalizeColor(raw) {
1627
+ if (raw.length > 200) return void 0;
1628
+ const value = raw.trim().replace(/\s*!important$/, "").toLowerCase();
1629
+ let match = value.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
1630
+ if (match) {
1631
+ const digits = match[1];
1632
+ return digits.length <= 4 ? "#" + [...digits.slice(0, 3)].map((d) => d + d).join("") : "#" + digits.slice(0, 6);
1633
+ }
1634
+ match = value.match(/^rgba?\(([^)]*)\)$/);
1635
+ if (match) {
1636
+ const [r, g, b] = parts(match[1]).map((part) => part.endsWith("%") ? parseFloat(part) * 2.55 : parseFloat(part));
1637
+ return [r, g, b].every((n) => Number.isFinite(n)) ? toHex(r, g, b) : void 0;
1638
+ }
1639
+ match = value.match(/^hsla?\(([^)]*)\)$/) ?? value.match(new RegExp(`^(${NUMBER}(?:deg)?\\s+${NUMBER}%\\s+${NUMBER}%)$`));
1640
+ if (match) {
1641
+ const [h, s, l] = parts(match[1]).map((part) => parseFloat(part));
1642
+ return [h, s, l].every((n) => Number.isFinite(n)) ? hslToHex(h, s, l) : void 0;
1643
+ }
1644
+ match = value.match(/^oklch\(([^)]*)\)$/);
1645
+ if (match) {
1646
+ const [lRaw, cRaw, hRaw] = parts(match[1]);
1647
+ const l = lRaw?.endsWith("%") ? parseFloat(lRaw) / 100 : parseFloat(lRaw ?? "");
1648
+ const c = cRaw?.endsWith("%") ? parseFloat(cRaw) / 100 * 0.4 : parseFloat(cRaw ?? "");
1649
+ const h = hRaw === void 0 || hRaw === "none" ? 0 : parseFloat(hRaw);
1650
+ return [l, c, h].every((n) => Number.isFinite(n)) ? oklchToHex(l, c, h) : void 0;
1651
+ }
1652
+ return void 0;
1653
+ }
1654
+ function cssBlocks(source) {
1655
+ const blocks = [];
1656
+ let segmentStart = 0;
1657
+ for (let at = 0; at < source.length; ) {
1658
+ const open = source.indexOf("{", at);
1659
+ if (open === -1) break;
1660
+ const nextOpen = source.indexOf("{", open + 1);
1661
+ const close = source.indexOf("}", open + 1);
1662
+ if (close === -1) break;
1663
+ if (nextOpen !== -1 && nextOpen < close) {
1664
+ segmentStart = open + 1;
1665
+ at = open + 1;
1666
+ continue;
1667
+ }
1668
+ blocks.push([source.slice(segmentStart, open), source.slice(open + 1, close)]);
1669
+ segmentStart = close + 1;
1670
+ at = close + 1;
1671
+ }
1672
+ return blocks;
1673
+ }
1674
+ function customProperty(declaration) {
1675
+ const colon = declaration.indexOf(":");
1676
+ if (colon === -1) return void 0;
1677
+ const name = declaration.slice(0, colon).trim();
1678
+ const value = declaration.slice(colon + 1).trim();
1679
+ return name.length <= 100 && value.length <= 200 && /^--[\w-]+$/.test(name) && value ? [name, value] : void 0;
1680
+ }
1681
+ function rootVariables(css) {
1682
+ const vars = /* @__PURE__ */ new Map();
1683
+ const source = stripComments(css);
1684
+ for (const [rawSelector, body] of cssBlocks(source)) {
1685
+ const selector = rawSelector.split(";").pop().trim();
1686
+ if (selector.length > 1e3) continue;
1687
+ if (!/(?:^|[\s,]):root\b(?![^,]*\.dark)|^@theme\b/.test(selector) || /\.dark|\[data-theme/.test(selector)) continue;
1688
+ for (const declaration of body.split(";")) {
1689
+ const found = customProperty(declaration);
1690
+ if (found && !vars.has(found[0])) vars.set(found[0], found[1]);
1691
+ }
1692
+ }
1693
+ return vars;
1694
+ }
1695
+ function stripComments(css) {
1696
+ let out = "";
1697
+ for (let at = 0; ; ) {
1698
+ const start = css.indexOf("/*", at);
1699
+ if (start === -1) return out + css.slice(at);
1700
+ out += css.slice(at, start);
1701
+ const end = css.indexOf("*/", start + 2);
1702
+ if (end === -1) return out;
1703
+ at = end + 2;
1704
+ }
1705
+ }
1706
+ function resolve(value, vars, depth = 0) {
1707
+ if (depth > 4) return void 0;
1708
+ const reference = value.match(/^(?:(hsla?|oklch|rgba?)\(\s*)?var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)\s*\)?$/i);
1709
+ if (!reference) return normalizeColor(value);
1710
+ const inner = vars.get(reference[2]);
1711
+ if (!inner) return void 0;
1712
+ const wrapper = reference[1]?.toLowerCase();
1713
+ return wrapper && !/^\s*(?:#|[a-z]+\()/i.test(inner) ? normalizeColor(wrapper + "(" + inner + ")") : resolve(inner, vars, depth + 1);
1714
+ }
1715
+ function radiusOption(value) {
1716
+ const match = value.trim().match(/^(\d*\.?\d+)(rem|px)?$/);
1717
+ if (!match) return void 0;
1718
+ const rem = match[2] === "px" ? parseFloat(match[1]) / 16 : parseFloat(match[1]);
1719
+ return rem < 0.525 ? "0.35" : rem < 0.9 ? "0.7" : "1.1";
1720
+ }
1721
+ var CSS_PRIORITY = [/(?:^|\/)app\/globals\.css$/, /(?:^|\/)styles\/globals\.css$/, /globals\.css$/, /\.css$/];
1722
+ var PRIMARY_VARS = ["--primary", "--color-primary", "--brand", "--color-brand", "--accent", "--color-accent"];
1723
+ function rank(path) {
1724
+ const index = CSS_PRIORITY.findIndex((pattern) => pattern.test(path));
1725
+ return index === -1 ? CSS_PRIORITY.length : index;
1726
+ }
1727
+ function inferTheme(files, root = "") {
1728
+ const inRoot = (path) => path.startsWith(root) && !/(?:^|\/)(?:node_modules|dist|\.next|vendor|coverage)\//.test(path);
1729
+ const theme = {};
1730
+ const sources = [];
1731
+ const set = (field, value, path) => {
1732
+ if (!value || theme[field]) return;
1733
+ theme[field] = value;
1734
+ sources.push({ field, path });
1735
+ };
1736
+ const cssFiles = Object.keys(files).filter((path) => inRoot(path) && path.endsWith(".css")).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
1737
+ const vars = /* @__PURE__ */ new Map();
1738
+ const varPath = /* @__PURE__ */ new Map();
1739
+ for (const path of cssFiles) {
1740
+ for (const [name, value] of rootVariables(files[path])) if (!vars.has(name)) {
1741
+ vars.set(name, value);
1742
+ varPath.set(name, path);
1743
+ }
1744
+ }
1745
+ for (const name of PRIMARY_VARS) if (vars.has(name)) set("primary", resolve(vars.get(name), vars), varPath.get(name));
1746
+ for (const name of ["--background", "--color-background"]) if (vars.has(name)) set("background", resolve(vars.get(name), vars), varPath.get(name));
1747
+ for (const name of ["--foreground", "--color-foreground"]) if (vars.has(name)) set("foreground", resolve(vars.get(name), vars), varPath.get(name));
1748
+ if (vars.has("--radius")) set("radius", radiusOption(vars.get("--radius")), varPath.get("--radius"));
1749
+ for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)tailwind\.config\.[cm]?[jt]s$/.test(p)).sort()) {
1750
+ const source = files[path];
1751
+ for (const key of ["primary", "brand", "accent"]) {
1752
+ const direct = source.match(new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
1753
+ const nested = source.match(new RegExp(`["']?${key}["']?\\s*:\\s*\\{[^}]*?DEFAULT\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
1754
+ const raw = direct?.[1] ?? nested?.[1];
1755
+ if (raw) set("primary", resolve(raw, vars), path);
1756
+ }
1757
+ }
1758
+ for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)app\/(?:\([^/]+\)\/)?layout\.tsx$/.test(p)).sort()) {
1759
+ const raw = files[path].match(/themeColor\s*:\s*(?:\[\s*\{[^}]*?color\s*:\s*)?["'`]([^"'`]+)["'`]/)?.[1];
1760
+ if (raw) set("primary", normalizeColor(raw), path);
1761
+ }
1762
+ for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)(?:manifest\.json|site\.webmanifest|manifest\.webmanifest)$/.test(p)).sort()) {
1763
+ try {
1764
+ const manifest = JSON.parse(files[path]);
1765
+ if (typeof manifest.theme_color === "string") set("primary", normalizeColor(manifest.theme_color), path);
1766
+ if (typeof manifest.background_color === "string") set("background", normalizeColor(manifest.background_color), path);
1767
+ } catch {
1768
+ }
1769
+ }
1770
+ if (!theme.primary) return void 0;
1771
+ return { theme: { ...theme, primary: theme.primary }, sources };
1772
+ }
1142
1773
  // Annotate the CommonJS export names for ESM import in node:
1143
1774
  0 && (module.exports = {
1144
1775
  FIREWORKS_MODELS,
1145
1776
  SKILL_TOOL_NAME,
1777
+ STANDARD_AGENT_IDS,
1146
1778
  STARTER_PROMPT_ICONS,
1147
1779
  activeSkills,
1148
1780
  agentConfigSchema,
@@ -1162,14 +1794,33 @@ function compareApiDocuments(before, after) {
1162
1794
  evaluateToolPolicy,
1163
1795
  featuresConfigSchema,
1164
1796
  frankieConfigSchema,
1797
+ inferTheme,
1798
+ insertImport,
1799
+ installAgentIds,
1800
+ installBranch,
1801
+ installTitle,
1802
+ knowledgeBaseSchema,
1803
+ knowledgeSourceSchema,
1165
1804
  limitsConfigSchema,
1166
1805
  matchPath,
1806
+ matchesAnyPagePattern,
1807
+ matchesInstallBranch,
1808
+ matchesPagePattern,
1167
1809
  mcpServerSchema,
1168
1810
  modelChoiceSchema,
1169
1811
  modelConfigSchema,
1812
+ moduleImporter,
1813
+ mountWidget,
1814
+ normalizeColor,
1170
1815
  operationId,
1171
1816
  parseFrankieConfig,
1817
+ personaEnvKey,
1818
+ personaInstructions,
1819
+ personaProfileSchema,
1820
+ plainText,
1172
1821
  planHostedInstall,
1822
+ renderJson,
1823
+ resolvePersonaConfig,
1173
1824
  sdkUpdateWorkflow,
1174
1825
  skillSchema,
1175
1826
  starterPromptSchema,
@@ -1178,6 +1829,7 @@ function compareApiDocuments(before, after) {
1178
1829
  toolDescriptions,
1179
1830
  toolPolicySchema,
1180
1831
  validateApiDocument,
1181
- widgetThemeSchema
1832
+ widgetThemeSchema,
1833
+ withDependencies
1182
1834
  });
1183
1835
  //# sourceMappingURL=index.cjs.map