@animaapp/cli 0.6.0 → 0.7.0

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/README.md CHANGED
@@ -38,7 +38,8 @@ Every command runs as a **scoped agent identity**: it acts only within the
38
38
  workspaces and capabilities a human approved, and your team can see and
39
39
  **revoke** it at any time. The CLI renews it in the background; after a few
40
40
  months the consent reaches its renewal limit and a human approves again. You get
41
- that identity one of three ways — the third also covers the case where you have no human to ask yet.
41
+ that identity one of four ways. Door 3 is the usual one — you lead with
42
+ something to show, and it needs neither a token nor an account on their side.
42
43
 
43
44
  ### 1. Device login — `anima login`
44
45
 
@@ -91,30 +92,53 @@ Resolution priority: `ANIMA_API_TOKEN` env var → stored credentials file → e
91
92
 
92
93
  ### 3. Create first, hand off later — anonymous artifacts
93
94
 
94
- When there is **no human available yet** and no token, you can still put work on
95
- AgentGrid. Create an artifact anonymously over REST (the CLI needs an identity,
96
- so this one call is plain `curl`), then hand it to a human when they appear:
95
+ This is the flow where **you** lead: no token, and a human who needs no
96
+ AgentGrid account until the moment they claim. Two commands:
97
97
 
98
98
  ```bash
99
- curl -s -X POST "https://public-api.animaapp.com/v1/generationSessions" \
100
- -H 'Content-Type: application/json' \
101
- -d '{"type":"import","name":"My project","clientName":"Claude Code",
102
- "files":{"index.html":"<!doctype html><h1>hi</h1>"}}'
99
+ # 1. Create it. Prints claimUrl — send your human that link.
100
+ anima create --anonymous -t import --from ./my-project \
101
+ --client-name "Claude Code" --json
102
+
103
+ # 2. Wait for them to claim it, then store the credentials that grants.
104
+ anima login --handoff --json
105
+ ```
106
+
107
+ `create --anonymous` needs no credential at all. It returns a `claimUrl` for
108
+ your human, and a **`handoffToken`** for you. The token has two uses:
109
+
110
+ ```bash
111
+ anima login --handoff <token> # log in, once they claim it
112
+ anima create --anonymous --handoff <token> --from … # another artifact, same claim
103
113
  ```
104
114
 
115
+ The CLI stores the token, so both commands default to your last one and you can
116
+ leave `<token>` off. Keep it yourself if this machine's config will not outlive
117
+ the process — it is delivered exactly once and can never be re-fetched. Lose it
118
+ and your human can still claim the artifact, but you can never be granted access
119
+ to it.
120
+
121
+ Once a claim window closes unclaimed, the next `create --anonymous` just starts
122
+ a fresh one.
123
+
105
124
  **Send `clientName` — say what product you are** (`"Claude Code"`, `"Cursor"`,
106
125
  …), the same name you would pass to `login --client-name`. The human sees it on
107
126
  the claim page and it becomes the agent name they approve; without it they are
108
127
  asked to invent a name for an artifact they did not create. Display-only, never
109
128
  verified, ≤120 characters.
110
129
 
111
- The response returns a claim link for your human plus a `handoffToken` for you.
112
- The token does **not** claim anything — it groups further anonymous creates into
113
- the same handoff, and you poll `POST /oauth/token` with it
114
- (`grant_type=urn:animaapp:params:oauth:grant-type:handoff_token`) until the
115
- human claims, at which point the poll returns your agent token. Store it and use
116
- it as `ANIMA_API_TOKEN` (door 2) for every command after that. Anonymous
117
- artifacts are read-only and expire in 24h if nobody claims them.
130
+ `login --handoff` blocks until the claim lands. Background it if you have other
131
+ work, and check the state whenever you like:
132
+
133
+ ```bash
134
+ anima login --handoff --json > anima-login.json &
135
+ anima auth --status --json # pendingHandoff.state: awaiting_claim | expired
136
+ ```
137
+
138
+ Anonymous artifacts are read-only and expire in 24h if nobody claims them. The
139
+ handoff token does **not** claim anything — the human never needs it, and never
140
+ gets it. A claim without agent access grants no token, and `login --handoff`
141
+ says so rather than polling forever.
118
142
 
119
143
  ### Which do I use?
120
144
 
@@ -124,7 +148,8 @@ artifacts are read-only and expire in 24h if nobody claims them.
124
148
  | Interactive agent, human nearby | `anima login` — show the URL + code, human approves on any device |
125
149
  | Headless agent, human reachable | `anima login --json` — relay the `verification_required` event, polling finishes automatically |
126
150
  | Fully headless / CI, no human | set `ANIMA_API_TOKEN` — no login step |
127
- | No human *yet*, nothing to authenticate with | create anonymously with `clientName`, send the claim link, poll for the token |
151
+ | You want to lead: share something first, no account needed on their side | `anima create --anonymous …` → send `claimUrl` → `anima login --handoff` |
152
+ | Your human led: they sent you an invite link to join | `anima login --invite <url-or-code>` — one exchange, no approval step |
128
153
 
129
154
  ---
130
155
 
