@layers/amba 1.0.1 → 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,15 +1,55 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
- import { access, mkdir, 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 } from "node:os";
9
8
  import open from "open";
10
- import { createWriteStream } from "node:fs";
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { createInterface } from "node:readline";
11
+ import { fileURLToPath } from "node:url";
12
+ import { createWriteStream, watch } from "node:fs";
13
+ import { spawn } from "node:child_process";
11
14
  import { build } from "esbuild";
12
- import { createHash } from "node:crypto";
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
13
53
  //#region src/_internal/shared.ts
14
54
  const DEFAULT_API_URL = "https://api.amba.dev";
15
55
  const CONSOLE_URL = "https://app.amba.dev";
@@ -73,11 +113,7 @@ function getReservationReason(name) {
73
113
  return null;
74
114
  }
75
115
  const RESERVED_BINDING_PREFIXES = ["AMBA_", "EDGE_"];
76
- const RESERVED_BINDING_EXACT_NAMES = [
77
- "STORAGE",
78
- "HYPERDRIVE",
79
- "EDGE_DB_PROXY"
80
- ];
116
+ const RESERVED_BINDING_EXACT_NAMES = ["STORAGE", "EDGE_DB_PROXY"];
81
117
  const VALID_BINDING_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
82
118
  const MAX_BINDING_NAME_LENGTH = 64;
83
119
  /** Return why a binding name is reserved/invalid, or `null` if acceptable. */
@@ -249,6 +285,16 @@ function setBearerOverride(token) {
249
285
  bearerOverride = token === null || token.length === 0 ? null : token;
250
286
  }
251
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
+ /**
252
298
  * Resolve the bearer token to send on the next admin API call.
253
299
  *
254
300
  * Returns the override (PAT or JWT supplied via flag/env) when set,
@@ -355,6 +401,14 @@ async function listProjects() {
355
401
  async function createProject(input) {
356
402
  return request("POST", "/projects", input);
357
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
+ }
358
412
  async function getProject(projectId) {
359
413
  return request("GET", `/projects/${projectId}`);
360
414
  }
@@ -559,7 +613,7 @@ async function updateSite(projectId, name, patch) {
559
613
  }
560
614
  /**
561
615
  * Add a custom domain to a site. The server-side proxy registers the
562
- * custom hostname, persists the resulting `cf_hostname_id`, and returns
616
+ * custom hostname, persists the resulting `provider_hostname_id`, and returns
563
617
  * the CNAME target the customer should point their DNS at.
564
618
  */
565
619
  async function addSiteDomainViaApi(projectId, siteName, hostname) {
@@ -574,10 +628,10 @@ async function removeSiteDomainViaApi(projectId, siteName, hostname) {
574
628
  return request("DELETE", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains/${encodeURIComponent(hostname)}`);
575
629
  }
576
630
  /**
577
- * Roll a live CF Pages deployment back to a prior `deployment_id`. CF's
578
- * rollback creates a NEW deployment that serves the prior bundle (git-
579
- * revert semantics, not git-reset), so the response shape mirrors
580
- * `DeploySiteResult` and the new `deployment_id` is what's now live.
631
+ * Roll a live deployment back to a prior `deployment_id`. Rollback creates
632
+ * a NEW deployment that serves the prior bundle (git-revert semantics,
633
+ * not git-reset), so the response shape mirrors `DeploySiteResult` and
634
+ * the new `deployment_id` is what's now live.
581
635
  */
582
636
  async function rollbackSiteViaApi(projectId, siteName, deploymentId) {
583
637
  return request("POST", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/rollback`, { deployment_id: deploymentId });
@@ -624,191 +678,2425 @@ async function validateApiKey(apiKey) {
624
678
  };
625
679
  }
626
680
  //#endregion
627
- //#region src/context-files.ts
681
+ //#region src/sandbox.ts
628
682
  /**
629
- * Generate AMBA.md project context file for AI agents.
683
+ * Headless agentic sandbox bootstrap.
684
+ *
685
+ * Implements `amba init --sandbox`: the zero-question, no-browser path
686
+ * an AI coding agent runs when a developer pastes the homepage prompt:
687
+ *
688
+ * Run `npx @layers/amba init --sandbox` and follow the
689
+ * instructions it prints.
690
+ *
691
+ * The CLI does everything: synthesize an anonymous email + password,
692
+ * sign the developer up via the public `/v1/auth/developer/signup`
693
+ * endpoint (no Bearer needed), pluck the returned PAT + project
694
+ * credentials, and write them into:
695
+ *
696
+ * - `~/.amba/credentials.json` (chmod 0600)
697
+ * - `<cwd>/.env.local` (.gitignored — SDK reads it)
698
+ * - `<cwd>/AMBA.md` (markdown context for the agent)
699
+ * - every detected MCP client config (`~/.claude.json`,
700
+ * `~/.cursor/mcp.json`, `~/.codeium/windsurf/mcp_config.json`,
701
+ * plus their project-local equivalents WHEN already present —
702
+ * never created from scratch, to avoid cluttering repos)
703
+ *
704
+ * The MCP-config merge is non-destructive: an existing `mcpServers.amba`
705
+ * entry is replaced with the new PAT, but the prior file is copied
706
+ * aside to `<path>.bak-<unix-ms>` first so a developer who had a real
707
+ * production PAT wired in can recover. Every other server entry is
708
+ * preserved. JSON files that already exist but lack an `mcpServers`
709
+ * key gain one; missing files for the global locations get scaffolded
710
+ * with a minimal `{ "mcpServers": { "amba": … }}`.
711
+ *
712
+ * Similarly, a pre-existing `~/.amba/credentials.json` whose `source`
713
+ * is not `'sandbox-init'` is backed up to `credentials.json.bak-<ms>`
714
+ * before the sandbox PAT replaces it. Idempotent re-runs from our own
715
+ * sandbox session do NOT trigger a backup.
716
+ *
717
+ * Provisioning polling: deliberately skipped. The server returns the
718
+ * project row with `provisioning_status: 'provisioning'` immediately and
719
+ * the workflow flips it to `'active'` within ~5s. The agent's next SDK
720
+ * call may briefly retry — fine. Blocking the CLI here would just hide
721
+ * the same wait behind a different progress indicator.
630
722
  */
631
- function generateAmbaMarkdown(opts) {
632
- const sdkPackage = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
633
- const providerExample = opts.framework === "expo" ? `
634
- ### Client Setup
635
-
636
- \`\`\`tsx
637
- // app/_layout.tsx
638
- import { useEffect } from 'react';
639
- import { Slot } from 'expo-router';
640
- import { Amba } from '@layers/amba-expo';
641
-
642
- export default function RootLayout() {
643
- useEffect(() => {
644
- Amba.configure({
645
- projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
646
- apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
647
- });
648
- }, []);
649
-
650
- return <Slot />;
723
+ /**
724
+ * Generate a deterministic-looking but globally-unique sandbox email.
725
+ *
726
+ * Pattern: `sandbox-<epoch>-<6char>@layers.com`.
727
+ *
728
+ * The control DB's `developers` table has a UNIQUE(email) constraint and
729
+ * a 5-per-minute / 50-per-day per-IP rate limit on signup. Embedding the
730
+ * epoch + a 6-char nonce keeps the collision probability negligible even
731
+ * across a herd of CI agents all running `amba init --sandbox` from the
732
+ * same VPC.
733
+ *
734
+ * We use `@layers.com` (not the customer's own domain) because the
735
+ * sandbox tier is pre-verification — the developer never receives or
736
+ * actions a verification email for this address. When they want to
737
+ * upgrade, the CLI prints the verify URL the API returned so they can
738
+ * claim a real email in the console.
739
+ */
740
+ function generateSandboxEmail() {
741
+ return `sandbox-${Math.floor(Date.now() / 1e3)}-${randomBytes(4).toString("base64url").slice(0, 6).toLowerCase()}@layers.com`;
651
742
  }
652
- \`\`\`
653
-
654
- ### Using the Client
655
-
656
- \`\`\`tsx
657
- import { Amba } from '@layers/amba-expo';
743
+ /**
744
+ * Random URL-safe password. 24 raw bytes → 32 base64url chars; well over
745
+ * the 8-char minimum the API enforces, with ~192 bits of entropy.
746
+ *
747
+ * The password is never shown to the developer or written anywhere — the
748
+ * PAT is what gets stored. We generate it solely because `POST /signup`
749
+ * requires it (and demands a non-empty value); a future API change could
750
+ * accept "agent signup" with no password and we'd drop this entirely.
751
+ */
752
+ function generateSandboxPassword() {
753
+ return randomBytes(24).toString("base64url");
754
+ }
755
+ /**
756
+ * POST the synthesized credentials at the public signup endpoint.
757
+ *
758
+ * No Bearer auth — this is the bootstrap call that mints one. We use
759
+ * `fetch` directly (not the api-client wrapper) because that wrapper
760
+ * always resolves a bearer token first, which is exactly what we don't
761
+ * have yet.
762
+ *
763
+ * Returns the unwrapped, flattened shape consumed by the rest of the
764
+ * sandbox flow. Throws with a human-readable message on any non-2xx so
765
+ * the CLI's `runAction` wrapper can surface it without crashing on a
766
+ * generic 'fetch failed'.
767
+ */
768
+ async function performSandboxSignup(req, options = {}) {
769
+ const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
770
+ const res = await (options.fetchImpl ?? fetch)(`${apiUrl}/v1/auth/developer/signup`, {
771
+ method: "POST",
772
+ headers: {
773
+ "Content-Type": "application/json",
774
+ "User-Agent": "amba-cli/sandbox"
775
+ },
776
+ body: JSON.stringify({
777
+ email: req.email,
778
+ password: req.password,
779
+ name: req.name ?? "amba-sandbox-cli"
780
+ })
781
+ });
782
+ if (!res.ok) {
783
+ let detail = `${res.status} ${res.statusText}`;
784
+ try {
785
+ const body = await res.json();
786
+ if (body.error?.message) detail = body.error.message;
787
+ } catch {}
788
+ throw new Error(`Sandbox signup failed: ${detail}`);
789
+ }
790
+ let raw;
791
+ try {
792
+ raw = await res.json();
793
+ } catch (parseErr) {
794
+ const reason = parseErr instanceof Error ? parseErr.message : String(parseErr);
795
+ throw new Error(`Sandbox signup returned ${res.status} but the response body was not valid JSON: ${reason}`);
796
+ }
797
+ const data = raw.data;
798
+ if (!data?.pat || !data.project?.project_id || !data.project.client_key) throw new Error("Sandbox signup response missing required fields (pat / project_id / client_key)");
799
+ return {
800
+ pat: data.pat,
801
+ project_id: data.project.project_id,
802
+ client_key: data.project.client_key,
803
+ server_key: data.project.server_key,
804
+ api_url: apiUrl,
805
+ provisioning_status: data.project.provisioning_status,
806
+ verify_url: data.project.verify_url,
807
+ email: req.email,
808
+ developer_id: data.developer?.id ?? "",
809
+ developer_name: data.developer?.name
810
+ };
811
+ }
812
+ /**
813
+ * Write or update `<cwd>/.env.local` with the sandbox project's keys.
814
+ *
815
+ * Mirrors the `init` interactive flow exactly so the existing env-read
816
+ * conventions in the SDKs and CLI commands keep working. The merge
817
+ * logic: if the file already exists and contains an `AMBA_PROJECT_ID`
818
+ * line we replace the Amba lines in place; otherwise we append a
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).
825
+ */
826
+ async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl, serverKey) {
827
+ const envPath = join(cwd, ".env.local");
828
+ const stanzaLines = [
829
+ "# Amba SDK configuration (sandbox tier)",
830
+ `AMBA_PROJECT_ID=${projectId}`,
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");
837
+ let existing = "";
838
+ try {
839
+ existing = await readFile(envPath, "utf-8");
840
+ } catch (err) {
841
+ if (!isEnoent$1(err)) throw err;
842
+ }
843
+ if (existing.length === 0) {
844
+ await writeFile(envPath, stanza, "utf-8");
845
+ return envPath;
846
+ }
847
+ if (/^AMBA_PROJECT_ID=/m.test(existing)) {
848
+ let updated = existing;
849
+ updated = updated.replace(/^AMBA_PROJECT_ID=.*/m, () => `AMBA_PROJECT_ID=${projectId}`);
850
+ updated = updated.replace(/^AMBA_API_URL=.*/m, () => `AMBA_API_URL=${apiUrl}`);
851
+ const hadClientKey = /^AMBA_CLIENT_KEY=/m.test(updated);
852
+ const hadApiKey = /^AMBA_API_KEY=/m.test(updated);
853
+ if (hadClientKey && hadApiKey) {
854
+ updated = updated.replace(/^AMBA_CLIENT_KEY=.*\n?/m, "");
855
+ updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
856
+ } else if (hadApiKey) updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
857
+ else if (hadClientKey) updated = updated.replace(/^AMBA_CLIENT_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
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
+ }
867
+ if (!/^AMBA_API_URL=/m.test(updated)) updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_API_URL=${apiUrl}\n`;
868
+ await writeFile(envPath, updated, "utf-8");
869
+ return envPath;
870
+ }
871
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
872
+ await writeFile(envPath, existing + separator + stanza, "utf-8");
873
+ return envPath;
874
+ }
875
+ /**
876
+ * Write the AMBA.md sandbox-tier guide to `<cwd>/AMBA.md`.
877
+ *
878
+ * Always overwrites — the file is meant to be regenerated, and the
879
+ * interactive `init` flow's longer AMBA.md template is replaced here
880
+ * with a sandbox-specific shorter one (with upgrade instructions).
881
+ */
882
+ async function writeSandboxAmbaMd(cwd, ctx) {
883
+ const ambaMdPath = join(cwd, "AMBA.md");
884
+ await writeFile(ambaMdPath, sandboxAmbaMdContent(ctx), "utf-8");
885
+ return ambaMdPath;
886
+ }
887
+ function sandboxAmbaMdContent(ctx) {
888
+ return `# Amba (Sandbox tier)
658
889
 
659
- export default function MyComponent() {
660
- const onPress = async () => {
661
- // Track an event
662
- await Amba.events.track('lesson_completed', { lesson_id: '123' });
890
+ This project was provisioned by \`amba init --sandbox\`. The sandbox is
891
+ real (real database, real API, real MCP) but capped so you can try Amba
892
+ without a credit card or email verification.
663
893
 
664
- // Sign in with Apple (requires expo-apple-authentication)
665
- await Amba.signInWithApple();
894
+ ## What was provisioned
666
895
 
667
- // Read remote config
668
- const showBanner = await Amba.config.fetch();
896
+ | Field | Value |
897
+ | --- | --- |
898
+ | Project ID | \`${ctx.projectId}\` |
899
+ | Email | \`${ctx.email}\` |
900
+ | API URL | \`${ctx.apiUrl}\` |
901
+ | SDK | \`${ctx.sdkPackage}\` |
902
+ | Framework | ${ctx.framework} |
669
903
 
670
- // Email sign-in
671
- await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
672
- };
904
+ The credentials live in **\`.env.local\`** (\`AMBA_PROJECT_ID\`,
905
+ \`AMBA_CLIENT_KEY\`, \`AMBA_API_URL\`) — gitignored by convention. Your
906
+ Personal Access Token is stored in \`~/.amba/credentials.json\`.
673
907
 
674
- // ...
675
- }
676
- \`\`\`` : `
677
- ### Client Setup
908
+ ## SDK quickstart
678
909
 
679
- \`\`\`typescript
680
- import { Amba } from '${sdkPackage}';
910
+ \`\`\`ts
911
+ import { Amba } from '${ctx.sdkPackage}';
681
912
 
682
- await Amba.configure({
913
+ Amba.configure({
683
914
  projectId: process.env.AMBA_PROJECT_ID!,
684
- apiKey: process.env.AMBA_API_KEY!,
915
+ clientKey: process.env.AMBA_CLIENT_KEY!,
685
916
  });
686
917
 
687
- // Track an event
688
- await Amba.events.track('page_viewed', { page: '/pricing' });
689
-
690
- // Read remote config
691
- const config = await Amba.config.fetch();
692
-
693
- // Email sign-in
694
- await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
695
- \`\`\``;
696
- return `# Amba Project Context
697
-
698
- > This file provides context about the Amba integration for AI coding agents.
699
-
700
- ## Project Info
701
-
702
- | Key | Value |
703
- |-----|-------|
704
- | Project ID | \`${opts.projectId}\` |
705
- | Project Name | ${opts.projectName} |
706
- | Framework | ${opts.framework} |
707
- | SDK | \`${sdkPackage}\` |
708
-
709
- ## Environment Variables
710
-
711
- These are configured in \`.env.local\`:
712
-
713
- - \`AMBA_PROJECT_ID\` — Your project identifier
714
- - \`AMBA_API_KEY\` — Client API key (safe for client-side use)
715
- - \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
716
-
717
- ## SDK Usage
718
- ${providerExample}
918
+ await Amba.events.track('app_opened');
919
+ \`\`\`
719
920
 
720
- ## Available Features
921
+ ## Sandbox limits
721
922
 
722
- - **Push Notifications** — Send targeted push notifications to user segments
723
- - **Remote Config** — Key-value configuration that updates without app releases
724
- - **Segments** — Group users by behavior, properties, or entitlements
725
- - **Streaks** — Track user engagement streaks (daily, weekly)
726
- - **Content Libraries** — Scheduled content delivery (daily tips, weekly challenges)
727
- - **Entitlements** — Subscription status via RevenueCat integration
728
- - **Analytics** — DAU, MAU, retention, and custom event tracking
923
+ | Limit | Sandbox | Free (after verification) |
924
+ | --- | --- | --- |
925
+ | Monthly active users | 100 | 1,000 |
926
+ | Database size | 10 MB | 500 MB |
927
+ | Push notifications / mo | 1,000 | 10,000 |
729
928
 
730
- ## API Reference
929
+ ## Upgrade past sandbox
731
930
 
732
- - Admin API: \`https://api.amba.dev/v1/admin\`
733
- - Client API: \`https://api.amba.dev/v1/client\`
734
- - Docs: \`https://docs.amba.dev\`
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.
735
936
 
736
- ## CLI Commands
937
+ ## Useful commands
737
938
 
738
939
  \`\`\`bash
739
- amba status # Check project health
740
- amba push test # Send a test push notification
741
- amba config list # List remote config values
742
- amba config set <key> <value> # Set a config value
940
+ amba status # health check
941
+ amba projects list # list your sandbox project
942
+ amba seed --preset=starter # populate sample data
743
943
  \`\`\`
944
+
945
+ Docs: https://docs.amba.dev
744
946
  `;
745
947
  }
