@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 +54 -18
- package/dist/index.js +383 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
95
|
-
AgentGrid
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
-
|
|
102
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
|
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)
|
|
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
|
-
|
|
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 = `${
|
|
68
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
65
69
|
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + "\n", { mode: 384 });
|
|
66
|
-
fs.renameSync(tmp,
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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)`);
|