@@ -163,10 +188,13 @@ npx @animaapp/cli@latest login --client-name "My CI Agent" # label on the cons
163
188
  npx @animaapp/cli@latest login --no-open # don't auto-open the browser
164
189
  npx @animaapp/cli@latest login --print-mcp-config # also emit an MCP server config
165
190
  npx @animaapp/cli@latest login --invite <url-or-code> # redeem a pre-approved invite — no approval step
191
+ npx @animaapp/cli@latest login --handoff # wait for a claim on your anonymous artifact
192
+ npx @animaapp/cli@latest login --handoff <token> # ...or name the token explicitly
166
193
  ```
167
194
 
168
195
  | Option | Description | Default |
169
196
  |--------|-------------|---------|
197
+ | `--handoff [token]` | Log in with the handoff token from an anonymous create. Waits until a human claims the artifact, then stores the credentials their claim grants — same store as every other door. Defaults to your last one. Blocks; background it with `&` and watch `auth --status`. Stops with a clear message if the window closes unclaimed, or if the human claimed without granting agent access | — |
170
198
  | `--invite <url-or-code>` | Redeem an invite link (`…/invite/<code>.md`) or bare code. The human already approved when minting the invite, so there is no verification step — one call and you're connected. The invite URL's origin is persisted as the `api-url`, so follow-up commands target the right server with no flags | — |
171
199
  | `--client-name <name>` | How the CLI appears on the consent screen | `AgentGrid CLI` |
172
200
  | `--no-open` | Don't try to open the verification page in a browser | — |
@@ -197,6 +225,7 @@ npx @animaapp/cli@latest create -t empty --framework react --name "My project"
197
225
  npx @animaapp/cli@latest create -t p2c -p "Analytics dashboard with a sidebar"
198
226
  npx @animaapp/cli@latest create -t l2c -u https://stripe.com
199
227
  npx @animaapp/cli@latest create -t f2c --file-key <key> --nodes 42:15
228
+ npx @animaapp/cli@latest create -t import --from ./my-project --anonymous # no account — hand off later
200
229
  ```
201
230
 
202
231
  `-t import` is the one-step upload path: your folder (or .zip) becomes the
@@ -223,7 +252,10 @@ uploaded as an app produce an artifact with nothing to render.
223
252
  | `--file-key` | Figma file key or URL | _(f2c)_ |
224
253
  | `--nodes` | Figma node IDs, comma-separated | _(f2c)_ |
225
254
  | `--figma-token` | Figma PAT (or `FIGMA_TOKEN` env) | _(f2c)_ |
226
- | `--artifact-type` | `app` (web page, needs an `index.html`), `markdown` (document, needs `.md` files), `asset` (images/video, with a zip) | _inferred from your files_ |
255
+ | `--artifact-type` | `app` (web page, needs an `index.html`), `markdown` (document, needs `.md` files) | _inferred from your files_ |
256
+ | `--anonymous` | Create with no account (`-t import` only). Returns `claimUrl` for your human and a `handoffToken` for you | off |
257
+ | `--handoff <token>` | Add this artifact to an existing claim (`--anonymous` only), so one link covers them all | _your last one_ |
258
+ | `--client-name` | Who to credit as the creator on the claim page (`--anonymous` only) | `AgentGrid CLI` |
227
259
  | `--framework` | `react`, `html` — apps only | `react` |
228
260
  | `--styling` | `tailwind`, `css`, `plain_css`, `css_modules`, `inline_styles`¹ | `tailwind` |
229
261
  | `--language` | `typescript`, `javascript` | `typescript` (react) |
@@ -357,11 +389,15 @@ The API URL resolves in this order: **`--api-url` flag → `ANIMA_API_URL` env
357
389
  ### `auth` — inspect or manage credentials
358
390
 
359
391
  ```bash
360
- npx @animaapp/cli@latest auth --status # token type + expiry
392
+ npx @animaapp/cli@latest auth --status # token type + expiry, and any pending claim
361
393
  npx @animaapp/cli@latest auth --figma-token <T> # save a Figma token for codegen / f2c
362
394
  npx @animaapp/cli@latest auth --logout # alias for `anima logout`
363
395
  ```
364
396
 
397
+ `--status` also reports a handoff waiting to be claimed — including when you are
398
+ not connected yet, which is exactly when you want to ask. `pendingHandoff.state`
399
+ is `awaiting_claim` or `expired`, alongside the `claimUrl` to re-send.
400
+
365
401
  ---
366
402
 
367
403
  ## Global flags
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import path from "path";
13
13
  import os from "os";
14
14
  var CONFIG_DIR = path.join(os.homedir(), ".config", "anima");
15
15
  var CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