746
948
  /**
747
- * Generate .cursor/rules/amba.mdc Cursor rules file.
949
+ * The canonical Amba MCP entry. Used as the value of
950
+ * `mcpServers.amba` in every detected client config.
951
+ *
952
+ * Shape note: Claude Code, Cursor, and Windsurf all read the same
953
+ * `type` + `url` + `headers` triple. (Windsurf historically also
954
+ * accepted `serverUrl` — we emit `url` to match the modern shape it
955
+ * also accepts, and skip emitting the legacy alias to keep the JSON
956
+ * minimal.)
748
957
  */
749
- function generateCursorRules(opts) {
750
- const sdk = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
751
- return `---
752
- description: Rules for working with the Amba SDK in this project
753
- globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
754
- ---
958
+ function buildAmbaMcpEntry(pat) {
959
+ return {
960
+ type: "http",
961
+ url: "https://mcp.amba.dev/mcp",
962
+ headers: { Authorization: `Bearer ${pat}` }
963
+ };
964
+ }
965
+ /**
966
+ * The list of MCP client config files we probe. Order matters only for
967
+ * the printed report.
968
+ *
969
+ * Project-local entries are listed but only get touched when the file
970
+ * already exists in CWD — we don't want to scatter `.mcp.json` /
971
+ * `.cursor/mcp.json` files into random user repos that have never been
972
+ * MCP-configured.
973
+ */
974
+ function mcpClientTargets(cwd, options = {}) {
975
+ const home = options.homeDir ?? homedir();
976
+ return [
977
+ {
978
+ label: "Claude Code (global)",
979
+ path: join(home, ".claude.json"),
980
+ scaffoldIfMissing: true
981
+ },
982
+ {
983
+ label: "Claude Code (project)",
984
+ path: join(cwd, ".mcp.json"),
985
+ scaffoldIfMissing: false
986
+ },
987
+ {
988
+ label: "Cursor (global)",
989
+ path: join(home, ".cursor", "mcp.json"),
990
+ scaffoldIfMissing: true
991
+ },
992
+ {
993
+ label: "Cursor (project)",
994
+ path: join(cwd, ".cursor", "mcp.json"),
995
+ scaffoldIfMissing: false
996
+ },
997
+ {
998
+ label: "Windsurf",
999
+ path: join(home, ".codeium", "windsurf", "mcp_config.json"),
1000
+ scaffoldIfMissing: true
1001
+ }
1002
+ ];
1003
+ }
1004
+ /**
1005
+ * Merge the `amba` entry into a single client config file.
1006
+ *
1007
+ * Strategy:
1008
+ * - If the file exists, load + JSON-parse. If parse fails, throw with
1009
+ * a clear "we won't clobber malformed JSON" error.
1010
+ * - If the file doesn't exist and scaffolding is allowed, create the
1011
+ * parent dir and write `{ "mcpServers": { "amba": ... } }`.
1012
+ * - In all cases, `mcpServers.amba` ends up set; every other
1013
+ * `mcpServers.*` entry is preserved.
1014
+ *
1015
+ * Real-credential safety: if the existing file ALREADY has a
1016
+ * `mcpServers.amba` entry, we copy the whole file aside to
1017
+ * `<path>.bak-<unix-ms>` BEFORE merging. This protects a developer who
1018
+ * had a real production PAT wired in and then ran `amba init --sandbox`
1019
+ * to "try" the flow. Idempotent re-runs from the same sandbox session
1020
+ * still trigger a backup — cheap, and the developer can `rm *.bak-*`
1021
+ * any time.
1022
+ */
1023
+ async function mergeMcpConfigFile(target, pat) {
1024
+ let existing = null;
1025
+ let rawExisting = null;
1026
+ try {
1027
+ rawExisting = await readFile(target.path, "utf-8");
1028
+ const parsed = JSON.parse(rawExisting);
1029
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) existing = parsed;
1030
+ else throw new Error(`Existing config at ${target.path} is not a JSON object`);
1031
+ } catch (err) {
1032
+ if (isEnoent$1(err)) {
1033
+ if (!target.scaffoldIfMissing) return {
1034
+ path: null,
1035
+ backedUpTo: null
1036
+ };
1037
+ existing = {};
1038
+ } else if (err instanceof SyntaxError) throw new Error(`Refusing to overwrite malformed JSON at ${target.path}: ${err.message}. Fix the file or remove it, then re-run \`amba init --sandbox\`.`);
1039
+ else throw err;
1040
+ }
1041
+ const config = existing ?? {};
1042
+ const serversRaw = config["mcpServers"];
1043
+ const servers = typeof serversRaw === "object" && serversRaw !== null && !Array.isArray(serversRaw) ? serversRaw : {};
1044
+ let backedUpTo = null;
1045
+ if ("amba" in servers && rawExisting !== null) {
1046
+ backedUpTo = `${target.path}.bak-${Date.now()}`;
1047
+ await writeFile(backedUpTo, rawExisting, "utf-8");
1048
+ }
1049
+ servers["amba"] = buildAmbaMcpEntry(pat);
1050
+ config["mcpServers"] = servers;
1051
+ await mkdir(dirname(target.path), { recursive: true });
1052
+ await writeFile(target.path, JSON.stringify(config, null, 2) + "\n", "utf-8");
1053
+ return {
1054
+ path: target.path,
1055
+ backedUpTo
1056
+ };
1057
+ }
1058
+ /**
1059
+ * Probe every known MCP client location and merge our entry into the
1060
+ * ones that exist (or that are flagged scaffoldIfMissing). Returns one
1061
+ * record per touched file (skipped files are omitted).
1062
+ *
1063
+ * `warn` is invoked (instead of `console.warn`) for per-target errors
1064
+ * so callers using `--json` can route those notices to stderr and keep
1065
+ * stdout machine-parseable.
1066
+ */
1067
+ async function writeAllMcpConfigs(cwd, pat, options = {}) {
1068
+ const warn = options.warn ?? ((msg) => console.warn(msg));
1069
+ const written = [];
1070
+ const targets = mcpClientTargets(cwd, options);
1071
+ for (const target of targets) {
1072
+ let result = {
1073
+ path: null,
1074
+ backedUpTo: null
1075
+ };
1076
+ try {
1077
+ if (!target.scaffoldIfMissing) {
1078
+ if (!await fileExists$1(target.path)) continue;
1079
+ }
1080
+ result = await mergeMcpConfigFile(target, pat);
1081
+ } catch (err) {
1082
+ const message = err instanceof Error ? err.message : String(err);
1083
+ warn(` ! Skipped ${target.label}: ${message}`);
1084
+ continue;
1085
+ }
1086
+ if (result.path) written.push({
1087
+ path: result.path,
1088
+ backedUpTo: result.backedUpTo
1089
+ });
1090
+ }
1091
+ return written;
1092
+ }
1093
+ async function fileExists$1(path) {
1094
+ try {
1095
+ await access(path);
1096
+ return true;
1097
+ } catch {
1098
+ return false;
1099
+ }
1100
+ }
1101
+ function isEnoent$1(err) {
1102
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1103
+ }
1104
+ /**
1105
+ * Render the canonical Amba MCP snippet for clients we can't auto-wire
1106
+ * (anything outside the {Claude Code, Cursor, Windsurf} set). Printed by
1107
+ * the CLI when no client configs are detected so the developer at least
1108
+ * has a paste-ready JSON blob.
1109
+ */
1110
+ function formatManualMcpSnippet(pat) {
1111
+ return JSON.stringify({ mcpServers: { amba: buildAmbaMcpEntry(pat) } }, null, 2);
1112
+ }
1113
+ //#endregion
1114
+ //#region src/credentials.ts
1115
+ /**
1116
+ * Two-scope credential model for `amba init`.
1117
+ *
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`).
1121
+ *
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.
1125
+ *
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.
1132
+ *
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;
2368
+ * it renders on docs.amba.dev). Re-run the drift test. The test will
2369
+ * fail with a diff. Apply the same diff here. The two are kept in
2370
+ * sync by hand because the MDX must be statically parseable for
2371
+ * fumadocs + we can't import `.md` files as raw strings without a
2372
+ * build-step that pulls in extra config.
2373
+ *
2374
+ * The body itself is plain CommonMark — no MDX components, no JSX —
2375
+ * so it renders identically as `.md` (the MCP / skill consumers) and
2376
+ * as `.mdx` (the docs site).
2377
+ */
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).
2380
+ > If you're reading an inlined snapshot from your
2381
+ > \`.claude/skills/amba-build/SKILL.md\`, check the URL above for updates.
2382
+
2383
+ This is the prompt an AI coding agent runs to build a full Expo app
2384
+ where Amba is the only backend. It's structured as a single \`/goal\`
2385
+ directive — paste it, replace \`<DESIGN_HASH>\` with whatever describes
2386
+ your design (a URL, a description, a Figma link), and let the agent
2387
+ execute.
2388
+
2389
+ ## Quick setup
2390
+
2391
+ The CLI handles signup, project provisioning, env-file writes, and MCP
2392
+ client config wiring in one command:
2393
+
2394
+ \`\`\`bash
2395
+ npx -y @layers/amba init
2396
+ \`\`\`
2397
+
2398
+ That's the entire setup. The CLI:
2399
+
2400
+ 1. Signs up an agent-mode developer account (no browser, no email
2401
+ verification needed for sandbox).
2402
+ 2. Creates an Amba project and mints a client key + admin PAT.
2403
+ 3. Writes \`.env.local\` (\`AMBA_PROJECT_ID\`, \`AMBA_CLIENT_KEY\`,
2404
+ \`AMBA_API_URL\`).
2405
+ 4. Writes \`AMBA.md\` (project-scoped context for the agent).
2406
+ 5. Auto-wires \`mcpServers.amba\` into every MCP client config it
2407
+ detects on disk — Claude Code, Cursor, Windsurf.
2408
+ 6. Verifies the PAT against the API and confirms it's good.
2409
+
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.
2416
+
2417
+ If you have the \`/amba-build\` skill installed (via
2418
+ \`npx -y @layers/amba init\`), invoke it directly:
2419
+
2420
+ \`\`\`
2421
+ /amba-build <DESIGN_HASH>
2422
+ \`\`\`
2423
+
2424
+ Otherwise paste the prompt below.
2425
+
2426
+ ## Current known gotchas
2427
+
2428
+ Three remaining wrinkles you may hit. Everything else from the 2026-05
2429
+ DX cascade is fixed.
2430
+
2431
+ - **Web CORS** — the public API does not currently send
2432
+ \`Access-Control-Allow-Origin\` for browser-origin requests. Use the
2433
+ agent's circuit-break-on-second-failure rule for web targets; for
2434
+ Expo (iOS + Android) you'll never see this.
2435
+ - **React Native bundle size** — the React Native SDK adds ~4 MB to
2436
+ the JS bundle today. Functional, just heavier than the long-term
2437
+ goal. Tracked separately.
2438
+ - **Sandbox MAU cap (100)** — the agent-mode sandbox tier caps at 100
2439
+ monthly active users. If you blow through it during testing, call
2440
+ \`amba_users_reset_sandbox\` to clear the counter — that tool exists
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.
2445
+
2446
+ ## How to read the design
2447
+
2448
+ If \`<DESIGN_HASH>\` is a URL to a packaged design (e.g. a download
2449
+ link from your design tool of choice), unpack it before you start:
2450
+
2451
+ \`\`\`bash
2452
+ mkdir -p design && cd design
2453
+ curl -L "<DESIGN_HASH>" -o design.tar.gz
2454
+ gunzip -c design.tar.gz | tar -x
2455
+ ls
2456
+ # Expected: README, chats/, project/ (or equivalent)
2457
+ \`\`\`
2458
+
2459
+ Read the README first — it should tell you what each subdirectory
2460
+ holds. The \`chats/\` directory typically contains conversation logs
2461
+ that capture the design intent in dialog form; treat them as the
2462
+ authoritative source for tone and feature priorities. The \`project/\`
2463
+ directory holds the structured asset graph (screens, components,
2464
+ styles).
2465
+
2466
+ If \`<DESIGN_HASH>\` is a freeform description (not a URL), skip the
2467
+ unpacking and treat the description text as the design brief.
2468
+
2469
+ ## Use Amba for everything
2470
+
2471
+ The rule: any feature that touches data, identity, scheduling,
2472
+ notifications, content, or social — use Amba. Don't reach for
2473
+ AsyncStorage-as-database, don't bring in Firebase / Supabase / your
2474
+ own server, don't roll a custom auth layer. The point of this build
2475
+ is that Amba covers it all.
2476
+
2477
+ Specifically: every feature in the design that needs a backend maps
2478
+ to an Amba primitive. If you can't find a fit, the rule is **escalate
2479
+ in the gaps log** (see the verification gate), not "ship without
2480
+ Amba." Skipping a primitive needs a written justification — the
2481
+ verification gate enforces this.
2482
+
2483
+ ## Feature → Amba primitive map
2484
+
2485
+ | App feature | Amba primitive | MCP tools |
2486
+ | -------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2487
+ | User accounts (anonymous + Apple + Google + email) | Auth | \`amba_developer_signup\` (one-time bootstrap), \`Amba.signIn()\` SDK calls |
2488
+ | Profile data (name, avatar, prefs) | App users | \`amba_users_list\`, \`amba_users_get\`, \`amba_users_bulk_update\` |
2489
+ | Daily content (tips, lessons, quotes) | Content libraries + schedules | \`amba_content_libraries_create\`, \`amba_content_items_add\`, \`amba_content_schedules_create\`, \`amba_content_list_libraries\`, \`amba_content_list_items\`, \`amba_content_list_schedules\` |
2490
+ | Push notifications | Push campaigns | \`amba_push_campaigns_create\`, \`amba_push_campaigns_send\`, \`amba_push_send_test\`, \`amba_push_list_campaigns\` |
2491
+ | User segments (e.g. inactive 7d, premium) | Segments | \`amba_segments_create\`, \`amba_segments_list\`, \`amba_segments_evaluate\` |
2492
+ | Daily streaks | Streaks | \`amba_streaks_create\`, \`amba_streaks_list\` (call \`streaks.qualify()\` from the SDK to record activity) |
2493
+ | XP and levels | XP rules | \`amba_xp_rules_create\`, \`amba_xp_rules_list\`, \`amba_users_get_xp\` |
2494
+ | Achievements / badges | Achievements | \`amba_achievements_create\`, \`amba_achievements_list\`, \`amba_achievements_get\` |
2495
+ | Challenges (time-limited goals) | Challenges | \`amba_challenges_create\`, \`amba_challenges_list\`, \`amba_challenges_list_participants\` |
2496
+ | Leaderboards | Leaderboards | \`amba_leaderboards_create\`, \`amba_leaderboards_list\`, \`amba_leaderboards_get\` |
2497
+ | In-app currency / virtual goods | Economy (currencies + catalog + stores) | \`amba_currencies_create\`, \`amba_catalog_items_create\`, \`amba_stores_create\`, \`amba_currencies_grant\`, \`amba_users_get_inventory\` |
2498
+ | Social (friends, groups, feed, DMs) | Social primitives | \`amba_create_group\`, \`amba_groups_list\`, \`amba_groups_update_member\`, \`amba_friendships_list\` (feeds + messaging via SDK: \`Amba.feeds.*\`, \`Amba.messaging.*\`) |
2499
+ | Remote feature flags / config | Configs | \`amba_configs_create\`, \`amba_configs_list\`, \`amba_configs_update\` |
2500
+ | Entitlements (premium / paywall) | RevenueCat / Superwall integration | \`amba_integrations_configure\`, \`amba_integrations_test\` |
2501
+ | Custom data (anything not above) | Collections | \`amba_collections_create\`, \`amba_collections_alter\`, \`amba_collections_list\`, \`amba_admin_insert_row\`, \`amba_admin_list_rows\`, plus client-side \`Amba.collections.*\` |
2502
+ | Analytics / event tracking | Events | \`Amba.events.track(...)\` from the SDK; query with \`amba_analytics_get\`, \`amba_users_list_events\` |
2503
+
2504
+ Every primitive above has list / read MCP tools you can use to verify
2505
+ seed data after creation — the verification gate uses these to catch
2506
+ "fake implementation" failure modes (where the app code thinks a thing
2507
+ was created but nothing actually landed in the backend).
2508
+
2509
+ ## Seed data
2510
+
2511
+ Before writing app code, seed the backend with enough data that every
2512
+ screen in the design has something realistic to render. Order:
2513
+
2514
+ 1. **Configs** — feature flags + tunable constants the app reads at
2515
+ boot (\`amba_configs_create\`).
2516
+ 2. **Segments** — at least one (e.g. "new_user", first 7 days) so
2517
+ targeting works downstream.
2518
+ 3. **Content libraries + schedules** — daily content for any
2519
+ tips/quotes/lessons screen. Seed ≥30 items so the carousel /
2520
+ day-stepper doesn't loop visibly.
2521
+ 4. **Streaks** — define the streak shape (daily / weekly, grace
2522
+ window, freeze policy).
2523
+ 5. **XP rules** — events → XP-award rules so XP accrues from real
2524
+ gameplay.
2525
+ 6. **Achievements** — unlock criteria for badges.
2526
+ 7. **Challenges** — at least one active challenge with rewards.
2527
+ 8. **Leaderboards** — XP, streaks, or any custom metric.
2528
+ 9. **Currencies + catalog + stores** — virtual currency, catalog
2529
+ items, store listings (only if the design has an economy screen).
2530
+ 10. **Collections** — schemas + sample rows for any custom data the
2531
+ app needs (e.g. user-generated content, journal entries, custom
2532
+ list items).
2533
+ 11. **Push campaigns** — at least one welcome push + one re-engagement
2534
+ push targeting your "new_user" segment.
2535
+
2536
+ After seeding, the verification gate (below) confirms each primitive
2537
+ exists by calling the matching \`amba_*_list\` MCP tool. Empty list →
2538
+ failure.
2539
+
2540
+ ## Engineering rules
2541
+
2542
+ These are non-negotiable. Violating any one of them fails the build
2543
+ gate.
2544
+
2545
+ - **Expo Router with typed routes.** Use \`expo-router\` and enable
2546
+ \`experiments.typedRoutes\` in \`app.json\`. Every screen is a
2547
+ filesystem route; no manual navigation stacks.
2548
+ - **TypeScript strict mode.** \`strict: true\` in \`tsconfig.json\`. Zero
2549
+ \`any\`. Zero \`@ts-ignore\`. \`tsc --noEmit\` must pass.
2550
+ - **React Native primitives only.** \`View\`, \`Text\`, \`Pressable\`,
2551
+ \`ScrollView\`, \`FlatList\`, \`Image\`. No \`div\`, no \`span\`, no DOM-only
2552
+ libs. The build target is iOS + Android + Web — every screen has to
2553
+ render on all three.
2554
+ - **Fonts via expo-font.** Don't ship system-font-only screens; load
2555
+ the design's typography via \`useFonts\` and gate the splash screen
2556
+ on load.
2557
+ - **Persistence via AsyncStorage.** Anything you cache client-side
2558
+ (theme choice, last-viewed-item, dismissed banners) goes in
2559
+ AsyncStorage. Never sprinkle direct file I/O.
2560
+ - **Theme system.** A single \`theme.ts\` exports light + dark token
2561
+ maps; consume via a \`useTheme()\` hook. The verification gate
2562
+ toggles light ↔ dark and screenshots; if any screen has hardcoded
2563
+ colors that don't flip, the gate fails.
2564
+ - **Circuit-break on second failure.** If two consecutive Amba API
2565
+ calls fail with the same error, stop retrying and surface a clean
2566
+ empty-state to the user. Don't loop forever; don't crash. The web
2567
+ CORS issue (above) is the most likely trigger.
2568
+ - **Deterministic offline fallback.** When \`fetch\` fails (airplane
2569
+ mode, network drop), the app renders **deterministic** placeholder
2570
+ content — same content per \`userId + day\` — never random. Real data
2571
+ swaps in when the network returns.
2572
+ - **Three-platform bundle gate.** \`expo export --platform web\`,
2573
+ \`expo export --platform ios\`, and \`expo export --platform android\`
2574
+ must all succeed. If any one fails, the build fails. No
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.)
2582
+
2583
+ ## Verification gate
2584
+
2585
+ Before declaring the build done, run every check in this list. Any
2586
+ failure means the build is not done — fix and re-run.
2587
+
2588
+ \`\`\`bash
2589
+ # Type-check
2590
+ pnpm tsc --noEmit
2591
+
2592
+ # Three-platform export
2593
+ pnpm expo export --platform web
2594
+ pnpm expo export --platform ios
2595
+ pnpm expo export --platform android
2596
+ \`\`\`
2597
+
2598
+ Then, from inside the agent (use the Amba MCP tools):
2599
+
2600
+ - \`amba_analytics_get\` → at least one event tracked end-to-end
2601
+ through \`Amba.events.track()\` from the app.
2602
+ - \`amba_users_list\` → at least one user exists (the agent's own
2603
+ anonymous signin counts).
2604
+ - For every primitive the seed step created, call the matching
2605
+ \`amba_*_list\` and assert non-empty:
2606
+ - \`amba_configs_list\`
2607
+ - \`amba_segments_list\`
2608
+ - \`amba_content_list_libraries\`, \`amba_content_list_items\`,
2609
+ \`amba_content_list_schedules\`
2610
+ - \`amba_streaks_list\`
2611
+ - \`amba_xp_rules_list\`
2612
+ - \`amba_achievements_list\`
2613
+ - \`amba_challenges_list\`
2614
+ - \`amba_leaderboards_list\`
2615
+ - \`amba_currencies_list\` (if economy seeded)
2616
+ - \`amba_catalog_list\` (if catalog seeded)
2617
+ - \`amba_collections_list\` + \`amba_admin_list_rows\` per collection
2618
+ - \`amba_push_list_campaigns\`
2619
+ - Empty list for any seeded primitive → the implementation is fake
2620
+ (UI exists but never wrote to the backend). Failure.
2621
+ - Manually walk every route in the browser (\`expo start --web\`),
2622
+ screenshot each, and confirm:
2623
+ - Light theme renders cleanly.
2624
+ - Dark theme renders cleanly (toggle and re-screenshot every
2625
+ route).
2626
+ - Empty states render when collections are empty (fresh-install
2627
+ simulation: wipe AsyncStorage, reload).
2628
+ - \`amba_users_reset_sandbox\` to confirm you can recover from the MAU
2629
+ cap if you blew past 50 during testing.
2630
+
2631
+ Skipping any primitive's seed step requires a one-line written
2632
+ justification in the gaps log (next section). "We don't need
2633
+ streaks" is fine; silence is not.
2634
+
2635
+ ## Final output
2636
+
2637
+ When done, write a final report to \`BUILD_REPORT.md\` in the project
2638
+ root. Required sections:
2639
+
2640
+ - **Start timestamp** (when the agent started).
2641
+ - **End timestamp** (when the verification gate last passed).
2642
+ - **MCP call inventory** — every \`amba_*\` tool you invoked, with a
2643
+ count. Lets a human reviewer audit "did this agent actually use
2644
+ Amba for X" at a glance.
2645
+ - **Primitive coverage table** — one row per primitive from the
2646
+ Feature → Amba primitive map. Mark each ✅ (used), ⚠️ (used with
2647
+ caveats — explain), or ⏭ (skipped — justify in one line).
2648
+ - **Gaps log** — every primitive you skipped, every feature you
2649
+ couldn't fit cleanly to an Amba primitive, every workaround. One
2650
+ line per gap, no marketing language.
2651
+ - **\`seed-report.json\`** — machine-readable seed summary:
2652
+ \`{ "primitive": "<name>", "created": <count>, "listed": <count> }\`
2653
+ for every primitive. The \`listed\` count comes from the
2654
+ \`amba_*_list\` call in the verification gate. \`created ===
2655
+ listed\` for every row is the success condition.
2656
+
2657
+ If \`BUILD_REPORT.md\` is missing any required section, or
2658
+ \`seed-report.json\` is missing, the build is not done.
2659
+ `;
2660
+ //#endregion
2661
+ //#region src/skills.ts
2662
+ /**
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.
2704
+ */
2705
+ /**
2706
+ * Build the contents of `.claude/skills/amba-build/SKILL.md`.
2707
+ *
2708
+ * Exported as a pure function so the unit tests can assert structural
2709
+ * properties (frontmatter, fetcher block, inlined snapshot fence)
2710
+ * without round-tripping through the filesystem.
2711
+ */
2712
+ function buildAmbaBuildSkillContent() {
2713
+ return `---
2714
+ description: Canonical /goal prompt for building a full Expo app with Amba as the only backend. Use as a starter when integrating Amba — Claude Code, Cursor, Windsurf.
2715
+ ---
2716
+
2717
+ # /amba-build
2718
+
2719
+ When invoked, fetch the canonical prompt from
2720
+ \`https://docs.amba.dev/prompts/expo-build.md\` and use it as the
2721
+ \`/goal\` directive for building a full Expo app with Amba as the only
2722
+ backend. The user supplies a design hash (URL or description) as the
2723
+ argument; substitute it for every \`<DESIGN_HASH>\` placeholder in the
2724
+ prompt before executing.
2725
+
2726
+ ## Usage
2727
+
2728
+ \`\`\`
2729
+ /amba-build <DESIGN_HASH>
2730
+ \`\`\`
2731
+
2732
+ Replace \`<DESIGN_HASH>\` with:
2733
+
2734
+ - A URL to a packaged design tarball, OR
2735
+ - A freeform description of the design intent.
2736
+
2737
+ ## Fetcher
2738
+
2739
+ \`\`\`bash
2740
+ curl -sf https://docs.amba.dev/prompts/expo-build.md
2741
+ \`\`\`
2742
+
2743
+ If \`curl\` fails (404, 5xx, network error, no internet), fall back to
2744
+ the **inlined snapshot** below — it was captured at CLI install time
2745
+ and is a complete, self-contained version of the prompt.
2746
+
2747
+ When the fetcher succeeds, prefer the live version: it's the source of
2748
+ truth and may have been updated since this skill was installed.
2749
+
2750
+ ## Inlined snapshot
2751
+
2752
+ The block between the AMBA-BUILD-PROMPT-START / -END markers is the
2753
+ prompt content captured at CLI install time. Re-run
2754
+ \`npx @layers/amba init --sandbox\` (or a future \`amba update-skills\`
2755
+ command) to refresh.
2756
+
2757
+ <!-- AMBA-BUILD-PROMPT-START -->
2758
+ ${EXPO_BUILD_PROMPT_MD}<!-- AMBA-BUILD-PROMPT-END -->
2759
+ `;
2760
+ }
2761
+ /**
2762
+ * Write `.claude/skills/amba-build/SKILL.md` into the target project.
2763
+ * Always overwrites — the inlined snapshot is meant to be regenerated
2764
+ * on each `amba init --sandbox` run.
2765
+ */
2766
+ async function writeAmbaBuildSkill(options = {}) {
2767
+ const skillDir = join(options.baseDir ?? process.cwd(), ".claude", "skills", "amba-build");
2768
+ await mkdir(skillDir, { recursive: true });
2769
+ const skillPath = join(skillDir, "SKILL.md");
2770
+ await writeFile(skillPath, buildAmbaBuildSkillContent(), "utf-8");
2771
+ return { path: skillPath };
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
755
2952
 
756
- # Amba SDK Rules
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.
757
2954
 
758
- ## Project Setup
759
- - Project ID: \`${opts.projectId}\`
760
- - SDK: \`${sdk}\`
761
- - API URL: \`https://api.amba.dev\`
2955
+ ## Bootstrap
762
2956
 
763
- ## Environment Variables
764
- - Always read Amba config from environment variables, never hardcode
765
- - Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
766
- - The .env.local file contains the project credentials
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.
767
2958
 
768
- ## SDK Patterns
769
- ${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
770
- - Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
771
- - The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
772
- - Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
773
- - Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
774
- - Use \`Amba.client.track()\` for all engagement events
775
- - Use \`Amba.client.config.get()\` for remote configuration
776
- - Use \`Amba.client.auth\` for sign-up / sign-in flows`}
2959
+ ## Playbook
777
2960
 
