@animaapp/cli 0.2.1 → 0.3.1

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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command6 } from "commander";
4
+ import { Command as Command9 } from "commander";
5
5
 
6
6
  // src/commands/create.ts
7
7
  import { Command } from "commander";
@@ -47,6 +47,16 @@ function deleteCredentials() {
47
47
  return false;
48
48
  }
49
49
  }
50
+ function clearStorage() {
51
+ const had = deleteCredentials();
52
+ try {
53
+ if (fs.existsSync(CONFIG_DIR) && fs.readdirSync(CONFIG_DIR).length === 0) {
54
+ fs.rmdirSync(CONFIG_DIR);
55
+ }
56
+ } catch {
57
+ }
58
+ return had;
59
+ }
50
60
  function getCredentialsPath() {
51
61
  return CREDENTIALS_FILE;
52
62
  }
@@ -97,9 +107,6 @@ function validationError(message, detail) {
97
107
  function apiError(message, detail) {
98
108
  return new CliError("API_ERROR", message, EXIT_CODES.API_ERROR, detail);
99
109
  }
100
- function streamError(message) {
101
- return new CliError("STREAM_ERROR", message, EXIT_CODES.API_ERROR);
102
- }
103
110
  function timeoutError() {
104
111
  return new CliError(
105
112
  "TIMEOUT",
@@ -121,139 +128,151 @@ function resolveAuth() {
121
128
  };
122
129
  }
123
130
  if (creds) {
131
+ if (creds.tokenType === "agent" && creds.expiresAt && new Date(creds.expiresAt) <= /* @__PURE__ */ new Date()) {
132
+ throw authError("Your agent token has expired. Run: anima login");
133
+ }
124
134
  return {
125
135
  token: creds.token,
126
136
  teamId: creds.teamId,
127
137
  figmaToken: process.env.FIGMA_TOKEN || creds.figmaToken,
128
- source: "file"
138
+ source: "file",
139
+ tokenType: creds.tokenType
129
140
  };
130
141
  }
131
- throw authError(
132
- "No API token found. Set ANIMA_API_TOKEN env var or run: anima auth --token <token>\nGet your token at: https://dev.animaapp.com > Settings > API Keys"
133
- );
142
+ throw authError("No credentials found. Run: anima login\n(or set the ANIMA_API_TOKEN env var)");
134
143
  }
135
144
 
136
- // src/api/client.ts
137
- async function streamGeneration(url, body, headers, callbacks = {}, timeoutMs = 6e5, signal) {
138
- const controller = new AbortController();
139
- const combinedSignal = signal ?? controller.signal;
140
- const timer = setTimeout(() => controller.abort(), timeoutMs);
141
- try {
142
- const response = await fetch(url, {
143
- method: "POST",
144
- headers: {
145
- ...headers,
146
- "Content-Type": "application/json",
147
- Accept: "text/event-stream"
148
- },
149
- body: JSON.stringify(body),
150
- signal: combinedSignal
145
+ // src/api/mcp.ts
146
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
147
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
148
+ import { CallToolResultSchema, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
149
+
150
+ // src/output/log.ts
151
+ import fs2 from "fs";
152
+ import path2 from "path";
153
+ var logPath = null;
154
+ var REDACT_KEY = /token|authorization|secret|password|_auth|api[-_]?key/i;
155
+ var REDACTED = "***REDACTED***";
156
+ function initLogger(filePath) {
157
+ const target = filePath || process.env.ANIMA_LOG_FILE;
158
+ if (!target || logPath) return;
159
+ logPath = path2.resolve(target);
160
+ fs2.mkdirSync(path2.dirname(logPath), { recursive: true });
161
+ logEvent("log_started", { pid: process.pid, argv: process.argv.slice(2) });
162
+ }
163
+ function logEvent(event, data) {
164
+ if (!logPath) return;
165
+ const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), event, ...data ? redact(data) : {} };
166
+ fs2.appendFileSync(logPath, JSON.stringify(entry) + "\n");
167
+ }
168
+ function redact(value) {
169
+ const out = {};
170
+ for (const [key, val] of Object.entries(value)) {
171
+ if (REDACT_KEY.test(key)) {
172
+ out[key] = REDACTED;
173
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
174
+ out[key] = redact(val);
175
+ } else {
176
+ out[key] = val;
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ // src/version.ts
183
+ import { createRequire } from "module";
184
+ var { version } = createRequire(import.meta.url)("../package.json");
185
+
186
+ // src/api/mcp.ts
187
+ var MCP_PATH = "/v1/mcp";
188
+ var CLIENT_INFO = { name: "anima-cli", version };
189
+ var DEFAULT_TIMEOUT_MS = 6e5;
190
+ var AnimaMcpClient = class {
191
+ #client;
192
+ #transport;
193
+ #url;
194
+ constructor(config) {
195
+ const headers = {
196
+ Authorization: `Bearer ${config.token}`
197
+ };
198
+ if (config.teamId) headers["X-Team-Id"] = config.teamId;
199
+ if (config.figmaToken) headers["X-Figma-Token"] = config.figmaToken;
200
+ this.#url = new URL(MCP_PATH, config.apiBaseUrl).toString();
201
+ this.#transport = new StreamableHTTPClientTransport(new URL(this.#url), {
202
+ requestInit: { headers }
151
203
  });
152
- if (!response.ok) {
153
- const errorText = await response.text();
154
- let detail;
155
- try {
156
- detail = JSON.parse(errorText);
157
- } catch {
158
- detail = errorText;
159
- }
160
- throw apiError(
161
- `API returned ${response.status}: ${response.statusText}`,
162
- detail
204
+ this.#client = new Client(CLIENT_INFO, { capabilities: {} });
205
+ }
206
+ async connect() {
207
+ logEvent("mcp_connect", { url: this.#url });
208
+ try {
209
+ await this.#client.connect(this.#transport);
210
+ } catch (err) {
211
+ logEvent("mcp_connect_failed", { url: this.#url, error: err?.message });
212
+ throw connectionError(err);
213
+ }
214
+ }
215
+ /**
216
+ * Call a tool and return its parsed result. Anima tools return a single JSON
217
+ * text block; a `{ success: false }` payload is a normal result, so callers
218
+ * still inspect `success` themselves.
219
+ */
220
+ async callTool(name, args, options = {}) {
221
+ logEvent("mcp_tool_call", { tool: name, arguments: args });
222
+ let result;
223
+ try {
224
+ result = await this.#client.callTool(
225
+ { name, arguments: args },
226
+ CallToolResultSchema,
227
+ {
228
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
229
+ resetTimeoutOnProgress: true,
230
+ onprogress: options.onProgress ? (p) => options.onProgress({ progress: p.progress, total: p.total, message: p.message }) : void 0
231
+ }
163
232
  );
233
+ } catch (err) {
234
+ logEvent("mcp_tool_error", { tool: name, error: err?.message });
235
+ if (err?.code === ErrorCode.RequestTimeout) {
236
+ throw timeoutError();
237
+ }
238
+ throw err;
164
239
  }
165
- if (!response.body) {
166
- throw streamError("Response body is null");
240
+ logEvent("mcp_tool_result", { tool: name, isError: result.isError ?? false });
241
+ const content = Array.isArray(result.content) ? result.content : [];
242
+ const text = content.find((c) => c.type === "text")?.text;
243
+ if (result.isError) {
244
+ throw apiError(text || "MCP tool returned an error");
167
245
  }
168
- return await parseSSEStream(
169
- response.body,
170
- (data, result) => {
171
- switch (data.type) {
172
- case "queueing":
173
- callbacks.onQueueing?.(data.payload.sessionId);
174
- break;
175
- case "start":
176
- result.sessionId = data.sessionId;
177
- callbacks.onStart?.(data.sessionId);
178
- break;
179
- case "progress_messages_updated":
180
- callbacks.onProgress?.(data.payload.progressMessages);
181
- break;
182
- case "generating_code":
183
- if (data.payload.status === "success") {
184
- result.files = data.payload.files;
185
- }
186
- callbacks.onGeneratingCode?.(data.payload.progress);
187
- break;
188
- case "assets_list":
189
- result.assets = data.payload.assets;
190
- break;
191
- case "codegen_completed":
192
- case "generation_completed":
193
- callbacks.onCompleted?.();
194
- break;
195
- case "error":
196
- throw apiError(
197
- `${data.payload.errorName}: ${data.payload.reason}`,
198
- data.payload
199
- );
200
- case "done":
201
- result.tokenUsage = data.payload.tokenUsage;
202
- result.sessionId = data.payload.sessionId;
203
- return true;
204
- }
205
- return false;
206
- },
207
- { sessionId: "", files: {}, tokenUsage: 0 }
208
- );
209
- } catch (err) {
210
- if (err instanceof Error && err.name === "AbortError") {
211
- throw timeoutError();
246
+ if (!text) {
247
+ throw apiError("MCP tool returned no text content");
248
+ }
249
+ try {
250
+ return JSON.parse(text);
251
+ } catch {
252
+ return text;
212
253
  }
213
- throw err;
214
- } finally {
215
- clearTimeout(timer);
216
254
  }
217
- }
218
- async function parseSSEStream(body, handler, initial) {
219
- const reader = body.getReader();
220
- const decoder = new TextDecoder();
221
- let buffer = "";
222
- const result = { ...initial };
255
+ async close() {
256
+ await this.#transport.close().catch(() => {
257
+ });
258
+ }
259
+ };
260
+ async function withMcp(config, fn) {
261
+ const client = new AnimaMcpClient(config);
262
+ await client.connect();
223
263
  try {
224
- while (true) {
225
- const { done, value } = await reader.read();
226
- if (done) break;
227
- buffer += decoder.decode(value, { stream: true });
228
- const lines = buffer.split("\n");
229
- buffer = lines.pop() || "";
230
- for (const line of lines) {
231
- if (!line.trim() || line.startsWith(":")) continue;
232
- if (line.startsWith("data: ")) {
233
- let data;
234
- try {
235
- data = JSON.parse(line.slice(6));
236
- } catch {
237
- continue;
238
- }
239
- const isDone = handler(data, result);
240
- if (isDone) return result;
241
- }
242
- }
243
- }
264
+ return await fn(client);
244
265
  } finally {
245
- reader.cancel();
266
+ await client.close();
246
267
  }
247
- throw streamError("Connection closed before receiving 'done' message");
248
268
  }
249
- function buildHeaders(token, teamId) {
250
- const headers = {
251
- Authorization: `Bearer ${token}`
252
- };
253
- if (teamId) {
254
- headers["X-Team-Id"] = teamId;
255
- }
256
- return headers;
269
+ function connectionError(err) {
270
+ const message = err instanceof Error ? err.message : String(err);
271
+ return new CliError(
272
+ "CONNECTION_ERROR",
273
+ `Could not reach the Anima MCP endpoint: ${message}`,
274
+ 1
275
+ );
257
276
  }
258
277
 
259
278
  // src/output/formatter.ts
@@ -409,50 +428,81 @@ function normalizeNodeId(nodeId) {
409
428
  return nodeId.replace(/-/g, ":");
410
429
  }
411
430
 
412
- // src/commands/create.ts
431
+ // src/config/store.ts
432
+ import fs3 from "fs";
433
+ import path3 from "path";
434
+ import os2 from "os";
435
+ var CONFIG_DIR2 = path3.join(os2.homedir(), ".config", "anima");
436
+ var CONFIG_FILE = path3.join(CONFIG_DIR2, "config.json");
413
437
  var DEFAULT_API_URL = "https://public-api.animaapp.com";
438
+ var CONFIG_KEYS = {
439
+ "api-url": "apiUrl"
440
+ };
441
+ function isConfigKey(key) {
442
+ return key in CONFIG_KEYS;
443
+ }
444
+ function configKeys() {
445
+ return Object.keys(CONFIG_KEYS);
446
+ }
447
+ function readConfig() {
448
+ try {
449
+ if (!fs3.existsSync(CONFIG_FILE)) return {};
450
+ const parsed = JSON.parse(fs3.readFileSync(CONFIG_FILE, "utf-8"));
451
+ return parsed && typeof parsed === "object" ? parsed : {};
452
+ } catch {
453
+ return {};
454
+ }
455
+ }
456
+ function setConfig(key, value) {
457
+ const merged = { ...readConfig(), [CONFIG_KEYS[key]]: value };
458
+ fs3.mkdirSync(CONFIG_DIR2, { recursive: true });
459
+ fs3.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n");
460
+ }
461
+ function unsetConfig(key) {
462
+ const config = readConfig();
463
+ const field = CONFIG_KEYS[key];
464
+ if (!(field in config)) return false;
465
+ delete config[field];
466
+ fs3.mkdirSync(CONFIG_DIR2, { recursive: true });
467
+ fs3.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
468
+ return true;
469
+ }
470
+ function getConfigPath() {
471
+ return CONFIG_FILE;
472
+ }
473
+ function resolveApiUrl(flagValue) {
474
+ return flagValue || process.env.ANIMA_API_URL || readConfig().apiUrl || DEFAULT_API_URL;
475
+ }
476
+
477
+ // src/commands/create.ts
414
478
  var PLAYGROUND_BASE = "https://dev.animaapp.com/chat";
415
- var createCommand = new Command("create").description("Create app from prompt, URL, or Figma design (3-7 min)").requiredOption("-t, --type <type>", "Generation type: p2c, l2c, or f2c").option("-p, --prompt <prompt>", "Text prompt (p2c)").option("-u, --url <url>", "Website URL (l2c)").option("--file-key <key>", "Figma file key or URL (f2c)").option("--nodes <nodes>", "Figma node IDs, comma-separated (f2c)").option("--figma-token <token>", "Figma personal access token (f2c)").option("--framework <fw>", "react | html", "react").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("--json", "Output as JSON").option("--timeout <ms>", "Timeout in milliseconds", "600000").option("--verbose", "Show progress on stderr").option("--api-url <url>", "API base URL", DEFAULT_API_URL).action(async (opts) => {
479
+ var createCommand = new Command("create").description("Create app from prompt, URL, or Figma design (3-7 min)").requiredOption("-t, --type <type>", "Generation type: p2c, l2c, or f2c").option("-p, --prompt <prompt>", "Text prompt (p2c)").option("-u, --url <url>", "Website URL (l2c)").option("--file-key <key>", "Figma file key or URL (f2c)").option("--nodes <nodes>", "Figma node IDs, comma-separated (f2c)").option("--figma-token <token>", "Figma personal access token (f2c)").option("--framework <fw>", "react | html", "react").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("--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) => {
416
480
  const mode = detectOutputMode(opts);
481
+ initLogger(opts.logFile);
417
482
  const progress = new ProgressReporter(mode, opts.verbose);
418
483
  try {
419
484
  const type = opts.type;
420
485
  validateCreateOptions(type, opts);
421
486
  const auth = resolveAuth();
422
- const headers = buildHeaders(auth.token, auth.teamId);
423
- const { endpoint, body } = buildCreateRequest(type, opts, auth.figmaToken);
487
+ const figmaToken = type === "f2c" ? resolveFigmaToken(opts, auth.figmaToken) : void 0;
488
+ const args = buildCreateArgs(type, opts);
424
489
  progress.start(`Creating ${type.toUpperCase()} playground...`);
425
- const result = await streamGeneration(
426
- `${opts.apiUrl}${endpoint}`,
427
- body,
428
- headers,
429
- {
430
- onQueueing: () => progress.update("Queued, waiting..."),
431
- onStart: (sid) => progress.update(`Started (${sid.slice(0, 8)}...)...`),
432
- onProgress: (msgs) => {
433
- const running = msgs.find((m) => m.status === "running");
434
- if (running) progress.update(running.title);
435
- },
436
- onGeneratingCode: (pct) => progress.update(`Generating code (${pct}%)...`),
437
- onCompleted: () => progress.update("Finalizing...")
438
- },
439
- parseInt(opts.timeout, 10)
490
+ const result = await withMcp(
491
+ { apiBaseUrl: resolveApiUrl(opts.apiUrl), token: auth.token, teamId: auth.teamId, figmaToken },
492
+ (client) => client.callTool("playground-create", args, {
493
+ timeoutMs: parseInt(opts.timeout, 10),
494
+ onProgress: (p) => progress.update(p.message ?? `Generating (${Math.round(p.progress)}%)...`)
495
+ })
440
496
  );
441
- const playgroundUrl = `${PLAYGROUND_BASE}/${result.sessionId}`;
497
+ if (!result.success || !result.sessionId) {
498
+ throw apiError(result.reason || result.error || "Generation failed", result);
499
+ }
500
+ const playgroundUrl = result.playgroundUrl || `${PLAYGROUND_BASE}/${result.sessionId}`;
442
501
  progress.succeed("Playground created!");
443
502
  if (mode === "pretty") {
444
- printSessionResult({
445
- sessionId: result.sessionId,
446
- playgroundUrl,
447
- tokenUsage: result.tokenUsage
448
- });
503
+ printSessionResult({ sessionId: result.sessionId, playgroundUrl });
449
504
  } else {
450
- outputResult(mode, {
451
- success: true,
452
- sessionId: result.sessionId,
453
- playgroundUrl,
454
- tokenUsage: result.tokenUsage
455
- });
505
+ outputResult(mode, { success: true, sessionId: result.sessionId, playgroundUrl });
456
506
  }
457
507
  } catch (err) {
458
508
  progress.fail("Failed");
@@ -481,42 +531,32 @@ function validateCreateOptions(type, opts) {
481
531
  throw validationError("--file-key is required for f2c type");
482
532
  }
483
533
  }
484
- function buildCreateRequest(type, opts, storedFigmaToken) {
534
+ function resolveFigmaToken(opts, stored) {
535
+ const figmaToken = opts.figmaToken || process.env.FIGMA_TOKEN || stored;
536
+ if (!figmaToken) {
537
+ throw validationError(
538
+ "Figma token required for f2c. Use --figma-token, set FIGMA_TOKEN env var, or run: anima auth --figma-token <token>\nGet one at: https://www.figma.com > Settings > Personal access tokens"
539
+ );
540
+ }
541
+ return figmaToken;
542
+ }
543
+ function buildCreateArgs(type, opts) {
485
544
  const language = opts.language ?? (opts.framework === "react" ? "typescript" : void 0);
486
- const conventions = {
545
+ const args = {
546
+ type,
487
547
  framework: opts.framework,
488
- language,
489
- styling: opts.styling,
490
- uiLibrary: opts.uiLibrary
548
+ styling: opts.styling
491
549
  };
492
- Object.keys(conventions).forEach((key) => {
493
- if (conventions[key] === void 0) {
494
- delete conventions[key];
495
- }
496
- });
550
+ if (language) args.language = language;
551
+ if (opts.uiLibrary) args.uiLibrary = opts.uiLibrary;
497
552
  switch (type) {
498
553
  case "p2c":
499
- return {
500
- endpoint: "/v1/p2c",
501
- body: {
502
- params: {
503
- prompt: opts.prompt,
504
- conventions,
505
- ...opts.guidelines ? { guidelines: opts.guidelines } : {}
506
- }
507
- }
508
- };
554
+ args.prompt = opts.prompt;
555
+ if (opts.guidelines) args.guidelines = opts.guidelines;
556
+ break;
509
557
  case "l2c":
510
- return {
511
- endpoint: "/v1/l2c",
512
- body: {
513
- params: {
514
- input: { type: "url", url: opts.url },
515
- conventions,
516
- assetsStorage: { type: "bundled" }
517
- }
518
- }
519
- };
558
+ args.url = opts.url;
559
+ break;
520
560
  case "f2c": {
521
561
  const { fileKey, nodeId: parsedNodeId } = parseFigmaUrl(opts.fileKey);
522
562
  let nodesId = [];
@@ -525,123 +565,19 @@ function buildCreateRequest(type, opts, storedFigmaToken) {
525
565
  } else if (parsedNodeId) {
526
566
  nodesId = [parsedNodeId];
527
567
  }
528
- const figmaToken = opts.figmaToken || process.env.FIGMA_TOKEN || storedFigmaToken;
529
- if (!figmaToken) {
530
- throw validationError(
531
- "Figma token required for f2c. Use --figma-token, set FIGMA_TOKEN env var, or run: anima auth --figma-token <token>\nGet one at: https://www.figma.com > Settings > Personal access tokens"
532
- );
533
- }
534
- return {
535
- endpoint: "/v1/codegen",
536
- body: {
537
- fileKey,
538
- nodesId,
539
- figmaToken,
540
- framework: conventions.framework,
541
- language: conventions.language,
542
- styling: conventions.styling,
543
- uiLibrary: conventions.uiLibrary
544
- }
545
- };
568
+ args.fileKey = fileKey;
569
+ args.nodesId = nodesId;
570
+ break;
546
571
  }
547
572
  }
573
+ return args;
548
574
  }
549
575
 
550
576
  // src/commands/publish.ts
551
577
  import { Command as Command2 } from "commander";
552
-
553
- // src/api/mcp-client.ts
554
- async function callMCPTool(apiBaseUrl, token, teamId, toolName, args) {
555
- const request = {
556
- jsonrpc: "2.0",
557
- id: 1,
558
- method: "tools/call",
559
- params: {
560
- name: toolName,
561
- arguments: args
562
- }
563
- };
564
- const headers = {
565
- "Content-Type": "application/json",
566
- Accept: "application/json, text/event-stream",
567
- Authorization: `Bearer ${token}`
568
- };
569
- if (teamId) {
570
- headers["X-Team-Id"] = teamId;
571
- }
572
- const response = await fetch(`${apiBaseUrl}/v1/mcp`, {
573
- method: "POST",
574
- headers,
575
- body: JSON.stringify(request)
576
- });
577
- if (!response.ok) {
578
- const errorText = await response.text();
579
- throw apiError(`MCP request failed (${response.status}): ${errorText}`);
580
- }
581
- const contentType = response.headers.get("content-type") || "";
582
- if (contentType.includes("text/event-stream")) {
583
- return parseMCPSSEResponse(response);
584
- }
585
- const data = await response.json();
586
- return extractMCPResult(data);
587
- }
588
- async function parseMCPSSEResponse(response) {
589
- if (!response.body) {
590
- throw streamError("Response body is null");
591
- }
592
- const reader = response.body.getReader();
593
- const decoder = new TextDecoder();
594
- let buffer = "";
595
- let lastData = null;
596
- try {
597
- while (true) {
598
- const { done, value } = await reader.read();
599
- if (done) break;
600
- buffer += decoder.decode(value, { stream: true });
601
- const lines = buffer.split("\n");
602
- buffer = lines.pop() || "";
603
- for (const line of lines) {
604
- if (!line.trim() || line.startsWith(":")) continue;
605
- if (line.startsWith("data: ")) {
606
- try {
607
- const parsed = JSON.parse(line.slice(6));
608
- if (parsed.jsonrpc === "2.0") {
609
- lastData = parsed;
610
- }
611
- } catch {
612
- }
613
- }
614
- }
615
- }
616
- } finally {
617
- reader.cancel();
618
- }
619
- if (!lastData) {
620
- throw apiError("MCP SSE stream ended without a JSON-RPC response");
621
- }
622
- return extractMCPResult(lastData);
623
- }
624
- function extractMCPResult(data) {
625
- if (data.error) {
626
- throw apiError(`MCP tool error: ${data.error.message}`);
627
- }
628
- if (!data.result?.content?.length) {
629
- throw apiError("MCP tool returned empty result");
630
- }
631
- const textContent = data.result.content.find((c) => c.type === "text");
632
- if (!textContent) {
633
- throw apiError("MCP tool returned no text content");
634
- }
635
- try {
636
- return JSON.parse(textContent.text);
637
- } catch {
638
- return textContent.text;
639
- }
640
- }
641
-
642
- // src/commands/publish.ts
643
- var publishCommand = new Command2("publish").description("Deploy playground to live URL or npm package (1-3 min)").argument("<sessionId>", "Session ID from create command").option("--mode <mode>", "webapp | designSystem", "webapp").option("--package-name <name>", "NPM package name (designSystem mode)").option("--package-version <version>", "NPM package version (designSystem mode)").option("--json", "Output as JSON").option("--verbose", "Show progress on stderr").option("--api-url <url>", "API base URL", "https://public-api.animaapp.com").action(async (sessionId, opts) => {
578
+ var publishCommand = new Command2("publish").description("Deploy playground to live URL or npm package (1-3 min)").argument("<sessionId>", "Session ID from create command").option("--mode <mode>", "webapp | designSystem", "webapp").option("--package-name <name>", "NPM package name (designSystem mode)").option("--package-version <version>", "NPM package version (designSystem mode)").option("--json", "Output as JSON").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 (sessionId, opts) => {
644
579
  const mode = detectOutputMode(opts);
580
+ initLogger(opts.logFile);
645
581
  const progress = new ProgressReporter(mode, opts.verbose);
646
582
  try {
647
583
  if (!["webapp", "designSystem"].includes(opts.mode)) {
@@ -660,12 +596,11 @@ var publishCommand = new Command2("publish").description("Deploy playground to l
660
596
  if (opts.packageName) args.packageName = opts.packageName;
661
597
  if (opts.packageVersion) args.packageVersion = opts.packageVersion;
662
598
  progress.start(`Publishing as ${opts.mode}...`);
663
- const result = await callMCPTool(
664
- opts.apiUrl,
665
- auth.token,
666
- auth.teamId,
667
- "playground-publish",
668
- args
599
+ const result = await withMcp(
600
+ { apiBaseUrl: resolveApiUrl(opts.apiUrl), token: auth.token, teamId: auth.teamId },
601
+ (client) => client.callTool("playground-publish", args, {
602
+ onProgress: (p) => progress.update(p.message ?? "Publishing...")
603
+ })
669
604
  );
670
605
  if (!result.success) {
671
606
  throw new CliError(
@@ -713,20 +648,24 @@ var publishCommand = new Command2("publish").description("Deploy playground to l
713
648
  import { Command as Command3 } from "commander";
714
649
 
715
650
  // src/utils/files.ts
716
- import fs2 from "fs";
717
- import path2 from "path";
651
+ import fs4 from "fs";
652
+ import path4 from "path";
718
653
  function writeFilesToDisk(files, outputDir) {
719
- fs2.mkdirSync(outputDir, { recursive: true });
654
+ fs4.mkdirSync(outputDir, { recursive: true });
720
655
  let count = 0;
656
+ const resolvedOutput = path4.resolve(outputDir);
721
657
  for (const [filePath, fileContent] of Object.entries(files)) {
722
- const fullPath = path2.join(outputDir, filePath);
723
- const dirPath = path2.dirname(fullPath);
724
- fs2.mkdirSync(dirPath, { recursive: true });
658
+ const fullPath = path4.resolve(outputDir, filePath);
659
+ if (!fullPath.startsWith(resolvedOutput + path4.sep)) {
660
+ throw new Error(`Path traversal detected: ${filePath}`);
661
+ }
662
+ const dirPath = path4.dirname(fullPath);
663
+ fs4.mkdirSync(dirPath, { recursive: true });
725
664
  if (fileContent.isBinary) {
726
665
  const buffer = Buffer.from(fileContent.content, "base64");
727
- fs2.writeFileSync(fullPath, buffer);
666
+ fs4.writeFileSync(fullPath, buffer);
728
667
  } else {
729
- fs2.writeFileSync(fullPath, fileContent.content, "utf-8");
668
+ fs4.writeFileSync(fullPath, fileContent.content, "utf-8");
730
669
  }
731
670
  count++;
732
671
  }
@@ -734,9 +673,9 @@ function writeFilesToDisk(files, outputDir) {
734
673
  }
735
674
 
736
675
  // src/commands/codegen.ts
737
- var DEFAULT_API_URL2 = "https://public-api.animaapp.com";
738
- var codegenCommand = new Command3("codegen").description("Convert Figma design to code files (no playground)").requiredOption("--file-key <key>", "Figma file key or URL").option("--nodes <nodes>", "Figma node IDs, comma-separated").option("--figma-token <token>", "Figma personal access token (or set FIGMA_TOKEN env var)").option("-o, --output <dir>", "Output directory", "./generated").option("--framework <fw>", "react | html", "react").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("--json", "Output as JSON").option("--timeout <ms>", "Timeout in milliseconds", "600000").option("--verbose", "Show progress on stderr").option("--api-url <url>", "API base URL", DEFAULT_API_URL2).action(async (opts) => {
676
+ var codegenCommand = new Command3("codegen").description("Convert Figma design to code files (no playground)").requiredOption("--file-key <key>", "Figma file key or URL").option("--nodes <nodes>", "Figma node IDs, comma-separated").option("--figma-token <token>", "Figma personal access token (or set FIGMA_TOKEN env var)").option("-o, --output <dir>", "Output directory", "./generated").option("--framework <fw>", "react | html", "react").option("--styling <style>", "tailwind | plain_css", "tailwind").option("--language <lang>", "typescript | javascript").option("--ui-library <lib>", "mui | antd | shadcn | clean_react").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) => {
739
677
  const mode = detectOutputMode(opts);
678
+ initLogger(opts.logFile);
740
679
  const progress = new ProgressReporter(mode, opts.verbose);
741
680
  try {
742
681
  const { fileKey, nodeId: parsedNodeId } = parseFigmaUrl(opts.fileKey);
@@ -752,40 +691,32 @@ var codegenCommand = new Command3("codegen").description("Convert Figma design t
752
691
  );
753
692
  }
754
693
  const auth = resolveAuth();
755
- const figmaToken = opts.figmaToken || auth.figmaToken;
694
+ const figmaToken = opts.figmaToken || process.env.FIGMA_TOKEN || auth.figmaToken;
756
695
  if (!figmaToken) {
757
696
  throw validationError(
758
697
  "Figma token required. Use --figma-token, set FIGMA_TOKEN env var, or run: anima auth --figma-token <token>\nGet one at: https://www.figma.com > Settings > Personal access tokens"
759
698
  );
760
699
  }
761
- const headers = buildHeaders(auth.token, auth.teamId);
762
700
  const language = opts.language ?? (opts.framework === "react" ? "typescript" : void 0);
763
- const requestBody = {
701
+ const args = {
764
702
  fileKey,
765
703
  nodesId,
766
- figmaToken,
767
704
  framework: opts.framework,
768
- language,
769
- styling: opts.styling,
770
- uiLibrary: opts.uiLibrary
705
+ styling: opts.styling
771
706
  };
707
+ if (language) args.language = language;
708
+ if (opts.uiLibrary) args.uiLibrary = opts.uiLibrary;
772
709
  progress.start("Generating code from Figma...");
773
- const result = await streamGeneration(
774
- `${opts.apiUrl}/v1/codegen`,
775
- requestBody,
776
- headers,
777
- {
778
- onQueueing: () => progress.update("Queued, waiting..."),
779
- onStart: (sid) => progress.update(`Started (${sid.slice(0, 8)}...)...`),
780
- onProgress: (msgs) => {
781
- const running = msgs.find((m) => m.status === "running");
782
- if (running) progress.update(running.title);
783
- },
784
- onGeneratingCode: (pct) => progress.update(`Generating code (${pct}%)...`),
785
- onCompleted: () => progress.update("Writing files...")
786
- },
787
- parseInt(opts.timeout, 10)
710
+ const result = await withMcp(
711
+ { apiBaseUrl: resolveApiUrl(opts.apiUrl), token: auth.token, teamId: auth.teamId, figmaToken },
712
+ (client) => client.callTool("codegen-figma_to_code", args, {
713
+ timeoutMs: parseInt(opts.timeout, 10),
714
+ onProgress: (p) => progress.update(p.message ?? `Generating (${Math.round(p.progress)}%)...`)
715
+ })
788
716
  );
717
+ if (!result.success || !result.files) {
718
+ throw apiError(result.reason || result.error || "Code generation failed", result);
719
+ }
789
720
  const count = writeFilesToDisk(result.files, opts.output);
790
721
  progress.succeed(`Generated ${count} files`);
791
722
  if (mode === "pretty") {
@@ -813,19 +744,11 @@ var codegenCommand = new Command3("codegen").description("Convert Figma design t
813
744
  }
814
745
  });
815
746
 
816
- // src/commands/download.ts
747
+ // src/commands/getGitToken.ts
817
748
  import { Command as Command4 } from "commander";
818
- import fs4 from "fs";
819
- import path4 from "path";
820
- import { pipeline as pipeline2 } from "stream/promises";
821
- import { createWriteStream } from "fs";
822
- import { Readable as Readable2 } from "stream";
749
+ import chalk2 from "chalk";
823
750
 
824
- // src/utils/download.ts
825
- import fs3 from "fs";
826
- import path3 from "path";
827
- import { pipeline } from "stream/promises";
828
- import { Readable } from "stream";
751
+ // src/utils/playgroundUrl.ts
829
752
  function parsePlaygroundUrl(input) {
830
753
  if (!input.includes("/")) {
831
754
  return input;
@@ -843,66 +766,61 @@ function parsePlaygroundUrl(input) {
843
766
  }
844
767
  }
845
768
 
846
- // src/commands/download.ts
847
- var DEFAULT_API_URL3 = "https://public-api.animaapp.com";
848
- var downloadCommand = new Command4("download").description("Download project from an Anima playground").argument("<playgroundUrl>", "Playground URL or session ID").option("-o, --output <dir>", "Output directory", "./downloaded").option("--json", "Output as JSON").option("--verbose", "Show progress on stderr").option("--api-url <url>", "API base URL", DEFAULT_API_URL3).action(async (playgroundUrl, opts) => {
769
+ // src/utils/gitToken.ts
770
+ function parseGitTokenResponse(text) {
771
+ const denied = text.match(/^Error \((\w+)\): ([\s\S]*)$/);
772
+ if (denied) {
773
+ throw apiError(`Git access ${denied[1]}: ${denied[2].trim()}`);
774
+ }
775
+ const url = text.match(/git clone (\S+)/)?.[1];
776
+ if (!url) {
777
+ throw apiError(`Unexpected response from project-get_git_token: ${text.slice(0, 200)}`);
778
+ }
779
+ const access = text.match(/\*\*(ro|rw)\*\*/)?.[1];
780
+ const expiresAt = text.match(/expires at (\S+)/)?.[1];
781
+ return { gitRemoteUrl: url, access: access ?? "ro", expiresAt };
782
+ }
783
+
784
+ // src/commands/getGitToken.ts
785
+ var getGitTokenCommand = new Command4("get-git-token").description("Mint a short-lived git remote URL for a playground (clone/push with git)").argument("<playgroundUrl>", "Playground URL or session ID").option("--json", "Output as JSON").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 (playgroundUrl, opts) => {
849
786
  const mode = detectOutputMode(opts);
787
+ initLogger(opts.logFile);
850
788
  const progress = new ProgressReporter(mode, opts.verbose);
851
789
  try {
852
790
  const sessionId = parsePlaygroundUrl(playgroundUrl);
853
791
  const auth = resolveAuth();
854
- progress.start("Requesting download...");
855
- const result = await callMCPTool(
856
- opts.apiUrl,
857
- auth.token,
858
- auth.teamId,
859
- "project-download_from_playground",
860
- { playgroundUrl: `https://dev.animaapp.com/chat/${sessionId}` }
792
+ progress.start("Requesting git access...");
793
+ const response = await withMcp(
794
+ { apiBaseUrl: resolveApiUrl(opts.apiUrl), token: auth.token, teamId: auth.teamId },
795
+ (client) => client.callTool("project-get_git_token", { session_id: sessionId })
861
796
  );
862
- if (!result.success) {
863
- throw apiError(
864
- result.error || "Download failed",
865
- result
866
- );
867
- }
868
- const downloadUrl = result.downloadUrl;
869
- const fileCount = result.fileCount;
870
- if (!downloadUrl) {
871
- throw apiError("No download URL returned");
872
- }
873
- progress.update(`Downloading zip (${fileCount} files)...`);
874
- const zipPath = path4.join(opts.output, "__download.zip");
875
- fs4.mkdirSync(opts.output, { recursive: true });
876
- const zipResponse = await fetch(downloadUrl);
877
- if (!zipResponse.ok || !zipResponse.body) {
878
- throw apiError(`Failed to download zip: ${zipResponse.status}`);
879
- }
880
- await pipeline2(
881
- Readable2.fromWeb(zipResponse.body),
882
- createWriteStream(zipPath)
883
- );
884
- progress.update("Extracting...");
885
- const { execSync } = await import("child_process");
886
- try {
887
- execSync(`unzip -o -q "${zipPath}" -d "${opts.output}"`, {
888
- stdio: "pipe"
889
- });
890
- } catch {
891
- execSync(`tar -xf "${zipPath}" -C "${opts.output}"`, {
892
- stdio: "pipe"
893
- });
894
- }
895
- fs4.unlinkSync(zipPath);
896
- const count = countFiles(opts.output);
897
- progress.succeed(`Downloaded ${count} files`);
797
+ const grant = parseGitTokenResponse(response);
798
+ logEvent("git_token_granted", { sessionId, access: grant.access, expiresAt: grant.expiresAt });
799
+ progress.succeed(`Git access granted (${grant.access === "rw" ? "read-write" : "read-only"})`);
898
800
  if (mode === "pretty") {
899
- printFilesWritten(opts.output, count);
801
+ console.log();
802
+ console.log(` ${chalk2.cyan(`git clone ${grant.gitRemoteUrl}`)}`);
803
+ console.log();
804
+ if (grant.access === "rw") {
805
+ console.log(" Commit and push to update the live playground.");
806
+ } else {
807
+ console.log(" Read-only: cloning works, pushing will be rejected.");
808
+ }
809
+ if (grant.expiresAt) {
810
+ console.log(
811
+ ` The URL embeds a token that expires at ${grant.expiresAt} and cannot be renewed;
812
+ re-run this command and ${chalk2.cyan("git remote set-url origin <new url>")} to keep pushing.`
813
+ );
814
+ }
815
+ console.log(" Treat the URL as a secret \u2014 it grants access to this playground.");
816
+ console.log();
900
817
  } else {
901
818
  outputResult(mode, {
902
819
  success: true,
903
- output: opts.output,
904
- fileCount: count,
905
- sessionId
820
+ sessionId,
821
+ gitRemoteUrl: grant.gitRemoteUrl,
822
+ access: grant.access,
823
+ expiresAt: grant.expiresAt
906
824
  });
907
825
  }
908
826
  } catch (err) {
@@ -918,71 +836,12 @@ var downloadCommand = new Command4("download").description("Download project fro
918
836
  throw err;
919
837
  }
920
838
  });
921
- function countFiles(dir) {
922
- let count = 0;
923
- for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
924
- if (entry.isDirectory()) {
925
- count += countFiles(path4.join(dir, entry.name));
926
- } else {
927
- count++;
928
- }
929
- }
930
- return count;
931
- }
932
839
 
933
840
  // src/commands/auth.ts
934
841
  import { Command as Command5 } from "commander";
935
- import chalk2 from "chalk";
936
-
937
- // src/auth/oauth.ts
938
- import http from "http";
939
- import open from "open";
940
- var OAUTH_PORT = 19284;
941
- var OAUTH_BASE = "https://dev.animaapp.com";
942
- async function loginWithBrowser() {
943
- return new Promise((resolve, reject) => {
944
- const server = http.createServer((req, res) => {
945
- const url = new URL(req.url, `http://localhost:${OAUTH_PORT}`);
946
- if (url.pathname === "/callback") {
947
- const token = url.searchParams.get("token");
948
- const teamId = url.searchParams.get("teamId") ?? void 0;
949
- if (!token) {
950
- res.writeHead(400, { "Content-Type": "text/html" });
951
- res.end(
952
- "<html><body><h1>Error</h1><p>No token received. Please try again.</p></body></html>"
953
- );
954
- server.close();
955
- reject(new Error("No token received from OAuth flow"));
956
- return;
957
- }
958
- writeCredentials({ token, teamId });
959
- res.writeHead(200, { "Content-Type": "text/html" });
960
- res.end(
961
- "<html><body><h1>Authenticated!</h1><p>You can close this window and return to the terminal.</p></body></html>"
962
- );
963
- server.close();
964
- resolve({ token, teamId });
965
- }
966
- });
967
- server.listen(OAUTH_PORT, () => {
968
- const authUrl = `${OAUTH_BASE}/cli-auth?redirect=http://localhost:${OAUTH_PORT}/callback`;
969
- open(authUrl);
970
- });
971
- server.on("error", (err) => {
972
- reject(
973
- new Error(`Failed to start OAuth callback server: ${err.message}`)
974
- );
975
- });
976
- setTimeout(() => {
977
- server.close();
978
- reject(new Error("OAuth login timed out after 5 minutes"));
979
- }, 3e5);
980
- });
981
- }
982
-
983
- // src/commands/auth.ts
984
- var DEFAULT_API_URL4 = "https://public-api.animaapp.com";
985
- var authCommand = new Command5("auth").description("Manage authentication").option("--token <token>", "Store Anima API token").option("--figma-token <token>", "Store Figma personal access token").option("--team-id <teamId>", "Team ID to associate with token").option("--login", "Login via browser OAuth flow").option("--status", "Show current auth status").option("--logout", "Clear stored credentials").option("--json", "Output as JSON").option("--api-url <url>", "API base URL", DEFAULT_API_URL4).action(async (opts) => {
842
+ import chalk3 from "chalk";
843
+ var DEFAULT_API_URL2 = "https://public-api.animaapp.com";
844
+ var authCommand = new Command5("auth").description("Manage authentication").option("--token <token>", "Store Anima 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) => {
986
845
  const mode = detectOutputMode(opts);
987
846
  try {
988
847
  if (opts.token || opts.figmaToken) {
@@ -1015,22 +874,8 @@ var authCommand = new Command5("auth").description("Manage authentication").opti
1015
874
  }
1016
875
  return;
1017
876
  }
1018
- if (opts.login) {
1019
- if (mode === "pretty") {
1020
- printInfo("Opening browser for authentication...");
1021
- }
1022
- const result = await loginWithBrowser();
1023
- if (mode === "pretty") {
1024
- printSuccess("Authenticated successfully!");
1025
- printKeyValue("Token", result.token.slice(0, 8) + "...");
1026
- if (result.teamId) printKeyValue("Team ID", result.teamId);
1027
- } else {
1028
- outputResult(mode, { success: true, action: "logged_in" });
1029
- }
1030
- return;
1031
- }
1032
877
  if (opts.logout) {
1033
- const deleted = deleteCredentials();
878
+ const deleted = clearStorage();
1034
879
  if (mode === "pretty") {
1035
880
  if (deleted) {
1036
881
  printSuccess("Credentials cleared");
@@ -1052,14 +897,18 @@ var authCommand = new Command5("auth").description("Manage authentication").opti
1052
897
  printSuccess("Token configured");
1053
898
  printKeyValue("Source", sourceName);
1054
899
  printKeyValue("Anima token", auth.token.slice(0, 12) + "..." + auth.token.slice(-4));
900
+ printKeyValue("Token type", auth.tokenType === "agent" ? "agent (scoped, revocable)" : "personal/API");
901
+ if (creds?.tokenType === "agent" && creds.expiresAt) printKeyValue("Expires", creds.expiresAt);
1055
902
  if (auth.teamId) printKeyValue("Team ID", auth.teamId);
1056
903
  printKeyValue("Figma token", hasFigma ? "configured" : "not set (needed for codegen/f2c)");
1057
904
  } else {
1058
905
  outputResult(mode, {
1059
906
  success: true,
1060
907
  source: auth.source,
908
+ tokenType: auth.tokenType ?? "personal",
1061
909
  tokenPreview: auth.token.slice(0, 12) + "..." + auth.token.slice(-4),
1062
910
  teamId: auth.teamId,
911
+ expiresAt: creds?.tokenType === "agent" ? creds.expiresAt : void 0,
1063
912
  figmaToken: hasFigma
1064
913
  });
1065
914
  }
@@ -1079,20 +928,194 @@ var authCommand = new Command5("auth").description("Manage authentication").opti
1079
928
  if (mode === "pretty") {
1080
929
  console.log();
1081
930
  printInfo("Auth commands:");
1082
- console.log(` ${chalk2.cyan("anima auth --token <T>")} Save Anima API token`);
1083
- console.log(` ${chalk2.cyan("anima auth --figma-token <T>")} Save Figma token`);
1084
- console.log(` ${chalk2.cyan("anima auth --token <T> --figma-token <T>")} Save both at once`);
1085
- console.log(` ${chalk2.cyan("anima auth --login")} Login via browser`);
1086
- console.log(` ${chalk2.cyan("anima auth --status")} Check auth status`);
1087
- console.log(` ${chalk2.cyan("anima auth --logout")} Clear all credentials`);
931
+ console.log(` ${chalk3.cyan("anima login")} Log in (device flow \u2014 approve on any browser)`);
932
+ console.log(` ${chalk3.cyan("anima logout")} Clear all credentials`);
933
+ console.log(` ${chalk3.cyan("anima auth --status")} Check auth status`);
934
+ console.log(` ${chalk3.cyan("anima auth --figma-token <T>")} Save Figma token (needed for codegen/f2c)`);
1088
935
  console.log();
1089
- console.log(` Anima token: ${chalk2.cyan.underline("https://dev.animaapp.com")} > Settings > API Keys`);
1090
- console.log(` Figma token: ${chalk2.cyan.underline("https://www.figma.com")} > Settings > Personal access tokens`);
936
+ console.log(` Figma token: ${chalk3.cyan.underline("https://www.figma.com")} > Settings > Personal access tokens`);
1091
937
  console.log();
1092
938
  } else {
1093
939
  outputResult(mode, {
1094
940
  success: true,
1095
- message: "Use --token, --login, --status, or --logout"
941
+ message: "Run `anima login`, or use --status / --logout / --figma-token"
942
+ });
943
+ }
944
+ } catch (err) {
945
+ if (err instanceof CliError) {
946
+ if (mode === "pretty") {
947
+ printError(err.message);
948
+ } else {
949
+ outputError(mode, { code: err.code, message: err.message });
950
+ }
951
+ process.exit(err.exitCode);
952
+ }
953
+ throw err;
954
+ }
955
+ });
956
+
957
+ // src/commands/login.ts
958
+ import { Command as Command6 } from "commander";
959
+ import chalk4 from "chalk";
960
+ import open from "open";
961
+
962
+ // src/auth/device.ts
963
+ var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
964
+ var DEFAULT_CLIENT_NAME = "Anima CLI";
965
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
966
+ function decodeJwtPayload(token) {
967
+ try {
968
+ const parts = token.split(".");
969
+ if (parts.length !== 3) return void 0;
970
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
971
+ } catch {
972
+ return void 0;
973
+ }
974
+ }
975
+ async function loginWithDeviceFlow(options) {
976
+ const fetchFn = options.fetchFn ?? fetch;
977
+ const sleep = options.sleepFn ?? defaultSleep;
978
+ const base = options.apiUrl.replace(/\/$/, "");
979
+ const startUrl = `${base}/oauth/device_authorization`;
980
+ logEvent("device_authorization_request", { url: startUrl });
981
+ const startResponse = await fetchFn(startUrl, {
982
+ method: "POST",
983
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
984
+ body: new URLSearchParams({ client_name: options.clientName ?? DEFAULT_CLIENT_NAME }).toString()
985
+ });
986
+ logEvent("device_authorization_response", { url: startUrl, status: startResponse.status });
987
+ if (!startResponse.ok) {
988
+ const detail = await startResponse.text().catch(() => "");
989
+ logEvent("device_authorization_failed", { url: startUrl, status: startResponse.status, body: detail.slice(0, 500) });
990
+ const hint = startResponse.status === 404 ? ` \u2014 no device endpoint at ${startUrl}. Check --api-url points to an API that has the device grant deployed.` : "";
991
+ throw new CliError(
992
+ "API_ERROR",
993
+ `Device authorization failed (HTTP ${startResponse.status})${hint}`,
994
+ EXIT_CODES.API_ERROR
995
+ );
996
+ }
997
+ const start = await startResponse.json();
998
+ options.onVerification({
999
+ userCode: start.user_code,
1000
+ verificationUri: start.verification_uri,
1001
+ verificationUriComplete: start.verification_uri_complete,
1002
+ expiresInSeconds: start.expires_in
1003
+ });
1004
+ let intervalMs = Math.max(1, start.interval) * 1e3;
1005
+ const deadline = Date.now() + start.expires_in * 1e3;
1006
+ while (Date.now() < deadline) {
1007
+ await sleep(intervalMs);
1008
+ const pollResponse = await fetchFn(`${base}/oauth/token`, {
1009
+ method: "POST",
1010
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1011
+ body: new URLSearchParams({
1012
+ grant_type: DEVICE_GRANT_TYPE,
1013
+ device_code: start.device_code
1014
+ }).toString()
1015
+ });
1016
+ const body = await pollResponse.json();
1017
+ logEvent("device_token_poll", { status: pollResponse.status, error: body.error });
1018
+ if (pollResponse.ok && typeof body.access_token === "string") {
1019
+ const token = body.access_token;
1020
+ const claims = decodeJwtPayload(token);
1021
+ const result = {
1022
+ token,
1023
+ teamId: typeof claims?.team_id === "string" ? claims.team_id : void 0,
1024
+ agentId: typeof claims?.agent_id === "string" ? claims.agent_id : void 0,
1025
+ expiresAt: typeof claims?.exp === "number" ? new Date(claims.exp * 1e3).toISOString() : void 0
1026
+ };
1027
+ writeCredentials({
1028
+ token: result.token,
1029
+ teamId: result.teamId,
1030
+ tokenType: "agent",
1031
+ agentId: result.agentId,
1032
+ expiresAt: result.expiresAt
1033
+ });
1034
+ return result;
1035
+ }
1036
+ switch (body.error) {
1037
+ case "authorization_pending":
1038
+ continue;
1039
+ case "slow_down":
1040
+ intervalMs += 5e3;
1041
+ continue;
1042
+ case "access_denied":
1043
+ throw authError("The authorization request was denied.");
1044
+ case "expired_token":
1045
+ throw authError("The device code expired before approval. Run the login again.");
1046
+ default:
1047
+ throw new CliError(
1048
+ "API_ERROR",
1049
+ `Unexpected token response: ${body.error ?? `HTTP ${pollResponse.status}`}`,
1050
+ EXIT_CODES.API_ERROR
1051
+ );
1052
+ }
1053
+ }
1054
+ throw authError("The device code expired before approval. Run the login again.");
1055
+ }
1056
+
1057
+ // src/commands/login.ts
1058
+ var loginCommand = new Command6("login").description("Log in via the device flow \u2014 approve in a browser on any device").option("--client-name <name>", "How this CLI appears on the consent screen", "Anima 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(
1059
+ "--print-mcp-config",
1060
+ "After login, also print an MCP server config (includes the token) to paste into an MCP-capable agent"
1061
+ ).action(async (opts) => {
1062
+ const mode = detectOutputMode(opts);
1063
+ initLogger(opts.logFile);
1064
+ const apiUrl = resolveApiUrl(opts.apiUrl);
1065
+ try {
1066
+ const result = await loginWithDeviceFlow({
1067
+ apiUrl,
1068
+ clientName: opts.clientName,
1069
+ onVerification: (info) => {
1070
+ if (mode === "pretty") {
1071
+ console.log();
1072
+ printInfo("To connect, open the verification page and enter this code:");
1073
+ console.log();
1074
+ console.log(` ${chalk4.cyan.underline(info.verificationUri)}`);
1075
+ console.log();
1076
+ console.log(` ${chalk4.bold.white(info.userCode)}`);
1077
+ console.log();
1078
+ printInfo(`Waiting for approval\u2026 (code expires in ${Math.round(info.expiresInSeconds / 60)} minutes)`);
1079
+ } else {
1080
+ process.stderr.write(
1081
+ JSON.stringify({
1082
+ event: "verification_required",
1083
+ verificationUri: info.verificationUri,
1084
+ verificationUriComplete: info.verificationUriComplete,
1085
+ userCode: info.userCode,
1086
+ expiresIn: info.expiresInSeconds
1087
+ }) + "\n"
1088
+ );
1089
+ }
1090
+ if (mode === "pretty" && opts.open) {
1091
+ open(info.verificationUriComplete).catch(() => {
1092
+ });
1093
+ }
1094
+ }
1095
+ });
1096
+ const mcpConfig = opts.printMcpConfig ? buildMcpConfig(apiUrl, result.token) : void 0;
1097
+ if (mode === "pretty") {
1098
+ printSuccess("Connected!");
1099
+ printKeyValue("Credentials", getCredentialsPath());
1100
+ if (result.agentId) printKeyValue("Agent", result.agentId);
1101
+ if (result.teamId) printKeyValue("Team ID", result.teamId);
1102
+ if (result.expiresAt) printKeyValue("Token expires", result.expiresAt);
1103
+ printInfo("This is a scoped agent token \u2014 it acts within what you approved, and your team can revoke it.");
1104
+ if (mcpConfig) {
1105
+ console.log();
1106
+ printInfo("MCP server config (token expires ~7 days \u2014 re-run `anima login` to refresh):");
1107
+ console.log();
1108
+ console.log(JSON.stringify(mcpConfig, null, 2));
1109
+ }
1110
+ } else {
1111
+ outputResult(mode, {
1112
+ success: true,
1113
+ action: "logged_in",
1114
+ tokenType: "agent",
1115
+ agentId: result.agentId,
1116
+ teamId: result.teamId,
1117
+ expiresAt: result.expiresAt,
1118
+ ...mcpConfig ? { mcpConfig } : {}
1096
1119
  });
1097
1120
  }
1098
1121
  } catch (err) {
@@ -1107,45 +1130,146 @@ var authCommand = new Command5("auth").description("Manage authentication").opti
1107
1130
  throw err;
1108
1131
  }
1109
1132
  });
1133
+ function buildMcpConfig(apiUrl, token) {
1134
+ return {
1135
+ mcpServers: {
1136
+ anima: {
1137
+ type: "http",
1138
+ url: `${apiUrl.replace(/\/$/, "")}/v1/mcp`,
1139
+ headers: { Authorization: `Bearer ${token}` }
1140
+ }
1141
+ }
1142
+ };
1143
+ }
1144
+
1145
+ // src/commands/logout.ts
1146
+ import { Command as Command7 } from "commander";
1147
+ var logoutCommand = new Command7("logout").description("Clear all stored credentials (Anima + Figma tokens) from this machine").option("--json", "Output as JSON").action((opts) => {
1148
+ const mode = detectOutputMode(opts);
1149
+ const hadCredentials = clearStorage();
1150
+ if (mode === "pretty") {
1151
+ if (hadCredentials) {
1152
+ printSuccess("Logged out \u2014 all credentials cleared from " + getCredentialsPath());
1153
+ } else {
1154
+ printInfo("No stored credentials found");
1155
+ }
1156
+ } else {
1157
+ outputResult(mode, { success: true, action: "logged_out", hadCredentials });
1158
+ }
1159
+ });
1160
+
1161
+ // src/commands/config.ts
1162
+ import { Command as Command8 } from "commander";
1163
+ var configCommand = new Command8("config").description(
1164
+ `Store CLI preferences (keys: ${configKeys().join(", ")})`
1165
+ );
1166
+ configCommand.command("set <key> <value>").description("Set a config value").option("--json", "Output as JSON").action((key, value, opts) => {
1167
+ const mode = detectOutputMode(opts);
1168
+ assertKey(key);
1169
+ if (key === "api-url") assertUrl(value);
1170
+ setConfig(key, value);
1171
+ if (mode === "pretty") printSuccess(`Set ${key} = ${value}`);
1172
+ else outputResult(mode, { success: true, action: "config_set", key, value });
1173
+ });
1174
+ configCommand.command("get <key>").description("Print a config value").option("--json", "Output as JSON").action((key, opts) => {
1175
+ const mode = detectOutputMode(opts);
1176
+ assertKey(key);
1177
+ const value = readConfig()[CONFIG_KEYS[key]];
1178
+ if (mode === "pretty") {
1179
+ if (value) console.log(value);
1180
+ else printInfo(`${key} is not set`);
1181
+ } else {
1182
+ outputResult(mode, { success: true, key, value: value ?? null });
1183
+ }
1184
+ });
1185
+ configCommand.command("list").alias("ls").description("Show all config values and where the API URL resolves from").option("--json", "Output as JSON").action((opts) => {
1186
+ const mode = detectOutputMode(opts);
1187
+ const config = readConfig();
1188
+ const resolvedApiUrl = resolveApiUrl();
1189
+ if (mode === "pretty") {
1190
+ printInfo("Config: " + getConfigPath());
1191
+ printKeyValue("api-url", config.apiUrl ?? `(default) ${DEFAULT_API_URL}`);
1192
+ if (process.env.ANIMA_API_URL) {
1193
+ printInfo(`ANIMA_API_URL env is set \u2014 it overrides config (effective: ${resolvedApiUrl})`);
1194
+ }
1195
+ } else {
1196
+ outputResult(mode, {
1197
+ success: true,
1198
+ path: getConfigPath(),
1199
+ config,
1200
+ resolved: { apiUrl: resolvedApiUrl }
1201
+ });
1202
+ }
1203
+ });
1204
+ configCommand.command("unset <key>").description("Remove a config value").option("--json", "Output as JSON").action((key, opts) => {
1205
+ const mode = detectOutputMode(opts);
1206
+ assertKey(key);
1207
+ const removed = unsetConfig(key);
1208
+ if (mode === "pretty") {
1209
+ if (removed) printSuccess(`Unset ${key}`);
1210
+ else printInfo(`${key} was not set`);
1211
+ } else {
1212
+ outputResult(mode, { success: true, action: "config_unset", key, removed });
1213
+ }
1214
+ });
1215
+ function assertKey(key) {
1216
+ if (!isConfigKey(key)) {
1217
+ throw validationError(`Unknown config key "${key}". Valid keys: ${configKeys().join(", ")}`);
1218
+ }
1219
+ }
1220
+ function assertUrl(value) {
1221
+ try {
1222
+ new URL(value);
1223
+ } catch {
1224
+ throw validationError(`"${value}" is not a valid URL`);
1225
+ }
1226
+ }
1110
1227
 
1111
1228
  // src/index.ts
1112
- var program = new Command6().name("anima").description(
1229
+ var program = new Command9().name("anima").description(
1113
1230
  `Anima CLI - Generate production-ready apps from prompts, URLs, or Figma designs.
1114
1231
 
1115
1232
  AUTHENTICATION:
1116
- Set ANIMA_API_TOKEN env var or run: npx @animaapp/cli auth --token <token>
1117
- Get key: https://dev.animaapp.com > Settings > API Keys
1233
+ Run: npx @animaapp/cli login
1234
+ Approve in a browser on any device \u2014 the CLI receives a scoped agent token
1235
+ (stored in ~/.config/anima/). CI/automation can set ANIMA_API_TOKEN instead.
1118
1236
 
1119
1237
  TIMING (important for AI agents):
1120
- create \u2192 3-7 minutes (SSE streaming, set timeout to 600000ms)
1121
- publish \u2192 1-3 minutes
1122
- codegen \u2192 2-5 minutes
1123
- download \u2192 seconds
1238
+ create \u2192 3-7 minutes (keep the default 600000ms timeout)
1239
+ publish \u2192 1-3 minutes
1240
+ codegen \u2192 2-5 minutes
1241
+ get-git-token \u2192 seconds
1124
1242
  The --timeout default is 600000ms (10 min). Do NOT reduce it.
1125
1243
 
1126
1244
  COMMANDS:
1127
- create Create app from prompt, URL, or Figma design
1128
- publish Deploy playground to live URL or npm package
1129
- codegen Convert Figma design to code files (no playground)
1130
- download Download project from an Anima playground
1131
- auth Manage authentication (token stored in ~/.config/anima/)
1245
+ login Log in via the device flow (no browser needed on this machine)
1246
+ logout Clear all stored credentials from this machine
1247
+ create Create app from prompt, URL, or Figma design
1248
+ publish Deploy playground to live URL or npm package
1249
+ codegen Convert Figma design to code files (no playground)
1250
+ get-git-token Mint a git remote URL for a playground; clone/edit/push with git
1251
+ config Store CLI preferences (e.g. a default API URL)
1252
+ auth Inspect or manage credentials (status / figma token)
1132
1253
 
1133
1254
  EXAMPLES:
1134
- npx @animaapp/cli auth --token <your-token>
1255
+ npx @animaapp/cli login
1135
1256
  npx @animaapp/cli create -t p2c -p "SaaS dashboard with sidebar and analytics"
1136
1257
  npx @animaapp/cli create -t l2c -u https://stripe.com
1137
1258
  npx @animaapp/cli create -t f2c --file-key abc --nodes 42:15
1138
1259
  npx @animaapp/cli publish abc123xyz
1139
1260
  npx @animaapp/cli codegen --file-key abc --nodes 42:15 -o ./components
1140
- npx @animaapp/cli download https://dev.animaapp.com/chat/abc123xyz
1261
+ npx @animaapp/cli get-git-token https://dev.animaapp.com/chat/abc123xyz
1141
1262
 
1142
1263
  OUTPUT: JSON to stdout when piped (for AI tools), pretty text in terminals.
1143
1264
  Progress is streamed to stderr via --verbose when in JSON mode.`
1144
- ).version("0.1.0", "-v, --version");
1265
+ ).version(version, "-v, --version");
1266
+ program.addCommand(loginCommand);
1267
+ program.addCommand(logoutCommand);
1145
1268
  program.addCommand(createCommand);
1146
1269
  program.addCommand(publishCommand);
1147
1270
  program.addCommand(codegenCommand);
1148
- program.addCommand(downloadCommand);
1271
+ program.addCommand(getGitTokenCommand);
1272
+ program.addCommand(configCommand);
1149
1273
  program.addCommand(authCommand);
1150
1274
  program.parseAsync(process.argv).catch((err) => {
1151
1275
  if (err instanceof CliError) {