16
+ var HANDOFF_FILE = path.join(CONFIG_DIR, "handoff.json");
16
17
  function classifyCredentials() {
17
18
  let parsed;
18
19
  try {
@@ -56,14 +57,17 @@ function saveFigmaToken(figmaToken) {
56
57
  write({ ...existing ?? {}, figmaToken });
57
58
  }
58
59
  function write(credentials) {
59
- const merged = { ...credentials };
60
+ writeSecretJson(CREDENTIALS_FILE, { ...credentials });
61
+ }
62
+ function writeSecretJson(file, data) {
63
+ const merged = { ...data };
60
64
  for (const key of Object.keys(merged)) {
61
65
  if (merged[key] === void 0) delete merged[key];
62
66
  }
63
67
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
64
- const tmp = `${CREDENTIALS_FILE}.${process.pid}.tmp`;
68
+ const tmp = `${file}.${process.pid}.tmp`;
65
69
  fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + "\n", { mode: 384 });
66
- fs.renameSync(tmp, CREDENTIALS_FILE);
70
+ fs.renameSync(tmp, file);
67
71
  }
68
72
  function deleteCredentials() {
69
73
  try {
@@ -76,8 +80,35 @@ function deleteCredentials() {
76
80
  return false;
77
81
  }
78
82
  }
83
+ function savePendingHandoff(pending) {
84
+ writeSecretJson(HANDOFF_FILE, { ...pending });
85
+ }
86
+ function readPendingHandoff() {
87
+ try {
88
+ if (!fs.existsSync(HANDOFF_FILE)) return null;
89
+ const parsed = JSON.parse(fs.readFileSync(HANDOFF_FILE, "utf-8"));
90
+ const required2 = ["handoffToken", "sessionId", "claimUrl", "claimableUntil", "apiUrl"];
91
+ if (required2.some((field) => typeof parsed[field] !== "string")) return null;
92
+ return parsed;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+ function clearPendingHandoff() {
98
+ try {
99
+ if (!fs.existsSync(HANDOFF_FILE)) return false;
100
+ fs.unlinkSync(HANDOFF_FILE);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+ function getHandoffPath() {
107
+ return HANDOFF_FILE;
108
+ }
79
109
  function clearStorage() {
80
110
  const had = deleteCredentials();
111
+ clearPendingHandoff();
81
112
  try {
82
113
  if (fs.existsSync(CONFIG_DIR) && fs.readdirSync(CONFIG_DIR).length === 0) {
83
114
  fs.rmdirSync(CONFIG_DIR);
@@ -734,6 +765,51 @@ function parsePlaygroundUrl(input) {
734
765
  }
735
766
  }
736
767
 
768
+ // src/api/anonymousCreate.ts
769
+ async function createAnonymousArtifact(request, fetchFn = fetch) {
770
+ const url = `${request.apiUrl.replace(/\/$/, "")}/v1/generationSessions`;
771
+ const body = {
772
+ type: "import",
773
+ ...request.files ? { files: request.files } : {},
774
+ ...request.zipUploadId ? { zipUploadId: request.zipUploadId } : {},
775
+ ...request.framework ? { framework: request.framework } : {},
776
+ ...request.artifactType ? { artifactType: request.artifactType } : {},
777
+ ...request.name ? { name: request.name } : {},
778
+ ...request.handoffToken ? { handoffToken: request.handoffToken } : {},
779
+ ...request.clientName ? { clientName: request.clientName } : {}
780
+ };
781
+ logEvent("anonymous_create_request", { url, type: "import", artifactType: request.artifactType });
782
+ const response = await fetchFn(url, {
783
+ method: "POST",
784
+ headers: { "Content-Type": "application/json" },
785
+ body: JSON.stringify(body)
786
+ });
787
+ const payload = await response.json().catch(() => ({}));
788
+ logEvent("anonymous_create_response", { url, status: response.status, error: payload.error });
789
+ if (!response.ok || payload.success !== true) {
790
+ const message = typeof payload.reason === "string" && payload.reason || typeof payload.error === "string" && payload.error || `Anonymous create failed (HTTP ${response.status})`;
791
+ const nextSteps = typeof payload.nextSteps === "string" ? payload.nextSteps : void 0;
792
+ throw apiError(nextSteps ? `${message}
793
+ ${nextSteps}` : message, payload);
794
+ }
795
+ for (const field of ["sessionId", "handoffToken", "claimUrl", "claimableUntil"]) {
796
+ if (typeof payload[field] !== "string") {
797
+ throw apiError(`The API's create response is missing \`${field}\`.`, payload);
798
+ }
799
+ }
800
+ return payload;
801
+ }
802
+ async function getAnonymousZipUploadUrl(apiUrl, fetchFn = fetch) {
803
+ const url = `${apiUrl.replace(/\/$/, "")}/v1/generationSessions/zip-upload-url`;
804
+ const response = await fetchFn(url, { method: "POST" });
805
+ const payload = await response.json().catch(() => ({}));
806
+ if (!response.ok || payload.success !== true || typeof payload.uploadUrl !== "string") {
807
+ const message = typeof payload.reason === "string" && payload.reason || typeof payload.error === "string" && payload.error || `Could not get a zip upload URL (HTTP ${response.status})`;
808
+ throw apiError(message, payload);
809
+ }
810
+ return { zipUploadId: payload.zipUploadId, uploadUrl: payload.uploadUrl };
811
+ }
812
+
737
813
  // src/commands/create.ts
738
814
  var createCommand = new Command("create").description(
739
815
  "Create an artifact from your own code: empty for code you'll push (instant) or import your existing code (instant). Also supports AI generation: p2c/l2c/f2c (3-7 min)."
@@ -746,13 +822,23 @@ var createCommand = new Command("create").description(
746
822
  ).option("--styling <style>", "tailwind | css | plain_css | css_modules | inline_styles", "tailwind").option("--language <lang>", "typescript | javascript").option("--ui-library <lib>", "mui | antd | shadcn | clean_react").option("--guidelines <text>", "Additional constraints (p2c only)").option(
747
823
  "--artifact-type <kind>",
748
824
  "app | markdown \u2014 what the artifact IS. app (default) needs an index.html; markdown needs .md files"
749
- ).option("--name <name>", "Project name (empty/import only)").option("--from <path>", "Folder or .zip with your project (import only) \u2014 becomes the first commit").option("--json", "Output as JSON").option("--timeout <ms>", "Timeout in milliseconds", "600000").option("--verbose", "Show progress on stderr").option("--api-url <url>", "API base URL (overrides config / ANIMA_API_URL)").option("--log-file <path>", "Append a JSON debug log of each step to this file").action(async (opts) => {
825
+ ).option("--name <name>", "Project name (empty/import only)").option("--from <path>", "Folder or .zip with your project (import only) \u2014 becomes the first commit").option(
826
+ "--anonymous",
827
+ "Create without an account (import only). Returns a claim link to send a human, and a handoff token to log in with once they claim it"
828
+ ).option(
829
+ "--handoff <token>",
830
+ "Add this artifact to an existing claim (--anonymous only), so one claim link covers them all. Defaults to the token from your last anonymous create"
831
+ ).option("--client-name <name>", "How this CLI is credited as the creator of an anonymous artifact", "AgentGrid CLI").option("--json", "Output as JSON").option("--timeout <ms>", "Timeout in milliseconds", "600000").option("--verbose", "Show progress on stderr").option("--api-url <url>", "API base URL (overrides config / ANIMA_API_URL)").option("--log-file <path>", "Append a JSON debug log of each step to this file").action(async (opts) => {
750
832
  const mode = detectOutputMode(opts);
751
833
  initLogger(opts.logFile);
752
834
  const progress = new ProgressReporter(mode, opts.verbose);
753
835
  try {
754
836
  const type = opts.type;
755
837
  validateCreateOptions(type, opts);
838
+ if (opts.anonymous) {
839
+ await runAnonymousCreate(opts, mode, progress);
840
+ return;
841
+ }
756
842
  const auth = await resolveAuth(opts.apiUrl);
757
843
  const figmaToken = type === "f2c" ? resolveFigmaToken(opts, auth.figmaToken) : void 0;
758
844
  const args = buildCreateArgs(type, opts);
@@ -856,6 +942,88 @@ ${result.nextSteps}` : message, result);
856
942
  throw err;
857
943
  }
858
944
  });
945
+ async function runAnonymousCreate(opts, mode, progress) {
946
+ const apiUrl = resolveApiUrl(opts.apiUrl);
947
+ progress.start("Collecting project files...");
948
+ const source = collectProjectSource(opts.from);
949
+ let files;
950
+ let zipUploadId;
951
+ if (source.kind === "files") {
952
+ files = source.files;
953
+ } else {
954
+ progress.update(`Uploading project zip (${source.reason})...`);
955
+ const grant = await getAnonymousZipUploadUrl(apiUrl);
956
+ const putResponse = await fetch(grant.uploadUrl, {
957
+ method: "PUT",
958
+ body: new Uint8Array(source.buffer)
959
+ });
960
+ if (!putResponse.ok) {
961
+ throw apiError(`Zip upload failed: HTTP ${putResponse.status}`, { status: putResponse.status });
962
+ }
963
+ zipUploadId = grant.zipUploadId;
964
+ }
965
+ progress.start("Creating anonymous artifact...");
966
+ const request = {
967
+ apiUrl,
968
+ files,
969
+ zipUploadId,
970
+ ...opts.framework && opts.artifactType !== "markdown" ? { framework: opts.framework } : {},
971
+ ...opts.artifactType ? { artifactType: opts.artifactType } : {},
972
+ ...opts.name ? { name: opts.name } : {},
973
+ clientName: opts.clientName
974
+ };
975
+ const reusable = opts.handoff ? { handoffToken: opts.handoff } : livePendingHandoff();
976
+ const result = reusable ? await createAnonymousArtifact({ ...request, handoffToken: reusable.handoffToken }).catch((err) => {
977
+ if (!isHandoffTokenInvalid(err)) throw err;
978
+ clearPendingHandoff();
979
+ progress.update("Previous claim is no longer open \u2014 starting a new one...");
980
+ return createAnonymousArtifact(request);
981
+ }) : await createAnonymousArtifact(request);
982
+ const claimUrl = result.claimUrl;
983
+ savePendingHandoff({
984
+ handoffToken: result.handoffToken,
985
+ sessionId: result.sessionId,
986
+ claimUrl,
987
+ claimableUntil: result.claimableUntil,
988
+ apiUrl
989
+ });
990
+ progress.succeed("Artifact created!");
991
+ if (mode === "pretty") {
992
+ printSessionResult({ sessionId: result.sessionId, playgroundUrl: result.previewUrl });
993
+ console.log(" Send your human this link to claim it:");
994
+ console.log(` ${chalk2.cyan.underline(claimUrl)}`);
995
+ console.log();
996
+ console.log(` Claimable until ${result.claimableUntil}. Until then the artifact is read-only.`);
997
+ console.log(` Then run ${chalk2.cyan("anima login --handoff")} to pick up your credentials.`);
998
+ if (result.notes) console.log(` Note: ${result.notes}`);
999
+ console.log();
1000
+ } else {
1001
+ outputResult(mode, {
1002
+ success: true,
1003
+ sessionId: result.sessionId,
1004
+ previewUrl: result.previewUrl,
1005
+ claimUrl,
1006
+ claimableUntil: result.claimableUntil,
1007
+ // Returned, not just stored: it is delivered exactly once and can never
1008
+ // be re-fetched, so a caller whose config does not outlive the process
1009
+ // would otherwise lose the only route to a credential.
1010
+ handoffToken: result.handoffToken,
1011
+ handoffStored: getHandoffPath(),
1012
+ ...result.fileCount !== void 0 ? { fileCount: result.fileCount } : {},
1013
+ ...result.skippedFiles?.length ? { skippedFiles: result.skippedFiles } : {},
1014
+ ...result.notes ? { notes: result.notes } : {},
1015
+ nextSteps: `Send claimUrl to your human \u2014 that is the link that transfers the artifact to them. handoffToken is yours, never theirs: use it to log in (\`anima login --handoff\`, which waits until they claim and then stores your credentials), or to add more artifacts to the same claim (\`anima create --anonymous --handoff <token>\`). Run the login in the background if you have other work.`
1016
+ });
1017
+ }
1018
+ }
1019
+ function livePendingHandoff() {
1020
+ const pending = readPendingHandoff();
1021
+ if (!pending) return null;
1022
+ return new Date(pending.claimableUntil).getTime() > Date.now() ? pending : null;
1023
+ }
1024
+ function isHandoffTokenInvalid(err) {
1025
+ return err instanceof CliError && /handoff_token_invalid/.test(JSON.stringify(err.detail ?? ""));
1026
+ }
859
1027
  function validateCreateOptions(type, opts) {
860
1028
  if (!["empty", "import", "p2c", "l2c", "f2c"].includes(type)) {
861
1029
  throw validationError(`Invalid type "${type}". Must be empty, import, p2c, l2c, or f2c.`);
@@ -869,11 +1037,27 @@ function validateCreateOptions(type, opts) {
869
1037
  if (type === "f2c" && !opts.fileKey) {
870
1038
  throw validationError("--file-key is required for f2c type");
871
1039
  }
872
- if (type === "empty" && !opts.framework) {
1040
+ if (type === "empty" && !opts.framework && opts.artifactType !== "markdown") {
873
1041
  throw validationError(
874
1042
  "--framework is required for type empty \u2014 html for plain HTML/CSS/JS, react for React (declare react if you will push React code)"
875
1043
  );
876
1044
  }
1045
+ if (opts.artifactType && !["app", "markdown"].includes(opts.artifactType)) {
1046
+ throw validationError(
1047
+ `Invalid --artifact-type "${opts.artifactType}". Must be app or markdown.`
1048
+ );
1049
+ }
1050
+ if (opts.handoff && !opts.anonymous) {
1051
+ throw validationError(
1052
+ "--handoff only applies to --anonymous creates \u2014 it adds the new artifact to an existing claim. An authenticated create already belongs to your team."
1053
+ );
1054
+ }
1055
+ if (opts.anonymous && type !== "import") {
1056
+ throw validationError(
1057
+ `--anonymous only works with -t import (got "${type}"). Anonymous artifacts are created FROM code you already have: there is no way to push to an empty one, and AI generation needs an account.
1058
+ Log in first for the other types: anima login`
1059
+ );
1060
+ }
877
1061
  if (type === "import" && !opts.from) {
878
1062
  throw validationError("--from <folder|zip> is required for type import \u2014 the path to the project to import");
879
1063
  }
@@ -894,7 +1078,10 @@ function buildCreateArgs(type, opts) {
894
1078
  if (type === "empty" || type === "import") {
895
1079
  return {
896
1080
  type,
897
- ...opts.framework ? { framework: opts.framework } : {},
1081
+ // A document has no framework to build with, and declaring one on an
1082
+ // import is what made markdown artifacts render as empty apps.
1083
+ ...opts.framework && opts.artifactType !== "markdown" ? { framework: opts.framework } : {},
1084
+ ...opts.artifactType ? { artifactType: opts.artifactType } : {},
898
1085
  ...opts.name ? { name: opts.name } : {}
899
1086
  };
900
1087
  }
@@ -1379,13 +1566,84 @@ async function loginWithInvite(options) {
1379
1566
  return session;
1380
1567
  }
1381
1568
 
1569
+ // src/auth/handoff.ts
1570
+ var HANDOFF_GRANT_TYPE = "urn:animaapp:params:oauth:grant-type:handoff_token";
1571
+ var POLL_INTERVAL_SECONDS = 30;
1572
+ var defaultSleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1573
+ async function loginWithHandoff(options) {
1574
+ const fetchFn = options.fetchFn ?? fetch;
1575
+ const sleep = options.sleepFn ?? defaultSleep2;
1576
+ const now = options.nowFn ?? Date.now;
1577
+ const base = options.apiUrl.replace(/\/$/, "");
1578
+ const tokenUrl = `${base}/oauth/token`;
1579
+ const deadline = resolveDeadline(options.claimableUntil, now);
1580
+ let intervalMs = POLL_INTERVAL_SECONDS * 1e3;
1581
+ for (; ; ) {
1582
+ const response = await fetchFn(tokenUrl, {
1583
+ method: "POST",
1584
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1585
+ body: new URLSearchParams({
1586
+ grant_type: HANDOFF_GRANT_TYPE,
1587
+ handoff_token: options.handoffToken
1588
+ }).toString()
1589
+ });
1590
+ const body = await response.json().catch(() => ({}));
1591
+ logEvent("handoff_token_poll", { status: response.status, error: body.error });
1592
+ if (response.ok && typeof body.access_token === "string") {
1593
+ const session = agentSessionFromTokenResponse(body);
1594
+ persistAgentSession(session);
1595
+ clearPendingHandoff();
1596
+ return session;
1597
+ }
1598
+ switch (body.error) {
1599
+ case "authorization_pending":
1600
+ break;
1601
+ case "slow_down":
1602
+ intervalMs += 5e3;
1603
+ break;
1604
+ case "expired_token":
1605
+ clearPendingHandoff();
1606
+ throw authError(
1607
+ "This handoff token can no longer be redeemed \u2014 the claim window closed unclaimed, the token was already redeemed, or the human claimed the artifact without granting agent access. Share again with a new `anima create --anonymous` \u2014 or, if they claimed it and want you to keep editing, have them grant your agent from the artifact page."
1608
+ );
1609
+ default:
1610
+ throw new CliError(
1611
+ "API_ERROR",
1612
+ `Unexpected token response: ${body.error ?? `HTTP ${response.status}`}`,
1613
+ EXIT_CODES.API_ERROR
1614
+ );
1615
+ }
1616
+ const remainingMs = deadline.getTime() - now();
1617
+ if (remainingMs <= 0) break;
1618
+ const waitMs = Math.min(intervalMs, remainingMs);
1619
+ options.onPending?.({ nextPollInSeconds: Math.round(waitMs / 1e3), deadline });
1620
+ await sleep(waitMs);
1621
+ }
1622
+ throw authError(
1623
+ "The claim window closed before anyone claimed the artifact. Share again with a new `anima create --anonymous` \u2014 the previous one has expired and can no longer be claimed."
1624
+ );
1625
+ }
1626
+ var FALLBACK_POLL_WINDOW_MS = 24 * 60 * 60 * 1e3;
1627
+ function resolveDeadline(claimableUntil, now) {
1628
+ const parsed = claimableUntil ? new Date(claimableUntil) : null;
1629
+ if (parsed && !Number.isNaN(parsed.getTime())) return parsed;
1630
+ return new Date(now() + FALLBACK_POLL_WINDOW_MS);
1631
+ }
1632
+
1382
1633
  // src/commands/login.ts
1383
- var loginCommand = new Command6("login").description("Log in via the device flow, or redeem a pre-approved invite with --invite").option("--invite <url-or-code>", "Redeem an invite link (\u2026/invite/<code>.md) or bare code \u2014 no approval step").option("--client-name <name>", "How this CLI appears on the consent screen", "AgentGrid CLI").option("--no-open", "Do not try to open the verification page in a browser").option("--json", "Output as JSON").option("--api-url <url>", "API base URL (overrides config / ANIMA_API_URL)").option("--log-file <path>", "Append a JSON debug log of each step to this file").option(
1634
+ var loginCommand = new Command6("login").description("Log in via the device flow, redeem an invite with --invite, or finish an anonymous create with --handoff").option("--invite <url-or-code>", "Redeem an invite link (\u2026/invite/<code>.md) or bare code \u2014 no approval step").option(
1635
+ "--handoff [token]",
1636
+ "Log in with the handoff token from an anonymous create. Waits until a human claims the artifact. Defaults to your last one"
1637
+ ).option("--client-name <name>", "How this CLI appears on the consent screen", "AgentGrid CLI").option("--no-open", "Do not try to open the verification page in a browser").option("--json", "Output as JSON").option("--api-url <url>", "API base URL (overrides config / ANIMA_API_URL)").option("--log-file <path>", "Append a JSON debug log of each step to this file").option(
1384
1638
  "--print-mcp-config",
1385
1639
  "After login, also print an MCP server config (URL only \u2014 the client authorizes itself) to paste into an MCP-capable agent"
1386
1640
  ).action(async (opts) => {
1387
1641
  const mode = detectOutputMode(opts);
1388
1642
  initLogger(opts.logFile);
1643
+ if (opts.handoff) {
1644
+ await runHandoffLogin(opts, mode);
1645
+ return;
1646
+ }
1389
1647
  if (opts.invite) {
1390
1648
  const inviteOrigin = parseInvite(opts.invite).origin;
1391
1649
  const apiUrl2 = opts.apiUrl ? resolveApiUrl(opts.apiUrl) : inviteOrigin ?? resolveApiUrl();
@@ -1502,6 +1760,89 @@ var loginCommand = new Command6("login").description("Log in via the device flow
1502
1760
  throw err;
1503
1761
  }
1504
1762
  });
1763
+ async function runHandoffLogin(opts, mode) {
1764
+ const explicitToken = typeof opts.handoff === "string" ? opts.handoff : void 0;
1765
+ const stored = readPendingHandoff();
1766
+ const pending = stored && (!explicitToken || stored.handoffToken === explicitToken) ? stored : explicitToken ? { handoffToken: explicitToken, sessionId: "", claimUrl: "", claimableUntil: "", apiUrl: resolveApiUrl() } : null;
1767
+ if (!pending) {
1768
+ const message = "No handoff token. Pass one (`anima login --handoff <token>`), or run `anima create --anonymous -t import --from <path>` to get one. (Already logged in another way? `anima auth --status`.)";
1769
+ if (mode === "pretty") {
1770
+ printError(message);
1771
+ } else {
1772
+ outputError(mode, { code: "VALIDATION_ERROR", message });
1773
+ }
1774
+ process.exit(1);
1775
+ }
1776
+ const apiUrl = opts.apiUrl ? resolveApiUrl(opts.apiUrl) : pending.apiUrl;
1777
+ try {
1778
+ if (mode === "pretty") {
1779
+ console.log();
1780
+ printInfo("Waiting for a human to claim the artifact:");
1781
+ if (pending.claimUrl) {
1782
+ console.log();
1783
+ console.log(` ${chalk5.cyan.underline(pending.claimUrl)}`);
1784
+ }
1785
+ console.log();
1786
+ printInfo(
1787
+ pending.claimableUntil ? `Claimable until ${pending.claimableUntil}. Ctrl-C to stop \u2014 this does not cancel the claim.` : "Ctrl-C to stop \u2014 this does not cancel the claim."
1788
+ );
1789
+ } else {
1790
+ process.stderr.write(
1791
+ JSON.stringify({
1792
+ event: "claim_required",
1793
+ ...pending.claimUrl ? { claimUrl: pending.claimUrl } : {},
1794
+ ...pending.sessionId ? { sessionId: pending.sessionId } : {},
1795
+ ...pending.claimableUntil ? { claimableUntil: pending.claimableUntil } : {}
1796
+ }) + "\n"
1797
+ );
1798
+ }
1799
+ const result = await loginWithHandoff({
1800
+ apiUrl,
1801
+ handoffToken: pending.handoffToken,
1802
+ claimableUntil: pending.claimableUntil || void 0
1803
+ });
1804
+ if (readConfig().apiUrl !== apiUrl) {
1805
+ setConfig("api-url", apiUrl);
1806
+ }
1807
+ const mcpConfig = opts.printMcpConfig ? buildMcpConfig(apiUrl) : void 0;
1808
+ if (mode === "pretty") {
1809
+ printSuccess("Claimed \u2014 you're connected!");
1810
+ printKeyValue("Credentials", getCredentialsPath());
1811
+ printKeyValue("Agent", result.agentId);
1812
+ printKeyValue("Team ID", result.teamId);
1813
+ printKeyValue("Token expires", result.expiresAt);
1814
+ printInfo("The artifact is now editable: anima get-git-token " + pending.sessionId);
1815
+ if (mcpConfig) {
1816
+ console.log();
1817
+ printInfo("MCP server config \u2014 your client will prompt to authorize on first use:");
1818
+ console.log();
1819
+ console.log(JSON.stringify(mcpConfig, null, 2));
1820
+ }
1821
+ } else {
1822
+ outputResult(mode, {
1823
+ success: true,
1824
+ action: "logged_in",
1825
+ via: "handoff",
1826
+ tokenType: "agent",
1827
+ agentId: result.agentId,
1828
+ teamId: result.teamId,
1829
+ expiresAt: result.expiresAt,
1830
+ sessionId: pending.sessionId,
1831
+ ...mcpConfig ? { mcpConfig } : {}
1832
+ });
1833
+ }
1834
+ } catch (err) {
1835
+ if (err instanceof CliError) {
1836
+ if (mode === "pretty") {
1837
+ printError(err.message);
1838
+ } else {
1839
+ outputError(mode, { code: err.code, message: err.message });
1840
+ }
1841
+ process.exit(err.exitCode);
1842
+ }
1843
+ throw err;
1844
+ }
1845
+ }
1505
1846
  function buildMcpConfig(apiUrl) {
1506
1847
  return {
1507
1848
  mcpServers: {
@@ -1544,6 +1885,25 @@ var mcpConfigCommand = new Command7("mcp-config").description("Print an MCP serv
1544
1885
  import { Command as Command8 } from "commander";
1545
1886
  import chalk6 from "chalk";
1546
1887
  var DEFAULT_API_URL2 = "https://api.agentgrid.io";
1888
+ function describePendingHandoff(pending, state) {
1889
+ return {
1890
+ state,
1891
+ sessionId: pending.sessionId,
1892
+ claimUrl: pending.claimUrl,
1893
+ claimableUntil: pending.claimableUntil,
1894
+ nextSteps: state === "expired" ? "The claim window closed. Create another artifact, or ask your human for an agent invite: anima login --invite <url-or-code>" : "Send claimUrl to your human, then run `anima login --handoff` to wait for the credentials their claim grants."
1895
+ };
1896
+ }
1897
+ function printPendingHandoff(pending, state) {
1898
+ console.log();
1899
+ if (state === "expired") {
1900
+ printInfo(`Anonymous artifact ${pending.sessionId} was never claimed \u2014 its window closed at ${pending.claimableUntil}.`);
1901
+ return;
1902
+ }
1903
+ printInfo(`Anonymous artifact ${pending.sessionId} is awaiting a claim (until ${pending.claimableUntil}):`);
1904
+ console.log(` ${chalk6.cyan.underline(pending.claimUrl)}`);
1905
+ console.log(` Run ${chalk6.cyan("anima login --handoff")} to wait for it.`);
1906
+ }
1547
1907
  var authCommand = new Command8("auth").description("Manage authentication").option("--token <token>", "Store AgentGrid API token").option("--figma-token <token>", "Store Figma personal access token").option("--team-id <teamId>", "Team ID to associate with token").option("--status", "Show current auth status").option("--logout", "Clear stored credentials (alias for `anima logout`)").option("--json", "Output as JSON").option("--api-url <url>", "API base URL", DEFAULT_API_URL2).action(async (opts) => {
1548
1908
  const mode = detectOutputMode(opts);
1549
1909
  try {
@@ -1591,6 +1951,8 @@ var authCommand = new Command8("auth").description("Manage authentication").opti
1591
1951
  return;
1592
1952
  }
1593
1953
  if (opts.status) {
1954
+ const pending = readPendingHandoff();
1955
+ const pendingState = !pending ? void 0 : new Date(pending.claimableUntil).getTime() <= Date.now() ? "expired" : "awaiting_claim";
1594
1956
  try {
1595
1957
  const auth = await resolveAuth(opts.apiUrl);
1596
1958
  const creds = readCredentials();
@@ -1604,6 +1966,7 @@ var authCommand = new Command8("auth").description("Manage authentication").opti
1604
1966
  if (creds?.tokenType === "agent" && creds.expiresAt) printKeyValue("Expires", creds.expiresAt);
1605
1967
  if (auth.teamId) printKeyValue("Team ID", auth.teamId);
1606
1968
  printKeyValue("Figma token", hasFigma ? "configured" : "not set (needed for codegen/f2c)");
1969
+ if (pending) printPendingHandoff(pending, pendingState);
1607
1970
  } else {
1608
1971
  outputResult(mode, {
1609
1972
  success: true,
@@ -1612,12 +1975,22 @@ var authCommand = new Command8("auth").description("Manage authentication").opti
1612
1975
  tokenPreview: auth.token.slice(0, 12) + "..." + auth.token.slice(-4),
1613
1976
  teamId: auth.teamId,
1614
1977
  expiresAt: creds?.tokenType === "agent" ? creds.expiresAt : void 0,
1615
- figmaToken: hasFigma
1978
+ figmaToken: hasFigma,
1979
+ ...pending ? { pendingHandoff: describePendingHandoff(pending, pendingState) } : {}
1616
1980
  });
1617
1981
  }
1618
1982
  } catch (err) {
1619
1983
  if (err instanceof CliError) {
1620
- if (mode === "pretty") {
1984
+ if (pending && mode === "pretty") {
1985
+ printInfo("Not connected yet.");
1986
+ printPendingHandoff(pending, pendingState);
1987
+ } else if (pending) {
1988
+ outputResult(mode, {
1989
+ success: true,
1990
+ tokenType: null,
1991
+ pendingHandoff: describePendingHandoff(pending, pendingState)
1992
+ });
1993
+ } else if (mode === "pretty") {
1621
1994
  printError(err.message);
1622
1995
  } else {
1623
1996
  outputError(mode, { code: err.code, message: err.message });
@@ -1632,6 +2005,7 @@ var authCommand = new Command8("auth").description("Manage authentication").opti
1632
2005
  console.log();
1633
2006
  printInfo("Auth commands:");
1634
2007
  console.log(` ${chalk6.cyan("anima login")} Log in (device flow \u2014 approve on any browser)`);
2008
+ console.log(` ${chalk6.cyan("anima login --handoff")} Wait for a human to claim your anonymous artifact`);
1635
2009
  console.log(` ${chalk6.cyan("anima logout")} Clear all credentials`);
1636
2010
  console.log(` ${chalk6.cyan("anima auth --status")} Check auth status`);
1637
2011
  console.log(` ${chalk6.cyan("anima auth --figma-token <T>")} Save Figma token (needed for codegen/f2c)`);