778
- ## Push Notifications
779
- - Register push tokens via the SDK \`registerPushToken()\` method
780
- - Handle notification payloads using the SDK's notification listener
781
- - Don't implement custom push token management
2961
+ When the user asks you to wire Amba into this codebase, run this journey end-to-end:
782
2962
 
783
- ## Remote Config
784
- - Use remote config for feature flags and dynamic values
785
- - Always provide sensible defaults when reading config values
786
- - Config values are cached — don't fetch on every render
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.
787
2968
 
788
- ## Streaks
789
- - Streaks are server-managed; the SDK provides read-only access
790
- - Use \`track()\` to record qualifying events — the server evaluates streaks
791
- - Show streak state from \`streak.current()\`, don't calculate manually
2969
+ ## Surfaces
792
2970
 
793
- ## Best Practices
794
- - Don't store Amba API keys in source code or commit them to git
795
- - Use \`.env.local\` for local development credentials
796
- - The client API key (prefixed \`amb_dev_ck_\` or \`amb_live_ck_\`) is safe for client-side use
797
- - Server keys (prefixed \`amb_dev_sk_\` or \`amb_live_sk_\`) must stay server-side only
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\`.
798
2998
  `;
799
2999
  }
800
3000
  /**
801
- * Write both context files to the project directory.
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`.
802
3006
  */
