@layers/amba 1.1.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,16 +1,55 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
- import { access, chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { access, chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { homedir, tmpdir } from "node:os";
5
6
  import { basename, dirname, join, relative, resolve } from "node:path";
6
- import { createInterface } from "node:readline";
7
7
  import { createServer } from "node:http";
8
- import { homedir, tmpdir } from "node:os";
9
8
  import open from "open";
10
9
  import { createHash, randomBytes } from "node:crypto";
10
+ import { createInterface } from "node:readline";
11
+ import { fileURLToPath } from "node:url";
11
12
  import { createWriteStream, watch } from "node:fs";
12
13
  import { spawn } from "node:child_process";
13
14
  import { build } from "esbuild";
15
+ //#region ../shared/dist/index.js
16
+ /**
17
+ * Shared email-shape validation.
18
+ *
19
+ * Lives in `@layers/amba-shared` so the API (validates inbound emails
20
+ * server-side before minting tokens / writing DB rows) and the CLI
21
+ * (validates locally before round-tripping `amba claim <email>`) share
22
+ * one canonical implementation. Previous drift between the two
23
+ * implementations caused a real BugBot finding: a >320-char email
24
+ * passed the CLI's loose regex but bounced server-side with
25
+ * `INVALID_INPUT` — confusing UX.
26
+ *
27
+ * Intentionally permissive: validates the structural shape ("no
28
+ * whitespace, has an `@`, has a dot in the domain part, ≤320 chars")
29
+ * rather than running the full RFC-5321 grammar. Most callers
30
+ * (hosted dashboard, Expo app form, CLI prompts) already validate
31
+ * client-side; anything weirder than this check will bounce at the
32
+ * upstream email provider anyway.
33
+ *
34
+ * The check exists so a 400 INVALID_INPUT surfaces before we mint a
35
+ * token / write a DB row / fire an outbound provider request — that's
36
+ * the load-bearing property, not "exactly RFC-compliant."
37
+ *
38
+ * The 320-char cap matches RFC 5321 §4.5.3.1.3 (path length, which
39
+ * includes the email + envelope wrappers). It's the conventional
40
+ * upper bound most validators converge on.
41
+ */
42
+ function isPlausibleEmail(value) {
43
+ if (typeof value !== "string") return false;
44
+ const trimmed = value.trim();
45
+ if (trimmed.length === 0 || trimmed.length > 320) return false;
46
+ const at = trimmed.indexOf("@");
47
+ if (at <= 0 || at === trimmed.length - 1) return false;
48
+ if (/\s/.test(trimmed)) return false;
49
+ if (!trimmed.slice(at + 1).includes(".")) return false;
50
+ return true;
51
+ }
52
+ //#endregion
14
53
  //#region src/_internal/shared.ts
15
54
  const DEFAULT_API_URL = "https://api.amba.dev";
16
55
  const CONSOLE_URL = "https://app.amba.dev";
@@ -246,6 +285,16 @@ function setBearerOverride(token) {
246
285
  bearerOverride = token === null || token.length === 0 ? null : token;
247
286
  }
248
287
  /**
288
+ * Read the current bearer override without consuming it. Returns
289
+ * `null` when no override is set. Used by commands that need to know
290
+ * "did the operator supply a PAT for this invocation?" — e.g. `init`
291
+ * branches on whether to bypass stored-creds and use the supplied
292
+ * token verbatim.
293
+ */
294
+ function getBearerOverride() {
295
+ return bearerOverride;
296
+ }
297
+ /**
249
298
  * Resolve the bearer token to send on the next admin API call.
250
299
  *
251
300
  * Returns the override (PAT or JWT supplied via flag/env) when set,
@@ -352,6 +401,14 @@ async function listProjects() {
352
401
  async function createProject(input) {
353
402
  return request("POST", "/projects", input);
354
403
  }
404
+ /**
405
+ * PATCH /admin/projects/:projectId — update mutable fields. Server
406
+ * silently ignores unknown keys; we filter to the documented allow-list
407
+ * before sending so a typo at the CLI doesn't pass the wire silently.
408
+ */
409
+ async function updateProject(projectId, patch) {
410
+ return request("PATCH", `/projects/${projectId}`, patch);
411
+ }
355
412
  async function getProject(projectId) {
356
413
  return request("GET", `/projects/${projectId}`);
357
414
  }
@@ -621,193 +678,6 @@ async function validateApiKey(apiKey) {
621
678
  };
622
679
  }
623
680
  //#endregion
624
- //#region src/context-files.ts
625
- /**
626
- * Generate AMBA.md project context file for AI agents.
627
- */
628
- function generateAmbaMarkdown(opts) {
629
- const sdkPackage = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
630
- const providerExample = opts.framework === "expo" ? `
631
- ### Client Setup
632
-
633
- \`\`\`tsx
634
- // app/_layout.tsx
635
- import { useEffect } from 'react';
636
- import { Slot } from 'expo-router';
637
- import { Amba } from '@layers/amba-expo';
638
-
639
- export default function RootLayout() {
640
- useEffect(() => {
641
- Amba.configure({
642
- projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
643
- apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
644
- });
645
- }, []);
646
-
647
- return <Slot />;
648
- }
649
- \`\`\`
650
-
651
- ### Using the Client
652
-
653
- \`\`\`tsx
654
- import { Amba } from '@layers/amba-expo';
655
-
656
- export default function MyComponent() {
657
- const onPress = async () => {
658
- // Track an event
659
- await Amba.events.track('lesson_completed', { lesson_id: '123' });
660
-
661
- // Sign in with Apple (requires expo-apple-authentication)
662
- await Amba.signInWithApple();
663
-
664
- // Read remote config
665
- const showBanner = await Amba.config.fetch();
666
-
667
- // Email sign-in
668
- await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
669
- };
670
-
671
- // ...
672
- }
673
- \`\`\`` : `
674
- ### Client Setup
675
-
676
- \`\`\`typescript
677
- import { Amba } from '${sdkPackage}';
678
-
679
- await Amba.configure({
680
- projectId: process.env.AMBA_PROJECT_ID!,
681
- apiKey: process.env.AMBA_API_KEY!,
682
- });
683
-
684
- // Track an event
685
- await Amba.events.track('page_viewed', { page: '/pricing' });
686
-
687
- // Read remote config
688
- const config = await Amba.config.fetch();
689
-
690
- // Email sign-in
691
- await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
692
- \`\`\``;
693
- return `# Amba Project Context
694
-
695
- > This file provides context about the Amba integration for AI coding agents.
696
-
697
- ## Project Info
698
-
699
- | Key | Value |
700
- |-----|-------|
701
- | Project ID | \`${opts.projectId}\` |
702
- | Project Name | ${opts.projectName} |
703
- | Framework | ${opts.framework} |
704
- | SDK | \`${sdkPackage}\` |
705
-
706
- ## Environment Variables
707
-
708
- These are configured in \`.env.local\`:
709
-
710
- - \`AMBA_PROJECT_ID\` — Your project identifier
711
- - \`AMBA_API_KEY\` — Client API key (safe for client-side use)
712
- - \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
713
-
714
- ## SDK Usage
715
- ${providerExample}
716
-
717
- ## Available Features
718
-
719
- - **Push Notifications** — Send targeted push notifications to user segments
720
- - **Remote Config** — Key-value configuration that updates without app releases
721
- - **Segments** — Group users by behavior, properties, or entitlements
722
- - **Streaks** — Track user engagement streaks (daily, weekly)
723
- - **Content Libraries** — Scheduled content delivery (daily tips, weekly challenges)
724
- - **Entitlements** — Subscription status via RevenueCat integration
725
- - **Analytics** — DAU, MAU, retention, and custom event tracking
726
-
727
- ## API Reference
728
-
729
- - Admin API: \`https://api.amba.dev/v1/admin\`
730
- - Client API: \`https://api.amba.dev/v1/client\`
731
- - Docs: \`https://docs.amba.dev\`
732
-
733
- ## CLI Commands
734
-
735
- \`\`\`bash
736
- amba status # Check project health
737
- amba push test # Send a test push notification
738
- amba config list # List remote config values
739
- amba config set <key> <value> # Set a config value
740
- \`\`\`
741
- `;
742
- }
743
- /**
744
- * Generate .cursor/rules/amba.mdc Cursor rules file.
745
- */
746
- function generateCursorRules(opts) {
747
- const sdk = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
748
- return `---
749
- description: Rules for working with the Amba SDK in this project
750
- globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
751
- ---
752
-
753
- # Amba SDK Rules
754
-
755
- ## Project Setup
756
- - Project ID: \`${opts.projectId}\`
757
- - SDK: \`${sdk}\`
758
- - API URL: \`https://api.amba.dev\`
759
-
760
- ## Environment Variables
761
- - Always read Amba config from environment variables, never hardcode
762
- - Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
763
- - The .env.local file contains the project credentials
764
-
765
- ## SDK Patterns
766
- ${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
767
- - Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
768
- - The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
769
- - Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
770
- - Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
771
- - Use \`Amba.client.track()\` for all engagement events
772
- - Use \`Amba.client.config.get()\` for remote configuration
773
- - Use \`Amba.client.auth\` for sign-up / sign-in flows`}
774
-
775
- ## Push Notifications
776
- - Register push tokens via the SDK \`registerPushToken()\` method
777
- - Handle notification payloads using the SDK's notification listener
778
- - Don't implement custom push token management
779
-
780
- ## Remote Config
781
- - Use remote config for feature flags and dynamic values
782
- - Always provide sensible defaults when reading config values
783
- - Config values are cached — don't fetch on every render
784
-
785
- ## Streaks
786
- - Streaks are server-managed; the SDK provides read-only access
787
- - Use \`track()\` to record qualifying events — the server evaluates streaks
788
- - Show streak state from \`streak.current()\`, don't calculate manually
789
-
790
- ## Best Practices
791
- - Don't store Amba API keys in source code or commit them to git
792
- - Use \`.env.local\` for local development credentials
793
- - The client API key (prefixed \`amb_dev_ck_\` or \`amb_live_ck_\`) is safe for client-side use
794
- - Server keys (prefixed \`amb_dev_sk_\` or \`amb_live_sk_\`) must stay server-side only
795
- `;
796
- }
797
- /**
798
- * Write both context files to the project directory.
799
- */
800
- async function generateContextFiles(opts) {
801
- const files = [];
802
- await writeFile(join(opts.cwd, "AMBA.md"), generateAmbaMarkdown(opts), "utf-8");
803
- files.push("AMBA.md");
804
- const cursorDir = join(opts.cwd, ".cursor", "rules");
805
- await mkdir(cursorDir, { recursive: true });
806
- await writeFile(join(cursorDir, "amba.mdc"), generateCursorRules(opts), "utf-8");
807
- files.push(".cursor/rules/amba.mdc");
808
- return files;
809
- }
810
- //#endregion
811
681
  //#region src/sandbox.ts