803
- async function generateContextFiles(opts) {
804
- const files = [];
805
- await writeFile(join(opts.cwd, "AMBA.md"), generateAmbaMarkdown(opts), "utf-8");
806
- files.push("AMBA.md");
807
- const cursorDir = join(opts.cwd, ".cursor", "rules");
808
- await mkdir(cursorDir, { recursive: true });
809
- await writeFile(join(cursorDir, "amba.mdc"), generateCursorRules(opts), "utf-8");
810
- files.push(".cursor/rules/amba.mdc");
811
- return files;
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
+ };
812
3100
  }
813
3101
  //#endregion
814
3102
  //#region src/commands/init.ts
@@ -824,6 +3112,85 @@ function prompt(question) {
824
3112
  });
825
3113
  });
826
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
+ }
827
3194
  async function fileExists(path) {
828
3195
  try {
829
3196
  await access(path);
@@ -923,173 +3290,411 @@ Docs: https://docs.amba.dev
923
3290
  }
924
3291
  async function initCommand(options = {}) {
925
3292
  const cwd = process.cwd();
926
- const environment = options.env ?? "development";
927
- console.log();
928
- console.log(pc.bold(" amba init"));
929
- console.log(pc.dim(" ─────────────────────────────────"));
930
- console.log();
931
- console.log(pc.bold(" Step 1/7 ") + pc.dim("Authenticate"));
932
- console.log();
933
- const headlessActive = resolveTokenSource({
934
- flagToken: void 0,
935
- envToken: process.env["AMBA_PAT"]
936
- }) !== null || process.argv.includes("--token") && process.argv.length > 2;
937
- let needsAuth = true;
938
- if (headlessActive) {
939
- console.log(pc.green(" ✓") + " Headless auth — using PAT from --token / AMBA_PAT");
940
- console.log(pc.dim(" (browser flow skipped; no credentials written to disk)"));
941
- console.log();
942
- needsAuth = false;
943
- } else try {
944
- if (!isTokenExpired(await loadCredentials())) {
945
- console.log(pc.green(" ✓") + " Already authenticated");
946
- console.log();
947
- needsAuth = false;
948
- }
949
- } catch {}
950
- if (needsAuth) {
951
- const creds = await browserAuthFlow();
952
- console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
953
- await storeCredentials(creds);
954
- console.log(pc.green(" ✓") + " Credentials saved to ~/.amba/credentials.json");
955
- console.log();
956
- } else {
957
- console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
958
- if (headlessActive) console.log(pc.green(" ✓") + " (skipped — PAT supplied)");
959
- else console.log(pc.green(" ✓") + " Using existing credentials");
960
- console.log();
3293
+ const isNonTTY = process.stdin.isTTY !== true;
3294
+ if (options.sandbox === true || options.json === true || isNonTTY) {
3295
+ const result = await runSandboxInit(cwd, {
3296
+ sandboxEmail: options.sandboxEmail,
3297
+ noMcpConfig: options.noMcpConfig,
3298
+ noSkills: options.noSkills,
3299
+ json: options.json,
3300
+ homeDir: options.homeDir
3301
+ });
3302
+ if (options.json) process.stdout.write(JSON.stringify(sandboxResultToJson(result), null, 2) + "\n");
3303
+ else printSandboxNextSteps(result);
3304
+ return;
961
3305
  }
962
- console.log(pc.bold(" Step 3/7 ") + pc.dim("Select project"));
963
- console.log();
964
- let projectId;
965
- let projectName;
3306
+ const environment = options.env ?? "development";
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");
966
3314
  try {
967
- const projects = (await listProjects()).data;
968
- if (projects.length > 0) {
969
- console.log(" Existing projects:");
970
- projects.forEach((p, i) => {
971
- console.log(pc.dim(` ${i + 1}.`) + ` ${p.name} ` + pc.dim(`(${p.id})`));
972
- });
973
- console.log(pc.dim(` ${projects.length + 1}.`) + " Create new project");
974
- console.log();
975
- const choice = await prompt(` Select project (1-${projects.length + 1}): `);
976
- const choiceNum = parseInt(choice, 10);
977
- if (choiceNum > 0 && choiceNum <= projects.length) {
978
- const selected = projects[choiceNum - 1];
979
- if (!selected) throw new Error("Invalid selection");
980
- projectId = selected.id;
981
- projectName = selected.name;
982
- console.log(pc.green(" ✓") + ` Selected: ${projectName}`);
983
- } else {
984
- const name = await prompt(" Project name: ");
985
- if (!name) {
986
- console.log(pc.red(" ✗") + " Project name is required");
987
- 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;
988
3401
  }
3402
+ } else {
3403
+ const name = await prompt(` Project name (default: ${defaultProjectName}): `);
3404
+ const finalName = name.length > 0 ? name : defaultProjectName;
989
3405
  projectId = (await createProject({
990
- name,
3406
+ name: finalName,
991
3407
  environment
992
3408
  })).data.id;
993
- projectName = name;
994
- console.log(pc.green(" ✓") + ` Created: ${projectName} ${pc.dim(`(${environment})`)}`);
3409
+ projectName = finalName;
995
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;
996
3421
  } else {
997
- const name = await prompt(" Project name: ");
998
- if (!name) {
999
- console.log(pc.red(" ✗") + " Project name is required");
1000
- process.exit(1);
1001
- }
1002
- projectId = (await createProject({ name })).data.id;
1003
- projectName = name;
1004
- 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;
1005
3426
  }
1006
- } catch (err) {
1007
- if (err instanceof Error && err.message.includes("authenticate")) throw err;
1008
- const name = await prompt(" Project name: ");
1009
- if (!name) {
1010
- 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."));
1011
3481
  process.exit(1);
1012
3482
  }
1013
- projectId = (await createProject({ name })).data.id;
1014
- projectName = name;
1015
- console.log(pc.green(" ✓") + ` Created: ${projectName}`);
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);
3487
+ console.log();
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);
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();
1016
3508
  }
1017
- console.log();
1018
- console.log(pc.bold(" Step 4/7 ") + pc.dim("Generate API keys"));
1019
- const keyRes = await createApiKey(projectId, "client", "development");
1020
- const apiKey = keyRes.data.key;
1021
- console.log(pc.green(" ✓") + " Development client key created");
1022
- console.log(pc.dim(` ${keyRes.data.key_prefix}...`));
1023
- console.log();
1024
- console.log(pc.bold(" Step 5/7 ") + pc.dim("Write environment file"));
1025
- const envPath = join(cwd, ".env.local");
1026
- const envLines = [
1027
- "# Amba SDK Configuration",
1028
- `AMBA_PROJECT_ID=${projectId}`,
1029
- `AMBA_API_KEY=${apiKey}`,
1030
- `AMBA_API_URL=https://api.amba.dev`,
1031
- ""
1032
- ];
1033
- if (await fileExists(envPath)) {
1034
- const existing = await readFile(envPath, "utf-8");
1035
- if (existing.includes("AMBA_PROJECT_ID")) {
1036
- console.log(pc.yellow(" !") + " .env.local already contains Amba config — updating");
1037
- let updated = existing;
1038
- updated = updated.replace(/AMBA_PROJECT_ID=.*/, `AMBA_PROJECT_ID=${projectId}`);
1039
- updated = updated.replace(/AMBA_API_KEY=.*/, `AMBA_API_KEY=${apiKey}`);
1040
- updated = updated.replace(/AMBA_API_URL=.*/, `AMBA_API_URL=https://api.amba.dev`);
1041
- await writeFile(envPath, updated, "utf-8");
1042
- } else await writeFile(envPath, existing + (existing.endsWith("\n") ? "\n" : "\n\n") + envLines.join("\n"), "utf-8");
1043
- } else await writeFile(envPath, envLines.join("\n"), "utf-8");
1044
- console.log(pc.green(" ✓") + " .env.local written");
1045
- console.log();
1046
- console.log(pc.bold(" Step 6/7 ") + pc.dim("Detect framework"));
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
3533
+ });
3534
+ setBearerOverride(identity.credentials.pat);
1047
3535
  const framework = await detectFramework(cwd);
1048
- const sdkPkg = getSdkPackage(framework);
1049
- if (framework !== "unknown") console.log(pc.green(" ✓") + ` Detected: ${pc.bold(framework)}`);
1050
- else console.log(pc.yellow(" !") + " Could not detect framework");
1051
- let installCmd = `npm install ${sdkPkg}`;
1052
- if (await fileExists(join(cwd, "bun.lockb"))) installCmd = `bun add ${sdkPkg}`;
1053
- else if (await fileExists(join(cwd, "pnpm-lock.yaml"))) installCmd = `pnpm add ${sdkPkg}`;
1054
- else if (await fileExists(join(cwd, "yarn.lock"))) installCmd = `yarn add ${sdkPkg}`;
1055
- console.log(pc.dim(` Install SDK: ${installCmd}`));
1056
- console.log();
1057
- console.log(pc.bold(" Step 7/7 ") + pc.dim("Generate context files"));
1058
- const generatedFiles = await generateContextFiles({
1059
- projectId,
1060
- projectName,
1061
- apiKey,
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);
3543
+ const ambaMdPath = await writeSandboxAmbaMd(cwd, {
3544
+ projectId: project.credentials.project_id,
3545
+ email: identity.credentials.email,
3546
+ verifyUrl: identity.firstProject?.verify_url ?? null,
3547
+ sdkPackage,
1062
3548
  framework,
1063
- cwd
3549
+ apiUrl: project.credentials.api_url
1064
3550
  });
1065
- for (const file of generatedFiles) console.log(pc.green(" ✓") + ` ${file}`);
1066
- if (options.withExample) {
1067
- const exampleFiles = await writeExampleScaffold(cwd, framework, projectName);
1068
- if (exampleFiles.length > 0) for (const file of exampleFiles) console.log(pc.green(" ✓") + ` ${file} ` + pc.dim("(example)"));
1069
- else console.log(pc.dim(" -") + " example files already present — skipping");
3551
+ let mcpConfigsWritten = [];
3552
+ if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd, identity.credentials.pat, {
3553
+ homeDir: options.homeDir,
3554
+ warn
3555
+ });
3556
+ let skillPath = null;
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
+ }));
1070
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.");
3581
+ return {
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,
3590
+ envLocalPath,
3591
+ ambaMdPath,
3592
+ mcpConfigsWritten,
3593
+ sdkPackage,
3594
+ framework,
3595
+ verifyUrl: identity.firstProject?.verify_url ?? null,
3596
+ provisioningStatus: identity.firstProject?.provisioning_status ?? "active",
3597
+ skillPath,
3598
+ setupTargets
3599
+ };
3600
+ }
3601
+ function sandboxResultToJson(r) {
3602
+ return {
3603
+ ok: true,
3604
+ mode: "sandbox",
3605
+ email: r.email,
3606
+ project_id: r.projectId,
3607
+ pat_preview: r.patPreview,
3608
+ client_key: r.clientKey,
3609
+ api_url: r.apiUrl,
3610
+ framework: r.framework,
3611
+ sdk_package: r.sdkPackage,
3612
+ credentials_path: r.credentialsPath,
3613
+ credentials_backed_up_to: r.credentialsBackedUpTo,
3614
+ env_local_path: r.envLocalPath,
3615
+ amba_md_path: r.ambaMdPath,
3616
+ mcp_configs_written: r.mcpConfigsWritten.map((m) => ({
3617
+ path: m.path,
3618
+ backed_up_to: m.backedUpTo
3619
+ })),
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
+ })),
3626
+ verify_url: r.verifyUrl,
3627
+ provisioning_status: r.provisioningStatus,
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
+ }
3634
+ };
3635
+ }
3636
+ /**
3637
+ * Build the plaintext (no ANSI) success-output block printed at the
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.
3661
+ */
3662
+ function buildSandboxNextStepsLines(r) {
3663
+ const lines = [];
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) {
3679
+ lines.push("");
3680
+ lines.push(` Paste into your MCP client config:`);
3681
+ for (const snippetLine of formatManualMcpSnippet(r.pat).split("\n")) lines.push(` ${snippetLine}`);
3682
+ }
3683
+ return lines;
3684
+ }
3685
+ function printSandboxNextSteps(r) {
3686
+ const lines = buildSandboxNextStepsLines(r);
1071
3687
  console.log();
1072
- console.log(pc.dim(" ─────────────────────────────────"));
1073
- console.log();
1074
- console.log(pc.bold(pc.green(" ✓ Project initialized!")));
1075
- console.log();
1076
- console.log(" Quick start:");
1077
- console.log();
1078
- console.log(pc.dim(" 1.") + ` Install the SDK`);
1079
- console.log(` ${pc.cyan(installCmd)}`);
1080
- console.log();
1081
- console.log(pc.dim(" 2.") + ` Add the provider to your app`);
1082
- if (framework === "expo") console.log(pc.dim(` See AMBA.md for Amba.init() setup`));
1083
- else if (framework === "react-native") console.log(pc.dim(` See AMBA.md for client initialization`));
1084
- else console.log(pc.dim(` See AMBA.md for client initialization`));
1085
- console.log();
1086
- console.log(pc.dim(" 3.") + ` Test the integration`);
1087
- console.log(` ${pc.cyan("amba status")}`);
1088
- console.log();
1089
- console.log(pc.dim(" 4.") + ` Send a test notification`);
1090
- console.log(` ${pc.cyan("amba push test")}`);
1091
- console.log();
1092
- console.log(` Docs: ${pc.underline("https://docs.amba.dev")}`);
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);
1093
3698
  console.log();
1094
3699
  }
1095
3700
  //#endregion
@@ -1462,7 +4067,7 @@ function confirm(question) {
1462
4067
  });
1463
4068
  });
1464
4069
  }
1465
- function handleError(err) {
4070
+ function handleError$1(err) {
1466
4071
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
1467
4072
  else console.log(pc.red(" ✗") + ` ${err.message}`);
1468
4073
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -1512,7 +4117,7 @@ async function projectsListCommand() {
1512
4117
  console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
1513
4118
  console.log();
1514
4119
  } catch (err) {
1515
- handleError(err);
4120
+ handleError$1(err);
1516
4121
  }
1517
4122
  }
1518
4123
  async function projectsCreateCommand(input) {
@@ -1538,6 +4143,7 @@ async function projectsCreateCommand(input) {
1538
4143
  const res = await createProject({
1539
4144
  name: input.name,
1540
4145
  bundle_id: input.bundleId,
4146
+ google_oauth_client_id: input.googleOauthClientId,
1541
4147
  platform: input.platform,
1542
4148
  environment
1543
4149
  });
@@ -1548,7 +4154,7 @@ async function projectsCreateCommand(input) {
1548
4154
  try {
1549
4155
  const s = (await getProvisioningStatus(id)).data;
1550
4156
  console.log(pc.dim(` Status: ${s.status}`));
1551
- if (s.errorMessage) console.log(pc.yellow(" !") + ` ${s.errorMessage}`);
4157
+ if (s.region) console.log(pc.dim(` Region: ${s.region}`));
1552
4158
  } catch {
1553
4159
  console.log(pc.dim(" (Provisioning runs asynchronously.)"));
1554
4160
  }
@@ -1557,7 +4163,33 @@ async function projectsCreateCommand(input) {
1557
4163
  console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
1558
4164
  console.log();
1559
4165
  } catch (err) {
1560
- 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);
1561
4193
  }
1562
4194
  }
1563
4195
  async function projectsShowCommand(projectId) {
@@ -1575,7 +4207,7 @@ async function projectsShowCommand(projectId) {
1575
4207
  console.log();
1576
4208
  process.exit(1);
1577
4209
  }
1578
- handleError(err);
4210
+ handleError$1(err);
1579
4211
  }
1580
4212
  }
1581
4213
  async function projectsDeleteCommand(projectId, opts = {}) {
@@ -1600,7 +4232,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
1600
4232
  console.log();
1601
4233
  process.exit(1);
1602
4234
  }
1603
- handleError(err);
4235
+ handleError$1(err);
1604
4236
  }
1605
4237
  }
1606
4238
  //#endregion