812
682
  /**
813
683
  * Headless agentic sandbox bootstrap.
@@ -934,60 +804,9 @@ async function performSandboxSignup(req, options = {}) {
934
804
  api_url: apiUrl,
935
805
  provisioning_status: data.project.provisioning_status,
936
806
  verify_url: data.project.verify_url,
937
- email: req.email
938
- };
939
- }
940
- /**
941
- * Write the PAT to `~/.amba/credentials.json` (chmod 0600) in a shape
942
- * the existing `loadCredentials` reader recognises.
943
- *
944
- * `auth.ts` was built around browser-OAuth tokens (`access_token` +
945
- * `refresh_token` + `expires_at`). PATs are long-lived and don't refresh
946
- * — but the stored-creds reader only inspects `access_token`, so we
947
- * write the PAT there and leave `refresh_token` empty + a far-future
948
- * `expires_at` so the expiry guard never fires.
949
- *
950
- * Real-credential safety: if the file already exists AND its `source`
951
- * is NOT `'sandbox-init'` AND `access_token` is non-empty, we treat it
952
- * as a real OAuth/PAT session and back it up to
953
- * `credentials.json.bak-<unix-ms>` before overwriting. The next
954
- * `--sandbox` run reuses our own previous sandbox creds without
955
- * back-up. This keeps the agentic flow idempotent while preventing a
956
- * silent clobber of a developer's real account.
957
- */
958
- async function writeSandboxCredentials(pat, options = {}) {
959
- const dir = join(options.homeDir ?? homedir(), ".amba");
960
- const path = join(dir, "credentials.json");
961
- await mkdir(dir, { recursive: true });
962
- let backedUpTo = null;
963
- try {
964
- const existingRaw = await readFile(path, "utf-8");
965
- const existing = JSON.parse(existingRaw);
966
- const hasToken = typeof existing.access_token === "string" && existing.access_token.length > 0;
967
- const isSandboxOwned = existing.source === "sandbox-init";
968
- if (hasToken && !isSandboxOwned) {
969
- backedUpTo = `${path}.bak-${Date.now()}`;
970
- await writeFile(backedUpTo, existingRaw, "utf-8");
971
- try {
972
- await chmod(backedUpTo, 384);
973
- } catch {}
974
- }
975
- } catch (err) {
976
- if (!isEnoent(err)) {}
977
- }
978
- const payload = {
979
- access_token: pat,
980
- refresh_token: "",
981
- expires_at: (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString(),
982
- source: "sandbox-init"
983
- };
984
- await writeFile(path, JSON.stringify(payload, null, 2), "utf-8");
985
- try {
986
- await chmod(path, 384);
987
- } catch {}
988
- return {
989
- path,
990
- backedUpTo
807
+ email: req.email,
808
+ developer_id: data.developer?.id ?? "",
809
+ developer_name: data.developer?.name
991
810
  };
992
811
  }
993
812
  /**
@@ -996,23 +815,30 @@ async function writeSandboxCredentials(pat, options = {}) {
996
815
  * Mirrors the `init` interactive flow exactly so the existing env-read
997
816
  * conventions in the SDKs and CLI commands keep working. The merge
998
817
  * logic: if the file already exists and contains an `AMBA_PROJECT_ID`
999
- * line we replace the three Amba lines in place; otherwise we append a
818
+ * line we replace the Amba lines in place; otherwise we append a
1000
819
  * fresh stanza.
820
+ *
821
+ * `serverKey` is optional — pass it on the new two-scope credential
822
+ * model where init mints both client+server. Pre-existing AMBA_SERVER_KEY
823
+ * lines are refreshed when a new value is provided and removed when
824
+ * serverKey is null AND no prior line existed (no-op on second case).
1001
825
  */
1002
- async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl) {
826
+ async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl, serverKey) {
1003
827
  const envPath = join(cwd, ".env.local");
1004
- const stanza = [
828
+ const stanzaLines = [
1005
829
  "# Amba SDK configuration (sandbox tier)",
1006
830
  `AMBA_PROJECT_ID=${projectId}`,
1007
- `AMBA_CLIENT_KEY=${clientKey}`,
1008
- `AMBA_API_URL=${apiUrl}`,
1009
- ""
1010
- ].join("\n");
831
+ `AMBA_CLIENT_KEY=${clientKey}`
832
+ ];
833
+ if (serverKey) stanzaLines.push(`AMBA_SERVER_KEY=${serverKey}`);
834
+ stanzaLines.push(`AMBA_API_URL=${apiUrl}`);
835
+ stanzaLines.push("");
836
+ const stanza = stanzaLines.join("\n");
1011
837
  let existing = "";
1012
838
  try {
1013
839
  existing = await readFile(envPath, "utf-8");
1014
840
  } catch (err) {
1015
- if (!isEnoent(err)) throw err;
841
+ if (!isEnoent$1(err)) throw err;
1016
842
  }
1017
843
  if (existing.length === 0) {
1018
844
  await writeFile(envPath, stanza, "utf-8");
@@ -1030,6 +856,14 @@ async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl) {
1030
856
  } else if (hadApiKey) updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
1031
857
  else if (hadClientKey) updated = updated.replace(/^AMBA_CLIENT_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
1032
858
  else updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_CLIENT_KEY=${clientKey}\n`;
859
+ if (serverKey) if (/^AMBA_SERVER_KEY=/m.test(updated)) updated = updated.replace(/^AMBA_SERVER_KEY=.*/m, () => `AMBA_SERVER_KEY=${serverKey}`);
860
+ else {
861
+ const clientKeyMatch = updated.match(/^AMBA_CLIENT_KEY=.*\n?/m);
862
+ if (clientKeyMatch) {
863
+ const insertAt = (clientKeyMatch.index ?? 0) + clientKeyMatch[0].length;
864
+ updated = updated.slice(0, insertAt) + `AMBA_SERVER_KEY=${serverKey}\n` + updated.slice(insertAt);
865
+ } else updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_SERVER_KEY=${serverKey}\n`;
866
+ }
1033
867
  if (!/^AMBA_API_URL=/m.test(updated)) updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_API_URL=${apiUrl}\n`;
1034
868
  await writeFile(envPath, updated, "utf-8");
1035
869
  return envPath;
@@ -1069,8 +903,7 @@ without a credit card or email verification.
1069
903
 
1070
904
  The credentials live in **\`.env.local\`** (\`AMBA_PROJECT_ID\`,
1071
905
  \`AMBA_CLIENT_KEY\`, \`AMBA_API_URL\`) — gitignored by convention. Your
1072
- Personal Access Token is stored in \`~/.amba/credentials.json\` and was
1073
- written into every MCP client config we could detect.
906
+ Personal Access Token is stored in \`~/.amba/credentials.json\`.
1074
907
 
1075
908
  ## SDK quickstart
1076
909
 
@@ -1095,10 +928,11 @@ await Amba.events.track('app_opened');
1095
928
 
1096
929
  ## Upgrade past sandbox
1097
930
 
1098
- The account is unverified. To upgrade to the Free tier (and stop being
1099
- capped at 100 MAU / 10 MB DB), claim the email on the account:
1100
-
1101
- ${ctx.verifyUrl ? `1. Open ${ctx.verifyUrl}\n2. Change the email on the developer record to one you control via [app.amba.dev](https://app.amba.dev/settings).` : `1. Open [app.amba.dev](https://app.amba.dev) and request a password reset for \`${ctx.email}\` — the sandbox password was random and isn't recoverable.\n2. Change the email on the developer record to one you control.`}
931
+ This account uses an auto-generated email (\`${ctx.email}\`). Run
932
+ \`amba claim me@example.com\` to bind it to a real address — you'll get
933
+ a one-click link in your inbox that promotes the project to the Free
934
+ tier (1,000 MAU, 500 MB DB) and lets you sign in from a browser if you
935
+ ever need to.
1102
936
 
1103
937
  ## Useful commands
1104
938
 
@@ -1129,42 +963,6 @@ function buildAmbaMcpEntry(pat) {
1129
963
  };
1130
964
  }
1131
965
  /**
1132
- * Map an MCP config file path back to its client family. Returns null
1133
- * for paths that don't match any known config location — defensive
1134
- * against future additions to `mcpClientTargets`.
1135
- *
1136
- * The match is on path tail rather than full equality so the cwd /
1137
- * homedir-injected variants both classify correctly. We deliberately
1138
- * accept both global and project-local Claude Code paths
1139
- * (`.claude.json` and `.mcp.json`) as 'claude-code'.
1140
- */
1141
- function classifyMcpPath(path) {
1142
- if (path.endsWith(".claude.json") || path.endsWith(".mcp.json")) return "claude-code";
1143
- if (path.includes(`/.cursor/`) || path.includes(`\\.cursor\\`)) return "cursor";
1144
- if (path.includes("/windsurf/mcp_config.json") || path.includes("\\windsurf\\mcp_config.json")) return "windsurf";
1145
- return null;
1146
- }
1147
- /**
1148
- * Reduce a list of written-config paths to the set of unique client
1149
- * families they belong to. Order: claude-code, cursor, windsurf (so
1150
- * the done-message renders consistently). Skips unclassified paths
1151
- * silently.
1152
- */
1153
- function clientKindsFromPaths(paths) {
1154
- const present = /* @__PURE__ */ new Set();
1155
- for (const p of paths) {
1156
- const kind = classifyMcpPath(p);
1157
- if (kind) present.add(kind);
1158
- }
1159
- const ordered = [];
1160
- for (const k of [
1161
- "claude-code",
1162
- "cursor",
1163
- "windsurf"
1164
- ]) if (present.has(k)) ordered.push(k);
1165
- return ordered;
1166
- }
1167
- /**
1168
966
  * The list of MCP client config files we probe. Order matters only for
1169
967
  * the printed report.
1170
968
  *
@@ -1231,7 +1029,7 @@ async function mergeMcpConfigFile(target, pat) {
1231
1029
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) existing = parsed;
1232
1030
  else throw new Error(`Existing config at ${target.path} is not a JSON object`);
1233
1031
  } catch (err) {
1234
- if (isEnoent(err)) {
1032
+ if (isEnoent$1(err)) {
1235
1033
  if (!target.scaffoldIfMissing) return {
1236
1034
  path: null,
1237
1035
  backedUpTo: null
@@ -1300,7 +1098,7 @@ async function fileExists$1(path) {
1300
1098
  return false;
1301
1099
  }
1302
1100
  }
1303
- function isEnoent(err) {
1101
+ function isEnoent$1(err) {
1304
1102
  return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1305
1103
  }
1306
1104
  /**
@@ -1313,27 +1111,1260 @@ function formatManualMcpSnippet(pat) {
1313
1111
  return JSON.stringify({ mcpServers: { amba: buildAmbaMcpEntry(pat) } }, null, 2);
1314
1112
  }
1315
1113
  //#endregion
1316
- //#region ../mcp/dist/expo-build-prompt.js
1114
+ //#region src/credentials.ts
1317
1115
  /**
1318
- * Canonical Amba Expo build prompt — markdown body (no MDX frontmatter).
1116
+ * Two-scope credential model for `amba init`.
1319
1117
  *
1320
- * Source of truth for three customer-facing surfaces:
1118
+ * Identity is **developer-scoped** (one machine identity, persisted in
1119
+ * `~/.amba/credentials.json`). State is **project-scoped** (one per
1120
+ * project directory, persisted in `<cwd>/.amba/project.json`).
1321
1121
  *
1322
- * 1. The published docs page at
1323
- * `https://docs.amba.dev/docs/prompts/expo-build` — the MDX file at
1324
- * `apps/docs/content/docs/prompts/expo-build.mdx` ships the same
1325
- * body wrapped in fumadocs frontmatter.
1326
- * 2. The MCP resource `amba://prompts/expo-build` registered by
1327
- * `registerAllResources()` in `./index.ts` and exposed by the
1328
- * hosted MCP server at `mcp.amba.dev`.
1329
- * 3. The inlined snapshot baked into the `/amba-build` Claude Code
1330
- * skill by `amba init --sandbox` (see `packages/cli/src/skills.ts`).
1122
+ * One Amba account can own N projects. Running `amba init` in five
1123
+ * different folders under one identity yields one developer row + five
1124
+ * project rows — exactly the model `apps/console` and the API enforce.
1331
1125
  *
1332
- * Drift between this constant and the MDX file is caught by
1333
- * `expo-build-prompt.test.ts` — that test reads the MDX from disk,
1334
- * strips the YAML frontmatter, and asserts it equals `EXPO_BUILD_PROMPT_MD`.
1126
+ * Backward compatibility
1127
+ * ----------------------
1128
+ * The legacy `~/.amba/credentials.json` (browser-OAuth era) carried
1129
+ * `{ access_token, refresh_token, expires_at }`. We read both shapes —
1130
+ * a missing `version` key signals legacy and triggers a one-shot
1131
+ * in-place upgrade after the first successful `developer_me` verify.
1335
1132
  *
1336
- * **Update protocol:** edit the MDX (it's the human-facing surface;
1133
+ * Idempotency
1134
+ * -----------
1135
+ * `ensureDeveloperIdentity` + `ensureProjectForCwd` are the two entry
1136
+ * points. Both are safe to call on every `amba init` run:
1137
+ * - identity: load → verify → upgrade-or-keep; only signs up if no
1138
+ * verified PAT exists anywhere.
1139
+ * - project: load `<cwd>/.amba/project.json` → verify the
1140
+ * `project_id` still belongs to the current developer; if missing
1141
+ * or stale, mint a new project under the dev's identity.
1142
+ */
1143
+ function developerCredentialsPath(homeDir) {
1144
+ return join(homeDir ?? homedir(), ".amba", "credentials.json");
1145
+ }
1146
+ function projectCredentialsPath(cwd) {
1147
+ return join(cwd, ".amba", "project.json");
1148
+ }
1149
+ /**
1150
+ * Read `~/.amba/credentials.json`. Returns null when the file is
1151
+ * missing, malformed, or empty. Handles both new (versioned) and
1152
+ * legacy shapes — legacy returns `version: 1` after migration but
1153
+ * with `source: 'legacy'` so callers can tell.
1154
+ *
1155
+ * Does NOT verify the PAT against the API. Caller must follow up
1156
+ * with `verifyPat` before trusting the identity.
1157
+ */
1158
+ async function loadDeveloperCredentials(options = {}) {
1159
+ const path = developerCredentialsPath(options.homeDir);
1160
+ let raw;
1161
+ try {
1162
+ raw = await readFile(path, "utf-8");
1163
+ } catch (err) {
1164
+ if (isEnoent(err)) return null;
1165
+ throw err;
1166
+ }
1167
+ if (raw.trim().length === 0) return null;
1168
+ let parsed;
1169
+ try {
1170
+ parsed = JSON.parse(raw);
1171
+ } catch {
1172
+ return null;
1173
+ }
1174
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
1175
+ const obj = parsed;
1176
+ if (obj["version"] === 1) {
1177
+ const v = obj;
1178
+ if (typeof v["pat"] !== "string" || v["pat"].length === 0) return null;
1179
+ return {
1180
+ version: 1,
1181
+ developer_id: typeof v["developer_id"] === "string" ? v["developer_id"] : null,
1182
+ email: typeof v["email"] === "string" ? v["email"] : "unknown",
1183
+ pat: v["pat"],
1184
+ api_url: typeof v["api_url"] === "string" ? v["api_url"] : DEFAULT_API_URL,
1185
+ source: normalizeSource(v["source"]),
1186
+ created_at: typeof v["created_at"] === "string" ? v["created_at"] : (/* @__PURE__ */ new Date()).toISOString(),
1187
+ access_token: v["pat"],
1188
+ refresh_token: "",
1189
+ expires_at: typeof v["expires_at"] === "string" ? v["expires_at"] : (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
1190
+ };
1191
+ }
1192
+ const accessToken = obj["access_token"];
1193
+ if (typeof accessToken !== "string" || accessToken.length === 0) return null;
1194
+ const onDiskSource = normalizeSource(obj["source"]);
1195
+ return {
1196
+ version: 1,
1197
+ developer_id: null,
1198
+ email: "unknown",
1199
+ pat: accessToken,
1200
+ api_url: DEFAULT_API_URL,
1201
+ source: typeof obj["source"] === "string" ? onDiskSource : "legacy",
1202
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1203
+ access_token: accessToken,
1204
+ refresh_token: "",
1205
+ expires_at: typeof obj["expires_at"] === "string" ? obj["expires_at"] : (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
1206
+ };
1207
+ }
1208
+ function normalizeSource(value) {
1209
+ if (value === "sandbox-init" || value === "browser-auth" || value === "manual" || value === "legacy") return value;
1210
+ return "manual";
1211
+ }
1212
+ /**
1213
+ * Atomically write developer credentials to `~/.amba/credentials.json`
1214
+ * with mode 0600. Writes to a sibling `.tmp` first and renames into
1215
+ * place so a crash mid-write doesn't leave the file empty.
1216
+ *
1217
+ * Backs up an existing file when its `source` is not one of the
1218
+ * managed sources OR when the existing PAT differs from the one being
1219
+ * written. The backup goes to `credentials.json.bak-<unix-ms>`.
1220
+ */
1221
+ async function writeDeveloperCredentials(creds, options = {}) {
1222
+ const path = developerCredentialsPath(options.homeDir);
1223
+ await mkdir(join(options.homeDir ?? homedir(), ".amba"), { recursive: true });
1224
+ let backedUpTo = null;
1225
+ try {
1226
+ const existingRaw = await readFile(path, "utf-8");
1227
+ const existing = JSON.parse(existingRaw);
1228
+ const existingToken = typeof existing.pat === "string" && existing.pat.length > 0 ? existing.pat : typeof existing.access_token === "string" ? existing.access_token : "";
1229
+ if (existingToken.length > 0 && existingToken !== creds.pat) {
1230
+ backedUpTo = `${path}.bak-${Date.now()}`;
1231
+ await writeFile(backedUpTo, existingRaw, "utf-8");
1232
+ try {
1233
+ await chmod(backedUpTo, 384);
1234
+ } catch {}
1235
+ }
1236
+ } catch {}
1237
+ const tmpPath = `${path}.tmp-${Date.now()}`;
1238
+ await writeFile(tmpPath, JSON.stringify(creds, null, 2), "utf-8");
1239
+ try {
1240
+ await chmod(tmpPath, 384);
1241
+ } catch {}
1242
+ await rename(tmpPath, path);
1243
+ return {
1244
+ path,
1245
+ backedUpTo
1246
+ };
1247
+ }
1248
+ async function loadProjectCredentials(cwd) {
1249
+ const path = projectCredentialsPath(cwd);
1250
+ let raw;
1251
+ try {
1252
+ raw = await readFile(path, "utf-8");
1253
+ } catch (err) {
1254
+ if (isEnoent(err)) return null;
1255
+ throw err;
1256
+ }
1257
+ if (raw.trim().length === 0) return null;
1258
+ let parsed;
1259
+ try {
1260
+ parsed = JSON.parse(raw);
1261
+ } catch {
1262
+ return null;
1263
+ }
1264
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
1265
+ const obj = parsed;
1266
+ if (obj["version"] !== 1) return null;
1267
+ if (typeof obj["project_id"] !== "string" || obj["project_id"].length === 0) return null;
1268
+ if (typeof obj["client_key"] !== "string" || obj["client_key"].length === 0) return null;
1269
+ return {
1270
+ version: 1,
1271
+ project_id: obj["project_id"],
1272
+ project_name: typeof obj["project_name"] === "string" ? obj["project_name"] : "unknown",
1273
+ environment: obj["environment"] === "production" ? "production" : "development",
1274
+ client_key: obj["client_key"],
1275
+ server_key: typeof obj["server_key"] === "string" && obj["server_key"].length > 0 ? obj["server_key"] : null,
1276
+ api_url: typeof obj["api_url"] === "string" ? obj["api_url"] : DEFAULT_API_URL,
1277
+ wired_surfaces: Array.isArray(obj["wired_surfaces"]) ? obj["wired_surfaces"].filter((s) => typeof s === "string") : [],
1278
+ created_at: typeof obj["created_at"] === "string" ? obj["created_at"] : (/* @__PURE__ */ new Date()).toISOString(),
1279
+ updated_at: typeof obj["updated_at"] === "string" ? obj["updated_at"] : (/* @__PURE__ */ new Date()).toISOString()
1280
+ };
1281
+ }
1282
+ async function writeProjectCredentials(cwd, creds) {
1283
+ const path = projectCredentialsPath(cwd);
1284
+ await mkdir(join(cwd, ".amba"), { recursive: true });
1285
+ const tmpPath = `${path}.tmp-${Date.now()}`;
1286
+ await writeFile(tmpPath, JSON.stringify(creds, null, 2), "utf-8");
1287
+ try {
1288
+ await chmod(tmpPath, 384);
1289
+ } catch {}
1290
+ await rename(tmpPath, path);
1291
+ return path;
1292
+ }
1293
+ /**
1294
+ * Verify a PAT by calling `GET /v1/auth/developer/me`. Returns the
1295
+ * developer row on success, `null` on 401/403/404 (PAT invalid or
1296
+ * developer not found), or throws on network / 5xx errors.
1297
+ *
1298
+ * This is the single source of truth for "do we have a working
1299
+ * identity." Used at the top of every init run.
1300
+ */
1301
+ async function verifyPat(pat, options = {}) {
1302
+ const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
1303
+ const res = await (options.fetchImpl ?? fetch)(`${apiUrl}/v1/auth/developer/me`, {
1304
+ method: "GET",
1305
+ headers: {
1306
+ Authorization: `Bearer ${pat}`,
1307
+ "User-Agent": "amba-cli/credentials"
1308
+ }
1309
+ });
1310
+ if (res.status === 401 || res.status === 403 || res.status === 404) return null;
1311
+ if (!res.ok) throw new Error(`developer/me verify returned ${res.status} ${res.statusText}`);
1312
+ let raw;
1313
+ try {
1314
+ raw = await res.json();
1315
+ } catch (err) {
1316
+ const reason = err instanceof Error ? err.message : String(err);
1317
+ throw new Error(`developer/me returned 2xx but body was not JSON: ${reason}`);
1318
+ }
1319
+ if (!raw.data?.id) return null;
1320
+ return {
1321
+ id: raw.data.id,
1322
+ email: raw.data.email ?? "unknown",
1323
+ name: raw.data.name
1324
+ };
1325
+ }
1326
+ /**
1327
+ * Ensure the machine has a verified Amba developer identity.
1328
+ *
1329
+ * Decision tree:
1330
+ * 1. Load existing `~/.amba/credentials.json`.
1331
+ * 2. If found, verify the PAT via `developer/me`.
1332
+ * - Valid → migrate shape if legacy, return.
1333
+ * - Invalid → fall through to signup (unless `signupOnMissing: false`).
1334
+ * 3. No creds (or invalid) + `signupOnMissing !== false` → call
1335
+ * `performSandboxSignup` with generated email/password, write the
1336
+ * result, return.
1337
+ * 4. No creds + `signupOnMissing === false` → throw.
1338
+ */
1339
+ async function ensureDeveloperIdentity(options = {}) {
1340
+ const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
1341
+ const fetchImpl = options.fetchImpl ?? fetch;
1342
+ const existing = await loadDeveloperCredentials({ homeDir: options.homeDir });
1343
+ if (existing) {
1344
+ let verified = null;
1345
+ try {
1346
+ verified = await verifyPat(existing.pat, {
1347
+ apiUrl,
1348
+ fetchImpl
1349
+ });
1350
+ } catch {
1351
+ throw new Error(`Could not verify existing Amba credentials at ${developerCredentialsPath(options.homeDir)} — check your network and try again.`);
1352
+ }
1353
+ if (verified) {
1354
+ if (existing.source === "legacy" || existing.developer_id !== verified.id || existing.email !== verified.email) {
1355
+ const upgraded = {
1356
+ ...existing,
1357
+ version: 1,
1358
+ developer_id: verified.id,
1359
+ email: verified.email,
1360
+ api_url: apiUrl,
1361
+ source: existing.source === "legacy" ? "manual" : existing.source,
1362
+ created_at: existing.created_at
1363
+ };
1364
+ const write = await writeDeveloperCredentials(upgraded, { homeDir: options.homeDir });
1365
+ return {
1366
+ credentials: upgraded,
1367
+ newlySignedUp: false,
1368
+ developer: verified,
1369
+ firstProject: null,
1370
+ credentialsBackedUpTo: write.backedUpTo,
1371
+ credentialsPath: write.path
1372
+ };
1373
+ }
1374
+ return {
1375
+ credentials: existing,
1376
+ newlySignedUp: false,
1377
+ developer: verified,
1378
+ firstProject: null,
1379
+ credentialsBackedUpTo: null,
1380
+ credentialsPath: developerCredentialsPath(options.homeDir)
1381
+ };
1382
+ }
1383
+ }
1384
+ if (options.signupOnMissing === false) throw new Error(`No verified Amba identity at ${developerCredentialsPath(options.homeDir)} and signup-on-missing is disabled. Run \`amba login\` to authenticate.`);
1385
+ const signup = await performSandboxSignup({
1386
+ email: options.sandboxEmail?.trim() || generateSandboxEmail(),
1387
+ password: generateSandboxPassword()
1388
+ }, {
1389
+ apiUrl,
1390
+ fetchImpl
1391
+ });
1392
+ const developerId = signup.developer_id.length > 0 ? signup.developer_id : null;
1393
+ const developer = {
1394
+ id: developerId ?? "pending",
1395
+ email: signup.email,
1396
+ ...signup.developer_name ? { name: signup.developer_name } : {}
1397
+ };
1398
+ const newCreds = {
1399
+ version: 1,
1400
+ developer_id: developerId,
1401
+ email: signup.email,
1402
+ pat: signup.pat,
1403
+ api_url: signup.api_url,
1404
+ source: "sandbox-init",
1405
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1406
+ access_token: signup.pat,
1407
+ refresh_token: "",
1408
+ expires_at: (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
1409
+ };
1410
+ const write = await writeDeveloperCredentials(newCreds, { homeDir: options.homeDir });
1411
+ return {
1412
+ credentials: newCreds,
1413
+ newlySignedUp: true,
1414
+ developer,
1415
+ firstProject: {
1416
+ project_id: signup.project_id,
1417
+ client_key: signup.client_key,
1418
+ server_key: signup.server_key ?? null,
1419
+ provisioning_status: signup.provisioning_status,
1420
+ verify_url: signup.verify_url
1421
+ },
1422
+ credentialsBackedUpTo: write.backedUpTo,
1423
+ credentialsPath: write.path
1424
+ };
1425
+ }
1426
+ /**
1427
+ * Ensure the current working directory is attached to an Amba project.
1428
+ *
1429
+ * Decision tree:
1430
+ * 1. Load existing `<cwd>/.amba/project.json`.
1431
+ * - Present → return (no API call; we trust the file's metadata
1432
+ * until something downstream fails, at which point the caller
1433
+ * re-keys).
1434
+ * 2. Missing + `signupFirstProject` provided → use those keys, write
1435
+ * `<cwd>/.amba/project.json`, return (newlyCreated=true).
1436
+ * 3. Missing + no signup payload + `attachToProjectId` provided →
1437
+ * mint a new client+server key under that project, write the
1438
+ * file, return.
1439
+ * 4. Missing + no signup payload + no attach → call
1440
+ * `createProject({ name, environment })` under the dev's PAT,
1441
+ * mint both keys, write the file, return.
1442
+ */
1443
+ async function ensureProjectForCwd(cwd, options) {
1444
+ const existing = await loadProjectCredentials(cwd);
1445
+ if (existing) return {
1446
+ credentials: existing,
1447
+ newlyCreated: false
1448
+ };
1449
+ const environment = options.environment ?? "development";
1450
+ const apiUrl = process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
1451
+ setBearerOverride(options.pat);
1452
+ if (options.signupFirstProject) {
1453
+ const creds = {
1454
+ version: 1,
1455
+ project_id: options.signupFirstProject.project_id,
1456
+ project_name: options.defaultName ?? (basename(cwd) || "amba-sandbox"),
1457
+ environment,
1458
+ client_key: options.signupFirstProject.client_key,
1459
+ server_key: options.signupFirstProject.server_key,
1460
+ api_url: apiUrl,
1461
+ wired_surfaces: [],
1462
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1463
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1464
+ };
1465
+ await writeProjectCredentials(cwd, creds);
1466
+ return {
1467
+ credentials: creds,
1468
+ newlyCreated: true
1469
+ };
1470
+ }
1471
+ if (options.attachToProjectId) {
1472
+ const { clientKey, serverKey } = await mintProjectKeyPair(options.attachToProjectId, environment);
1473
+ const creds = {
1474
+ version: 1,
1475
+ project_id: options.attachToProjectId,
1476
+ project_name: options.defaultName ?? (basename(cwd) || "amba-project"),
1477
+ environment,
1478
+ client_key: clientKey,
1479
+ server_key: serverKey,
1480
+ api_url: apiUrl,
1481
+ wired_surfaces: [],
1482
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1483
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1484
+ };
1485
+ await writeProjectCredentials(cwd, creds);
1486
+ return {
1487
+ credentials: creds,
1488
+ newlyCreated: true
1489
+ };
1490
+ }
1491
+ const uniqueName = await uniqueProjectName(sanitizeProjectName(options.defaultName ?? (basename(cwd) || "amba-project")));
1492
+ const project = await createProject({
1493
+ name: uniqueName,
1494
+ environment
1495
+ });
1496
+ const { clientKey, serverKey } = await mintProjectKeyPair(project.data.id, environment);
1497
+ const creds = {
1498
+ version: 1,
1499
+ project_id: project.data.id,
1500
+ project_name: uniqueName,
1501
+ environment,
1502
+ client_key: clientKey,
1503
+ server_key: serverKey,
1504
+ api_url: apiUrl,
1505
+ wired_surfaces: [],
1506
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1507
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1508
+ };
1509
+ await writeProjectCredentials(cwd, creds);
1510
+ return {
1511
+ credentials: creds,
1512
+ newlyCreated: true
1513
+ };
1514
+ }
1515
+ async function mintProjectKeyPair(projectId, environment) {
1516
+ const clientRes = await createApiKey(projectId, "client", environment);
1517
+ const serverRes = await createApiKey(projectId, "server", environment);
1518
+ return {
1519
+ clientKey: clientRes.data.key,
1520
+ serverKey: serverRes.data.key
1521
+ };
1522
+ }
1523
+ /**
1524
+ * Pick a project name unique against the developer's current set.
1525
+ *
1526
+ * Multi-folder reality: a developer running `amba init` from
1527
+ * `~/code/fitness-app` then `~/code/fitness-app-v2` will get names
1528
+ * derived from different basenames already; the disambiguation is
1529
+ * only for the rare case where two folders end up with the same
1530
+ * basename (e.g. `~/work/fitness` and `~/personal/fitness`).
1531
+ */
1532
+ async function uniqueProjectName(base) {
1533
+ let existing;
1534
+ try {
1535
+ existing = (await listProjects()).data.map((p) => p.name);
1536
+ } catch {
1537
+ return base;
1538
+ }
1539
+ if (!existing.includes(base)) return base;
1540
+ for (let i = 2; i < 100; i += 1) {
1541
+ const candidate = `${base}-${i}`;
1542
+ if (!existing.includes(candidate)) return candidate;
1543
+ }
1544
+ return `${base}-${Date.now().toString(36)}`;
1545
+ }
1546
+ /**
1547
+ * Sanitize a candidate project name. The control-plane enforces
1548
+ * `^[a-zA-Z0-9-_]{1,64}$` (see `apps/api/src/routes/projects.ts`); the
1549
+ * basename of a project folder often contains spaces or dots. We
1550
+ * collapse runs of non-allowed chars to `-`, trim outer dashes, and
1551
+ * truncate to 64.
1552
+ */
1553
+ function sanitizeProjectName(input) {
1554
+ const collapsed = input.normalize("NFKD").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
1555
+ return collapsed.length > 0 ? collapsed : "amba-project";
1556
+ }
1557
+ function isEnoent(err) {
1558
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1559
+ }
1560
+ //#endregion
1561
+ //#region src/commands/claim.ts
1562
+ /**
1563
+ * `amba claim <email>` — bind a sandbox account to a real email address
1564
+ * via a one-click magic link.
1565
+ *
1566
+ * Sandbox accounts are minted with an auto-generated address
1567
+ * (`sandbox-<epoch>-<nonce>@layers.com`) and remain capped at 100 MAU /
1568
+ * 10 MB DB until the developer claims a real email. This command POSTs
1569
+ * the target email to `/v1/auth/developer/claim` under the developer's
1570
+ * stored PAT; the backend emails a single-use magic link that — when
1571
+ * clicked — updates the developer row and flips the project tier from
1572
+ * `sandbox` to `verified_free` (1,000 MAU, 500 MB DB).
1573
+ *
1574
+ * Wire shape:
1575
+ *
1576
+ * POST {AMBA_API_URL}/v1/auth/developer/claim
1577
+ * Authorization: Bearer {pat}
1578
+ * Content-Type: application/json
1579
+ * Body: { "email": "<target-email>" }
1580
+ *
1581
+ * Success: HTTP 200 `{ "ok": true }`
1582
+ * Errors: HTTP 400 INVALID_INPUT
1583
+ * HTTP 409 EMAIL_TAKEN — that address already owns another account
1584
+ * HTTP 409 ALREADY_CLAIMED — this account is already verified
1585
+ * HTTP 429 — rate-limited
1586
+ * HTTP 5xx — surface verbatim with code + message
1587
+ *
1588
+ * UX contract: a single ✓ line + a hint that the link expires in 15
1589
+ * minutes. No copy-paste tokens, no follow-up commands. The click in
1590
+ * the email is the whole flow.
1591
+ */
1592
+ async function claimCommand(email, options = {}) {
1593
+ console.log();
1594
+ console.log(pc.bold(" amba claim"));
1595
+ console.log(pc.dim(" ─────────────────────────────────"));
1596
+ console.log();
1597
+ const trimmed = email.trim();
1598
+ if (!isPlausibleEmail(trimmed)) {
1599
+ console.log(pc.red(" ✗") + " Invalid email format.");
1600
+ console.log();
1601
+ process.exit(1);
1602
+ }
1603
+ let pat = options.pat ?? null;
1604
+ if (!pat) try {
1605
+ const dev = await loadDeveloperCredentials({ homeDir: options.homeDir });
1606
+ if (dev?.pat) pat = dev.pat;
1607
+ } catch {}
1608
+ if (!pat) {
1609
+ console.log(pc.red(" ✗") + " No Amba credentials found. Run " + pc.bold("amba init") + " first.");
1610
+ console.log();
1611
+ process.exit(1);
1612
+ }
1613
+ const url = `${options.apiUrl?.trim() || process.env["AMBA_API_URL"]?.trim() || "https://api.amba.dev"}/v1/auth/developer/claim`;
1614
+ const fetchImpl = options.fetchImpl ?? fetch;
1615
+ let res;
1616
+ try {
1617
+ res = await fetchImpl(url, {
1618
+ method: "POST",
1619
+ headers: {
1620
+ Authorization: `Bearer ${pat}`,
1621
+ "Content-Type": "application/json",
1622
+ "User-Agent": "amba-cli/claim"
1623
+ },
1624
+ body: JSON.stringify({ email: trimmed })
1625
+ });
1626
+ } catch (err) {
1627
+ const reason = err instanceof Error ? err.message : String(err);
1628
+ console.log(pc.red(" ✗") + ` Could not reach Amba: ${reason}`);
1629
+ console.log();
1630
+ process.exit(1);
1631
+ }
1632
+ if (res.status === 200) {
1633
+ try {
1634
+ await res.text();
1635
+ } catch {}
1636
+ console.log(pc.green(" ✓") + ` Check ${pc.bold(trimmed)} for a one-click link.`);
1637
+ console.log(pc.dim(" (Link expires in 15 minutes.)"));
1638
+ console.log();
1639
+ return;
1640
+ }
1641
+ let errCode = "";
1642
+ let errMessage = "";
1643
+ try {
1644
+ const body = await res.json();
1645
+ errCode = body.error?.code ?? "";
1646
+ errMessage = body.error?.message ?? "";
1647
+ } catch {}
1648
+ if (res.status === 400 && errCode === "INVALID_INPUT") {
1649
+ console.log(pc.red(" ✗") + " Invalid email format.");
1650
+ console.log();
1651
+ process.exit(1);
1652
+ }
1653
+ if (res.status === 409 && errCode === "EMAIL_TAKEN") {
1654
+ console.log(pc.red(" ✗") + ` That email is already on another Amba account. If it's yours, sign in via ` + pc.bold("amba login") + " or reach out to support@layers.com.");
1655
+ console.log();
1656
+ process.exit(1);
1657
+ }
1658
+ if (res.status === 409 && errCode === "ALREADY_CLAIMED") {
1659
+ console.log(pc.red(" ✗") + " This account is already verified.");
1660
+ console.log();
1661
+ process.exit(1);
1662
+ }
1663
+ if (res.status === 429) {
1664
+ console.log(pc.red(" ✗") + " Too many claim attempts. Try again in a minute.");
1665
+ console.log();
1666
+ process.exit(1);
1667
+ }
1668
+ const codeLabel = errCode || `HTTP_${res.status}`;
1669
+ const messageLabel = errMessage || res.statusText || "Request failed";
1670
+ console.log(pc.red(" ✗") + ` ${codeLabel}: ${messageLabel}`);
1671
+ console.log();
1672
+ process.exit(1);
1673
+ }
1674
+ //#endregion
1675
+ //#region src/context-files.ts
1676
+ /**
1677
+ * Generate AMBA.md project context file for AI agents.
1678
+ */
1679
+ function generateAmbaMarkdown(opts) {
1680
+ const sdkPackage = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
1681
+ const providerExample = opts.framework === "expo" ? `
1682
+ ### Client Setup
1683
+
1684
+ \`\`\`tsx
1685
+ // app/_layout.tsx
1686
+ import { useEffect } from 'react';
1687
+ import { Slot } from 'expo-router';
1688
+ import { Amba } from '@layers/amba-expo';
1689
+
1690
+ export default function RootLayout() {
1691
+ useEffect(() => {
1692
+ Amba.configure({
1693
+ projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
1694
+ apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
1695
+ });
1696
+ }, []);
1697
+
1698
+ return <Slot />;
1699
+ }
1700
+ \`\`\`
1701
+
1702
+ ### Using the Client
1703
+
1704
+ \`\`\`tsx
1705
+ import { Amba } from '@layers/amba-expo';
1706
+
1707
+ export default function MyComponent() {
1708
+ const onPress = async () => {
1709
+ // Track an event
1710
+ await Amba.events.track('lesson_completed', { lesson_id: '123' });
1711
+
1712
+ // Sign in with Apple (requires expo-apple-authentication)
1713
+ await Amba.signInWithApple();
1714
+
1715
+ // Read remote config
1716
+ const showBanner = await Amba.config.fetch();
1717
+
1718
+ // Email sign-in
1719
+ await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
1720
+ };
1721
+
1722
+ // ...
1723
+ }
1724
+ \`\`\`` : `
1725
+ ### Client Setup
1726
+
1727
+ \`\`\`typescript
1728
+ import { Amba } from '${sdkPackage}';
1729
+
1730
+ await Amba.configure({
1731
+ projectId: process.env.AMBA_PROJECT_ID!,
1732
+ apiKey: process.env.AMBA_API_KEY!,
1733
+ });
1734
+
1735
+ // Track an event
1736
+ await Amba.events.track('page_viewed', { page: '/pricing' });
1737
+
1738
+ // Read remote config
1739
+ const config = await Amba.config.fetch();
1740
+
1741
+ // Email sign-in
1742
+ await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
1743
+ \`\`\``;
1744
+ return `# Amba Project Context
1745
+
1746
+ > This file provides context about the Amba integration for AI coding agents.
1747
+
1748
+ ## Project Info
1749
+
1750
+ | Key | Value |
1751
+ |-----|-------|
1752
+ | Project ID | \`${opts.projectId}\` |
1753
+ | Project Name | ${opts.projectName} |
1754
+ | Framework | ${opts.framework} |
1755
+ | SDK | \`${sdkPackage}\` |
1756
+
1757
+ ## Environment Variables
1758
+
1759
+ These are configured in \`.env.local\`:
1760
+
1761
+ - \`AMBA_PROJECT_ID\` — Your project identifier
1762
+ - \`AMBA_API_KEY\` — Client API key (safe for client-side use)
1763
+ - \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
1764
+
1765
+ ## SDK Usage
1766
+ ${providerExample}
1767
+
1768
+ ## Available Features
1769
+
1770
+ - **Push Notifications** — Send targeted push notifications to user segments
1771
+ - **Remote Config** — Key-value configuration that updates without app releases
1772
+ - **Segments** — Group users by behavior, properties, or entitlements
1773
+ - **Streaks** — Track user engagement streaks (daily, weekly)
1774
+ - **Content Libraries** — Scheduled content delivery (daily tips, weekly challenges)
1775
+ - **Entitlements** — Subscription status via RevenueCat integration
1776
+ - **Analytics** — DAU, MAU, retention, and custom event tracking
1777
+
1778
+ ## API Reference
1779
+
1780
+ - Admin API: \`https://api.amba.dev/v1/admin\`
1781
+ - Client API: \`https://api.amba.dev/v1/client\`
1782
+ - Docs: \`https://docs.amba.dev\`
1783
+
1784
+ ## CLI Commands
1785
+
1786
+ \`\`\`bash
1787
+ amba status # Check project health
1788
+ amba push test # Send a test push notification
1789
+ amba config list # List remote config values
1790
+ amba config set <key> <value> # Set a config value
1791
+ \`\`\`
1792
+ `;
1793
+ }
1794
+ /**
1795
+ * Generate .cursor/rules/amba.mdc Cursor rules file.
1796
+ */
1797
+ function generateCursorRules(opts) {
1798
+ const sdk = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
1799
+ return `---
1800
+ description: Rules for working with the Amba SDK in this project
1801
+ globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
1802
+ ---
1803
+
1804
+ # Amba SDK Rules
1805
+
1806
+ ## Project Setup
1807
+ - Project ID: \`${opts.projectId}\`
1808
+ - SDK: \`${sdk}\`
1809
+ - API URL: \`https://api.amba.dev\`
1810
+
1811
+ ## Environment Variables
1812
+ - Always read Amba config from environment variables, never hardcode
1813
+ - Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
1814
+ - The .env.local file contains the project credentials
1815
+
1816
+ ## SDK Patterns
1817
+ ${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
1818
+ - Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
1819
+ - The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
1820
+ - Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
1821
+ - Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
1822
+ - Use \`Amba.client.track()\` for all engagement events
1823
+ - Use \`Amba.client.config.get()\` for remote configuration
1824
+ - Use \`Amba.client.auth\` for sign-up / sign-in flows`}
1825
+
1826
+ ## Push Notifications
1827
+ - Register push tokens via the SDK \`registerPushToken()\` method
1828
+ - Handle notification payloads using the SDK's notification listener
1829
+ - Don't implement custom push token management
1830
+
1831
+ ## Remote Config
1832
+ - Use remote config for feature flags and dynamic values
1833
+ - Always provide sensible defaults when reading config values
1834
+ - Config values are cached — don't fetch on every render
1835
+
1836
+ ## Streaks
1837
+ - Streaks are server-managed; the SDK provides read-only access
1838
+ - Use \`track()\` to record qualifying events — the server evaluates streaks
1839
+ - Show streak state from \`streak.current()\`, don't calculate manually
1840
+
1841
+ ## Best Practices
1842
+ - Don't store Amba API keys in source code or commit them to git
1843
+ - Use \`.env.local\` for local development credentials
1844
+ - The client API key (prefixed \`amb_dev_ck_\` or \`amb_live_ck_\`) is safe for client-side use
1845
+ - Server keys (prefixed \`amb_dev_sk_\` or \`amb_live_sk_\`) must stay server-side only
1846
+ `;
1847
+ }
1848
+ /**
1849
+ * Write both context files to the project directory.
1850
+ */
1851
+ async function generateContextFiles(opts) {
1852
+ const files = [];
1853
+ await writeFile(join(opts.cwd, "AMBA.md"), generateAmbaMarkdown(opts), "utf-8");
1854
+ files.push("AMBA.md");
1855
+ const cursorDir = join(opts.cwd, ".cursor", "rules");
1856
+ await mkdir(cursorDir, { recursive: true });
1857
+ await writeFile(join(cursorDir, "amba.mdc"), generateCursorRules(opts), "utf-8");
1858
+ files.push(".cursor/rules/amba.mdc");
1859
+ return files;
1860
+ }
1861
+ //#endregion
1862
+ //#region src/skill-installer.ts
1863
+ /**
1864
+ * Amba skill bundle installer.
1865
+ *
1866
+ * The bundled `skill-bundle/` directory contains `SKILL.md` plus a
1867
+ * `references/` folder with one file per Amba surface area. The skill
1868
+ * teaches the agent the classify → confirm → wire-up playbook for
1869
+ * adding Amba primitives to a developer's codebase. See
1870
+ * `packages/cli/skill-bundle/SKILL.md` for the source.
1871
+ *
1872
+ * Why ship a bundled skill (instead of `npx skills add layers/amba`):
1873
+ * the CLI run is the same install step. Bundling avoids a second
1874
+ * fetch, keeps the skill version locked to the CLI version, and means
1875
+ * `amba init` produces a fully-wired agent on offline networks too.
1876
+ *
1877
+ * Cross-agent install — we drop the same body into every detected
1878
+ * coding agent's skill directory. Agents read their own location:
1879
+ *
1880
+ * - Claude Code: `.claude/skills/amba/`
1881
+ * - Cursor: `.cursor/skills/amba/`
1882
+ * - Codex CLI: `.codex/skills/amba/`
1883
+ * - Windsurf: `.windsurf/skills/amba/`
1884
+ *
1885
+ * We also write a project-root copy at `.agents/skills/amba/` which
1886
+ * the `npx skills add ...` distribution tool reads from (and which any
1887
+ * agent that pre-registers an `.agents/skills/` lookup picks up). Five
1888
+ * locations, one body — same fan-out pattern `writeAllSetupTargets`
1889
+ * already uses for the legacy setup guide.
1890
+ *
1891
+ * Idempotency: re-running `amba init` overwrites the bundled
1892
+ * `SKILL.md` and `references/*.md` so every developer ends up on the
1893
+ * latest playbook. We back up a pre-existing `SKILL.md` to a sibling
1894
+ * `.bak-<unix-ms>` ONLY when its first frontmatter key (`name:`) is
1895
+ * not `amba` — that's the signal it was hand-authored / unrelated and
1896
+ * shouldn't be silently clobbered. Bundled Amba files are refreshed
1897
+ * without backup.
1898
+ */
1899
+ /**
1900
+ * Resolve the bundled skill source directory.
1901
+ *
1902
+ * The bundle lives at `<package-root>/skill-bundle/` in both the
1903
+ * source tree and the published tarball (via `files[]` in
1904
+ * `package.json`). From a built `dist/commands/init.js` the path is
1905
+ * `../../skill-bundle/`. From the source tree
1906
+ * (`src/skill-installer.ts`) the path is `../skill-bundle/`. We try
1907
+ * both relative to `import.meta.url` and pick the one that exists.
1908
+ */
1909
+ async function resolveBundleDir() {
1910
+ const here = fileURLToPath(import.meta.url);
1911
+ const candidates = [
1912
+ join(dirname(here), "..", "skill-bundle"),
1913
+ join(dirname(here), "..", "..", "skill-bundle"),
1914
+ join(dirname(here), "..", "..", "..", "skill-bundle")
1915
+ ];
1916
+ for (const candidate of candidates) try {
1917
+ await access(join(candidate, "SKILL.md"));
1918
+ return candidate;
1919
+ } catch {}
1920
+ throw new Error(`Amba skill bundle not found. Looked at: ${candidates.join(", ")}. This is a CLI packaging bug — please file an issue at https://github.com/layers/amba/issues.`);
1921
+ }
1922
+ /**
1923
+ * List the five install targets the CLI fans out to. Project-local
1924
+ * directories (`<cwd>/.claude/skills/amba/`, etc.) — the agent reads
1925
+ * project-local skills with priority over global ones, so this is the
1926
+ * canonical install location for a tool meant to wire up THIS project.
1927
+ */
1928
+ function skillInstallTargets(cwd) {
1929
+ return [
1930
+ {
1931
+ kind: "claude-code",
1932
+ path: join(cwd, ".claude", "skills", "amba")
1933
+ },
1934
+ {
1935
+ kind: "cursor",
1936
+ path: join(cwd, ".cursor", "skills", "amba")
1937
+ },
1938
+ {
1939
+ kind: "codex",
1940
+ path: join(cwd, ".codex", "skills", "amba")
1941
+ },
1942
+ {
1943
+ kind: "windsurf",
1944
+ path: join(cwd, ".windsurf", "skills", "amba")
1945
+ },
1946
+ {
1947
+ kind: "generic-agents",
1948
+ path: join(cwd, ".agents", "skills", "amba")
1949
+ }
1950
+ ];
1951
+ }
1952
+ /**
1953
+ * Copy SKILL.md + every file under references/ into the target
1954
+ * directory. Creates the directory tree if missing. Returns the list
1955
+ * of files touched and any backup paths.
1956
+ *
1957
+ * Backup rule: a pre-existing `SKILL.md` is backed up to
1958
+ * `SKILL.md.bak-<unix-ms>` ONLY when its first `name:` frontmatter
1959
+ * line is NOT `name: amba`. That's the signal it was authored by the
1960
+ * user for an unrelated purpose and shouldn't be silently overwritten.
1961
+ * Amba-owned files get refreshed without backup so developers
1962
+ * tracking the latest playbook don't accumulate junk.
1963
+ */
1964
+ async function installSkillBundle(cwd, options = {}) {
1965
+ const bundleDir = options.bundleDir ?? await resolveBundleDir();
1966
+ const targets = skillInstallTargets(cwd);
1967
+ const results = [];
1968
+ const skillBody = await readFile(join(bundleDir, "SKILL.md"), "utf-8");
1969
+ const referencesDir = join(bundleDir, "references");
1970
+ let referenceEntries = [];
1971
+ try {
1972
+ referenceEntries = await readdir(referencesDir);
1973
+ } catch {
1974
+ referenceEntries = [];
1975
+ }
1976
+ const referenceBodies = /* @__PURE__ */ new Map();
1977
+ for (const entry of referenceEntries) {
1978
+ if (!entry.endsWith(".md")) continue;
1979
+ const body = await readFile(join(referencesDir, entry), "utf-8");
1980
+ referenceBodies.set(entry, body);
1981
+ }
1982
+ for (const target of targets) {
1983
+ await mkdir(join(target.path, "references"), { recursive: true });
1984
+ const files = [];
1985
+ const skillPath = join(target.path, "SKILL.md");
1986
+ const skillBackup = await backupIfForeignSkill(skillPath);
1987
+ await writeFile(skillPath, skillBody, "utf-8");
1988
+ files.push({
1989
+ path: skillPath,
1990
+ backedUpTo: skillBackup
1991
+ });
1992
+ for (const [name, body] of referenceBodies) {
1993
+ const refPath = join(target.path, "references", name);
1994
+ await writeFile(refPath, body, "utf-8");
1995
+ files.push({
1996
+ path: refPath,
1997
+ backedUpTo: null
1998
+ });
1999
+ }
2000
+ results.push({
2001
+ target,
2002
+ files
2003
+ });
2004
+ }
2005
+ return results;
2006
+ }
2007
+ /**
2008
+ * If a pre-existing `SKILL.md` at `path` has a different `name:`
2009
+ * frontmatter value than `amba`, copy it to a timestamped backup and
2010
+ * return the backup path. Otherwise return null (no backup needed).
2011
+ *
2012
+ * Frontmatter parsing is intentionally cheap — just the first
2013
+ * occurrence of `^name:\s*<value>` within the leading `---` block. A
2014
+ * malformed file falls through to "back up" (safe default).
2015
+ */
2016
+ async function backupIfForeignSkill(path) {
2017
+ let raw;
2018
+ try {
2019
+ raw = await readFile(path, "utf-8");
2020
+ } catch {
2021
+ return null;
2022
+ }
2023
+ const nameMatch = raw.slice(0, 512).match(/^name:\s*([A-Za-z0-9_-]+)/m);
2024
+ if (nameMatch && nameMatch[1] === "amba") return null;
2025
+ const backupPath = `${path}.bak-${Date.now()}`;
2026
+ await writeFile(backupPath, raw, "utf-8");
2027
+ return backupPath;
2028
+ }
2029
+ /**
2030
+ * Convenience: returns the count of skill files written and the list
2031
+ * of target kinds, for the CLI's done-message summary.
2032
+ */
2033
+ function summarizeSkillInstall(results) {
2034
+ return {
2035
+ totalFiles: results.reduce((sum, r) => sum + r.files.length, 0),
2036
+ targetKinds: results.map((r) => r.target.kind)
2037
+ };
2038
+ }
2039
+ //#endregion
2040
+ //#region ../mcp/dist/expo-build-prompt.js
2041
+ /**
2042
+ * Canonical long-form Amba setup guide — markdown body.
2043
+ *
2044
+ * Companion to the short-form `instructions` field served by the MCP
2045
+ * server's initialize response. The pointer "Full guide: amba://setup"
2046
+ * in those instructions tells the agent to fetch this resource when it
2047
+ * needs more detail than the ~1 KB summary provides.
2048
+ *
2049
+ * Consumed by:
2050
+ *
2051
+ * - The MCP resource at `amba://setup`, registered by
2052
+ * `registerAllResources()` in `./index.ts` and exposed by the
2053
+ * hosted MCP server at `mcp.amba.dev`. Any client (Claude Code,
2054
+ * Cursor, Codex, Cowork, etc.) can fetch it via `resources/read`.
2055
+ *
2056
+ * Twin: this body is the server-side mirror of
2057
+ * `packages/cli/skill-bundle/SKILL.md`, which the CLI installs locally
2058
+ * during `npx @layers/amba init`. The two surfaces target two
2059
+ * different audiences:
2060
+ *
2061
+ * - `SKILL.md` ships to a local `.claude/skills/amba/` and assumes
2062
+ * the agent CAN shell out (the bootstrap path can `npx @layers/amba
2063
+ * signup`). It also writes credentials into `.env.local` + `~/.amba/`.
2064
+ * - `AMBA_SETUP_GUIDE_MD` (this constant) is served by the hosted MCP
2065
+ * and assumes the agent CANNOT shell out (e.g. Claude.ai web).
2066
+ * The bootstrap path must therefore use the `amba_developer_signup`
2067
+ * MCP tool (the only pre-auth tool the server registers).
2068
+ *
2069
+ * The playbook shape (Step 0 → Step 1 classify → Step 2 confirm →
2070
+ * Step 3 wire → Step 4 report) is identical between the two, so an
2071
+ * agent reading either ends up at the same outcome. A drift gate test
2072
+ * in `amba-setup.test.ts` asserts the structural anchors match.
2073
+ *
2074
+ * Taxonomy: the SDK capability map below is grouped under the canonical
2075
+ * 7-category taxonomy (Identity / Engagement / Gamification / Economy /
2076
+ * Social / Analytics / Infrastructure) — same shape as `categories.ts`,
2077
+ * the marketing-site feature grid, and the docs IA. Drift is caught by
2078
+ * `amba-setup.test.ts`.
2079
+ *
2080
+ * The body is plain CommonMark — no MDX, no JSX — so it renders
2081
+ * identically wherever it lands.
2082
+ */
2083
+ const AMBA_SETUP_GUIDE_MD = `# Amba
2084
+
2085
+ ## What Amba is
2086
+
2087
+ Amba is the backend your app calls at runtime via the SDK. The MCP tools (\`amba_*\`) are for provisioning at build time — call them as the coding agent.
2088
+
2089
+ One Amba project = one isolated tenant database + one set of API keys + a hosted MCP at \`mcp.amba.dev\` for agent-driven provisioning. The runtime traffic goes through an SDK (\`@layers/amba-*\` on npm, \`Amba\` on SPM / Maven / pub.dev). The provisioning traffic — what you're about to do — goes through the tools listed below.
2090
+
2091
+ Your job is to take the user from "I have an app" to "Amba is wired in and the features are real" in one pass, without 12 follow-up questions. The journey below is the standard playbook. Don't deviate without a reason.
2092
+
2093
+ ## Step 0 — Bootstrap credentials
2094
+
2095
+ You're in an MCP client (Claude.ai web, Claude Desktop, Cursor, Windsurf, etc.) without a local CLI. The MCP server has one tool that works **without** a Bearer token: \`amba_developer_signup\`. Use it to mint a personal access token (PAT), project, and client/server keys in a single round trip — no browser, no email verification.
2096
+
2097
+ Call:
2098
+
2099
+ \`\`\`
2100
+ amba_developer_signup({
2101
+ email: "sandbox-<unix-seconds>-<6char-nonce>@layers.com",
2102
+ password: "<32 random base64url chars>",
2103
+ name: "amba-sandbox-agent"
2104
+ })
2105
+ \`\`\`
2106
+
2107
+ The response shape:
2108
+
2109
+ \`\`\`json
2110
+ {
2111
+ "data": {
2112
+ "pat": "amb_dpat_…",
2113
+ "developer": { "id": "…", "email": "…", "tier": "agent_sandbox" },
2114
+ "project": {
2115
+ "project_id": "…",
2116
+ "client_key": "amb_ck_…",
2117
+ "server_key": "amb_sk_…",
2118
+ "provisioning_status": "provisioning",
2119
+ "verify_url": "https://app.amba.dev/verify?token=…",
2120
+ "verify_token": "…"
2121
+ }
2122
+ }
2123
+ }
2124
+ \`\`\`
2125
+
2126
+ After signup, the PAT is in your hand — keep building in THIS session immediately. Pass \`pat: "<the PAT from the response>"\` as an inline argument on every subsequent \`amba_*\` tool call; the inline arg overrides the inbound Bearer for that one call so no client config change is needed mid-session. For future sessions, write the matching snippet from \`mcp_config\` to the customer's MCP-client config file so the static Bearer takes over automatically on the next launch.
2127
+
2128
+ The project status is \`"provisioning"\` immediately after signup; the per-tenant DB activates within ~5 seconds. Don't block — your next call may briefly retry, that's fine. If you want to be polite, call \`amba_projects_get_provisioning_status({ project_id })\` once and proceed when it returns \`"active"\` (or after 15s, whichever first).
2129
+
2130
+ Tell the user where their credentials live:
2131
+
2132
+ - \`pat\` — the Bearer they should configure in this MCP client's settings (and treat like a password).
2133
+ - \`project_id\`, \`client_key\` — the values they paste into their app's \`.env.local\` / \`.env\`.
2134
+ - \`server_key\` — never ship to user devices; only into a server \`.env\` or a secret manager. The \`amb_dev_sk_\` / \`amb_live_sk_\` prefix is the marker.
2135
+
2136
+ **Already have a PAT?** Skip the signup. Call \`amba_developer_me({})\` to verify the Bearer; if it succeeds, either reuse the most recent project (\`amba_projects_list\`) or call \`amba_projects_create({ name: "<app-name>", platform: "all" })\` and then \`amba_api_keys_create\` twice to mint client + server keys for \`environment: "development"\`.
2137
+
2138
+ ## Step 1 — Classify the app
2139
+
2140
+ Look at what the user told you and at any files they shared. You're trying to pick one of ten presets in 30 seconds, not write a treatise. Inputs:
2141
+
2142
+ - The user's prompt — "I'm building a fitness tracker" / "a marketplace for…" / "a Duolingo for X".
2143
+ - README content if shared.
2144
+ - \`package.json\` / \`pubspec.yaml\` / \`build.gradle.kts\` / \`Package.swift\` — framework + dependencies.
2145
+ - Screen / view names — \`WorkoutScreen\`, \`MatchView\`, \`LessonPage\`, \`CartView\`, \`ProductDetail\`, \`ChatThread\`.
2146
+
2147
+ Pick the closest match:
2148
+
2149
+ | Preset | When | Default Amba surfaces |
2150
+ | --- | --- | --- |
2151
+ | **fitness** | health / fitness tracker (workouts, steps, meditation) | identity (Apple+Google), push, XP, achievements, streaks, leaderboards, content (daily tips) |
2152
+ | **social** | social network / community (friends, feeds, groups) | identity, push, friends, groups, feeds, messaging, moderation, content |
2153
+ | **marketplace** | commerce / marketplace (catalog, stores, payments) | identity, push, catalog, stores, currencies (loyalty), reviews, segments |
2154
+ | **productivity** | productivity / SaaS tool (collaboration, milestones) | identity (Apple+Google+OTP), push, collections, achievements, content (changelog), segments |
2155
+ | **education** | education / learning app (courses, progress, rewards) | identity, push, XP, achievements, streaks, leaderboards, content (lessons), onboarding |
2156
+ | **game** | game / casual gaming | identity (anon-first), push, XP, achievements, currencies, inventory, leaderboards, challenges, stores |
2157
+ | **dating** | dating / matching app | identity (phone-OTP), push, friends (matches), messaging, moderation (heavy), reviews |
2158
+ | **content_creator** | content platform (feeds, subscriptions, tips) | identity, push, feeds, content, currencies (tips), referrals, stores (subscriptions) |
2159
+ | **ai_chatbot** | AI / chatbot / assistant app | identity, push, AI prompts, currencies (credits), content (system prompts), onboarding |
2160
+ | **custom** | none of the above | pick features individually |
2161
+
2162
+ Detection heuristics, in priority order:
2163
+
2164
+ 1. The user's own description — most direct signal.
2165
+ 2. Filename match in \`screens/\` or \`views/\` (high signal).
2166
+ 3. Dependency in \`package.json\` — \`react-native-health\` → fitness, \`@stream-io/*\` → social or dating, \`@stripe/*\` → marketplace, \`revenuecat\` → marketplace or content_creator.
2167
+ 4. README copy — "fitness", "habit", "match", "chat", "store", "subscription".
2168
+
2169
+ If two presets tie, pick the one the user's filenames match more closely. If still tied or no signal, fall back to **custom** and let them pick.
2170
+
2171
+ ## Step 2 — Confirm with the user
2172
+
2173
+ Use a single multi-choice. Quote the surfaces from the table above so they know what they're getting.
2174
+
2175
+ **Question 1: classification + scope**
2176
+
2177
+ > I'm reading this as a **\\{kind\\}** app. I'd wire up: **\\{surfaces\\}**. Sound right?
2178
+ >
2179
+ > 1. Yes, wire it up as proposed (Recommended)
2180
+ > 2. Same kind but I want to pick features individually
2181
+ > 3. Wrong kind — let me pick from the list
2182
+ > 4. Custom — I'll pick features manually
2183
+
2184
+ If the user picks 1, go to Step 3. If 2 or 4, follow up with a multi-select of surfaces. If 3, present the table again and pick a different preset.
2185
+
2186
+ **Question 2 (preset-specific):** see the per-surface sub-resources (\`amba://setup/<surface>\`) for the full "Common follow-ups" list. Examples:
2187
+
2188
+ - **fitness / game / education** — leaderboard scope? (all-time, weekly, daily, none)
2189
+ - **game / content_creator** — virtual currency name? (\`gold\`, \`gems\`, \`coins\`, \`credits\` — defaults to \`coins\`)
2190
+ - **content_creator** — monetization? (tips, subscriptions, both)
2191
+ - **dating** — phone OTP or email-only? (phone strongly recommended)
2192
+ - **ai_chatbot** — daily free credit cap?
2193
+
2194
+ Batch the follow-ups into one or two multi-choice rounds. Don't drip-feed six separate questions.
2195
+
2196
+ ## Step 3 — Wire it up
2197
+
2198
+ For each surface in the confirmed set, read the relevant sub-resource and execute its procedure. Each sub-resource is the full per-surface playbook (MCP tools + SDK init per stack + common follow-ups + re-run behavior):
2199
+
2200
+ - **identity** (auth, anonymous/Apple/Google/OTP/magic-link, link/unlink) → \`amba://setup/identity\`
2201
+ - **engagement** (push, segments, content libraries, onboarding flows, deeplinks, referrals, tracked links) → \`amba://setup/engagement\`
2202
+ - **gamification** (XP rules, achievements, streaks, leaderboards, challenges) → \`amba://setup/gamification\`
2203
+ - **economy** (currencies, catalog, stores, inventory) → \`amba://setup/economy\`
2204
+ - **social** (friends, groups, feeds, messaging, moderation, reviews) → \`amba://setup/social\`
2205
+ - **infrastructure** (collections / DB tables, functions, analytics, AI prompts, media, secrets, configs, integrations, sites) → \`amba://setup/infrastructure\`
2206
+
2207
+ The general flow for every surface:
2208
+
2209
+ 1. **Detect stack.** Look at \`package.json\`, \`pubspec.yaml\`, \`build.gradle.kts\`, \`ios/*.xcodeproj\`. The detection rules:
2210
+ - \`pubspec.yaml\` present → Flutter.
2211
+ - \`package.json\` with \`expo\` → Expo.
2212
+ - \`package.json\` with \`react-native\` (no \`expo\`) → bare React Native.
2213
+ - \`package.json\` with \`react\` (no \`react-native\`) → web (or Next.js — same SDK).
2214
+ - \`Package.swift\` or \`*.xcodeproj\` only → iOS Swift.
2215
+ - \`build.gradle.kts\` or \`build.gradle\` with \`com.android.application\` → Android Kotlin.
2216
+ - Multiple (e.g. \`ios/\` + \`android/\` inside an Expo repo) → Expo wins.
2217
+
2218
+ 2. **Create resources via MCP.** Call the \`amba_<surface>_create\` tools to mint the definitions. Always include \`project_id\` from the project you created in Step 0. Always show the user the tool call before making destructive changes (creating a resource isn't destructive — but creating 30 of them is noisy).
2219
+
2220
+ 3. **Write SDK init code.** Drop the per-stack snippet (from the sub-resource) into the user's entry file. Detection:
2221
+ - Expo / React Native: \`app/_layout.tsx\`, \`App.tsx\`, \`index.js\` (in that order)
2222
+ - web / Next.js: \`app/layout.tsx\`, \`pages/_app.tsx\`, \`src/main.tsx\`, \`src/App.tsx\`
2223
+ - iOS Swift: \`Sources/<App>/<App>App.swift\`, \`App/AppDelegate.swift\`
2224
+ - Android Kotlin: \`app/src/main/java/.../<App>.kt\` (the \`Application\` subclass — create one if missing)
2225
+ - Flutter: \`lib/main.dart\`
2226
+
2227
+ Always make additive edits — \`await Amba.configure(...)\` next to existing init, not replacing it. Never refactor existing auth or storage code; if the user has Firebase Auth or Supabase, leave it. Amba's auth is opt-in per call.
2228
+
2229
+ 4. **Run the project's existing test command** to confirm nothing broke. Detection:
2230
+ - \`package.json\` \`scripts.test\` → \`npm test\` (or \`pnpm test\` if \`pnpm-lock.yaml\` present)
2231
+ - \`pubspec.yaml\` → \`flutter test\`
2232
+ - \`build.gradle.kts\` → \`./gradlew test\` (skip on first wire-up — slow)
2233
+ - iOS — skip (need a simulator).
2234
+
2235
+ If tests fail because of your edits, undo the offending edit and surface a clear error. If they fail for unrelated reasons (pre-existing red), note it and proceed.
2236
+
2237
+ 5. **Verify with the SDK.** Tell the user to call \`Amba.diagnostics.ping()\` (\`Amba.Diagnostics.Ping()\` on Unity) in their entry file. It returns \`{ ok, server_project_id, environment, key_fingerprint, latency_ms }\`. \`ok: true\` with the expected \`server_project_id\` confirms the wiring.
2238
+
2239
+ ## Step 4 — Report
2240
+
2241
+ Tell the user a structured summary. Use this exact shape so they can skim it fast:
2242
+
2243
+ \`\`\`
2244
+ Amba is wired in. Here's what changed:
2245
+
2246
+ DONE
2247
+ - identity: Apple + Google sign-in available; signInAnonymously() called at app start
2248
+ - gamification: 3 achievements, 1 streak, 1 leaderboard created
2249
+ resources: first_workout, week_warrior, century_club / daily_workout / weekly_xp
2250
+ - engagement: push registration wired; default segment "active_users" created
2251
+
2252
+ SKIPPED (low signal — re-run with /amba <feature> if you want them)
2253
+ - economy: no in-app currency UI found in your screens
2254
+ - social: no friends/feed surfaces found
2255
+
2256
+ NEEDS YOUR INPUT
2257
+ - Apple Sign In: add the "Sign in with Apple" capability in Xcode > Signing & Capabilities.
2258
+ - Google Sign In: paste your Google OAuth client ID into amba_projects_update({ google_oauth_client_id: "..." }).
2259
+ - APNs / FCM: upload credentials in app.amba.dev before push delivers.
2260
+
2261
+ NEXT STEPS
2262
+ - Paste AMBA_CLIENT_KEY into your build env (already shown above)
2263
+ - Trigger a workout in your existing flow — watch the achievement unlock + XP land
2264
+ - Open https://app.amba.dev to see users pour in
2265
+ \`\`\`
2266
+
2267
+ Be specific. List resources by key, not "some achievements". If something needs the user's input (third-party credentials, OAuth client IDs, push certs), say it clearly with the exact next action.
2268
+
2269
+ ## Stance (read this once)
2270
+
2271
+ - **Don't ask which surfaces to use.** Classify, then confirm in one multi-choice. The taxonomy is the whole point.
2272
+ - **Default to additive, non-breaking changes.** Don't refactor existing auth, storage, or networking code. Drop in \`await Amba.configure(...)\` next to whatever the user already has.
2273
+ - **Never create resources without the user's confirmation in Step 2.** A 3rd-party "convenience" achievement called \`first_login\` is debt.
2274
+ - **If something is genuinely ambiguous** (leaderboard scope, currency real-money vs virtual, dating phone vs email), ask via a follow-up multi-choice. Don't guess and don't paragraph-it.
2275
+ - **clientKey vs serverKey.** \`AMBA_CLIENT_KEY\` (\`amb_dev_ck_…\` in dev, \`amb_live_ck_…\` in prod) ships to user devices. \`AMBA_SERVER_KEY\` (\`amb_dev_sk_…\` / \`amb_live_sk_…\`) never does — only into server \`.env\` or a secret manager. Mixing them is the #1 security mistake; if you're writing into a file that ships with the app binary, it's the client key, period.
2276
+ - **Don't echo the PAT in chat output on every call.** Showing it once after signup is fine; do not repeat it.
2277
+
2278
+ ## Get credentials (cheat sheet)
2279
+
2280
+ - No terminal, in an MCP client: call \`amba_developer_signup\` (no Bearer required) — this guide's Step 0.
2281
+ - With a terminal: \`npx -y @layers/amba init\` signs up, mints a project + client/server keys, writes \`.env.local\` + \`AMBA.md\`, installs the \`/amba\` skill, and wires \`mcpServers.amba\` into every detected MCP-client config in one command. Auto-detects non-TTY invocations (the coding-agent bash-tool case) and runs headlessly.
2282
+ - Bind the sandbox account to a real email later: \`npx @layers/amba claim me@example.com\`. The backend emails a one-click magic link; clicking it lifts the sandbox cap to the Free tier.
2283
+ - Hosted MCP endpoint: \`https://mcp.amba.dev/mcp\` (Streamable HTTP, Bearer auth).
2284
+
2285
+ ## SDKs
2286
+
2287
+ | Stack | Registry | Package |
2288
+ |---|---|---|
2289
+ | Browser / Node / React / React Native / Expo | npm | \`@layers/amba-{web,node,react,react-native,expo}\` |
2290
+ | Swift | SPM | \`https://github.com/layers/amba-sdk-ios\` |
2291
+ | Kotlin | Maven Central | \`com.layers.amba:amba-sdk-android\` |
2292
+ | Flutter | pub.dev | \`amba\` |
2293
+ | Unity | UPM (git) | \`https://github.com/layers/amba-sdk-unity.git\` |
2294
+
2295
+ All SDKs expose the same surface: \`Amba.configure({ projectId, apiKey })\`, then \`Amba.events.track(...)\`, \`Amba.users.*\`, \`Amba.collections.*\`, etc. Per-stack quickstart pages with the exact initialization snippet: \`https://docs.amba.dev/sdk/<framework>\`.
2296
+
2297
+ ## What Amba does
2298
+
2299
+ ### Identity
2300
+ - **users** — app-user registry. Auto-created on first SDK call; admin via \`amba_users_*\`.
2301
+ - **roles + permissions** — RBAC. Define with \`amba_roles_create\`; assign via \`amba_roles_assign\`.
2302
+ - **api_keys** — client + server keys per project. Mint via \`amba_api_keys_create\`.
2303
+
2304
+ ### Engagement
2305
+ - **onboarding** — multi-step first-run flows. Define with \`amba_onboarding_create\`; SDK \`Amba.onboarding.next()\`.
2306
+ - **segments** — user cohorts. Define with \`amba_segments_create\`; used as push/feed targets.
2307
+ - **push** — scheduled or triggered notifications. Chain: configure integrations (apns/fcm) → \`amba_push_campaigns_create\` → \`amba_push_campaigns_send\` (or schedule).
2308
+ - **referrals** — referral codes. Define with \`amba_referrals_create\`.
2309
+ - **deeplinks** — universal links. Set domain with \`amba_deeplinks_set_config\`.
2310
+ - **tracked_links** — UTM-tagged outbound links. Define with \`amba_tracked_links_create\`.
2311
+ - **content** — episodic delivery (lessons, quotes, daily prompts). Chain: \`amba_content_libraries_create\` → \`amba_content_items_add\` → \`amba_content_schedules_create\`.
2312
+
2313
+ ### Gamification
2314
+ - **xp** — experience points + level. Define rules with \`amba_xp_rules_create\`; SDK \`Amba.xp.getBalance\`.
2315
+ - **achievements** — earnable badges. Define with \`amba_achievements_create\`; unlock via xp rules or \`amba_inventory_grant_item\`.
2316
+ - **streaks** — recurring engagement counters. Define with \`amba_streaks_create\`; client calls \`Amba.streaks.qualify(key)\`.
2317
+ - **leaderboards** — ranked user lists. Define with \`amba_leaderboards_create\`; populated from events.
2318
+ - **challenges** — time-bounded goals. Define with \`amba_challenges_create\`; progress via SDK.
2319
+
2320
+ ### Economy
2321
+ - **currencies** — virtual currencies (coins, gems). Define with \`amba_currencies_create\`; grant via \`amba_currencies_grant\` or event rules via \`amba_currency_grant_rules_create\`.
2322
+ - **catalog + stores** — purchasable items + storefronts. Chain: \`amba_catalog_items_create\` → \`amba_catalog_items_set_price\` → \`amba_stores_create\` → \`amba_stores_add_listing\`. (Define currency first.)
2323
+ - **inventory** — items users own. Read via SDK \`Amba.inventory.*\`; grant with \`amba_inventory_grant_item\`.
2324
+
2325
+ ### Social
2326
+ - **friendships** — friend graph. SDK \`Amba.friends.*\`; admin via \`amba_friendships_*\`.
2327
+ - **groups** — guilds/parties/chats. Define with \`amba_groups_create\`; members managed via SDK + admin tools.
2328
+ - **messaging** — DMs + group chat. Enabled by default; moderate via \`amba_messaging_*\`.
2329
+ - **feeds** — algorithmic activity feeds. Define ranking with \`amba_feeds_rules_create\`.
2330
+ - **reviews** — user-submitted reviews. Enabled by default; moderate via \`amba_reviews_*\`.
2331
+ - **moderation** — content review queue + trust scores. Configure with \`amba_moderation_configure\`; review via \`amba_moderation_queue_list\`.
2332
+
2333
+ ### Analytics
2334
+ - **events** — track user actions. SDK \`Amba.events.track()\`; query via \`amba_events_count\`.
2335
+ - **sessions** — session telemetry. Tracked automatically; query via \`amba_sessions_list\`.
2336
+ - **analytics** — funnels + retention. Query via \`amba_analytics_get\`.
2337
+
2338
+ ### Infrastructure
2339
+ - **collections** — your own typed key-value tables. Define with \`amba_collections_create\`; read/write from SDK \`Amba.client.*\`.
2340
+ - **functions** — serverless TypeScript handlers. Deploy with \`amba_functions_deploy\`; schedule with \`amba_functions_schedule\`.
2341
+ - **sites** — static site hosting at \`*.app.amba.host\`. Deploy with \`amba_sites_deploy\`.
2342
+ - **media** — file storage + CDN. Upload via \`amba_media_upload\`.
2343
+ - **secrets** — env vars for functions. Set via \`amba_secrets_set\`.
2344
+ - **configs** — remote config flags. Define with \`amba_configs_create\`.
2345
+ - **integrations** — third-party webhooks (RevenueCat, Superwall, AppsFlyer, etc.). Configure with \`amba_integrations_configure\`.
2346
+ - **ai_prompts** — versioned LLM prompts callable from SDK. Define with \`amba_ai_prompts_create\`; call via \`amba_ai_prompts_invoke\`.
2347
+ `;
2348
+ /**
2349
+ * Canonical Amba Expo build prompt — markdown body (no MDX frontmatter).
2350
+ *
2351
+ * Source of truth for three customer-facing surfaces:
2352
+ *
2353
+ * 1. The published docs page at
2354
+ * `https://docs.amba.dev/prompts/expo-build` — the MDX file at
2355
+ * `apps/docs/content/docs/prompts/expo-build.mdx` ships the same
2356
+ * body wrapped in fumadocs frontmatter.
2357
+ * 2. The MCP resource `amba://prompts/expo-build` registered by
2358
+ * `registerAllResources()` in `./index.ts` and exposed by the
2359
+ * hosted MCP server at `mcp.amba.dev`.
2360
+ * 3. The inlined snapshot baked into the `/amba-build` Claude Code
2361
+ * skill by `amba init --sandbox` (see `packages/cli/src/skills.ts`).
2362
+ *
2363
+ * Drift between this constant and the MDX file is caught by
2364
+ * `expo-build-prompt.test.ts` — that test reads the MDX from disk,
2365
+ * strips the YAML frontmatter, and asserts it equals `EXPO_BUILD_PROMPT_MD`.
2366
+ *
2367
+ * **Update protocol:** edit the MDX (it's the human-facing surface;
1337
2368
  * it renders on docs.amba.dev). Re-run the drift test. The test will
1338
2369
  * fail with a diff. Apply the same diff here. The two are kept in
1339
2370
  * sync by hand because the MDX must be statically parseable for
@@ -1344,8 +2375,8 @@ function formatManualMcpSnippet(pat) {
1344
2375
  * so it renders identically as `.md` (the MCP / skill consumers) and
1345
2376
  * as `.mdx` (the docs site).
1346
2377
  */
1347
- const EXPO_BUILD_PROMPT_MD = `> **Last reviewed:** 2026-05-16. The canonical version of this page lives
1348
- > at [docs.amba.dev/docs/prompts/expo-build](https://docs.amba.dev/docs/prompts/expo-build).
2378
+ const EXPO_BUILD_PROMPT_MD = `> **Last reviewed:** 2026-05-17. The canonical version of this page lives
2379
+ > at [docs.amba.dev/prompts/expo-build](https://docs.amba.dev/prompts/expo-build).
1349
2380
  > If you're reading an inlined snapshot from your
1350
2381
  > \`.claude/skills/amba-build/SKILL.md\`, check the URL above for updates.
1351
2382
 
@@ -1361,7 +2392,7 @@ The CLI handles signup, project provisioning, env-file writes, and MCP
1361
2392
  client config wiring in one command:
1362
2393
 
1363
2394
  \`\`\`bash
1364
- npx @layers/amba init --sandbox
2395
+ npx -y @layers/amba init
1365
2396
  \`\`\`
1366
2397
 
1367
2398
  That's the entire setup. The CLI:
@@ -1374,14 +2405,17 @@ That's the entire setup. The CLI:
1374
2405
  4. Writes \`AMBA.md\` (project-scoped context for the agent).
1375
2406
  5. Auto-wires \`mcpServers.amba\` into every MCP client config it
1376
2407
  detects on disk — Claude Code, Cursor, Windsurf.
1377
- 6. Prints a per-client restart instruction.
2408
+ 6. Verifies the PAT against the API and confirms it's good.
1378
2409
 
1379
- After restarting your MCP client, the Amba MCP toolset is available
1380
- (\`amba_*\` tools — ~130 of them) and the agent can drive the backend
1381
- end-to-end.
2410
+ The Amba MCP toolset (\`amba_*\` tools — ~130 of them) is available to
2411
+ the agent immediately: pass the freshly-minted \`pat\` as an inline
2412
+ argument on every \`amba_*\` call in the current session. The next time
2413
+ your MCP client starts it picks the PAT up from the config as the
2414
+ inbound Bearer automatically — at that point the \`pat\` arg becomes
2415
+ optional. No restart needed; nothing for you to do.
1382
2416
 
1383
2417
  If you have the \`/amba-build\` skill installed (via
1384
- \`npx @layers/amba init --sandbox\`), invoke it directly:
2418
+ \`npx -y @layers/amba init\`), invoke it directly:
1385
2419
 
1386
2420
  \`\`\`
1387
2421
  /amba-build <DESIGN_HASH>
@@ -1401,11 +2435,13 @@ DX cascade is fixed.
1401
2435
  - **React Native bundle size** — the React Native SDK adds ~4 MB to
1402
2436
  the JS bundle today. Functional, just heavier than the long-term
1403
2437
  goal. Tracked separately.
1404
- - **Sandbox MAU cap (50)** — the agent-mode sandbox tier caps at 50
2438
+ - **Sandbox MAU cap (100)** — the agent-mode sandbox tier caps at 100
1405
2439
  monthly active users. If you blow through it during testing, call
1406
2440
  \`amba_users_reset_sandbox\` to clear the counter — that tool exists
1407
- specifically for this. Upgrade to the Free tier (claim the email)
1408
- to lift the cap to 1,000.
2441
+ specifically for this. Upgrade to the Free tier (1,000 MAU, 500 MB
2442
+ DB) by running \`amba claim me@example.com\` from the terminal — the
2443
+ backend emails a one-click magic link to the address you pass in;
2444
+ clicking it binds the account to that email and lifts the cap.
1409
2445
 
1410
2446
  ## How to read the design
1411
2447
 
@@ -1537,6 +2573,12 @@ gate.
1537
2573
  \`expo export --platform ios\`, and \`expo export --platform android\`
1538
2574
  must all succeed. If any one fails, the build fails. No
1539
2575
  "shipped iOS-only, web is broken" — the rule is parity.
2576
+ - **Don't name a tab \`settings.tsx\`.** Use \`account.tsx\` or
2577
+ \`preferences.tsx\` instead. Expo Router's static web export generates
2578
+ \`settings.html\` correctly but does not resolve direct URL navigation
2579
+ to \`/settings\` — the client-side router shows an unmatched-route
2580
+ error while other tab names work fine. (Observed in dogfood; upstream
2581
+ behavior, not an Amba issue.)
1540
2582
 
1541
2583
  ## Verification gate
1542
2584
 
@@ -1618,33 +2660,47 @@ If \`BUILD_REPORT.md\` is missing any required section, or
1618
2660
  //#endregion
1619
2661
  //#region src/skills.ts
1620
2662
  /**
1621
- * Claude Code skill installer for `amba init --sandbox`.
1622
- *
1623
- * Writes a project-local `.claude/skills/amba-build/SKILL.md` file
1624
- * containing the canonical "/goal" prompt for building a full Expo
1625
- * app with Amba as the only backend. Two behaviors:
1626
- *
1627
- * 1. **Live fetch first.** The skill's runtime instructions tell
1628
- * Claude Code to `curl` the live MDX from
1629
- * `https://docs.amba.dev/docs/prompts/expo-build.md`. So as long
1630
- * as docs is reachable, the agent always uses the latest version.
1631
- *
1632
- * 2. **Inlined snapshot fallback.** The same SKILL.md file embeds a
1633
- * verbatim copy of the prompt body — captured at CLI install
1634
- * time from `@layers/amba-mcp/prompts`. Offline agents (or ones
1635
- * whose curl 404s during the docs deploy gap) still get a
1636
- * usable prompt.
1637
- *
1638
- * Out of scope (per DX-16): Cursor / Windsurf shortcut files. Those
1639
- * editors don't ingest Claude Code skills; their users paste the
1640
- * URL directly. The CLI's success line still surfaces the
1641
- * `/amba-build` command + the docs URL so both audiences are served.
1642
- *
1643
- * Project-local install is the default because skills written into
1644
- * `~/.claude/skills/` are user-global and would persist across
1645
- * unrelated projects (and accumulate stale copies of the inlined
1646
- * snapshot from old `amba init` runs). A project-scoped install
1647
- * disappears when the user `rm -rf`s the project.
2663
+ * Per-agent skill / rule file installer for `amba init`.
2664
+ *
2665
+ * Two distinct surfaces, both project-local:
2666
+ *
2667
+ * 1. **Build task skill** — `.claude/skills/amba-build/SKILL.md`.
2668
+ * Scaffolds a full Expo app via the canonical "/goal" prompt.
2669
+ * Task-shaped: the user invokes it explicitly. Lives behind
2670
+ * `writeAmbaBuildSkill` (legacy export, unchanged).
2671
+ *
2672
+ * 2. **Reference / setup skill** — fanned out into five locations,
2673
+ * one per agent family, so the same Amba setup guide reaches
2674
+ * whatever coding agent the user has installed:
2675
+ *
2676
+ * | Surface | Path | Wrapper |
2677
+ * |------------------------------------------|-------------------------------|--------------------------|
2678
+ * | Claude Code (proactive, auto-injected) | \`CLAUDE.md\` (append) | plain markdown |
2679
+ * | Claude Code (invokable skill) | \`.claude/skills/amba/SKILL.md\` | \`description:\` frontmatter |
2680
+ * | Cursor | \`.cursor/rules/amba.mdc\` | \`alwaysApply\`/\`description\`/\`globs\` |
2681
+ * | Codex / Aider / Zed / Copilot / Gemini | \`AGENTS.md\` (append) | plain markdown |
2682
+ * | Windsurf | \`.windsurf/rules/amba.md\` | \`trigger: always_on\` |
2683
+ *
2684
+ * The two append targets (\`CLAUDE.md\`, \`AGENTS.md\`) use marker
2685
+ * fencing — \`<!-- AMBA-SETUP-START -->\` / \`<!-- AMBA-SETUP-END -->\` —
2686
+ * so a re-init refreshes only Amba's section without clobbering user
2687
+ * edits to the surrounding file. The standalone targets (\`.cursor\`,
2688
+ * \`.windsurf\`, \`.claude/skills/amba\`) live in their own files and
2689
+ * are overwritten wholesale per re-init.
2690
+ *
2691
+ * The shared body comes from \`@layers/amba-mcp/prompts\`
2692
+ * (\`AMBA_SETUP_GUIDE_MD\`) — one canonical source, five wrappers. The
2693
+ * CLI bundles that constant at publish time via tsdown's
2694
+ * \`noExternal: [/^@layers\\/amba-/]\` rule (same path \`EXPO_BUILD_PROMPT_MD\`
2695
+ * already uses).
2696
+ *
2697
+ * Vendor-name discipline
2698
+ * ----------------------
2699
+ * Everything written by this module is customer-facing. The body
2700
+ * (sourced from the MCP package) is vetted there; the wrappers below
2701
+ * intentionally avoid naming Cloudflare / GCP / Neon / Temporal /
2702
+ * Rust / WASM / UniFFI / Resend / Doppler. See \`skills.test.ts\` for
2703
+ * the per-writer drift gate.
1648
2704
  */
1649
2705
  /**
1650
2706
  * Build the contents of `.claude/skills/amba-build/SKILL.md`.
@@ -1652,17 +2708,6 @@ If \`BUILD_REPORT.md\` is missing any required section, or
1652
2708
  * Exported as a pure function so the unit tests can assert structural
1653
2709
  * properties (frontmatter, fetcher block, inlined snapshot fence)
1654
2710
  * without round-tripping through the filesystem.
1655
- *
1656
- * Structure:
1657
- * 1. YAML frontmatter — `description` so Claude Code's skill
1658
- * indexer picks it up.
1659
- * 2. Skill body — invocation instructions, fetcher one-liner,
1660
- * fallback rule.
1661
- * 3. Inlined snapshot — fenced code block containing the
1662
- * EXPO_BUILD_PROMPT_MD body verbatim. The snapshot is bounded
1663
- * by a marker comment so a future `amba update-skills` command
1664
- * can find and refresh just the inlined region without
1665
- * clobbering user customizations above it.
1666
2711
  */
1667
2712
  function buildAmbaBuildSkillContent() {
1668
2713
  return `---
@@ -1672,7 +2717,7 @@ description: Canonical /goal prompt for building a full Expo app with Amba as th
1672
2717
  # /amba-build
1673
2718
 
1674
2719
  When invoked, fetch the canonical prompt from
1675
- \`https://docs.amba.dev/docs/prompts/expo-build.md\` and use it as the
2720
+ \`https://docs.amba.dev/prompts/expo-build.md\` and use it as the
1676
2721
  \`/goal\` directive for building a full Expo app with Amba as the only
1677
2722
  backend. The user supplies a design hash (URL or description) as the
1678
2723
  argument; substitute it for every \`<DESIGN_HASH>\` placeholder in the
@@ -1692,7 +2737,7 @@ Replace \`<DESIGN_HASH>\` with:
1692
2737
  ## Fetcher
1693
2738
 
1694
2739
  \`\`\`bash
1695
- curl -sf https://docs.amba.dev/docs/prompts/expo-build.md
2740
+ curl -sf https://docs.amba.dev/prompts/expo-build.md
1696
2741
  \`\`\`
1697
2742
 
1698
2743
  If \`curl\` fails (404, 5xx, network error, no internet), fall back to
@@ -1715,17 +2760,8 @@ ${EXPO_BUILD_PROMPT_MD}<!-- AMBA-BUILD-PROMPT-END -->
1715
2760
  }
1716
2761
  /**
1717
2762
  * Write `.claude/skills/amba-build/SKILL.md` into the target project.
1718
- *
1719
- * Always overwrites — the skill file is meant to be regenerated each
1720
- * time `amba init --sandbox` runs (so the inlined snapshot stays
1721
- * fresh). Anything the user customized above the snapshot markers
1722
- * would be lost on a re-init; that's an accepted trade-off for the
1723
- * agentic single-command flow.
1724
- *
1725
- * If a future need for "preserve user edits across re-init" surfaces,
1726
- * the right shape is a separate `amba update-skills` command that
1727
- * surgically rewrites the inlined-snapshot region only — leaving the
1728
- * surrounding text untouched. Out of scope for DX-16.
2763
+ * Always overwrites — the inlined snapshot is meant to be regenerated
2764
+ * on each `amba init --sandbox` run.
1729
2765
  */
1730
2766
  async function writeAmbaBuildSkill(options = {}) {
1731
2767
  const skillDir = join(options.baseDir ?? process.cwd(), ".claude", "skills", "amba-build");
@@ -1734,6 +2770,334 @@ async function writeAmbaBuildSkill(options = {}) {
1734
2770
  await writeFile(skillPath, buildAmbaBuildSkillContent(), "utf-8");
1735
2771
  return { path: skillPath };
1736
2772
  }
2773
+ /**
2774
+ * Marker fence sentinels for the two append-targets (`CLAUDE.md`,
2775
+ * `AGENTS.md`). Used by `markerFencedAppend` to find + refresh the
2776
+ * Amba section without clobbering surrounding user content.
2777
+ */
2778
+ const AMBA_SETUP_START_MARKER = "<!-- AMBA-SETUP-START -->";
2779
+ const AMBA_SETUP_END_MARKER = "<!-- AMBA-SETUP-END -->";
2780
+ var AmbaSkillFileCorrupted = class extends Error {
2781
+ path;
2782
+ shape;
2783
+ constructor(filePath, shape) {
2784
+ super(`Found ${shape.startCount} AMBA-SETUP-START marker(s) and ${shape.endCount} AMBA-SETUP-END marker(s) in ${filePath}; expected exactly 1 of each in order. Refusing to auto-fix: cutting either side could discard user content. Please remove the extras (or the orphan marker) manually and re-run \`amba init\`.`);
2785
+ this.name = "AmbaSkillFileCorrupted";
2786
+ this.path = filePath;
2787
+ this.shape = shape;
2788
+ }
2789
+ };
2790
+ /**
2791
+ * Insert or refresh a marker-fenced block in a file.
2792
+ *
2793
+ * Marker-shape invariant: the target file must have either
2794
+ * (a) zero start/end markers (clean append), or
2795
+ * (b) exactly one start marker and one end marker, with the end
2796
+ * marker after the start (clean in-place refresh).
2797
+ *
2798
+ * Any other shape — orphan single marker, duplicate paired blocks,
2799
+ * end-before-start, mixed counts (e.g. 1 start + 2 ends) — is
2800
+ * treated as corruption and throws `AmbaSkillFileCorrupted`. The
2801
+ * caller's `warn` sink surfaces the error to the developer; the
2802
+ * file is left untouched. Earlier revisions tried to auto-recover
2803
+ * malformed states via strip-and-replace, but every recovery
2804
+ * heuristic risked deleting user content outside the Amba block
2805
+ * (BugBot cycle-5..7 all flagged adjacent failure modes — the
2806
+ * strict invariant kills the whole class).
2807
+ *
2808
+ * Behavior:
2809
+ *
2810
+ * - File does not exist (`ENOENT`) → create with just the block.
2811
+ * **Any other read error (EACCES, EISDIR, transient I/O) is
2812
+ * re-thrown** — we never silently overwrite a file we couldn't
2813
+ * read.
2814
+ * - File exists, zero markers → append the block (with a blank-
2815
+ * line separator so it doesn't fuse onto the last paragraph).
2816
+ * - File exists, well-formed 1+1 pair (end after start) → replace
2817
+ * the content between markers, preserving surrounding text.
2818
+ * - Any other marker shape → throw `AmbaSkillFileCorrupted` with
2819
+ * the observed (startCount, endCount, startIdx, endIdx).
2820
+ *
2821
+ * Returns the absolute path + whether this was a fresh create or a
2822
+ * refresh.
2823
+ *
2824
+ * The `body` argument is the text we want **inside** the markers —
2825
+ * the markers themselves are added by this helper. Callers must NOT
2826
+ * include the start/end marker lines in `body`.
2827
+ */
2828
+ async function markerFencedAppend(filePath, body, startMarker, endMarker) {
2829
+ await mkdir(dirname(filePath), { recursive: true });
2830
+ let existing = null;
2831
+ try {
2832
+ existing = await readFile(filePath, "utf-8");
2833
+ } catch (err) {
2834
+ if (err?.code === "ENOENT") existing = null;
2835
+ else throw err;
2836
+ }
2837
+ const block = `${startMarker}\n${body}\n${endMarker}`;
2838
+ if (existing === null) {
2839
+ await writeFile(filePath, block + "\n", "utf-8");
2840
+ return {
2841
+ path: filePath,
2842
+ mode: "created"
2843
+ };
2844
+ }
2845
+ const startCount = countOccurrences(existing, startMarker);
2846
+ const endCount = countOccurrences(existing, endMarker);
2847
+ const startIdx = existing.indexOf(startMarker);
2848
+ const endIdx = existing.indexOf(endMarker);
2849
+ if (startCount === 1 && endCount === 1 && endIdx > startIdx) {
2850
+ const before = existing.slice(0, startIdx);
2851
+ const after = existing.slice(endIdx + endMarker.length);
2852
+ await writeFile(filePath, before + block + after, "utf-8");
2853
+ return {
2854
+ path: filePath,
2855
+ mode: "refreshed"
2856
+ };
2857
+ }
2858
+ if (startCount === 0 && endCount === 0) {
2859
+ const separator = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2860
+ await writeFile(filePath, existing + separator + block + "\n", "utf-8");
2861
+ return {
2862
+ path: filePath,
2863
+ mode: "refreshed"
2864
+ };
2865
+ }
2866
+ throw new AmbaSkillFileCorrupted(filePath, {
2867
+ startCount,
2868
+ endCount,
2869
+ startIdx,
2870
+ endIdx
2871
+ });
2872
+ }
2873
+ /**
2874
+ * Count non-overlapping occurrences of `needle` in `haystack`.
2875
+ * Used to classify the marker state of an existing file.
2876
+ */
2877
+ function countOccurrences(haystack, needle) {
2878
+ if (needle.length === 0) return 0;
2879
+ let count = 0;
2880
+ let pos = 0;
2881
+ while (true) {
2882
+ const idx = haystack.indexOf(needle, pos);
2883
+ if (idx === -1) break;
2884
+ count += 1;
2885
+ pos = idx + needle.length;
2886
+ }
2887
+ return count;
2888
+ }
2889
+ /**
2890
+ * Build the canonical setup body. Sourced from the MCP package so
2891
+ * docs + MCP + every coding-agent surface stay in sync.
2892
+ *
2893
+ * Exposed as a function (not a const) so future versions can swap in
2894
+ * a build-time generator without breaking import sites.
2895
+ */
2896
+ function buildAmbaSetupBody() {
2897
+ return AMBA_SETUP_GUIDE_MD;
2898
+ }
2899
+ /**
2900
+ * Build the Cursor `.cursor/rules/amba.mdc` flavor.
2901
+ *
2902
+ * `alwaysApply: true` makes Cursor inject the rule at the start of
2903
+ * every turn (Cursor's most-proactive mode). `globs: ""` keeps the
2904
+ * rule globally-scoped instead of file-pattern-attached.
2905
+ */
2906
+ function buildCursorRuleContent() {
2907
+ return `---
2908
+ alwaysApply: true
2909
+ description: "Amba SDK + MCP guide"
2910
+ globs: ""
2911
+ ---
2912
+
2913
+ ${buildAmbaSetupBody()}
2914
+ `;
2915
+ }
2916
+ /**
2917
+ * Build the Windsurf `.windsurf/rules/amba.md` flavor.
2918
+ *
2919
+ * `trigger: always_on` is Windsurf's equivalent of Cursor's
2920
+ * `alwaysApply: true`. Workspace rules **cap at 12k chars** — a hard
2921
+ * Windsurf limit, not negotiable. The canonical
2922
+ * `AMBA_SETUP_GUIDE_MD` body is the full classify → confirm →
2923
+ * wire-up playbook (~17k) and won't fit, so Windsurf gets a
2924
+ * trimmed-down summary that points at the long-form resource
2925
+ * (`amba://setup`) for full detail. Same posture as
2926
+ * `AMBA_INIT_INSTRUCTIONS` in the hosted MCP server.
2927
+ */
2928
+ function buildWindsurfRuleContent() {
2929
+ return `---
2930
+ trigger: always_on
2931
+ ---
2932
+
2933
+ ${buildWindsurfSummaryBody()}
2934
+ `;
2935
+ }
2936
+ /**
2937
+ * Shorter summary of the Amba setup playbook for Windsurf rules.
2938
+ *
2939
+ * Constraints:
2940
+ * - Wrapped frontmatter + body must fit under Windsurf's 12k cap.
2941
+ * - Must name the same five-step journey shape so an agent acting
2942
+ * on this summary doesn't drift from the canonical guide.
2943
+ * - Customer-facing — no vendor leakage.
2944
+ *
2945
+ * For full detail (per-surface MCP tool tables, per-stack SDK init
2946
+ * snippets, common follow-ups, re-run rules), the agent fetches
2947
+ * `amba://setup` (or per-surface \`amba://setup/<surface>\`) from the
2948
+ * hosted MCP server.
2949
+ */
2950
+ function buildWindsurfSummaryBody() {
2951
+ return `# Amba
2952
+
2953
+ Amba is the backend your app calls at runtime via the SDK; the MCP tools (\`amba_*\`) are for provisioning, called by the coding agent.
2954
+
2955
+ ## Bootstrap
2956
+
2957
+ \`amba_developer_signup\` is the only pre-auth MCP tool — call it with no Bearer to mint a PAT, project, and client/server keys in one round trip. After signup, set \`Authorization: Bearer <pat>\` on every subsequent MCP call. Save \`project_id\` + \`client_key\` to \`.env.local\`; never write \`server_key\` next to client code.
2958
+
2959
+ ## Playbook
2960
+
2961
+ When the user asks you to wire Amba into this codebase, run this journey end-to-end:
2962
+
2963
+ 1. **Step 0 — Bootstrap credentials.** Call \`amba_developer_signup\` (pre-auth) OR \`amba_developer_me\` if a PAT already exists. Persist \`project_id\` + \`client_key\` to \`.env.local\`.
2964
+ 2. **Step 1 — Classify the app.** Pick one of ten presets (fitness / social / marketplace / productivity / education / game / dating / content_creator / ai_chatbot / custom) using README + filenames + dependencies.
2965
+ 3. **Step 2 — Confirm with the user.** One multi-choice question listing the surfaces the preset implies. Don't drip-feed.
2966
+ 4. **Step 3 — Wire it up.** For each surface in the confirmed scope, fetch its sub-resource (\`amba://setup/<surface>\`) and execute its MCP tools + drop in the per-stack SDK init snippet.
2967
+ 5. **Step 4 — Report.** Structured DONE / SKIPPED / NEEDS YOUR INPUT / NEXT STEPS summary.
2968
+
2969
+ ## Surfaces
2970
+
2971
+ - **identity** — anonymous + Apple + Google + OTP + magic link (see \`amba://setup/identity\`).
2972
+ - **engagement** — push, segments, content, onboarding, deeplinks, referrals, tracked links (\`amba://setup/engagement\`).
2973
+ - **gamification** — XP, achievements, streaks, leaderboards, challenges (\`amba://setup/gamification\`).
2974
+ - **economy** — currencies, catalog, stores, inventory (\`amba://setup/economy\`).
2975
+ - **social** — friends, groups, feeds, messaging, moderation, reviews (\`amba://setup/social\`).
2976
+ - **infrastructure** — collections (typed tables), functions, AI prompts, secrets, configs, integrations, media, sites (\`amba://setup/infrastructure\`).
2977
+
2978
+ ## SDKs
2979
+
2980
+ | Stack | Registry | Package |
2981
+ |---|---|---|
2982
+ | Browser / Node / React / RN / Expo | npm | \`@layers/amba-{web,node,react,react-native,expo}\` |
2983
+ | Swift | SPM | \`https://github.com/layers/amba-sdk-ios\` |
2984
+ | Kotlin | Maven Central | \`com.layers.amba:amba-sdk-android\` |
2985
+ | Flutter | pub.dev | \`amba\` |
2986
+ | Unity | UPM (git) | \`https://github.com/layers/amba-sdk-unity.git\` |
2987
+
2988
+ All SDKs expose \`Amba.configure({ projectId, apiKey })\` then \`Amba.events.track(...)\`, \`Amba.users.*\`, \`Amba.collections.*\`, etc.
2989
+
2990
+ ## Stance
2991
+
2992
+ - **clientKey vs serverKey.** \`AMBA_CLIENT_KEY\` (\`amb_dev_ck_…\` / \`amb_live_ck_…\`) ships to user devices. \`AMBA_SERVER_KEY\` (\`amb_dev_sk_…\` / \`amb_live_sk_…\`) never does — server \`.env\` or a secret manager. Mixing them is the #1 security mistake.
2993
+ - **Default to additive, non-breaking edits.** Drop \`await Amba.configure(...)\` next to existing init, don't refactor.
2994
+ - **Don't create resources without Step 2 confirmation.**
2995
+ - **Use canonical (post-DX-12) tool names** — \`amba_<resource>_<verb>\` form. Legacy verb-leading aliases (\`amba_create_*\`, \`amba_list_*\`, etc.) still resolve but the canonical names are what to advertise.
2996
+
2997
+ For the full playbook (per-surface MCP tool tables, per-stack SDK init snippets, common follow-ups, re-run behavior), read \`amba://setup\` and \`amba://setup/<surface>\` from the hosted MCP at \`mcp.amba.dev\`.
2998
+ `;
2999
+ }
3000
+ /**
3001
+ * Body for the marker-fenced append into `CLAUDE.md` or `AGENTS.md`.
3002
+ *
3003
+ * Plain markdown, no frontmatter — both conventions are
3004
+ * frontmatter-free. Returns the inner body only; the marker fence is
3005
+ * added by `markerFencedAppend`.
3006
+ */
3007
+ function buildAppendableSetupBody() {
3008
+ return buildAmbaSetupBody();
3009
+ }
3010
+ /** Write `.cursor/rules/amba.mdc`. */
3011
+ async function writeCursorRule(options = {}) {
3012
+ const ruleDir = join(options.baseDir ?? process.cwd(), ".cursor", "rules");
3013
+ await mkdir(ruleDir, { recursive: true });
3014
+ const rulePath = join(ruleDir, "amba.mdc");
3015
+ let mode = "created";
3016
+ try {
3017
+ await readFile(rulePath, "utf-8");
3018
+ mode = "refreshed";
3019
+ } catch {
3020
+ mode = "created";
3021
+ }
3022
+ await writeFile(rulePath, buildCursorRuleContent(), "utf-8");
3023
+ return {
3024
+ path: rulePath,
3025
+ mode
3026
+ };
3027
+ }
3028
+ /** Write `.windsurf/rules/amba.md`. */
3029
+ async function writeWindsurfRule(options = {}) {
3030
+ const ruleDir = join(options.baseDir ?? process.cwd(), ".windsurf", "rules");
3031
+ await mkdir(ruleDir, { recursive: true });
3032
+ const rulePath = join(ruleDir, "amba.md");
3033
+ let mode = "created";
3034
+ try {
3035
+ await readFile(rulePath, "utf-8");
3036
+ mode = "refreshed";
3037
+ } catch {
3038
+ mode = "created";
3039
+ }
3040
+ await writeFile(rulePath, buildWindsurfRuleContent(), "utf-8");
3041
+ return {
3042
+ path: rulePath,
3043
+ mode
3044
+ };
3045
+ }
3046
+ /**
3047
+ * Append (or refresh) the Amba setup section in `CLAUDE.md` at the
3048
+ * project root. Marker-fenced so it can be safely refreshed by
3049
+ * subsequent re-inits.
3050
+ */
3051
+ async function writeClaudeMd(options = {}) {
3052
+ return markerFencedAppend(join(options.baseDir ?? process.cwd(), "CLAUDE.md"), buildAppendableSetupBody(), AMBA_SETUP_START_MARKER, AMBA_SETUP_END_MARKER);
3053
+ }
3054
+ /**
3055
+ * Append (or refresh) the Amba setup section in `AGENTS.md` at the
3056
+ * project root. The `AGENTS.md` convention is read by 20+ agentic
3057
+ * tools (Codex, Aider, Zed, Copilot, Gemini CLI, Warp, etc.) so this
3058
+ * single file covers most of the long tail.
3059
+ */
3060
+ async function writeAgentsMd(options = {}) {
3061
+ return markerFencedAppend(join(options.baseDir ?? process.cwd(), "AGENTS.md"), buildAppendableSetupBody(), AMBA_SETUP_START_MARKER, AMBA_SETUP_END_MARKER);
3062
+ }
3063
+ async function writeAllSetupTargets(options = {}) {
3064
+ const baseDir = options.baseDir ?? process.cwd();
3065
+ const warn = options.warn ?? (() => {});
3066
+ const written = [];
3067
+ const tasks = [
3068
+ {
3069
+ target: "claude-md",
3070
+ write: () => writeClaudeMd({ baseDir })
3071
+ },
3072
+ {
3073
+ target: "cursor-rule",
3074
+ write: () => writeCursorRule({ baseDir })
3075
+ },
3076
+ {
3077
+ target: "agents-md",
3078
+ write: () => writeAgentsMd({ baseDir })
3079
+ },
3080
+ {
3081
+ target: "windsurf-rule",
3082
+ write: () => writeWindsurfRule({ baseDir })
3083
+ }
3084
+ ];
3085
+ for (const task of tasks) try {
3086
+ const res = await task.write();
3087
+ written.push({
3088
+ target: task.target,
3089
+ path: res.path,
3090
+ mode: res.mode
3091
+ });
3092
+ } catch (err) {
3093
+ const message = err instanceof Error ? err.message : String(err);
3094
+ warn(` ! Skipped ${task.target} setup file: ${message}`);
3095
+ }
3096
+ return {
3097
+ written,
3098
+ bodyVersion: "v2"
3099
+ };
3100
+ }
1737
3101
  //#endregion
1738
3102
  //#region src/commands/init.ts
1739
3103
  function prompt(question) {
@@ -1748,6 +3112,85 @@ function prompt(question) {
1748
3112
  });
1749
3113
  });
1750
3114
  }
3115
+ function createSpinner(label) {
3116
+ if (!process.stdout.isTTY) return {
3117
+ setLabel: () => {},
3118
+ stop: () => {},
3119
+ get stopped() {
3120
+ return true;
3121
+ }
3122
+ };
3123
+ const frames = [
3124
+ "⠋",
3125
+ "⠙",
3126
+ "⠹",
3127
+ "⠸",
3128
+ "⠼",
3129
+ "⠴",
3130
+ "⠦",
3131
+ "⠧",
3132
+ "⠇",
3133
+ "⠏"
3134
+ ];
3135
+ let i = 0;
3136
+ let current = label;
3137
+ let isStopped = false;
3138
+ const render = () => {
3139
+ const frame = frames[i % frames.length];
3140
+ process.stdout.write(`\r ${pc.cyan(frame)} ${pc.dim(current)}${" ".repeat(8)}`);
3141
+ i += 1;
3142
+ };
3143
+ render();
3144
+ const handle = setInterval(render, 80);
3145
+ return {
3146
+ setLabel: (next) => {
3147
+ current = next;
3148
+ },
3149
+ stop: () => {
3150
+ if (isStopped) return;
3151
+ isStopped = true;
3152
+ clearInterval(handle);
3153
+ process.stdout.write("\r" + " ".repeat(80) + "\r");
3154
+ },
3155
+ get stopped() {
3156
+ return isStopped;
3157
+ }
3158
+ };
3159
+ }
3160
+ /**
3161
+ * Pretty-print the elapsed time. Sub-minute renders as seconds
3162
+ * ("12s"); above that renders as "1m 04s". The spinner ends with this
3163
+ * stamped into the first line of the success block so a developer who
3164
+ * just ran the command knows how long the network round trips took.
3165
+ */
3166
+ function formatElapsed(ms) {
3167
+ const totalSec = Math.max(0, Math.round(ms / 1e3));
3168
+ if (totalSec < 60) return `${totalSec}s`;
3169
+ const m = Math.floor(totalSec / 60);
3170
+ const s = totalSec % 60;
3171
+ return `${m}m ${String(s).padStart(2, "0")}s`;
3172
+ }
3173
+ /**
3174
+ * Truncate a long id to a compact preview — first 7 chars + ellipsis.
3175
+ * Mirrors how the API surfaces `id.slice(0, 8)` in other places. Keeps
3176
+ * the success block readable when project ids are full UUIDs.
3177
+ */
3178
+ function shortId(id) {
3179
+ if (id.length <= 10) return id;
3180
+ return `${id.slice(0, 7)}…`;
3181
+ }
3182
+ /**
3183
+ * Strip the project root from an absolute path for compact display in
3184
+ * the success block — `/Users/me/proj/.env.local` → `.env.local`,
3185
+ * `/Users/me/.claude.json` → `~/.claude.json` when a home dir is
3186
+ * provided. Pure cosmetic, never used for actual fs operations.
3187
+ */
3188
+ function relPathForDisplay(absPath, cwd, home) {
3189
+ if (absPath.startsWith(cwd + "/")) return absPath.slice(cwd.length + 1);
3190
+ const homeDir = home ?? process.env["HOME"] ?? "";
3191
+ if (homeDir && absPath.startsWith(homeDir + "/")) return "~/" + absPath.slice(homeDir.length + 1);
3192
+ return absPath;
3193
+ }
1751
3194
  async function fileExists(path) {
1752
3195
  try {
1753
3196
  await access(path);
@@ -1847,7 +3290,8 @@ Docs: https://docs.amba.dev
1847
3290
  }
1848
3291
  async function initCommand(options = {}) {
1849
3292
  const cwd = process.cwd();
1850
- if (options.sandbox) {
3293
+ const isNonTTY = process.stdin.isTTY !== true;
3294
+ if (options.sandbox === true || options.json === true || isNonTTY) {
1851
3295
  const result = await runSandboxInit(cwd, {
1852
3296
  sandboxEmail: options.sandboxEmail,
1853
3297
  noMcpConfig: options.noMcpConfig,
@@ -1860,226 +3304,298 @@ async function initCommand(options = {}) {
1860
3304
  return;
1861
3305
  }
1862
3306
  const environment = options.env ?? "development";
1863
- console.log();
1864
- console.log(pc.bold(" amba init"));
1865
- console.log(pc.dim(" ─────────────────────────────────"));
1866
- console.log();
1867
- console.log(pc.bold(" Step 1/7 ") + pc.dim("Authenticate"));
1868
- console.log();
1869
- const headlessActive = resolveTokenSource({
1870
- flagToken: void 0,
1871
- envToken: process.env["AMBA_PAT"]
1872
- }) !== null || process.argv.includes("--token") && process.argv.length > 2;
1873
- let needsAuth = true;
1874
- if (headlessActive) {
1875
- console.log(pc.green(" ✓") + " Headless auth — using PAT from --token / AMBA_PAT");
1876
- console.log(pc.dim(" (browser flow skipped; no credentials written to disk)"));
1877
- console.log();
1878
- needsAuth = false;
1879
- } else try {
1880
- if (!isTokenExpired(await loadCredentials())) {
1881
- console.log(pc.green(" ✓") + " Already authenticated");
1882
- console.log();
1883
- needsAuth = false;
1884
- }
1885
- } catch {}
1886
- if (needsAuth) {
1887
- const creds = await browserAuthFlow();
1888
- console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
1889
- await storeCredentials(creds);
1890
- console.log(pc.green(" ✓") + " Credentials saved to ~/.amba/credentials.json");
1891
- console.log();
1892
- } else {
1893
- console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
1894
- if (headlessActive) console.log(pc.green(" ✓") + " (skipped — PAT supplied)");
1895
- else console.log(pc.green(" ✓") + " Using existing credentials");
1896
- console.log();
1897
- }
1898
- console.log(pc.bold(" Step 3/7 ") + pc.dim("Select project"));
1899
- console.log();
1900
- let projectId;
1901
- let projectName;
3307
+ const startedAt = Date.now();
3308
+ const overridePat = getBearerOverride() ?? resolveTokenSource({ envToken: process.env["AMBA_PAT"] });
3309
+ const headlessActive = overridePat !== null;
3310
+ let identityPat = null;
3311
+ let identity = null;
3312
+ let credsBackedUpTo = null;
3313
+ let spinner = createSpinner("authenticating");
1902
3314
  try {
1903
- const projects = (await listProjects()).data;
1904
- if (projects.length > 0) {
1905
- console.log(" Existing projects:");
1906
- projects.forEach((p, i) => {
1907
- console.log(pc.dim(` ${i + 1}.`) + ` ${p.name} ` + pc.dim(`(${p.id})`));
1908
- });
1909
- console.log(pc.dim(` ${projects.length + 1}.`) + " Create new project");
1910
- console.log();
1911
- const choice = await prompt(` Select project (1-${projects.length + 1}): `);
1912
- const choiceNum = parseInt(choice, 10);
1913
- if (choiceNum > 0 && choiceNum <= projects.length) {
1914
- const selected = projects[choiceNum - 1];
1915
- if (!selected) throw new Error("Invalid selection");
1916
- projectId = selected.id;
1917
- projectName = selected.name;
1918
- console.log(pc.green(" ✓") + ` Selected: ${projectName}`);
1919
- } else {
1920
- const name = await prompt(" Project name: ");
1921
- if (!name) {
1922
- console.log(pc.red(" ✗") + " Project name is required");
1923
- process.exit(1);
3315
+ if (headlessActive) {
3316
+ identityPat = overridePat;
3317
+ try {
3318
+ identity = await verifyPat(identityPat);
3319
+ } catch (err) {
3320
+ const reason = err instanceof Error ? err.message : String(err);
3321
+ spinner.stop();
3322
+ console.error(pc.red(" ✗") + ` Could not verify supplied token: ${reason}`);
3323
+ process.exit(1);
3324
+ }
3325
+ if (!identity) {
3326
+ spinner.stop();
3327
+ console.error(pc.red(" ✗") + " Supplied token failed verification. Check --token / AMBA_PAT and try again.");
3328
+ process.exit(1);
3329
+ }
3330
+ } else {
3331
+ let needsBrowser = false;
3332
+ try {
3333
+ const ensured = await ensureDeveloperIdentity({ signupOnMissing: false });
3334
+ identity = ensured.developer;
3335
+ identityPat = ensured.credentials.pat;
3336
+ credsBackedUpTo = ensured.credentialsBackedUpTo;
3337
+ setBearerOverride(ensured.credentials.pat);
3338
+ } catch {
3339
+ needsBrowser = true;
3340
+ }
3341
+ if (needsBrowser) {
3342
+ spinner.stop();
3343
+ await storeCredentials(await browserAuthFlow());
3344
+ const ensured = await ensureDeveloperIdentity({ signupOnMissing: false });
3345
+ identity = ensured.developer;
3346
+ identityPat = ensured.credentials.pat;
3347
+ credsBackedUpTo = ensured.credentialsBackedUpTo;
3348
+ }
3349
+ }
3350
+ if (!identityPat || !identity) {
3351
+ spinner.stop();
3352
+ console.error(pc.red(" ✗") + " Could not establish an Amba identity.");
3353
+ process.exit(1);
3354
+ return;
3355
+ }
3356
+ const linkedProject = await loadProjectCredentials(cwd);
3357
+ const defaultProjectName = basename(cwd) || "amba-project";
3358
+ let projectId;
3359
+ let projectName;
3360
+ if (linkedProject) {
3361
+ projectId = linkedProject.project_id;
3362
+ projectName = linkedProject.project_name;
3363
+ } else {
3364
+ spinner.setLabel("loading projects");
3365
+ let projectsList = [];
3366
+ try {
3367
+ projectsList = (await listProjects()).data;
3368
+ } catch (err) {
3369
+ if (err instanceof Error && err.message.includes("authenticate")) {
3370
+ spinner.stop();
3371
+ throw err;
3372
+ }
3373
+ projectsList = [];
3374
+ }
3375
+ spinner.stop();
3376
+ if (projectsList.length > 0) {
3377
+ console.log();
3378
+ console.log(" Existing projects:");
3379
+ projectsList.forEach((p, i) => {
3380
+ const envBadge = p.environment ? pc.dim(` [${p.environment}]`) : "";
3381
+ console.log(pc.dim(` ${i + 1}.`) + ` ${p.name}${envBadge} ` + pc.dim(`(${p.id.slice(0, 8)}…)`));
3382
+ });
3383
+ const newOptionIdx = projectsList.length + 1;
3384
+ console.log(pc.dim(` ${newOptionIdx}.`) + ` Create new project ` + pc.dim(`(default name: ${defaultProjectName})`));
3385
+ console.log();
3386
+ const choice = await prompt(` Select project (1-${newOptionIdx}, default ${newOptionIdx}): `);
3387
+ const choiceNum = choice.length === 0 ? newOptionIdx : parseInt(choice, 10);
3388
+ if (choiceNum > 0 && choiceNum <= projectsList.length) {
3389
+ const selected = projectsList[choiceNum - 1];
3390
+ if (!selected) throw new Error("Invalid selection");
3391
+ projectId = selected.id;
3392
+ projectName = selected.name;
3393
+ } else {
3394
+ const name = await prompt(` Project name (default: ${defaultProjectName}): `);
3395
+ const finalName = name.length > 0 ? name : defaultProjectName;
3396
+ projectId = (await createProject({
3397
+ name: finalName,
3398
+ environment
3399
+ })).data.id;
3400
+ projectName = finalName;
1924
3401
  }
3402
+ } else {
3403
+ const name = await prompt(` Project name (default: ${defaultProjectName}): `);
3404
+ const finalName = name.length > 0 ? name : defaultProjectName;
1925
3405
  projectId = (await createProject({
1926
- name,
3406
+ name: finalName,
1927
3407
  environment
1928
3408
  })).data.id;
1929
- projectName = name;
1930
- console.log(pc.green(" ✓") + ` Created: ${projectName} ${pc.dim(`(${environment})`)}`);
3409
+ projectName = finalName;
1931
3410
  }
3411
+ }
3412
+ if (spinner.stopped) spinner = createSpinner("minting keys");
3413
+ const work = spinner;
3414
+ work.setLabel("minting keys");
3415
+ let clientKey;
3416
+ let serverKey;
3417
+ if (linkedProject) {
3418
+ clientKey = linkedProject.client_key;
3419
+ if (linkedProject.server_key) serverKey = linkedProject.server_key;
3420
+ else serverKey = (await createApiKey(projectId, "server", environment)).data.key;
1932
3421
  } else {
1933
- const name = await prompt(" Project name: ");
1934
- if (!name) {
1935
- console.log(pc.red(" ✗") + " Project name is required");
1936
- process.exit(1);
1937
- }
1938
- projectId = (await createProject({ name })).data.id;
1939
- projectName = name;
1940
- console.log(pc.green(" ✓") + ` Created: ${projectName}`);
3422
+ const clientRes = await createApiKey(projectId, "client", environment);
3423
+ const serverRes = await createApiKey(projectId, "server", environment);
3424
+ clientKey = clientRes.data.key;
3425
+ serverKey = serverRes.data.key;
1941
3426
  }
1942
- } catch (err) {
1943
- if (err instanceof Error && err.message.includes("authenticate")) throw err;
1944
- const name = await prompt(" Project name: ");
1945
- if (!name) {
1946
- console.log(pc.red(" ✗") + " Project name is required");
3427
+ work.setLabel("writing project state");
3428
+ const apiUrl = process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
3429
+ const envLocalPath = await writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl, serverKey);
3430
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
3431
+ const projectJsonPath = await writeProjectCredentials(cwd, linkedProject ? {
3432
+ ...linkedProject,
3433
+ client_key: clientKey,
3434
+ server_key: serverKey,
3435
+ environment,
3436
+ api_url: apiUrl,
3437
+ updated_at: nowIso
3438
+ } : {
3439
+ version: 1,
3440
+ project_id: projectId,
3441
+ project_name: projectName,
3442
+ environment,
3443
+ client_key: clientKey,
3444
+ server_key: serverKey,
3445
+ api_url: apiUrl,
3446
+ wired_surfaces: [],
3447
+ created_at: nowIso,
3448
+ updated_at: nowIso
3449
+ });
3450
+ work.setLabel("detecting framework");
3451
+ const framework = await detectFramework(cwd);
3452
+ const sdkPkg = getSdkPackage(framework);
3453
+ let installCmd = `npm install ${sdkPkg}`;
3454
+ if (await fileExists(join(cwd, "bun.lockb"))) installCmd = `bun add ${sdkPkg}`;
3455
+ else if (await fileExists(join(cwd, "pnpm-lock.yaml"))) installCmd = `pnpm add ${sdkPkg}`;
3456
+ else if (await fileExists(join(cwd, "yarn.lock"))) installCmd = `yarn add ${sdkPkg}`;
3457
+ work.setLabel("wiring agents");
3458
+ await generateContextFiles({
3459
+ projectId,
3460
+ projectName,
3461
+ apiKey: clientKey,
3462
+ framework,
3463
+ cwd
3464
+ });
3465
+ let mcpResults = [];
3466
+ let mcpManualSnippetNeeded = false;
3467
+ if (!options.noMcpConfig) {
3468
+ mcpResults = await writeAllMcpConfigs(cwd, identityPat, {
3469
+ homeDir: options.homeDir,
3470
+ warn: (msg) => process.stderr.write(msg + "\n")
3471
+ });
3472
+ mcpManualSnippetNeeded = mcpResults.length === 0;
3473
+ }
3474
+ if (options.withExample) await writeExampleScaffold(cwd, framework, projectName);
3475
+ work.setLabel("verifying");
3476
+ const finalVerify = await verifyPat(identityPat);
3477
+ work.stop();
3478
+ if (!finalVerify) {
3479
+ console.error(pc.red(" ✗") + " Post-write verify failed. PAT was minted but no longer accepted.");
3480
+ console.error(pc.dim(" Inspect ~/.amba/credentials.json + ") + pc.dim(".env.local — your provisioning may be incomplete."));
1947
3481
  process.exit(1);
1948
3482
  }
1949
- projectId = (await createProject({ name })).data.id;
1950
- projectName = name;
1951
- console.log(pc.green(" ✓") + ` Created: ${projectName}`);
1952
- }
1953
- console.log();
1954
- console.log(pc.bold(" Step 4/7 ") + pc.dim("Generate API keys"));
1955
- const keyRes = await createApiKey(projectId, "client", "development");
1956
- const apiKey = keyRes.data.key;
1957
- console.log(pc.green(" ✓") + " Development client key created");
1958
- console.log(pc.dim(` ${keyRes.data.key_prefix}...`));
1959
- console.log();
1960
- console.log(pc.bold(" Step 5/7 ") + pc.dim("Write environment file"));
1961
- const envPath = join(cwd, ".env.local");
1962
- const envLines = [
1963
- "# Amba SDK Configuration",
1964
- `AMBA_PROJECT_ID=${projectId}`,
1965
- `AMBA_API_KEY=${apiKey}`,
1966
- `AMBA_API_URL=https://api.amba.dev`,
1967
- ""
1968
- ];
1969
- if (await fileExists(envPath)) {
1970
- const existing = await readFile(envPath, "utf-8");
1971
- if (existing.includes("AMBA_PROJECT_ID")) {
1972
- console.log(pc.yellow(" !") + " .env.local already contains Amba config — updating");
1973
- let updated = existing;
1974
- updated = updated.replace(/AMBA_PROJECT_ID=.*/, `AMBA_PROJECT_ID=${projectId}`);
1975
- updated = updated.replace(/AMBA_API_KEY=.*/, `AMBA_API_KEY=${apiKey}`);
1976
- updated = updated.replace(/AMBA_API_URL=.*/, `AMBA_API_URL=https://api.amba.dev`);
1977
- await writeFile(envPath, updated, "utf-8");
1978
- } else await writeFile(envPath, existing + (existing.endsWith("\n") ? "\n" : "\n\n") + envLines.join("\n"), "utf-8");
1979
- } else await writeFile(envPath, envLines.join("\n"), "utf-8");
1980
- console.log(pc.green(" ✓") + " .env.local written");
1981
- console.log();
1982
- console.log(pc.bold(" Step 6/7 ") + pc.dim("Detect framework"));
1983
- const framework = await detectFramework(cwd);
1984
- const sdkPkg = getSdkPackage(framework);
1985
- if (framework !== "unknown") console.log(pc.green(" ✓") + ` Detected: ${pc.bold(framework)}`);
1986
- else console.log(pc.yellow(" !") + " Could not detect framework");
1987
- let installCmd = `npm install ${sdkPkg}`;
1988
- if (await fileExists(join(cwd, "bun.lockb"))) installCmd = `bun add ${sdkPkg}`;
1989
- else if (await fileExists(join(cwd, "pnpm-lock.yaml"))) installCmd = `pnpm add ${sdkPkg}`;
1990
- else if (await fileExists(join(cwd, "yarn.lock"))) installCmd = `yarn add ${sdkPkg}`;
1991
- console.log(pc.dim(` Install SDK: ${installCmd}`));
1992
- console.log();
1993
- console.log(pc.bold(" Step 7/7 ") + pc.dim("Generate context files"));
1994
- const generatedFiles = await generateContextFiles({
1995
- projectId,
1996
- projectName,
1997
- apiKey,
1998
- framework,
1999
- cwd
2000
- });
2001
- for (const file of generatedFiles) console.log(pc.green(" ✓") + ` ${file}`);
2002
- if (options.withExample) {
2003
- const exampleFiles = await writeExampleScaffold(cwd, framework, projectName);
2004
- if (exampleFiles.length > 0) for (const file of exampleFiles) console.log(pc.green(" ✓") + ` ${file} ` + pc.dim("(example)"));
2005
- else console.log(pc.dim(" -") + " example files already present — skipping");
2006
- }
2007
- console.log();
2008
- console.log(pc.dim(" ─────────────────────────────────"));
2009
- console.log();
2010
- console.log(pc.bold(pc.green(" ✓ Project initialized!")));
2011
- console.log();
2012
- console.log(" Quick start:");
2013
- console.log();
2014
- console.log(pc.dim(" 1.") + ` Install the SDK`);
2015
- console.log(` ${pc.cyan(installCmd)}`);
2016
- console.log();
2017
- console.log(pc.dim(" 2.") + ` Add the provider to your app`);
2018
- if (framework === "expo") console.log(pc.dim(` See AMBA.md for Amba.init() setup`));
2019
- else if (framework === "react-native") console.log(pc.dim(` See AMBA.md for client initialization`));
2020
- else console.log(pc.dim(` See AMBA.md for client initialization`));
2021
- console.log();
2022
- console.log(pc.dim(" 3.") + ` Test the integration`);
2023
- console.log(` ${pc.cyan("amba status")}`);
2024
- console.log();
2025
- console.log(pc.dim(" 4.") + ` Send a test notification`);
2026
- console.log(` ${pc.cyan("amba push test")}`);
2027
- console.log();
2028
- console.log(` Docs: ${pc.underline("https://docs.amba.dev")}`);
2029
- console.log();
2030
- }
2031
- async function runSandboxInit(cwd, options) {
2032
- if (!options.json) {
3483
+ const elapsed = formatElapsed(Date.now() - startedAt);
3484
+ const homeForDisplay = options.homeDir;
3485
+ const envRel = relPathForDisplay(envLocalPath, cwd, homeForDisplay);
3486
+ const projectJsonRel = relPathForDisplay(projectJsonPath, cwd, homeForDisplay);
2033
3487
  console.log();
2034
- console.log(pc.bold(" amba init --sandbox"));
2035
- console.log(pc.dim(" ─────────────────────────────────"));
3488
+ console.log(pc.green(" ✓") + pc.bold(` Amba ready in ${elapsed}`));
3489
+ console.log(pc.dim(" project: ") + projectName + pc.dim(` (id: ${shortId(projectId)})`));
3490
+ console.log(pc.dim(" keys → ") + envRel + pc.dim(` · state → ${projectJsonRel}`));
3491
+ if (mcpResults.length > 0) {
3492
+ const mcpPathsRel = mcpResults.map((m) => relPathForDisplay(m.path, cwd, homeForDisplay)).join(", ");
3493
+ console.log(pc.dim(" mcp → ") + mcpPathsRel + pc.dim(" (active next agent launch)"));
3494
+ for (const m of mcpResults) if (m.backedUpTo) console.log(pc.yellow(" note: previous amba entry backed up to ") + relPathForDisplay(m.backedUpTo, cwd, homeForDisplay));
3495
+ } else if (mcpManualSnippetNeeded) console.log(pc.dim(" mcp → ") + "no MCP client config detected (paste snippet below)");
3496
+ if (credsBackedUpTo) console.log(pc.dim(" note: previous credentials backed up to ") + credsBackedUpTo);
2036
3497
  console.log();
3498
+ console.log(pc.dim(" next: ") + pc.cyan(installCmd));
3499
+ console.log(pc.dim(" later: ") + pc.cyan("amba claim <your-email>") + pc.dim(" to upgrade past sandbox"));
3500
+ console.log();
3501
+ if (mcpManualSnippetNeeded && !options.noMcpConfig) {
3502
+ console.log(pc.dim(" Paste into your MCP client config:"));
3503
+ for (const line of formatManualMcpSnippet(identityPat).split("\n")) console.log(pc.dim(" ") + line);
3504
+ console.log();
3505
+ }
3506
+ } finally {
3507
+ spinner.stop();
2037
3508
  }
2038
- const signup = await performSandboxSignup({
2039
- email: options.sandboxEmail?.trim() || generateSandboxEmail(),
2040
- password: generateSandboxPassword()
3509
+ }
3510
+ async function runSandboxInit(cwd, options) {
3511
+ const warn = (msg) => {
3512
+ process.stderr.write(msg + "\n");
3513
+ };
3514
+ const overridePat = getBearerOverride() ?? resolveTokenSource({ envToken: process.env["AMBA_PAT"] });
3515
+ let identity;
3516
+ if (overridePat) {
3517
+ const verified = await verifyPat(overridePat);
3518
+ if (!verified) throw new Error("Supplied --token / AMBA_PAT failed verification against /developer/me. Check that the token is valid and try again.");
3519
+ identity = {
3520
+ credentials: {
3521
+ pat: overridePat,
3522
+ email: verified.email
3523
+ },
3524
+ newlySignedUp: false,
3525
+ developer: verified,
3526
+ firstProject: null,
3527
+ credentialsBackedUpTo: null,
3528
+ credentialsPath: "(supplied via --token / AMBA_PAT — not persisted)"
3529
+ };
3530
+ } else identity = await ensureDeveloperIdentity({
3531
+ homeDir: options.homeDir,
3532
+ sandboxEmail: options.sandboxEmail
2041
3533
  });
2042
- const envLocalPath = await writeSandboxEnvLocal(cwd, signup.project_id, signup.client_key, signup.api_url);
3534
+ setBearerOverride(identity.credentials.pat);
2043
3535
  const framework = await detectFramework(cwd);
2044
3536
  const sdkPackage = getSdkPackage(framework);
3537
+ const project = await ensureProjectForCwd(cwd, {
3538
+ pat: identity.credentials.pat,
3539
+ signupFirstProject: identity.firstProject ?? void 0,
3540
+ defaultName: basename(cwd) || "amba-sandbox"
3541
+ });
3542
+ const envLocalPath = await writeSandboxEnvLocal(cwd, project.credentials.project_id, project.credentials.client_key, project.credentials.api_url, project.credentials.server_key);
2045
3543
  const ambaMdPath = await writeSandboxAmbaMd(cwd, {
2046
- projectId: signup.project_id,
2047
- email: signup.email,
2048
- verifyUrl: signup.verify_url ?? null,
3544
+ projectId: project.credentials.project_id,
3545
+ email: identity.credentials.email,
3546
+ verifyUrl: identity.firstProject?.verify_url ?? null,
2049
3547
  sdkPackage,
2050
3548
  framework,
2051
- apiUrl: signup.api_url
3549
+ apiUrl: project.credentials.api_url
2052
3550
  });
2053
- const warn = options.json ? (msg) => process.stderr.write(msg + "\n") : (msg) => console.warn(msg);
2054
3551
  let mcpConfigsWritten = [];
2055
- if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd, signup.pat, {
3552
+ if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd, identity.credentials.pat, {
2056
3553
  homeDir: options.homeDir,
2057
3554
  warn
2058
3555
  });
2059
- const credentialsResult = await writeSandboxCredentials(signup.pat, { homeDir: options.homeDir });
2060
3556
  let skillPath = null;
2061
- if (!options.noSkills) try {
2062
- skillPath = (await writeAmbaBuildSkill({ baseDir: cwd })).path;
2063
- } catch (err) {
2064
- warn(` ! Skipped /amba-build skill install: ${err instanceof Error ? err.message : String(err)}`);
3557
+ let setupTargets = [];
3558
+ if (!options.noSkills) {
3559
+ try {
3560
+ const installResults = await installSkillBundle(cwd);
3561
+ summarizeSkillInstall(installResults);
3562
+ skillPath = (installResults.find((r) => r.target.kind === "claude-code")?.files.find((f) => f.path.endsWith("SKILL.md")))?.path ?? null;
3563
+ } catch (err) {
3564
+ warn(` ! Skipped amba skill bundle install: ${err instanceof Error ? err.message : String(err)}`);
3565
+ }
3566
+ try {
3567
+ await writeAmbaBuildSkill({ baseDir: cwd });
3568
+ } catch (err) {
3569
+ warn(` ! Skipped legacy /amba-build skill: ${err instanceof Error ? err.message : String(err)}`);
3570
+ }
3571
+ setupTargets = (await writeAllSetupTargets({
3572
+ baseDir: cwd,
3573
+ warn
3574
+ })).written.map((w) => ({
3575
+ target: w.target,
3576
+ path: w.path,
3577
+ mode: w.mode
3578
+ }));
2065
3579
  }
3580
+ if (!await verifyPat(identity.credentials.pat, { apiUrl: project.credentials.api_url })) throw new Error("Post-write verify failed: PAT was provisioned but no longer accepted by /developer/me. This usually means the control-plane signup race hasn't settled yet — retry in 5s.");
2066
3581
  return {
2067
- email: signup.email,
2068
- projectId: signup.project_id,
2069
- pat: signup.pat,
2070
- patPreview: `${signup.pat.slice(0, 12)}…${signup.pat.slice(-4)}`,
2071
- clientKey: signup.client_key,
2072
- apiUrl: signup.api_url,
2073
- credentialsPath: credentialsResult.path,
2074
- credentialsBackedUpTo: credentialsResult.backedUpTo,
3582
+ email: identity.credentials.email,
3583
+ projectId: project.credentials.project_id,
3584
+ pat: identity.credentials.pat,
3585
+ patPreview: `${identity.credentials.pat.slice(0, 12)}…${identity.credentials.pat.slice(-4)}`,
3586
+ clientKey: project.credentials.client_key,
3587
+ apiUrl: project.credentials.api_url,
3588
+ credentialsPath: identity.credentialsPath,
3589
+ credentialsBackedUpTo: identity.credentialsBackedUpTo,
2075
3590
  envLocalPath,
2076
3591
  ambaMdPath,
2077
3592
  mcpConfigsWritten,
2078
3593
  sdkPackage,
2079
3594
  framework,
2080
- verifyUrl: signup.verify_url ?? null,
2081
- provisioningStatus: signup.provisioning_status ?? "unknown",
2082
- skillPath
3595
+ verifyUrl: identity.firstProject?.verify_url ?? null,
3596
+ provisioningStatus: identity.firstProject?.provisioning_status ?? "active",
3597
+ skillPath,
3598
+ setupTargets
2083
3599
  };
2084
3600
  }
2085
3601
  function sandboxResultToJson(r) {
@@ -2102,83 +3618,83 @@ function sandboxResultToJson(r) {
2102
3618
  backed_up_to: m.backedUpTo
2103
3619
  })),
2104
3620
  skill_path: r.skillPath,
3621
+ setup_targets: r.setupTargets.map((t) => ({
3622
+ target: t.target,
3623
+ path: t.path,
3624
+ mode: t.mode
3625
+ })),
2105
3626
  verify_url: r.verifyUrl,
2106
3627
  provisioning_status: r.provisioningStatus,
2107
- next_steps: ["restart your MCP client"]
3628
+ next_steps: [`npm install ${r.sdkPackage}`, "call Amba.configure({ projectId, clientKey }) at app startup"],
3629
+ runtime_mcp: {
3630
+ configs_written: r.mcpConfigsWritten.map((m) => m.path),
3631
+ activates_on: "next agent launch",
3632
+ in_session_inline_pat: true
3633
+ }
2108
3634
  };
2109
3635
  }
2110
3636
  /**
2111
3637
  * Build the plaintext (no ANSI) success-output block printed at the
2112
- * end of `amba init --sandbox`. Pure function — exported only for the
2113
- * vitest cases that assert on per-line content. The CLI wraps each
2114
- * line with picocolors in `printSandboxNextSteps` below.
2115
- *
2116
- * Structure (in order):
2117
- * 1. Per-artifact checklist (`✓ ...` lines)
2118
- * 2. SDK install + configure hint
2119
- * 3. "Done." + per-client restart bullets — ONLY for clients we
2120
- * actually wrote configs to. Never instruct the user to "restart
2121
- * Cursor" if we only touched `~/.claude.json`. When no config
2122
- * was written (manual-paste path), falls back to a generic
2123
- * "quit + reopen" line.
2124
- * 4. Tail-line resume hint + sandbox limits + verify URL.
2125
- *
2126
- * We intentionally do NOT print any "kill -HUP / SIGHUP" advanced
2127
- * workaround. Telling the agent to surface a clean "restart your MCP
2128
- * client" instruction to the human is the whole optimization — adding
2129
- * an experimental fallback just dilutes the signal and risks the
2130
- * agent surfacing the workaround instead.
3638
+ * end of `amba init --sandbox`. Pure function — exported for the
3639
+ * vitest cases that assert on per-line content. The CLI wraps the
3640
+ * output with picocolors in `printSandboxNextSteps` below.
3641
+ *
3642
+ * Design: silent-until-done. The install (provision account, mint
3643
+ * keys, write .env.local, write MCP config, install skill) is COMPLETE
3644
+ * the moment this output lands. The MCP config has been persisted —
3645
+ * it activates on the next launch of the developer's coding agent. We
3646
+ * do NOT instruct the developer to restart anything; we just state
3647
+ * what's wired and what's next.
3648
+ *
3649
+ * Shape (~6 lines, Vercel/Stripe aesthetic):
3650
+ * ✓ Amba ready
3651
+ * project: <id>
3652
+ * keys → .env.local
3653
+ * mcp → <paths> (active next agent launch)
3654
+ * skill → <skill paths> (when installed)
3655
+ *
3656
+ * next: npm install <sdk-pkg>
3657
+ * Amba.configure({ projectId, clientKey }) at app startup
3658
+ *
3659
+ * The fallback for "no MCP client config detected" is a one-line
3660
+ * note + a paste-ready snippet — still no restart copy.
2131
3661
  */
2132
3662
  function buildSandboxNextStepsLines(r) {
2133
3663
  const lines = [];
2134
- lines.push(`✓ Sandbox account provisioned (${r.email})`);
2135
- lines.push(`✓ Project created: ${r.projectId}`);
2136
- lines.push(`✓ Credentials saved to ~/.amba/credentials.json`);
2137
- if (r.credentialsBackedUpTo) lines.push(` ! Existing non-sandbox credentials backed up to ${r.credentialsBackedUpTo}`);
2138
- lines.push(`✓ .env.local updated with AMBA_PROJECT_ID + AMBA_CLIENT_KEY + AMBA_API_URL`);
2139
- lines.push(`✓ AMBA.md written (sandbox guide)`);
2140
- if (r.skillPath) lines.push(`✓ /amba-build skill installed: ${r.skillPath}`);
2141
- const clientKinds = clientKindsFromPaths(r.mcpConfigsWritten.map((m) => m.path));
2142
- if (r.mcpConfigsWritten.length > 0) for (const m of r.mcpConfigsWritten) {
2143
- lines.push(`✓ MCP config updated: ${m.path} (entry: 'amba')`);
2144
- if (m.backedUpTo) lines.push(` ! Existing amba entry backed up to ${m.backedUpTo}`);
2145
- }
2146
- else {
2147
- lines.push(`! No MCP client config detected — paste this into your MCP client's config manually:`);
3664
+ lines.push(`✓ Amba ready`);
3665
+ lines.push(` project: ${shortId(r.projectId)} (${r.email})`);
3666
+ lines.push(` keys → ${r.envLocalPath}`);
3667
+ if (r.mcpConfigsWritten.length > 0) {
3668
+ const mcpPaths = r.mcpConfigsWritten.map((m) => m.path).join(", ");
3669
+ lines.push(` mcp → ${mcpPaths} (active next agent launch)`);
3670
+ for (const m of r.mcpConfigsWritten) if (m.backedUpTo) lines.push(` note: previous amba entry backed up to ${m.backedUpTo}`);
3671
+ } else lines.push(` mcp → no MCP client config detected (paste snippet below)`);
3672
+ if (r.skillPath) lines.push(` skill → ${r.skillPath}`);
3673
+ if (r.credentialsBackedUpTo) lines.push(` note: previous non-sandbox credentials backed up to ${r.credentialsBackedUpTo}`);
3674
+ lines.push("");
3675
+ lines.push(` next: npm install ${r.sdkPackage}`);
3676
+ lines.push(` Amba.configure({ projectId: process.env.AMBA_PROJECT_ID, clientKey: process.env.AMBA_CLIENT_KEY })`);
3677
+ lines.push(` later: amba claim <your-email> to upgrade past sandbox`);
3678
+ if (r.mcpConfigsWritten.length === 0) {
2148
3679
  lines.push("");
3680
+ lines.push(` Paste into your MCP client config:`);
2149
3681
  for (const snippetLine of formatManualMcpSnippet(r.pat).split("\n")) lines.push(` ${snippetLine}`);
2150
- lines.push("");
2151
3682
  }
2152
- lines.push("");
2153
- lines.push("Next:");
2154
- lines.push(` → npm install ${r.sdkPackage}`);
2155
- lines.push(` → Initialize the SDK in your app entry: Amba.configure({ projectId: process.env.AMBA_PROJECT_ID, clientKey: process.env.AMBA_CLIENT_KEY })`);
2156
- lines.push("");
2157
- lines.push("Done. Restart your MCP client so it loads the Amba MCP server:");
2158
- if (clientKinds.length === 0) lines.push(" • Quit your MCP client (Cmd+Q on macOS) and reopen it.");
2159
- else for (const kind of clientKinds) lines.push(` • ${restartHintForClient(kind)}`);
2160
- lines.push("");
2161
- lines.push("After the restart, re-ask the original question — Amba's MCP toolset will be available.");
2162
- if (r.skillPath) lines.push("Tip: paste `/amba-build <design-url>` to scaffold a full Expo app with Amba as the backend.");
2163
- lines.push("");
2164
- lines.push(`Sandbox limits: 100 MAU, 10 MB DB. Verify ${r.email} in the console to upgrade to Free.`);
2165
- if (r.verifyUrl) lines.push(`Verify URL: ${r.verifyUrl}`);
2166
3683
  return lines;
2167
3684
  }
2168
- function restartHintForClient(kind) {
2169
- switch (kind) {
2170
- case "claude-code": return "Claude Code: Cmd+Q, then reopen";
2171
- case "cursor": return "Cursor: Cmd+Q, then reopen";
2172
- case "windsurf": return "Windsurf: quit + relaunch";
2173
- }
2174
- }
2175
3685
  function printSandboxNextSteps(r) {
2176
- for (const line of buildSandboxNextStepsLines(r)) if (line.startsWith("✓ ")) console.log(pc.green(" " + line.slice(0, 2)) + line.slice(2));
2177
- else if (line.startsWith("! ")) console.log(pc.yellow(" " + line.slice(0, 2)) + line.slice(2));
2178
- else if (line.startsWith(" ! ")) console.log(pc.yellow(" ! ") + line.slice(4));
2179
- else if (line.startsWith("Next:") || line.startsWith("Done. Restart your MCP client")) console.log(pc.bold(" " + line));
2180
- else if (line.startsWith("Sandbox limits:") || line.startsWith("Verify URL:") || line.startsWith("After the restart")) console.log(pc.dim(" " + line));
2181
- else console.log(" " + line);
3686
+ const lines = buildSandboxNextStepsLines(r);
3687
+ console.log();
3688
+ for (const line of lines) if (line.startsWith("✓ ")) console.log(" " + pc.green("✓") + pc.bold(line.slice(1)));
3689
+ else if (line.startsWith(" note:")) console.log(" " + pc.yellow(line.slice(2)));
3690
+ else if (line.startsWith(" next:")) {
3691
+ const cmd = line.slice(8);
3692
+ console.log(" " + pc.dim("next: ") + pc.cyan(cmd));
3693
+ } else if (line.startsWith(" later:")) {
3694
+ const cmd = line.slice(9);
3695
+ console.log(" " + pc.dim("later: ") + pc.cyan(cmd));
3696
+ } else if (line.startsWith(" project:") || line.startsWith(" keys") || line.startsWith(" mcp") || line.startsWith(" skill")) console.log(pc.dim(line));
3697
+ else console.log(line);
2182
3698
  console.log();
2183
3699
  }
2184
3700
  //#endregion
@@ -2551,7 +4067,7 @@ function confirm(question) {
2551
4067
  });
2552
4068
  });
2553
4069
  }
2554
- function handleError(err) {
4070
+ function handleError$1(err) {
2555
4071
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
2556
4072
  else console.log(pc.red(" ✗") + ` ${err.message}`);
2557
4073
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -2601,7 +4117,7 @@ async function projectsListCommand() {
2601
4117
  console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
2602
4118
  console.log();
2603
4119
  } catch (err) {
2604
- handleError(err);
4120
+ handleError$1(err);
2605
4121
  }
2606
4122
  }
2607
4123
  async function projectsCreateCommand(input) {
@@ -2627,6 +4143,7 @@ async function projectsCreateCommand(input) {
2627
4143
  const res = await createProject({
2628
4144
  name: input.name,
2629
4145
  bundle_id: input.bundleId,
4146
+ google_oauth_client_id: input.googleOauthClientId,
2630
4147
  platform: input.platform,
2631
4148
  environment
2632
4149
  });
@@ -2637,7 +4154,7 @@ async function projectsCreateCommand(input) {
2637
4154
  try {
2638
4155
  const s = (await getProvisioningStatus(id)).data;
2639
4156
  console.log(pc.dim(` Status: ${s.status}`));
2640
- if (s.errorMessage) console.log(pc.yellow(" !") + ` ${s.errorMessage}`);
4157
+ if (s.region) console.log(pc.dim(` Region: ${s.region}`));
2641
4158
  } catch {
2642
4159
  console.log(pc.dim(" (Provisioning runs asynchronously.)"));
2643
4160
  }
@@ -2646,7 +4163,33 @@ async function projectsCreateCommand(input) {
2646
4163
  console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
2647
4164
  console.log();
2648
4165
  } catch (err) {
2649
- handleError(err);
4166
+ handleError$1(err);
4167
+ }
4168
+ }
4169
+ async function projectsUpdateCommand(projectId, input) {
4170
+ console.log();
4171
+ console.log(pc.bold(` amba projects update ${projectId}`));
4172
+ console.log(pc.dim(" ─────────────────────────────────"));
4173
+ console.log();
4174
+ const patch = {};
4175
+ if (input.name !== void 0) patch.name = input.name;
4176
+ if (input.bundleId !== void 0) patch.bundle_id = input.bundleId;
4177
+ if (input.googleOauthClientId !== void 0) patch.google_oauth_client_id = input.googleOauthClientId;
4178
+ if (input.platform !== void 0) patch.platform = input.platform;
4179
+ if (input.environment !== void 0) patch.environment = input.environment;
4180
+ try {
4181
+ const res = await updateProject(projectId, patch);
4182
+ console.log(pc.green(" ✓") + ` Updated ${pc.bold(res.data.name)} ${pc.dim(`(${res.data.id})`)}`);
4183
+ if (res.data.bundle_id) console.log(pc.dim(` Bundle ID: ${res.data.bundle_id}`));
4184
+ if (res.data.google_oauth_client_id) console.log(pc.dim(` Google OAuth client id: ${res.data.google_oauth_client_id}`));
4185
+ console.log();
4186
+ } catch (err) {
4187
+ if (err instanceof ApiClientError && err.statusCode === 404) {
4188
+ console.log(pc.red(" ✗") + ` Project not found: ${projectId}`);
4189
+ console.log();
4190
+ process.exit(1);
4191
+ }
4192
+ handleError$1(err);
2650
4193
  }
2651
4194
  }
2652
4195
  async function projectsShowCommand(projectId) {
@@ -2664,7 +4207,7 @@ async function projectsShowCommand(projectId) {
2664
4207
  console.log();
2665
4208
  process.exit(1);
2666
4209
  }
2667
- handleError(err);
4210
+ handleError$1(err);
2668
4211
  }
2669
4212
  }
2670
4213
  async function projectsDeleteCommand(projectId, opts = {}) {
@@ -2689,7 +4232,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
2689
4232
  console.log();
2690
4233
  process.exit(1);
2691
4234
  }
2692
- handleError(err);
4235
+ handleError$1(err);
2693
4236
  }
2694
4237
  }
2695
4238
  //#endregion
@@ -2993,14 +4536,14 @@ async function dbMigrateCommand(opts = {}) {
2993
4536
  console.log(pc.dim(" Running tenant migrations..."));
2994
4537
  console.log();
2995
4538
  try {
2996
- const workflowId = (await reprovisionProject(projectId)).data.workflowId;
2997
- console.log(pc.green(" ✓") + " Reprovision workflow started");
2998
- if (workflowId) console.log(pc.dim(` workflowId: ${workflowId}`));
4539
+ const jobId = (await reprovisionProject(projectId)).data.job_id;
4540
+ console.log(pc.green(" ✓") + " Reprovision started");
4541
+ if (jobId) console.log(pc.dim(` job: ${jobId}`));
2999
4542
  console.log();
3000
4543
  try {
3001
4544
  const status = await getProvisioningStatus(projectId);
3002
4545
  console.log(pc.dim(` Status: ${status.data.status}`));
3003
- if (status.data.errorMessage) console.log(pc.yellow(" !") + ` ${status.data.errorMessage}`);
4546
+ if (status.data.region) console.log(pc.dim(` Region: ${status.data.region}`));
3004
4547
  } catch {}
3005
4548
  console.log();
3006
4549
  console.log(pc.dim(" Check again with: ") + pc.cyan(`amba projects show ${projectId}`));
@@ -3410,36 +4953,44 @@ function parseEnv(content) {
3410
4953
  /**
3411
4954
  * Customer-function bundling for `amba functions deploy`.
3412
4955
  *
3413
- * Uses esbuild (the Workers ecosystem bundler-of-record). The shared
3414
- * runtime stdlib is marked `external` so customer bundles don't
3415
- * re-include megabytes of `@anthropic-ai/sdk`, `postgres`, `zod`, etc.;
3416
- * these resolve at dispatch time via platform-level bindings.
4956
+ * Uses esbuild. Customer code is bundled into a single self-contained
4957
+ * ES module — the upstream runtime resolves nothing at dispatch time
4958
+ * except built-in JavaScript globals.
3417
4959
  *
3418
4960
  * Two checks gate the bundle before upload:
3419
4961
  * 1. Pre-upload size check against `BUNDLE_MAX_SIZE_BYTES` (8 MB
3420
4962
  * default — the platform's 10 MB compressed cap minus 2 MB
3421
4963
  * headroom) with a clear error pointing at the externalization
3422
4964
  * config.
3423
- * 2. Bundle-shape report — the CLI prints what's externalized vs
3424
- * bundled at deploy time so size issues are debuggable.
4965
+ * 2. Bundle-shape report — the CLI prints what's bundled vs
4966
+ * externalized at deploy time so size issues are debuggable.
4967
+ *
4968
+ * History note (2026-05-27): the prior version of this file pinned
4969
+ * `@layers/amba-functions` + `@layers/amba-api-middleware` as
4970
+ * "platform-level bindings" externals. Neither is — they were
4971
+ * server-side packages, and `@layers/amba-functions` was unpublished
4972
+ * in the 4.0.2 cutover (a deprecated wrapper that never matched the
4973
+ * actual runtime). Any function importing one of them was rejected
4974
+ * upstream as "no such module." The default externals list is now
4975
+ * empty; customer code is expected to be self-contained.
3425
4976
  */
3426
4977
  /**
3427
- * Modules customer code MUST externalize. The runtime exposes these as
3428
- * platform-level bindings; bundling them per-script wastes hundreds of
3429
- * KB to MBs and quickly hits the script-size cap.
4978
+ * Modules the bundler treats as `external` by default. The Amba
4979
+ * function runtime exposes zero npm packages — there is no "platform
4980
+ * stdlib" for customer functions to import. Keep this list empty.
4981
+ *
4982
+ * The `extraExternals` field on `BundleOptions` is a programmatic
4983
+ * escape hatch (used by tests + future CLI wiring). It's intentionally
4984
+ * not exposed as a `amba functions deploy` flag today — externalizing
4985
+ * a module that isn't actually provided at runtime is exactly the
4986
+ * footgun this list-defaults-to-empty change closes.
3430
4987
  */
3431
- const RUNTIME_STDLIB_EXTERNALS = [
3432
- "@layers/amba-functions",
3433
- "@layers/amba-api-middleware",
3434
- "@anthropic-ai/sdk",
3435
- "postgres",
3436
- "zod"
3437
- ];
4988
+ const RUNTIME_STDLIB_EXTERNALS = [];
3438
4989
  var BundleSizeError = class extends Error {
3439
4990
  sizeBytes;
3440
4991
  maxBytes;
3441
4992
  constructor(sizeBytes, maxBytes) {
3442
- super(`Function bundle is ${formatBytes$1(sizeBytes)} which exceeds the ${formatBytes$1(maxBytes)} cap. Externalize heavy dependencies via the runtime stdlib (see RUNTIME_STDLIB_EXTERNALS) or split your function into smaller pieces.`);
4993
+ super(`Function bundle is ${formatBytes$1(sizeBytes)} which exceeds the ${formatBytes$1(maxBytes)} cap. Split the function into smaller pieces or drop heavy dependencies. (There is no runtime-provided npm stdlib to externalize against — every import must bundle.)`);
3443
4994
  this.sizeBytes = sizeBytes;
3444
4995
  this.maxBytes = maxBytes;
3445
4996
  this.name = "BundleSizeError";
@@ -3496,8 +5047,10 @@ function printBundleReport(bundle) {
3496
5047
  console.log(pc.dim(" Bundle:"));
3497
5048
  console.log(pc.dim(" size: ") + `${formatBytes$1(bundle.compressedSize)} compressed ` + pc.dim(`(${formatBytes$1(bundle.uncompressedSize)} raw)`));
3498
5049
  console.log(pc.dim(" sha: ") + bundle.sha256.slice(0, 16) + pc.dim("…"));
3499
- console.log(pc.dim(" externalized:"));
3500
- for (const e of bundle.externals) console.log(pc.dim(" • ") + e);
5050
+ if (bundle.externals.length > 0) {
5051
+ console.log(pc.dim(" externalized:"));
5052
+ for (const e of bundle.externals) console.log(pc.dim(" • ") + e);
5053
+ }
3501
5054
  console.log();
3502
5055
  }
3503
5056
  async function gzipSizeOf(bytes) {
@@ -4148,7 +5701,6 @@ async function aiProvidersAddCommand(provider, options) {
4148
5701
  });
4149
5702
  console.log(pc.green(" ✓") + ` Registered ${provider}`);
4150
5703
  if (res.data.api_key_preview) console.log(pc.dim(` key preview: ${res.data.api_key_preview}`));
4151
- console.log(pc.dim(` secret_name: ${res.data.api_key_secret_name ?? "(unset)"}`));
4152
5704
  console.log();
4153
5705
  }
4154
5706
  async function aiProvidersListCommand() {
@@ -4160,7 +5712,7 @@ async function aiProvidersListCommand() {
4160
5712
  console.log();
4161
5713
  return;
4162
5714
  }
4163
- for (const p of res.data) console.log(` ${pc.bold(p.name)} ` + pc.dim(`secret=${p.api_key_secret_name ?? "(unset)"}`) + (p.updated_at ? pc.dim(` updated=${p.updated_at}`) : ""));
5715
+ for (const p of res.data) console.log(` ${pc.bold(p.name)} ` + pc.dim(`configured=${p.configured ? "yes" : "no"}`) + (p.updated_at ? pc.dim(` updated=${p.updated_at}`) : ""));
4164
5716
  console.log();
4165
5717
  }
4166
5718
  async function aiProvidersDeleteCommand(provider) {
@@ -4256,6 +5808,168 @@ function renderStatus(status) {
4256
5808
  }
4257
5809
  }
4258
5810
  //#endregion
5811
+ //#region src/commands/billing.ts
5812
+ /**
5813
+ * `amba billing *` subcommands — CLI access to the per-project billing
5814
+ * surface that ships behind `/v1/admin/projects/:id/billing/*`.
5815
+ *
5816
+ * amba billing status — tier, headroom on each
5817
+ * metered axis, next-bill date,
5818
+ * human_action_required.
5819
+ *
5820
+ * amba billing upgrade --tier <t> — print the Stripe Checkout
5821
+ * URL for tier ∈ {pro, scale}
5822
+ * [--interval month|year] at the chosen interval. CLI
5823
+ * deliberately does NOT auto-
5824
+ * open a browser: agents pipe
5825
+ * the URL into a confirmation
5826
+ * step, humans copy-paste.
5827
+ *
5828
+ * amba billing portal — print the Customer Portal
5829
+ * URL for card / cancel / etc.
5830
+ *
5831
+ * amba billing set-ceiling <usd|off> — cap (or remove) the monthly
5832
+ * spend ceiling.
5833
+ *
5834
+ * Project is resolved via `loadProjectConfig` (AMBA_PROJECT_ID env, then
5835
+ * .env / .env.local in the cwd) — same pattern as `amba secrets *`.
5836
+ */
5837
+ function handleError(err) {
5838
+ if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
5839
+ else console.log(pc.red(" ✗") + ` ${err.message}`);
5840
+ else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
5841
+ else console.log(pc.red(" ✗") + " Unknown error");
5842
+ console.log();
5843
+ process.exit(1);
5844
+ }
5845
+ /**
5846
+ * Issue a POST/PUT to the admin API. `api-client.ts` doesn't export a
5847
+ * generic POST helper for arbitrary paths, so this command file owns
5848
+ * its own request wrapper — same auth flow as `request()` in api-client,
5849
+ * intentionally not exported there so we don't grow the public surface.
5850
+ */
5851
+ async function adminWrite(method, path, body) {
5852
+ const token = await resolveBearerToken();
5853
+ const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
5854
+ const res = await fetch(url, {
5855
+ method,
5856
+ headers: {
5857
+ Authorization: `Bearer ${token}`,
5858
+ "Content-Type": "application/json",
5859
+ "User-Agent": "amba-cli/0.1.1"
5860
+ },
5861
+ body: body === void 0 ? void 0 : JSON.stringify(body)
5862
+ });
5863
+ if (!res.ok) {
5864
+ let message = `API request failed: ${res.status} ${res.statusText}`;
5865
+ let code;
5866
+ try {
5867
+ const errorBody = await res.json();
5868
+ if (errorBody.error?.message) {
5869
+ message = errorBody.error.message;
5870
+ code = errorBody.error.code;
5871
+ }
5872
+ } catch {}
5873
+ throw new ApiClientError(message, res.status, code);
5874
+ }
5875
+ return await res.json();
5876
+ }
5877
+ function formatAxis(label, axis, isStorage) {
5878
+ const used = axis.used === null ? "—" : isStorage ? axis.used >= 1024 ? `${(axis.used / 1024).toFixed(2)} GB` : `${axis.used} MB` : axis.used.toLocaleString();
5879
+ const limit = axis.limit === null ? "unlimited" : isStorage ? axis.limit >= 1024 ? `${(axis.limit / 1024).toFixed(0)} GB` : `${axis.limit} MB` : axis.limit.toLocaleString();
5880
+ const pct = axis.pct === null ? "—" : `${Math.round(axis.pct * 100)}%`;
5881
+ return ` ${pc.dim(label.padEnd(22))} ${used} / ${limit} ${pc.dim(`(${pct})`)}`;
5882
+ }
5883
+ async function billingStatusCommand() {
5884
+ console.log();
5885
+ console.log(pc.bold(" amba billing status"));
5886
+ console.log(pc.dim(" ─────────────────────────────────"));
5887
+ console.log();
5888
+ try {
5889
+ const { projectId } = await loadProjectConfig();
5890
+ const s = (await adminGet(`/projects/${projectId}/billing/status`)).data;
5891
+ console.log(` Tier: ${pc.bold(s.tier)}`);
5892
+ console.log(` Subscription: ${s.subscription_status ?? pc.dim("—")}`);
5893
+ console.log(` Next bill anchor: ${s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : pc.dim("—")}`);
5894
+ console.log(` Spend ceiling: ${s.ceiling_usd === null ? pc.dim("no cap") : `$${s.ceiling_usd}`}`);
5895
+ console.log(` Spend mode: ${s.mode}`);
5896
+ console.log(` Projected overage: $${s.projected_overage_usd_this_month.toFixed(2)}`);
5897
+ if (s.paused_at) console.log(` ${pc.yellow("Paused at:")} ${s.paused_at} ${pc.dim("(wakes on next request)")}`);
5898
+ console.log();
5899
+ console.log(pc.bold(" Usage — rolling 30 days"));
5900
+ console.log(formatAxis("Monthly active users", s.headroom.mau, false));
5901
+ console.log(formatAxis("Engagement events", s.headroom.engagement_events, false));
5902
+ console.log(formatAxis("Telemetry events", s.headroom.telemetry_events, false));
5903
+ console.log(formatAxis("Push deliveries", s.headroom.push, false));
5904
+ console.log(formatAxis("Database storage", s.headroom.db_storage_mb, true));
5905
+ console.log(formatAxis("Media storage", s.headroom.media_storage_mb, true));
5906
+ console.log();
5907
+ if (s.human_action_required !== "none") {
5908
+ const action = s.human_action_required.replaceAll("_", " ");
5909
+ console.log(pc.yellow(" Action required: ") + pc.bold(action));
5910
+ console.log();
5911
+ }
5912
+ } catch (err) {
5913
+ handleError(err);
5914
+ }
5915
+ }
5916
+ async function billingUpgradeCommand(input) {
5917
+ console.log();
5918
+ console.log(pc.bold(" amba billing upgrade"));
5919
+ console.log(pc.dim(" ─────────────────────────────────"));
5920
+ console.log();
5921
+ try {
5922
+ const { projectId } = await loadProjectConfig();
5923
+ const interval = input.interval ?? "month";
5924
+ const res = await adminWrite("POST", `/projects/${projectId}/billing/checkout`, {
5925
+ tier: input.tier,
5926
+ interval
5927
+ });
5928
+ console.log(` Tier: ${input.tier} (${interval}ly)`);
5929
+ console.log(` Session: ${pc.dim(res.data.session_id)}`);
5930
+ console.log();
5931
+ console.log(pc.bold(" Open this URL to complete checkout:"));
5932
+ console.log();
5933
+ console.log(` ${pc.cyan(res.data.url)}`);
5934
+ console.log();
5935
+ console.log(pc.dim(" Subscription status updates automatically once Stripe confirms (a few seconds)."));
5936
+ console.log();
5937
+ } catch (err) {
5938
+ handleError(err);
5939
+ }
5940
+ }
5941
+ async function billingPortalCommand() {
5942
+ console.log();
5943
+ console.log(pc.bold(" amba billing portal"));
5944
+ console.log(pc.dim(" ─────────────────────────────────"));
5945
+ console.log();
5946
+ try {
5947
+ const { projectId } = await loadProjectConfig();
5948
+ const res = await adminWrite("POST", `/projects/${projectId}/billing/portal`);
5949
+ console.log(pc.bold(" Open this URL to manage your subscription:"));
5950
+ console.log();
5951
+ console.log(` ${pc.cyan(res.data.url)}`);
5952
+ console.log();
5953
+ } catch (err) {
5954
+ handleError(err);
5955
+ }
5956
+ }
5957
+ async function billingSetCeilingCommand(input) {
5958
+ console.log();
5959
+ console.log(pc.bold(" amba billing set-ceiling"));
5960
+ console.log(pc.dim(" ─────────────────────────────────"));
5961
+ console.log();
5962
+ try {
5963
+ const { projectId } = await loadProjectConfig();
5964
+ await adminWrite(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
5965
+ if (input.ceiling === null) console.log(pc.green(" ✓") + " Spend ceiling removed (linear overage continues).");
5966
+ else console.log(pc.green(" ✓") + ` Spend ceiling set to $${input.ceiling}/mo.`);
5967
+ console.log();
5968
+ } catch (err) {
5969
+ handleError(err);
5970
+ }
5971
+ }
5972
+ //#endregion
4259
5973
  //#region src/commands/collections.ts
4260
5974
  /**
4261
5975
  * `amba collections ...` — thin shells over the admin collection routes.
@@ -4982,6 +6696,9 @@ program.command("init").description("Initialize Amba in the current project (min
4982
6696
  json: opts.json
4983
6697
  }));
4984
6698
  });
6699
+ program.command("claim <email>").description("Bind your sandbox account to a real email (sends a one-click magic link)").action(async (email) => {
6700
+ await runAction(() => claimCommand(email));
6701
+ });
4985
6702
  program.command("login").description("Authenticate with Amba").action(async () => {
4986
6703
  await runAction(loginCommand);
4987
6704
  });
@@ -5010,14 +6727,24 @@ const projects = program.command("projects").description("Project management com
5010
6727
  projects.command("list").description("List all projects in the authenticated developer account").action(async () => {
5011
6728
  await runAction(projectsListCommand);
5012
6729
  });
5013
- projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; new projects default to the 'development' environment)").option("--bundle-id <id>", "Bundle identifier (iOS/Android)").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").action(async (opts) => {
6730
+ projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; new projects default to the 'development' environment)").option("--bundle-id <id>", "Bundle identifier (iOS/Android). Audience for Sign in with Apple.").option("--google-oauth-client-id <id>", "Google OAuth 2.0 client id. Audience for Sign in with Google.").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").action(async (opts) => {
5014
6731
  await runAction(() => projectsCreateCommand({
5015
6732
  name: opts.name,
5016
6733
  env: opts.env,
5017
6734
  bundleId: opts.bundleId,
6735
+ googleOauthClientId: opts.googleOauthClientId,
5018
6736
  platform: opts.platform
5019
6737
  }));
5020
6738
  });
6739
+ projects.command("update <projectId>").description("Update mutable fields on a project").option("--name <name>", "Display name").option("--bundle-id <id>", "Bundle identifier (Apple Sign In audience)").option("--google-oauth-client-id <id>", "Google OAuth 2.0 client id (Google Sign In audience). Public identifier, not a secret.").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").option("--environment <env>", "Environment label: 'development' | 'production'").action(async (projectId, opts) => {
6740
+ await runAction(() => projectsUpdateCommand(projectId, {
6741
+ name: opts.name,
6742
+ bundleId: opts.bundleId,
6743
+ googleOauthClientId: opts.googleOauthClientId,
6744
+ platform: opts.platform,
6745
+ environment: opts.environment
6746
+ }));
6747
+ });
5021
6748
  projects.command("show <projectId>").description("Show full project details as JSON").action(async (projectId) => {
5022
6749
  await runAction(() => projectsShowCommand(projectId));
5023
6750
  });
@@ -5113,6 +6840,40 @@ secrets.command("list").description("List secret sync status for the current pro
5113
6840
  secrets.command("unset <name>").description("Remove a secret from GCP Secret Manager (Workers Secret cleared on next deploy)").requiredOption("--function <name>", "Function name the secret binds to").action(async (name, opts) => {
5114
6841
  await runAction(() => secretsUnsetCommand(name, opts));
5115
6842
  });
6843
+ const billing = program.command("billing").description("Per-project subscription, headroom, and spend controls");
6844
+ billing.command("status").description("Show current tier, headroom on each metered axis, and projected overage").action(async () => {
6845
+ await runAction(billingStatusCommand);
6846
+ });
6847
+ billing.command("upgrade").description("Print the Stripe Checkout URL for the chosen tier (does not auto-open a browser)").requiredOption("--tier <tier>", "'pro' or 'scale'").option("--interval <interval>", "'month' (default) or 'year' (20% off)", "month").action(async (opts) => {
6848
+ if (opts.tier !== "pro" && opts.tier !== "scale") {
6849
+ console.log(" ✗ --tier must be \"pro\" or \"scale\"");
6850
+ process.exit(1);
6851
+ }
6852
+ if (opts.interval !== "month" && opts.interval !== "year") {
6853
+ console.log(" ✗ --interval must be \"month\" or \"year\"");
6854
+ process.exit(1);
6855
+ }
6856
+ await runAction(() => billingUpgradeCommand({
6857
+ tier: opts.tier,
6858
+ interval: opts.interval
6859
+ }));
6860
+ });
6861
+ billing.command("portal").description("Print the Stripe Customer Portal URL (card, cancel, invoice download)").action(async () => {
6862
+ await runAction(billingPortalCommand);
6863
+ });
6864
+ billing.command("set-ceiling <amount>").description("Cap the monthly bill at <amount> USD, or pass 'off' to remove the cap").action(async (amount) => {
6865
+ let ceiling;
6866
+ if (amount.toLowerCase() === "off" || amount === "") ceiling = null;
6867
+ else {
6868
+ const parsed = Number(amount);
6869
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1e5) {
6870
+ console.log(" ✗ amount must be a number between 0 and 100000, or \"off\"");
6871
+ process.exit(1);
6872
+ }
6873
+ ceiling = parsed;
6874
+ }
6875
+ await runAction(() => billingSetCeilingCommand({ ceiling }));
6876
+ });
5116
6877
  const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
5117
6878
  collections.command("create <name>").description("Create a collection with the given fields").option("--field <spec>", "Field spec: name:type[:nullable] (e.g. user_id:uuid, parsed:jsonb:nullable). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--index <spec>", "Index spec: \"col1 [asc|desc], col2 [asc|desc]\". Repeatable.", (val, prev) => [...prev ?? [], val], []).action(async (name, opts) => {
5118
6879
  await runAction(() => collectionsCreateCommand(name, opts));