@@ -1904,14 +4536,14 @@ async function dbMigrateCommand(opts = {}) {
1904
4536
  console.log(pc.dim(" Running tenant migrations..."));
1905
4537
  console.log();
1906
4538
  try {
1907
- const workflowId = (await reprovisionProject(projectId)).data.workflowId;
1908
- console.log(pc.green(" ✓") + " Reprovision workflow started");
1909
- 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}`));
1910
4542
  console.log();
1911
4543
  try {
1912
4544
  const status = await getProvisioningStatus(projectId);
1913
4545
  console.log(pc.dim(` Status: ${status.data.status}`));
1914
- if (status.data.errorMessage) console.log(pc.yellow(" !") + ` ${status.data.errorMessage}`);
4546
+ if (status.data.region) console.log(pc.dim(` Region: ${status.data.region}`));
1915
4547
  } catch {}
1916
4548
  console.log();
1917
4549
  console.log(pc.dim(" Check again with: ") + pc.cyan(`amba projects show ${projectId}`));
@@ -2257,45 +4889,108 @@ async function schemaExportCommand(opts) {
2257
4889
  console.log();
2258
4890
  process.exit(1);
2259
4891
  }
2260
- const selected = domain === "all" ? Object.values(SCHEMAS) : [SCHEMAS[domain]];
2261
- const output = format === "json" ? renderJson(selected) : renderTypescript(selected);
2262
- process.stdout.write(output);
4892
+ const selected = domain === "all" ? Object.values(SCHEMAS) : [SCHEMAS[domain]];
4893
+ const output = format === "json" ? renderJson(selected) : renderTypescript(selected);
4894
+ process.stdout.write(output);
4895
+ }
4896
+ //#endregion
4897
+ //#region src/project-config.ts
4898
+ /**
4899
+ * Local project config loader.
4900
+ *
4901
+ * `amba init` writes `.env.local` with `AMBA_PROJECT_ID` + `AMBA_API_KEY`.
4902
+ * Subsequent commands resolve the active project by reading `.env.local`
4903
+ * (or the OS env if exported); fail with a clear "run amba init first"
4904
+ * error if neither is set.
4905
+ *
4906
+ * Kept tiny on purpose — the CLI's full config story (per-environment
4907
+ * dev/prod selection) is a v2 follow-up; v1 just needs project id.
4908
+ */
4909
+ const ENV_LOCAL_FILES = [".env.local", ".env"];
4910
+ async function loadProjectConfig(cwd = process.cwd()) {
4911
+ let projectId = process.env["AMBA_PROJECT_ID"];
4912
+ let apiUrl = process.env["AMBA_API_URL"];
4913
+ if (!projectId || !apiUrl) for (const filename of ENV_LOCAL_FILES) {
4914
+ const content = await readFile(join(cwd, filename), "utf-8").catch(() => null);
4915
+ if (!content) continue;
4916
+ const parsed = parseEnv(content);
4917
+ if (!projectId) projectId = parsed["AMBA_PROJECT_ID"];
4918
+ if (!apiUrl) apiUrl = parsed["AMBA_API_URL"];
4919
+ if (projectId && apiUrl) break;
4920
+ }
4921
+ if (!projectId) throw new Error(`AMBA_PROJECT_ID not found. Run ${pc.cyan("amba init")} or set it in .env.local.`);
4922
+ return {
4923
+ projectId,
4924
+ apiUrl: apiUrl ?? "https://api.amba.dev"
4925
+ };
4926
+ }
4927
+ /**
4928
+ * Parse a `.env`-style file body into a flat string map.
4929
+ *
4930
+ * Lines are trimmed; blank lines and `#`-comments are skipped; lines
4931
+ * without an `=` are skipped. Values may be wrapped in matching single
4932
+ * or double quotes which are stripped on read. Bug fixes should land
4933
+ * here once — both `project-config.ts` (resolves `AMBA_PROJECT_ID`)
4934
+ * and `commands/functions.ts` (loads `.env.local` for the local dev
4935
+ * server) call this.
4936
+ */
4937
+ function parseEnv(content) {
4938
+ const out = {};
4939
+ for (const rawLine of content.split("\n")) {
4940
+ const line = rawLine.trim();
4941
+ if (!line || line.startsWith("#")) continue;
4942
+ const eq = line.indexOf("=");
4943
+ if (eq === -1) continue;
4944
+ const key = line.slice(0, eq).trim();
4945
+ let value = line.slice(eq + 1).trim();
4946
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
4947
+ out[key] = value;
4948
+ }
4949
+ return out;
2263
4950
  }
2264
4951
  //#endregion
2265
4952
  //#region src/bundle.ts
2266
4953
  /**
2267
4954
  * Customer-function bundling for `amba functions deploy`.
2268
4955
  *
2269
- * Uses esbuild (the Workers ecosystem bundler-of-record). The shared
2270
- * runtime stdlib is marked `external` so customer bundles don't
2271
- * re-include megabytes of `@anthropic-ai/sdk`, `postgres`, `zod`, etc.;
2272
- * 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.
2273
4959
  *
2274
4960
  * Two checks gate the bundle before upload:
2275
4961
  * 1. Pre-upload size check against `BUNDLE_MAX_SIZE_BYTES` (8 MB
2276
4962
  * default — the platform's 10 MB compressed cap minus 2 MB
2277
4963
  * headroom) with a clear error pointing at the externalization
2278
4964
  * config.
2279
- * 2. Bundle-shape report — the CLI prints what's externalized vs
2280
- * 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.
2281
4976
  */
2282
4977
  /**
2283
- * Modules customer code MUST externalize. The runtime exposes these as
2284
- * platform-level bindings; bundling them per-script wastes hundreds of
2285
- * 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.
2286
4987
  */
2287
- const RUNTIME_STDLIB_EXTERNALS = [
2288
- "@layers/amba-functions",
2289
- "@layers/amba-api-middleware",
2290
- "@anthropic-ai/sdk",
2291
- "postgres",
2292
- "zod"
2293
- ];
4988
+ const RUNTIME_STDLIB_EXTERNALS = [];
2294
4989
  var BundleSizeError = class extends Error {
2295
4990
  sizeBytes;
2296
4991
  maxBytes;
2297
4992
  constructor(sizeBytes, maxBytes) {
2298
- 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.)`);
2299
4994
  this.sizeBytes = sizeBytes;
2300
4995
  this.maxBytes = maxBytes;
2301
4996
  this.name = "BundleSizeError";
@@ -2352,8 +5047,10 @@ function printBundleReport(bundle) {
2352
5047
  console.log(pc.dim(" Bundle:"));
2353
5048
  console.log(pc.dim(" size: ") + `${formatBytes$1(bundle.compressedSize)} compressed ` + pc.dim(`(${formatBytes$1(bundle.uncompressedSize)} raw)`));
2354
5049
  console.log(pc.dim(" sha: ") + bundle.sha256.slice(0, 16) + pc.dim("…"));
2355
- console.log(pc.dim(" externalized:"));
2356
- 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
+ }
2357
5054
  console.log();
2358
5055
  }
2359
5056
  async function gzipSizeOf(bytes) {
@@ -2370,51 +5067,6 @@ function formatBytes$1(n) {
2370
5067
  return `${(n / 1024 / 1024).toFixed(2)}MB`;
2371
5068
  }
2372
5069
  //#endregion
2373
- //#region src/project-config.ts
2374
- /**
2375
- * Local project config loader.
2376
- *
2377
- * `amba init` writes `.env.local` with `AMBA_PROJECT_ID` + `AMBA_API_KEY`.
2378
- * Subsequent commands resolve the active project by reading `.env.local`
2379
- * (or the OS env if exported); fail with a clear "run amba init first"
2380
- * error if neither is set.
2381
- *
2382
- * Kept tiny on purpose — the CLI's full config story (per-environment
2383
- * dev/prod selection) is a v2 follow-up; v1 just needs project id.
2384
- */
2385
- const ENV_LOCAL_FILES = [".env.local", ".env"];
2386
- async function loadProjectConfig(cwd = process.cwd()) {
2387
- let projectId = process.env["AMBA_PROJECT_ID"];
2388
- let apiUrl = process.env["AMBA_API_URL"];
2389
- if (!projectId || !apiUrl) for (const filename of ENV_LOCAL_FILES) {
2390
- const content = await readFile(join(cwd, filename), "utf-8").catch(() => null);
2391
- if (!content) continue;
2392
- const parsed = parseEnv(content);
2393
- if (!projectId) projectId = parsed["AMBA_PROJECT_ID"];
2394
- if (!apiUrl) apiUrl = parsed["AMBA_API_URL"];
2395
- if (projectId && apiUrl) break;
2396
- }
2397
- if (!projectId) throw new Error(`AMBA_PROJECT_ID not found. Run ${pc.cyan("amba init")} or set it in .env.local.`);
2398
- return {
2399
- projectId,
2400
- apiUrl: apiUrl ?? "https://api.amba.dev"
2401
- };
2402
- }
2403
- function parseEnv(content) {
2404
- const out = {};
2405
- for (const rawLine of content.split("\n")) {
2406
- const line = rawLine.trim();
2407
- if (!line || line.startsWith("#")) continue;
2408
- const eq = line.indexOf("=");
2409
- if (eq === -1) continue;
2410
- const key = line.slice(0, eq).trim();
2411
- let value = line.slice(eq + 1).trim();
2412
- if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
2413
- out[key] = value;
2414
- }
2415
- return out;
2416
- }
2417
- //#endregion
2418
5070
  //#region src/commands/functions.ts
2419
5071
  /**
2420
5072
  * `amba functions ...` commands.
@@ -2447,7 +5099,7 @@ async function functionsDeployCommand(entryPoint, options = {}) {
2447
5099
  bundleCode: bundle.code,
2448
5100
  rate_limit: rateLimit
2449
5101
  });
2450
- console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version} (${result.data.cf_script_name})`)}`);
5102
+ console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}`)}`);
2451
5103
  console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
2452
5104
  if (rateLimit) console.log(pc.dim(` Rate limit: ${rateLimit.max} per ${rateLimit.window} (key=${rateLimit.key}) — enforced pre-dispatch`));
2453
5105
  console.log();
@@ -2465,10 +5117,10 @@ async function functionsListCommand() {
2465
5117
  }
2466
5118
  async function functionsDeleteCommand(name, options = {}) {
2467
5119
  validateFunctionName(name);
2468
- if (!options.confirm || options.confirm !== name) throw new Error(`Delete is destructive. Pass --confirm ${name} to proceed. Customer Workers calling this function will start 404'ing immediately.`);
5120
+ if (!options.confirm || options.confirm !== name) throw new Error(`Delete is destructive. Pass --confirm ${name} to proceed. Clients calling this function will start 404'ing immediately.`);
2469
5121
  const cascade = (await deleteFunctionViaApi((await loadProjectConfig()).projectId, name, { confirm: name })).data.cascade;
2470
5122
  console.log(pc.green(" ✓") + ` Deleted ${pc.bold(name)}.`);
2471
- console.log(pc.dim(` Cascade: cf_dispatch_script_deleted=${cascade.cf_dispatch_script_deleted ?? false}, function_deployments_marked_disabled=${cascade.function_deployments_marked_disabled ?? 0}`));
5123
+ console.log(pc.dim(` Cascade: runtime_script_removed=${cascade.runtime_script_removed ?? false}, function_deployments_marked_disabled=${cascade.function_deployments_marked_disabled ?? 0}`));
2472
5124
  }
2473
5125
  async function functionsScheduleCommand(name, cron, options = {}) {
2474
5126
  const projectConfig = await loadProjectConfig();
@@ -2485,15 +5137,364 @@ async function functionsScheduleCommand(name, cron, options = {}) {
2485
5137
  console.log();
2486
5138
  }
2487
5139
  /**
2488
- * `amba functions dev` — not configured in this release. Use
2489
- * `amba functions deploy <file>` to deploy via the platform API.
5140
+ * `amba functions dev <file>` — run the function locally with file-change
5141
+ * hot reload.
5142
+ *
5143
+ * Architecture: the CLI process owns the file watcher + bundler; the
5144
+ * actual HTTP server lives in a child Node process that imports the
5145
+ * bundle and binds to the user's port. On file change, the parent kills
5146
+ * the child, writes a fresh bundle, and respawns. This bounds memory
5147
+ * (each rebuild = fresh V8 heap) and isolates customer-handler crashes
5148
+ * from the CLI. Brief downtime per rebuild (~100ms while the port is
5149
+ * released and re-bound); incoming connections during the swap queue
5150
+ * at the TCP listen backlog and complete once the new child is up.
5151
+ *
5152
+ * The bundler is the same esbuild pipeline `amba functions deploy` uses
5153
+ * — externalization rules, size cap, and source-map handling are
5154
+ * identical so dev → prod parity is automatic.
5155
+ *
5156
+ * Handler shape: `export default async function (req: Request): Promise<Response>`.
5157
+ * `.env.local` values in the customer's working directory are passed
5158
+ * through to the child process so the handler can read them via
5159
+ * `process.env.MY_KEY`. Existing values in the parent's env take
5160
+ * priority (so `MY_KEY=x amba functions dev …` wins).
2490
5161
  */
2491
- async function functionsDevCommand(_entryPoint) {
2492
- console.error(pc.red(" Error: `amba functions dev` is not available in this release."));
2493
- console.error(pc.dim(" Use `amba functions deploy <file>` to deploy via the platform API."));
2494
- process.exit(1);
5162
+ async function functionsDevCommand(entryPoint, options = {}) {
5163
+ const port = validatePort(options.port);
5164
+ const resolvedEntry = resolve(entryPoint);
5165
+ const childEnv = {
5166
+ ...await loadEnvLocal(),
5167
+ ...process.env
5168
+ };
5169
+ const tmpRoot = await mkdtemp(join(tmpdir(), "amba-fn-dev-"));
5170
+ const bundlePath = join(tmpRoot, "fn.mjs");
5171
+ const runnerPath = join(tmpRoot, "runner.mjs");
5172
+ await writeFile(runnerPath, DEV_RUNNER_SOURCE, "utf8");
5173
+ let child = null;
5174
+ let buildSeq = 0;
5175
+ let shuttingDown = false;
5176
+ let inFlightRebuild = null;
5177
+ let rebuildQueue = Promise.resolve(true);
5178
+ const scheduleRebuild = () => {
5179
+ rebuildQueue = rebuildQueue.then(() => rebuildLocked());
5180
+ inFlightRebuild = rebuildQueue;
5181
+ return rebuildQueue;
5182
+ };
5183
+ /**
5184
+ * Returns true when the child was spawned and serving on the port,
5185
+ * false on any failure path. The caller decides whether to surface a
5186
+ * "server is listening" banner based on that — a green checkmark
5187
+ * after a red error would mislead users.
5188
+ */
5189
+ async function rebuildLocked() {
5190
+ buildSeq++;
5191
+ const seq = buildSeq;
5192
+ let bundleResult;
5193
+ try {
5194
+ bundleResult = await bundleFunction({
5195
+ entryPoint: resolvedEntry,
5196
+ sourcemap: "inline"
5197
+ });
5198
+ } catch (err) {
5199
+ console.error(pc.red(" Bundle failed:"), err instanceof Error ? err.message : String(err));
5200
+ return false;
5201
+ }
5202
+ if (seq !== buildSeq || shuttingDown) return false;
5203
+ try {
5204
+ await writeFile(bundlePath, bundleResult.code, "utf8");
5205
+ } catch (err) {
5206
+ console.error(pc.red(" Write failed:"), err instanceof Error ? err.message : String(err));
5207
+ return false;
5208
+ }
5209
+ if (seq !== buildSeq || shuttingDown) return false;
5210
+ if (child) {
5211
+ const prev = child;
5212
+ prev.kill("SIGTERM");
5213
+ await waitForChildExit(prev, 2e3);
5214
+ if (child === prev) child = null;
5215
+ }
5216
+ if (seq !== buildSeq || shuttingDown) return false;
5217
+ const next = spawn(process.execPath, [
5218
+ runnerPath,
5219
+ bundlePath,
5220
+ String(port)
5221
+ ], {
5222
+ env: childEnv,
5223
+ stdio: [
5224
+ "ignore",
5225
+ "pipe",
5226
+ "inherit"
5227
+ ]
5228
+ });
5229
+ let spawnError = null;
5230
+ next.on("error", (err) => {
5231
+ spawnError = err;
5232
+ });
5233
+ try {
5234
+ await waitForChildReady(next, 5e3);
5235
+ } catch (err) {
5236
+ const cause = spawnError ?? (err instanceof Error ? err : new Error(String(err)));
5237
+ console.error(pc.red(" Child failed to start:"), cause.message);
5238
+ next.kill("SIGKILL");
5239
+ return false;
5240
+ }
5241
+ if (spawnError) {
5242
+ console.error(pc.red(" Child failed to start:"), spawnError.message);
5243
+ next.kill("SIGKILL");
5244
+ return false;
5245
+ }
5246
+ if (seq !== buildSeq || shuttingDown) {
5247
+ next.kill("SIGTERM");
5248
+ return false;
5249
+ }
5250
+ child = next;
5251
+ next.stdout?.on("data", (chunk) => process.stdout.write(chunk));
5252
+ next.on("exit", (code, signal) => {
5253
+ if (child === next && !shuttingDown && signal !== "SIGTERM") {
5254
+ console.error(pc.red(` Child exited unexpectedly (code=${code}, signal=${signal ?? "none"})`));
5255
+ child = null;
5256
+ }
5257
+ });
5258
+ console.log(pc.green(" ✓") + pc.dim(` bundle ready — ${bundleResult.uncompressedSize} bytes (${bundleResult.sha256.slice(0, 12)}…)`));
5259
+ return true;
5260
+ }
5261
+ const initialOk = await scheduleRebuild();
5262
+ let watcherCloser = null;
5263
+ let pendingRebuildTimer = null;
5264
+ if (!options.noWatch) {
5265
+ const watcher = watch(resolvedEntry, () => {
5266
+ if (shuttingDown) return;
5267
+ if (pendingRebuildTimer) clearTimeout(pendingRebuildTimer);
5268
+ pendingRebuildTimer = setTimeout(() => {
5269
+ pendingRebuildTimer = null;
5270
+ if (shuttingDown) return;
5271
+ console.log(pc.dim("\n ↻ file changed — rebuilding"));
5272
+ scheduleRebuild();
5273
+ }, 100);
5274
+ });
5275
+ watcherCloser = () => watcher.close();
5276
+ }
5277
+ console.log();
5278
+ if (initialOk) console.log(pc.green(" ✓") + ` Local dev server: ${pc.underline(`http://localhost:${port}`)}`);
5279
+ else console.log(pc.yellow(" !") + " Dev server is NOT listening — fix the bundle / handler errors above and save the file.");
5280
+ console.log(pc.dim(` Entry: ${entryPoint}`));
5281
+ if (!options.noWatch) console.log(pc.dim(" Watching for changes. Ctrl+C to stop."));
5282
+ console.log();
5283
+ const shutdown = async (exitCode = 0) => {
5284
+ if (shuttingDown) return;
5285
+ shuttingDown = true;
5286
+ if (pendingRebuildTimer) clearTimeout(pendingRebuildTimer);
5287
+ watcherCloser?.();
5288
+ if (inFlightRebuild) try {
5289
+ await inFlightRebuild;
5290
+ } catch {}
5291
+ if (child) {
5292
+ const c = child;
5293
+ child = null;
5294
+ c.kill("SIGTERM");
5295
+ await waitForChildExit(c, 2e3);
5296
+ }
5297
+ await rm(tmpRoot, {
5298
+ recursive: true,
5299
+ force: true
5300
+ }).catch(() => {});
5301
+ process.exit(exitCode);
5302
+ };
5303
+ process.on("SIGINT", () => void shutdown(0));
5304
+ process.on("SIGTERM", () => void shutdown(0));
5305
+ process.on("SIGHUP", () => void shutdown(0));
5306
+ process.on("uncaughtException", (err) => {
5307
+ console.error(pc.red(" Uncaught exception in CLI:"), err);
5308
+ shutdown(1);
5309
+ });
5310
+ process.on("unhandledRejection", (err) => {
5311
+ console.error(pc.red(" Unhandled rejection in CLI:"), err);
5312
+ shutdown(1);
5313
+ });
5314
+ }
5315
+ /**
5316
+ * Validate `--port` value. Defaults to 8787 when unset. Rejects
5317
+ * non-integers, ports below 1024 (which need root on POSIX), and
5318
+ * anything above 65535. Bailing here means the spawned child never
5319
+ * gets a NaN port that quietly picks a random ephemeral port — that
5320
+ * was confusing for customers ("the banner says :NaN").
5321
+ */
5322
+ function validatePort(raw) {
5323
+ if (raw === void 0) return 8787;
5324
+ if (!Number.isInteger(raw) || raw < 1 || raw > 65535) throw new Error(`Invalid --port ${raw}; must be an integer between 1 and 65535`);
5325
+ if (raw < 1024) throw new Error(`--port ${raw} is in the privileged range (<1024) and would need root on POSIX. Pick a port ≥ 1024.`);
5326
+ return raw;
5327
+ }
5328
+ /**
5329
+ * Reads `.env.local` from the current working directory (if present)
5330
+ * and returns the parsed KEY → VALUE map. Uses the shared `parseEnv`
5331
+ * from `project-config.ts` so any fix to the parser propagates here
5332
+ * automatically.
5333
+ */
5334
+ async function loadEnvLocal() {
5335
+ try {
5336
+ return parseEnv(await readFile(".env.local", "utf8"));
5337
+ } catch {
5338
+ return {};
5339
+ }
5340
+ }
5341
+ /**
5342
+ * Wait for a child process to print `AMBA_DEV_READY` on stdout (the
5343
+ * marker emitted by `DEV_RUNNER_SOURCE` once its HTTP server has bound
5344
+ * to the port). Rejects if the child exits before the marker arrives
5345
+ * or the timeout elapses.
5346
+ */
5347
+ function waitForChildReady(child, timeoutMs) {
5348
+ return new Promise((resolveReady, rejectReady) => {
5349
+ let buffered = "";
5350
+ let settled = false;
5351
+ const settle = (fn) => {
5352
+ if (settled) return;
5353
+ settled = true;
5354
+ child.stdout?.off("data", onData);
5355
+ child.off("exit", onExit);
5356
+ clearTimeout(timer);
5357
+ fn();
5358
+ };
5359
+ const onData = (chunk) => {
5360
+ buffered += typeof chunk === "string" ? chunk : chunk.toString("utf8");
5361
+ const idx = buffered.indexOf("AMBA_DEV_READY\n");
5362
+ if (idx === -1) return;
5363
+ const before = buffered.slice(0, idx);
5364
+ const after = buffered.slice(idx + 15);
5365
+ if (before.length > 0) process.stdout.write(before);
5366
+ if (after.length > 0) process.stdout.write(after);
5367
+ settle(() => resolveReady());
5368
+ };
5369
+ const onExit = (code, signal) => {
5370
+ settle(() => rejectReady(/* @__PURE__ */ new Error(`child exited before ready (code=${code}, signal=${signal ?? "-"})`)));
5371
+ };
5372
+ const timer = setTimeout(() => {
5373
+ settle(() => rejectReady(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms waiting for child ready`)));
5374
+ }, timeoutMs);
5375
+ child.stdout?.on("data", onData);
5376
+ child.on("exit", onExit);
5377
+ });
5378
+ }
5379
+ /**
5380
+ * Wait for a child to exit. If `timeoutMs` elapses first, SIGKILL it
5381
+ * and resolve anyway — the caller doesn't care which path closed the
5382
+ * port, only that it's closed.
5383
+ */
5384
+ function waitForChildExit(child, timeoutMs) {
5385
+ return new Promise((resolveExit) => {
5386
+ if (child.exitCode !== null || child.signalCode !== null) return resolveExit();
5387
+ const timer = setTimeout(() => {
5388
+ child.kill("SIGKILL");
5389
+ }, timeoutMs);
5390
+ child.once("exit", () => {
5391
+ clearTimeout(timer);
5392
+ resolveExit();
5393
+ });
5394
+ });
2495
5395
  }
2496
5396
  /**
5397
+ * The script the child Node process runs. Bound as a string template
5398
+ * so the parent CLI ships with no separate file to package. The runner
5399
+ * imports the bundle once at startup (so the V8 heap holds exactly one
5400
+ * version of the customer code) and serves it on the user's port.
5401
+ *
5402
+ * READY handshake: the runner writes `AMBA_DEV_READY\n` to stdout once
5403
+ * `server.listen()` calls back — the parent uses that to gate killing
5404
+ * the previous child.
5405
+ */
5406
+ const DEV_RUNNER_SOURCE = `
5407
+ import { createServer } from 'node:http';
5408
+ import { pathToFileURL } from 'node:url';
5409
+
5410
+ const bundlePath = process.argv[2];
5411
+ const port = Number(process.argv[3]);
5412
+ const parentPid = process.ppid;
5413
+
5414
+ // Parent-pid watchdog. If the CLI gets SIGKILL'd or its terminal
5415
+ // closes without delivering SIGHUP, the parent's signal handlers
5416
+ // don't run and this child would orphan — keep holding the port,
5417
+ // keep serving stale code. Polling ppid every 2s catches the orphan
5418
+ // case: when the parent dies, the OS reparents us (ppid changes,
5419
+ // becomes 1 on POSIX). On a clean SIGTERM from the parent, the
5420
+ // signal handler at the bottom exits us first so this never fires.
5421
+ setInterval(() => {
5422
+ if (process.ppid !== parentPid) {
5423
+ process.stderr.write(' Parent CLI is gone; exiting child to free port.\\n');
5424
+ process.exit(0);
5425
+ }
5426
+ }, 2000).unref();
5427
+
5428
+ let handler;
5429
+ try {
5430
+ // Node's ESM \`import()\` requires a URL on Windows — a raw absolute
5431
+ // path like C:\\\\foo\\\\bar.mjs throws ERR_UNSUPPORTED_ESM_URL_SCHEME.
5432
+ // \`pathToFileURL\` is a no-op equivalent on POSIX (returns file:///...).
5433
+ const bundleUrl = pathToFileURL(bundlePath).href;
5434
+ const mod = await import(bundleUrl);
5435
+ if (typeof mod.default !== 'function') {
5436
+ process.stderr.write(' Error: entry file must export default async function (req: Request): Promise<Response>\\n');
5437
+ process.exit(2);
5438
+ }
5439
+ handler = mod.default;
5440
+ } catch (err) {
5441
+ process.stderr.write(' Bundle import failed: ' + (err && err.stack ? err.stack : err) + '\\n');
5442
+ process.exit(2);
5443
+ }
5444
+
5445
+ const server = createServer(async (req, res) => {
5446
+ try {
5447
+ const proto = req.socket && req.socket.encrypted ? 'https' : 'http';
5448
+ const url = proto + '://' + (req.headers.host || 'localhost') + (req.url || '/');
5449
+ const headers = new Headers();
5450
+ for (const [k, v] of Object.entries(req.headers)) {
5451
+ if (typeof v === 'string') headers.set(k, v);
5452
+ else if (Array.isArray(v)) for (const vv of v) headers.append(k, vv);
5453
+ }
5454
+ let body = null;
5455
+ if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
5456
+ const chunks = [];
5457
+ for await (const chunk of req) chunks.push(chunk);
5458
+ body = Buffer.concat(chunks);
5459
+ }
5460
+ const request = new Request(url, { method: req.method, headers, body });
5461
+ const response = await handler(request);
5462
+ res.statusCode = response.status;
5463
+ response.headers.forEach((value, key) => res.setHeader(key, value));
5464
+ res.end(Buffer.from(await response.arrayBuffer()));
5465
+ } catch (err) {
5466
+ process.stderr.write(' Handler error: ' + (err && err.stack ? err.stack : err) + '\\n');
5467
+ if (!res.headersSent) {
5468
+ res.statusCode = 500;
5469
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
5470
+ }
5471
+ res.end('Internal Server Error');
5472
+ }
5473
+ });
5474
+
5475
+ server.on('error', (err) => {
5476
+ process.stderr.write(' Server error: ' + (err && err.message ? err.message : err) + '\\n');
5477
+ process.exit(3);
5478
+ });
5479
+
5480
+ // Bind to 127.0.0.1 explicitly so the dev server is loopback-only.
5481
+ // Node's default \`server.listen(port)\` binds 0.0.0.0 (or ::), which
5482
+ // exposes the local handler — potentially reading secrets from
5483
+ // .env.local — to every device on the same Wi-Fi. The banner says
5484
+ // "localhost" so the customer's mental model expects local-only;
5485
+ // match that. Aligns with Vite / wrangler / next dev defaults.
5486
+ server.listen(port, '127.0.0.1', () => {
5487
+ process.stdout.write('AMBA_DEV_READY\\n');
5488
+ });
5489
+
5490
+ const shutdown = (signal) => {
5491
+ server.close(() => process.exit(0));
5492
+ setTimeout(() => process.exit(0), 1000).unref();
5493
+ };
5494
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
5495
+ process.on('SIGINT', () => shutdown('SIGINT'));
5496
+ `;
5497
+ /**
2497
5498
  * `amba functions consume <queue> <function>` — bind a function as the
2498
5499
  * consumer for a queue. Customers send to a queue with `ctx.queue.send`;
2499
5500
  * the genericQueueJobWorkflow looks up the binding and invokes the
@@ -2700,7 +5701,6 @@ async function aiProvidersAddCommand(provider, options) {
2700
5701
  });
2701
5702
  console.log(pc.green(" ✓") + ` Registered ${provider}`);
2702
5703
  if (res.data.api_key_preview) console.log(pc.dim(` key preview: ${res.data.api_key_preview}`));
2703
- console.log(pc.dim(` secret_name: ${res.data.api_key_secret_name ?? "(unset)"}`));
2704
5704
  console.log();
2705
5705
  }
2706
5706
  async function aiProvidersListCommand() {
@@ -2712,7 +5712,7 @@ async function aiProvidersListCommand() {
2712
5712
  console.log();
2713
5713
  return;
2714
5714
  }
2715
- 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}`) : ""));
2716
5716
  console.log();
2717
5717
  }
2718
5718
  async function aiProvidersDeleteCommand(provider) {
@@ -2808,6 +5808,168 @@ function renderStatus(status) {
2808
5808
  }
2809
5809
  }
2810
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
2811
5973
  //#region src/commands/collections.ts
2812
5974
  /**
2813
5975
  * `amba collections ...` — thin shells over the admin collection routes.
@@ -3238,10 +6400,9 @@ function validateHostname(host) {
3238
6400
  if (!HOSTNAME_RE.test(host)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
3239
6401
  }
3240
6402
  /**
3241
- * Per-deploy size cap. CF Pages enforces 25 MiB per file + 25k files; we
3242
- * pre-flight at 100 MiB total so the developer sees an actionable error
3243
- * before we spend their time on a multi-second upload. Above this, point
3244
- * them at a Pages-only deployment outside amba.
6403
+ * Per-deploy size cap. The hosting provider enforces 25 MiB per file +
6404
+ * 25k files; we pre-flight at 100 MiB total so the developer sees an
6405
+ * actionable error before we spend their time on a multi-second upload.
3245
6406
  */
3246
6407
  const MAX_DEPLOYMENT_BYTES = 100 * 1024 * 1024;
3247
6408
  const MAX_DEPLOYMENT_FILES = 2e4;
@@ -3264,23 +6425,23 @@ async function sitesDeployCommand(inputDir, options = {}) {
3264
6425
  if (totalBytes > MAX_DEPLOYMENT_BYTES) throw new Error(`Deployment too large (${formatBytes(totalBytes)} > ${formatBytes(MAX_DEPLOYMENT_BYTES)}). Trim assets or split into multiple sites.`);
3265
6426
  console.log(pc.dim(` ${files.length} files, ${formatBytes(totalBytes)}`));
3266
6427
  if (options.dryRun) {
3267
- console.log(pc.yellow(" ! Dry run — skipping CF Pages upload + control-plane write."));
6428
+ console.log(pc.yellow(" ! Dry run — skipping upload + control-plane write."));
3268
6429
  console.log();
3269
6430
  return;
3270
6431
  }
3271
- let cfPagesProjectName;
6432
+ let slug;
3272
6433
  try {
3273
- cfPagesProjectName = (await createSite(projectId, { name: siteName })).data.cf_pages_project_name;
3274
- console.log(pc.green(" ✓") + ` Registered site (cf_pages_project=${cfPagesProjectName})`);
6434
+ slug = (await createSite(projectId, { name: siteName })).data.slug;
6435
+ console.log(pc.green(" ✓") + ` Registered site (slug=${slug})`);
3275
6436
  } catch (err) {
3276
- cfPagesProjectName = (await describeSite(projectId, siteName)).data.cf_pages_project_name;
3277
- console.log(pc.dim(` Site already registered (cf_pages_project=${cfPagesProjectName})`));
6437
+ slug = (await describeSite(projectId, siteName)).data.slug;
6438
+ console.log(pc.dim(` Site already registered (slug=${slug})`));
3278
6439
  }
3279
6440
  console.log(pc.dim(" Uploading…"));
3280
6441
  const dep = (await deploySiteViaApi(projectId, siteName, await buildPagesDeploymentForm(files))).data;
3281
6442
  console.log(pc.green(" ✓") + ` Deployed ${dep.deployment_id.slice(0, 12)} ${pc.dim(`(branch=${dep.branch}, status=${dep.status})`)}`);
3282
6443
  console.log(pc.green(" ✓") + ` URL: ${pc.underline(dep.url)}`);
3283
- if (dep.preview_url && dep.preview_url !== dep.url) console.log(pc.dim(` preview (CF): ${dep.preview_url}`));
6444
+ if (dep.preview_url && dep.preview_url !== dep.url) console.log(pc.dim(` preview: ${dep.preview_url}`));
3284
6445
  const domains = await listSiteDomains(projectId, siteName);
3285
6446
  if (domains.data.length > 0) {
3286
6447
  console.log();
@@ -3299,7 +6460,7 @@ async function sitesListCommand() {
3299
6460
  }
3300
6461
  for (const s of res.data) {
3301
6462
  const status = s.status === "active" ? pc.green("active") : pc.yellow(s.status);
3302
- console.log(` ${pc.bold(s.name)} ${status} ${pc.dim(`pages=${s.cf_pages_project_name} ${s.created_at}`)}`);
6463
+ console.log(` ${pc.bold(s.name)} ${status} ${pc.dim(`slug=${s.slug} ${s.created_at}`)}`);
3303
6464
  }
3304
6465
  console.log();
3305
6466
  }
@@ -3311,7 +6472,7 @@ async function sitesListCommand() {
3311
6472
  async function sitesLogsCommand(name) {
3312
6473
  validateSiteName(name);
3313
6474
  console.log();
3314
- console.log(pc.yellow(" ! `amba sites logs` is not available in this release."));
6475
+ console.log(pc.yellow(" ! `amba sites logs` is not available via the CLI."));
3315
6476
  console.log();
3316
6477
  console.log(pc.dim(" Alternatives:"));
3317
6478
  console.log(pc.dim(" amba sites describe <name> (current state + domains/certs)"));
@@ -3334,7 +6495,7 @@ async function sitesDomainAddCommand(hostname, options) {
3334
6495
  console.log(pc.dim(` → site ${pc.cyan(options.site)}`));
3335
6496
  console.log();
3336
6497
  const res = await addSiteDomainViaApi(projectId, options.site, hostname);
3337
- console.log(pc.green(" ✓") + ` Custom Hostname registered (cf_id=${res.data.cf_hostname_id})`);
6498
+ console.log(pc.green(" ✓") + ` Custom hostname registered`);
3338
6499
  console.log();
3339
6500
  console.log(pc.dim(" Point your DNS at:"));
3340
6501
  console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
@@ -3397,7 +6558,7 @@ async function sitesArchiveCommand(name, options = {}) {
3397
6558
  if (!options.confirm || options.confirm !== name) throw new Error(`Archive is destructive. Pass --confirm ${name} to proceed. The site project will be removed and traffic will 404.`);
3398
6559
  const cascade = (await deleteSiteViaApi((await loadProjectConfig()).projectId, name, { confirm: name })).data.cascade;
3399
6560
  console.log(pc.green(" ✓") + ` Archived ${name}.`);
3400
- console.log(pc.dim(` Cascade: domains_removed=${cascade.domains_removed ?? 0}, cf_pages_project_deleted=${cascade.cf_pages_project_deleted ?? false}`));
6561
+ console.log(pc.dim(` Cascade: domains_removed=${cascade.domains_removed ?? 0}, site_runtime_removed=${cascade.site_runtime_removed ?? false}`));
3401
6562
  }
3402
6563
  /**
3403
6564
  * Sites are static-only. Dynamic logic belongs in `amba functions deploy`;
@@ -3501,7 +6662,7 @@ function runAction(fn) {
3501
6662
  process.exit(1);
3502
6663
  });
3503
6664
  }
3504
- program.command("init").description("Initialize Amba in the current project (mints a personal dev project by default)").option("--with-example", "Scaffold a sample app.tsx + README snippet into the current directory").option("--env <env>", "'development' (default) or 'production'").action(async (opts) => {
6665
+ program.command("init").description("Initialize Amba in the current project (mints a personal dev project by default)").option("--with-example", "Scaffold a sample app.tsx + README snippet into the current directory").option("--env <env>", "'development' (default) or 'production'").option("--sandbox", "Headless agentic mode: auto-signup, write .env.local + AMBA.md, auto-wire MCP client configs. No prompts.").option("--email <email>", "Override the auto-generated sandbox email (requires --sandbox)").option("--no-mcp-config", "Skip writing MCP client config files (rare; mostly for testing)").option("--no-skills", "Skip installing the project-local /amba-build Claude Code skill (default: install during --sandbox)").option("--json", "Emit a machine-readable JSON summary on stdout instead of human-readable lines").action(async (opts) => {
3505
6666
  let env;
3506
6667
  if (opts.env === "development" || opts.env === "dev") env = "development";
3507
6668
  else if (opts.env === "production" || opts.env === "prod") env = "production";
@@ -3509,11 +6670,35 @@ program.command("init").description("Initialize Amba in the current project (min
3509
6670
  console.error(`Error: --env must be 'development' or 'production' (got '${opts.env}').`);
3510
6671
  process.exit(1);
3511
6672
  }
6673
+ if (opts.email && !opts.sandbox) {
6674
+ console.error("Error: --email is only valid with --sandbox.");
6675
+ process.exit(1);
6676
+ }
6677
+ if (opts.json && !opts.sandbox) {
6678
+ console.error("Error: --json is only valid with --sandbox.");
6679
+ process.exit(1);
6680
+ }
6681
+ if (opts.mcpConfig === false && !opts.sandbox) {
6682
+ console.error("Error: --no-mcp-config is only valid with --sandbox.");
6683
+ process.exit(1);
6684
+ }
6685
+ if (opts.skills === false && !opts.sandbox) {
6686
+ console.error("Error: --no-skills is only valid with --sandbox.");
6687
+ process.exit(1);
6688
+ }
3512
6689
  await runAction(() => initCommand({
3513
6690
  withExample: opts.withExample,
3514
- env
6691
+ env,
6692
+ sandbox: opts.sandbox,
6693
+ sandboxEmail: opts.email,
6694
+ noMcpConfig: opts.mcpConfig === false,
6695
+ noSkills: opts.skills === false,
6696
+ json: opts.json
3515
6697
  }));
3516
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
+ });
3517
6702
  program.command("login").description("Authenticate with Amba").action(async () => {
3518
6703
  await runAction(loginCommand);
3519
6704
  });
@@ -3542,14 +6727,24 @@ const projects = program.command("projects").description("Project management com
3542
6727
  projects.command("list").description("List all projects in the authenticated developer account").action(async () => {
3543
6728
  await runAction(projectsListCommand);
3544
6729
  });
3545
- projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; projects start in development)").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) => {
3546
6731
  await runAction(() => projectsCreateCommand({
3547
6732
  name: opts.name,
3548
6733
  env: opts.env,
3549
6734
  bundleId: opts.bundleId,
6735
+ googleOauthClientId: opts.googleOauthClientId,
3550
6736
  platform: opts.platform
3551
6737
  }));
3552
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
+ });
3553
6748
  projects.command("show <projectId>").description("Show full project details as JSON").action(async (projectId) => {
3554
6749
  await runAction(() => projectsShowCommand(projectId));
3555
6750
  });
@@ -3593,7 +6788,7 @@ program.command("schema").description("Schema export commands").command("export"
3593
6788
  format: opts.format ?? "json"
3594
6789
  }));
3595
6790
  });
3596
- const functions = program.command("functions").description("Customer Worker functions (Cloudflare Workers for Platforms)");
6791
+ const functions = program.command("functions").description("Customer serverless functions deployed to the edge");
3597
6792
  functions.command("deploy <file>").description("Bundle a function file and deploy to the dispatch namespace").option("--name <name>", "Function name (default: filename without extension)").option("--dry-run", "Bundle and report size without uploading").option("--rate-limit-window <duration>", "Rate-limit window: 60s | 5m | 1h").option("--rate-limit-max <int>", "Rate-limit max requests per window", (v) => Number.parseInt(v, 10)).option("--rate-limit-key <kind>", "Rate-limit bucket key: user_id | ip").action(async (file, opts) => {
3598
6793
  await runAction(() => functionsDeployCommand(file, opts));
3599
6794
  });
@@ -3606,8 +6801,11 @@ functions.command("delete <name>").description("Disable + remove a function from
3606
6801
  functions.command("schedule <name> <cron>").description("Register a cron schedule that invokes a deployed function").option("--tz <iana>", "IANA timezone for the schedule (default: UTC)").action(async (name, cron, opts) => {
3607
6802
  await runAction(() => functionsScheduleCommand(name, cron, opts));
3608
6803
  });
3609
- functions.command("dev <file>").description("Run wrangler dev --remote against your dev project").action(async (file) => {
3610
- await runAction(() => functionsDevCommand(file));
6804
+ functions.command("dev <file>").description("Run a local dev server for your function with hot reload on file changes").option("--port <n>", "Port to listen on (default 8787)", (v) => parseInt(v, 10)).option("--no-watch", "Disable file-change hot reload").action(async (file, opts) => {
6805
+ await runAction(() => functionsDevCommand(file, {
6806
+ port: opts.port,
6807
+ noWatch: opts.watch === false
6808
+ }));
3611
6809
  });
3612
6810
  functions.command("logs <name>").description("Stream log events for a deployed function").option("--since <iso>", "Start of the time range (default: 1 hour ago)").option("--until <iso>", "End of the time range (default: now). Ignored on --tail.").option("--limit <n>", "Max events per fetch (default 100, max 1000)", (v) => parseInt(v, 10)).option("--tail", "Follow new events; polls every 3s. Ctrl+C to stop.").option("--follow", "Alias for --tail (kept for backwards compatibility with v1 log commands).").option("--json", "NDJSON output to stdout (one event per line)").action(async (name, opts) => {
3613
6811
  await runAction(() => functionsLogsCommand(name, {
@@ -3642,7 +6840,41 @@ secrets.command("list").description("List secret sync status for the current pro
3642
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) => {
3643
6841
  await runAction(() => secretsUnsetCommand(name, opts));
3644
6842
  });
3645
- const collections = program.command("collections").description("Customer collections (schema-first Postgres in tenant Neon)");
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
+ });
6877
+ const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
3646
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) => {
3647
6879
  await runAction(() => collectionsCreateCommand(name, opts));
3648
6880
  });
@@ -3681,7 +6913,7 @@ sites.command("archive <name>").description("Archive a site (DESTRUCTIVE — del
3681
6913
  await runAction(() => sitesArchiveCommand(name, opts));
3682
6914
  });
3683
6915
  const sitesDomain = sites.command("domain").description("Manage custom hostnames per site");
3684
- sitesDomain.command("add <hostname>").description("Attach a custom hostname (CF for SaaS — DV cert, polls until active)").requiredOption("--site <name>", "Site name to attach the hostname to").option("--zone-id <id>", "CF zone id (default: env CLOUDFLARE_AMBA_HOST_ZONE_ID)").option("--no-wait", "Skip the cert-status poll loop; return as soon as the row is recorded").option("--timeout <seconds>", "Cert poll timeout (default 600)", (v) => parseInt(v, 10)).action(async (hostname, opts) => {
6916
+ sitesDomain.command("add <hostname>").description("Attach a custom hostname (DV cert, polls until active)").requiredOption("--site <name>", "Site name to attach the hostname to").option("--zone-id <id>", "DNS zone id (default: env AMBA_DNS_ZONE_ID)").option("--no-wait", "Skip the cert-status poll loop; return as soon as the row is recorded").option("--timeout <seconds>", "Cert poll timeout (default 600)", (v) => parseInt(v, 10)).action(async (hostname, opts) => {
3685
6917
  await runAction(() => sitesDomainAddCommand(hostname, {
3686
6918
  site: opts.site,
3687
6919
  zoneId: opts.zoneId,
@@ -3692,7 +6924,7 @@ sitesDomain.command("add <hostname>").description("Attach a custom hostname (CF
3692
6924
  sitesDomain.command("list <site>").description("List custom hostnames attached to a site").action(async (site) => {
3693
6925
  await runAction(() => sitesDomainListCommand(site));
3694
6926
  });
3695
- sitesDomain.command("remove <hostname>").description("Detach a custom hostname (best-effort CF detach + control-plane row delete)").requiredOption("--site <name>", "Site name the hostname is attached to").option("--zone-id <id>", "CF zone id (default: env CLOUDFLARE_AMBA_HOST_ZONE_ID)").action(async (hostname, opts) => {
6927
+ sitesDomain.command("remove <hostname>").description("Detach a custom hostname (best-effort edge detach + control-plane row delete)").requiredOption("--site <name>", "Site name the hostname is attached to").option("--zone-id <id>", "DNS zone id (default: env AMBA_DNS_ZONE_ID)").action(async (hostname, opts) => {
3696
6928
  await runAction(() => sitesDomainRemoveCommand(hostname, {
3697
6929
  site: opts.site,
3698
6930
  zoneId: opts.zoneId