@animaapp/cli 0.2.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,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 Command10 } 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}` }
861
- );
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)
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 })
883
796
  );
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,240 @@ 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++;
839
+
840
+ // src/commands/mcpConfig.ts
841
+ import { Command as Command6 } from "commander";
842
+
843
+ // src/commands/login.ts
844
+ import { Command as Command5 } from "commander";
845
+ import chalk3 from "chalk";
846
+ import open from "open";
847
+
848
+ // src/auth/device.ts
849
+ var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
850
+ var DEFAULT_CLIENT_NAME = "Anima CLI";
851
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
852
+ function decodeJwtPayload(token) {
853
+ try {
854
+ const parts = token.split(".");
855
+ if (parts.length !== 3) return void 0;
856
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
857
+ } catch {
858
+ return void 0;
859
+ }
860
+ }
861
+ async function loginWithDeviceFlow(options) {
862
+ const fetchFn = options.fetchFn ?? fetch;
863
+ const sleep = options.sleepFn ?? defaultSleep;
864
+ const base = options.apiUrl.replace(/\/$/, "");
865
+ const startUrl = `${base}/oauth/device_authorization`;
866
+ logEvent("device_authorization_request", { url: startUrl });
867
+ const startResponse = await fetchFn(startUrl, {
868
+ method: "POST",
869
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
870
+ body: new URLSearchParams({ client_name: options.clientName ?? DEFAULT_CLIENT_NAME }).toString()
871
+ });
872
+ logEvent("device_authorization_response", { url: startUrl, status: startResponse.status });
873
+ if (!startResponse.ok) {
874
+ const detail = await startResponse.text().catch(() => "");
875
+ logEvent("device_authorization_failed", { url: startUrl, status: startResponse.status, body: detail.slice(0, 500) });
876
+ const hint = startResponse.status === 404 ? ` \u2014 no device endpoint at ${startUrl}. Check --api-url points to an API that has the device grant deployed.` : "";
877
+ throw new CliError(
878
+ "API_ERROR",
879
+ `Device authorization failed (HTTP ${startResponse.status})${hint}`,
880
+ EXIT_CODES.API_ERROR
881
+ );
882
+ }
883
+ const start = await startResponse.json();
884
+ options.onVerification({
885
+ userCode: start.user_code,
886
+ verificationUri: start.verification_uri,
887
+ verificationUriComplete: start.verification_uri_complete,
888
+ expiresInSeconds: start.expires_in
889
+ });
890
+ let intervalMs = Math.max(1, start.interval) * 1e3;
891
+ const deadline = Date.now() + start.expires_in * 1e3;
892
+ while (Date.now() < deadline) {
893
+ await sleep(intervalMs);
894
+ const pollResponse = await fetchFn(`${base}/oauth/token`, {
895
+ method: "POST",
896
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
897
+ body: new URLSearchParams({
898
+ grant_type: DEVICE_GRANT_TYPE,
899
+ device_code: start.device_code
900
+ }).toString()
901
+ });
902
+ const body = await pollResponse.json();
903
+ logEvent("device_token_poll", { status: pollResponse.status, error: body.error });
904
+ if (pollResponse.ok && typeof body.access_token === "string") {
905
+ const token = body.access_token;
906
+ const claims = decodeJwtPayload(token);
907
+ const result = {
908
+ token,
909
+ teamId: typeof claims?.team_id === "string" ? claims.team_id : void 0,
910
+ agentId: typeof claims?.agent_id === "string" ? claims.agent_id : void 0,
911
+ expiresAt: typeof claims?.exp === "number" ? new Date(claims.exp * 1e3).toISOString() : void 0
912
+ };
913
+ writeCredentials({
914
+ token: result.token,
915
+ teamId: result.teamId,
916
+ tokenType: "agent",
917
+ agentId: result.agentId,
918
+ expiresAt: result.expiresAt
919
+ });
920
+ return result;
921
+ }
922
+ switch (body.error) {
923
+ case "authorization_pending":
924
+ continue;
925
+ case "slow_down":
926
+ intervalMs += 5e3;
927
+ continue;
928
+ case "access_denied":
929
+ throw authError("The authorization request was denied.");
930
+ case "expired_token":
931
+ throw authError("The device code expired before approval. Run the login again.");
932
+ default:
933
+ throw new CliError(
934
+ "API_ERROR",
935
+ `Unexpected token response: ${body.error ?? `HTTP ${pollResponse.status}`}`,
936
+ EXIT_CODES.API_ERROR
937
+ );
928
938
  }
929
939
  }
930
- return count;
940
+ throw authError("The device code expired before approval. Run the login again.");
931
941
  }
932
942
 
933
- // src/commands/auth.ts
934
- 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>"
943
+ // src/commands/login.ts
944
+ var loginCommand = new Command5("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(
945
+ "--print-mcp-config",
946
+ "After login, also print an MCP server config (includes the token) to paste into an MCP-capable agent"
947
+ ).action(async (opts) => {
948
+ const mode = detectOutputMode(opts);
949
+ initLogger(opts.logFile);
950
+ const apiUrl = resolveApiUrl(opts.apiUrl);
951
+ try {
952
+ const result = await loginWithDeviceFlow({
953
+ apiUrl,
954
+ clientName: opts.clientName,
955
+ onVerification: (info) => {
956
+ if (mode === "pretty") {
957
+ console.log();
958
+ printInfo("To connect, open the verification page and enter this code:");
959
+ console.log();
960
+ console.log(` ${chalk3.cyan.underline(info.verificationUri)}`);
961
+ console.log();
962
+ console.log(` ${chalk3.bold.white(info.userCode)}`);
963
+ console.log();
964
+ printInfo(`Waiting for approval\u2026 (code expires in ${Math.round(info.expiresInSeconds / 60)} minutes)`);
965
+ } else {
966
+ process.stderr.write(
967
+ JSON.stringify({
968
+ event: "verification_required",
969
+ verificationUri: info.verificationUri,
970
+ verificationUriComplete: info.verificationUriComplete,
971
+ userCode: info.userCode,
972
+ expiresIn: info.expiresInSeconds
973
+ }) + "\n"
953
974
  );
954
- server.close();
955
- reject(new Error("No token received from OAuth flow"));
956
- return;
957
975
  }
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 });
976
+ if (mode === "pretty" && opts.open) {
977
+ open(info.verificationUriComplete).catch(() => {
978
+ });
979
+ }
965
980
  }
966
981
  });
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
- });
982
+ const mcpConfig = opts.printMcpConfig ? buildMcpConfig(apiUrl, result.token) : void 0;
983
+ if (mode === "pretty") {
984
+ printSuccess("Connected!");
985
+ printKeyValue("Credentials", getCredentialsPath());
986
+ if (result.agentId) printKeyValue("Agent", result.agentId);
987
+ if (result.teamId) printKeyValue("Team ID", result.teamId);
988
+ if (result.expiresAt) printKeyValue("Token expires", result.expiresAt);
989
+ printInfo("This is a scoped agent token \u2014 it acts within what you approved, and your team can revoke it.");
990
+ if (mcpConfig) {
991
+ console.log();
992
+ printInfo("MCP server config (token expires ~7 days \u2014 re-run `anima login` to refresh):");
993
+ console.log();
994
+ console.log(JSON.stringify(mcpConfig, null, 2));
995
+ }
996
+ } else {
997
+ outputResult(mode, {
998
+ success: true,
999
+ action: "logged_in",
1000
+ tokenType: "agent",
1001
+ agentId: result.agentId,
1002
+ teamId: result.teamId,
1003
+ expiresAt: result.expiresAt,
1004
+ ...mcpConfig ? { mcpConfig } : {}
1005
+ });
1006
+ }
1007
+ } catch (err) {
1008
+ if (err instanceof CliError) {
1009
+ if (mode === "pretty") {
1010
+ printError(err.message);
1011
+ } else {
1012
+ outputError(mode, { code: err.code, message: err.message });
1013
+ }
1014
+ process.exit(err.exitCode);
1015
+ }
1016
+ throw err;
1017
+ }
1018
+ });
1019
+ function buildMcpConfig(apiUrl, token) {
1020
+ return {
1021
+ mcpServers: {
1022
+ anima: {
1023
+ type: "http",
1024
+ url: `${apiUrl.replace(/\/$/, "")}/v1/mcp`,
1025
+ headers: { Authorization: `Bearer ${token}` }
1026
+ }
1027
+ }
1028
+ };
981
1029
  }
982
1030
 
1031
+ // src/commands/mcpConfig.ts
1032
+ var mcpConfigCommand = new Command6("mcp-config").description("Print an MCP server config from your stored credentials (run `login` first)").option("--json", "Output as JSON").option("--api-url <url>", "API base URL (overrides config / ANIMA_API_URL)").action(async (opts) => {
1033
+ const mode = detectOutputMode(opts);
1034
+ try {
1035
+ const auth = resolveAuth();
1036
+ const creds = readCredentials();
1037
+ const expiresAt = auth.source === "env" ? void 0 : creds?.expiresAt;
1038
+ const mcpConfig = buildMcpConfig(resolveApiUrl(opts.apiUrl), auth.token);
1039
+ if (mode === "pretty") {
1040
+ printInfo("MCP server config (add to your MCP client; the token is a secret):");
1041
+ console.log();
1042
+ console.log(JSON.stringify(mcpConfig, null, 2));
1043
+ console.log();
1044
+ if (expiresAt) {
1045
+ printInfo(`Token expires ${expiresAt} \u2014 run \`anima login\` then \`anima mcp-config\` to refresh.`);
1046
+ }
1047
+ } else {
1048
+ outputResult(mode, {
1049
+ success: true,
1050
+ mcpConfig,
1051
+ ...expiresAt ? { expiresAt } : {},
1052
+ ...creds?.agentId && auth.source !== "env" ? { agentId: creds.agentId } : {}
1053
+ });
1054
+ }
1055
+ } catch (err) {
1056
+ if (err instanceof CliError) {
1057
+ if (mode === "pretty") {
1058
+ console.error(err.message);
1059
+ } else {
1060
+ outputError(mode, { code: err.code, message: err.message });
1061
+ }
1062
+ process.exit(err.exitCode);
1063
+ }
1064
+ throw err;
1065
+ }
1066
+ });
1067
+
983
1068
  // 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) => {
1069
+ import { Command as Command7 } from "commander";
1070
+ import chalk4 from "chalk";
1071
+ var DEFAULT_API_URL2 = "https://public-api.animaapp.com";
1072
+ var authCommand = new Command7("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
1073
  const mode = detectOutputMode(opts);
987
1074
  try {
988
1075
  if (opts.token || opts.figmaToken) {
@@ -1015,22 +1102,8 @@ var authCommand = new Command5("auth").description("Manage authentication").opti
1015
1102
  }
1016
1103
  return;
1017
1104
  }
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
1105
  if (opts.logout) {
1033
- const deleted = deleteCredentials();
1106
+ const deleted = clearStorage();
1034
1107
  if (mode === "pretty") {
1035
1108
  if (deleted) {
1036
1109
  printSuccess("Credentials cleared");
@@ -1052,14 +1125,18 @@ var authCommand = new Command5("auth").description("Manage authentication").opti
1052
1125
  printSuccess("Token configured");
1053
1126
  printKeyValue("Source", sourceName);
1054
1127
  printKeyValue("Anima token", auth.token.slice(0, 12) + "..." + auth.token.slice(-4));
1128
+ printKeyValue("Token type", auth.tokenType === "agent" ? "agent (scoped, revocable)" : "personal/API");
1129
+ if (creds?.tokenType === "agent" && creds.expiresAt) printKeyValue("Expires", creds.expiresAt);
1055
1130
  if (auth.teamId) printKeyValue("Team ID", auth.teamId);
1056
1131
  printKeyValue("Figma token", hasFigma ? "configured" : "not set (needed for codegen/f2c)");
1057
1132
  } else {
1058
1133
  outputResult(mode, {
1059
1134
  success: true,
1060
1135
  source: auth.source,
1136
+ tokenType: auth.tokenType ?? "personal",
1061
1137
  tokenPreview: auth.token.slice(0, 12) + "..." + auth.token.slice(-4),
1062
1138
  teamId: auth.teamId,
1139
+ expiresAt: creds?.tokenType === "agent" ? creds.expiresAt : void 0,
1063
1140
  figmaToken: hasFigma
1064
1141
  });
1065
1142
  }
@@ -1079,20 +1156,17 @@ var authCommand = new Command5("auth").description("Manage authentication").opti
1079
1156
  if (mode === "pretty") {
1080
1157
  console.log();
1081
1158
  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`);
1159
+ console.log(` ${chalk4.cyan("anima login")} Log in (device flow \u2014 approve on any browser)`);
1160
+ console.log(` ${chalk4.cyan("anima logout")} Clear all credentials`);
1161
+ console.log(` ${chalk4.cyan("anima auth --status")} Check auth status`);
1162
+ console.log(` ${chalk4.cyan("anima auth --figma-token <T>")} Save Figma token (needed for codegen/f2c)`);
1088
1163
  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`);
1164
+ console.log(` Figma token: ${chalk4.cyan.underline("https://www.figma.com")} > Settings > Personal access tokens`);
1091
1165
  console.log();
1092
1166
  } else {
1093
1167
  outputResult(mode, {
1094
1168
  success: true,
1095
- message: "Use --token, --login, --status, or --logout"
1169
+ message: "Run `anima login`, or use --status / --logout / --figma-token"
1096
1170
  });
1097
1171
  }
1098
1172
  } catch (err) {
@@ -1108,44 +1182,136 @@ var authCommand = new Command5("auth").description("Manage authentication").opti
1108
1182
  }
1109
1183
  });
1110
1184
 
1185
+ // src/commands/logout.ts
1186
+ import { Command as Command8 } from "commander";
1187
+ var logoutCommand = new Command8("logout").description("Clear all stored credentials (Anima + Figma tokens) from this machine").option("--json", "Output as JSON").action((opts) => {
1188
+ const mode = detectOutputMode(opts);
1189
+ const hadCredentials = clearStorage();
1190
+ if (mode === "pretty") {
1191
+ if (hadCredentials) {
1192
+ printSuccess("Logged out \u2014 all credentials cleared from " + getCredentialsPath());
1193
+ } else {
1194
+ printInfo("No stored credentials found");
1195
+ }
1196
+ } else {
1197
+ outputResult(mode, { success: true, action: "logged_out", hadCredentials });
1198
+ }
1199
+ });
1200
+
1201
+ // src/commands/config.ts
1202
+ import { Command as Command9 } from "commander";
1203
+ var configCommand = new Command9("config").description(
1204
+ `Store CLI preferences (keys: ${configKeys().join(", ")})`
1205
+ );
1206
+ configCommand.command("set <key> <value>").description("Set a config value").option("--json", "Output as JSON").action((key, value, opts) => {
1207
+ const mode = detectOutputMode(opts);
1208
+ assertKey(key);
1209
+ if (key === "api-url") assertUrl(value);
1210
+ setConfig(key, value);
1211
+ if (mode === "pretty") printSuccess(`Set ${key} = ${value}`);
1212
+ else outputResult(mode, { success: true, action: "config_set", key, value });
1213
+ });
1214
+ configCommand.command("get <key>").description("Print a config value").option("--json", "Output as JSON").action((key, opts) => {
1215
+ const mode = detectOutputMode(opts);
1216
+ assertKey(key);
1217
+ const value = readConfig()[CONFIG_KEYS[key]];
1218
+ if (mode === "pretty") {
1219
+ if (value) console.log(value);
1220
+ else printInfo(`${key} is not set`);
1221
+ } else {
1222
+ outputResult(mode, { success: true, key, value: value ?? null });
1223
+ }
1224
+ });
1225
+ configCommand.command("list").alias("ls").description("Show all config values and where the API URL resolves from").option("--json", "Output as JSON").action((opts) => {
1226
+ const mode = detectOutputMode(opts);
1227
+ const config = readConfig();
1228
+ const resolvedApiUrl = resolveApiUrl();
1229
+ if (mode === "pretty") {
1230
+ printInfo("Config: " + getConfigPath());
1231
+ printKeyValue("api-url", config.apiUrl ?? `(default) ${DEFAULT_API_URL}`);
1232
+ if (process.env.ANIMA_API_URL) {
1233
+ printInfo(`ANIMA_API_URL env is set \u2014 it overrides config (effective: ${resolvedApiUrl})`);
1234
+ }
1235
+ } else {
1236
+ outputResult(mode, {
1237
+ success: true,
1238
+ path: getConfigPath(),
1239
+ config,
1240
+ resolved: { apiUrl: resolvedApiUrl }
1241
+ });
1242
+ }
1243
+ });
1244
+ configCommand.command("unset <key>").description("Remove a config value").option("--json", "Output as JSON").action((key, opts) => {
1245
+ const mode = detectOutputMode(opts);
1246
+ assertKey(key);
1247
+ const removed = unsetConfig(key);
1248
+ if (mode === "pretty") {
1249
+ if (removed) printSuccess(`Unset ${key}`);
1250
+ else printInfo(`${key} was not set`);
1251
+ } else {
1252
+ outputResult(mode, { success: true, action: "config_unset", key, removed });
1253
+ }
1254
+ });
1255
+ function assertKey(key) {
1256
+ if (!isConfigKey(key)) {
1257
+ throw validationError(`Unknown config key "${key}". Valid keys: ${configKeys().join(", ")}`);
1258
+ }
1259
+ }
1260
+ function assertUrl(value) {
1261
+ try {
1262
+ new URL(value);
1263
+ } catch {
1264
+ throw validationError(`"${value}" is not a valid URL`);
1265
+ }
1266
+ }
1267
+
1111
1268
  // src/index.ts
1112
- var program = new Command6().name("anima").description(
1269
+ var program = new Command10().name("anima").description(
1113
1270
  `Anima CLI - Generate production-ready apps from prompts, URLs, or Figma designs.
1114
1271
 
1115
1272
  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
1273
+ Run: npx @animaapp/cli login
1274
+ Approve in a browser on any device \u2014 the CLI receives a scoped agent token
1275
+ (stored in ~/.config/anima/). CI/automation can set ANIMA_API_TOKEN instead.
1118
1276
 
1119
1277
  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
1278
+ create \u2192 3-7 minutes (keep the default 600000ms timeout)
1279
+ publish \u2192 1-3 minutes
1280
+ codegen \u2192 2-5 minutes
1281
+ get-git-token \u2192 seconds
1124
1282
  The --timeout default is 600000ms (10 min). Do NOT reduce it.
1125
1283
 
1126
1284
  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/)
1285
+ login Log in via the device flow (no browser needed on this machine)
1286
+ logout Clear all stored credentials from this machine
1287
+ mcp-config Print an MCP server config from stored credentials (no new login)
1288
+ create Create app from prompt, URL, or Figma design
1289
+ publish Deploy playground to live URL or npm package
1290
+ codegen Convert Figma design to code files (no playground)
1291
+ get-git-token Mint a git remote URL for a playground; clone/edit/push with git
1292
+ config Store CLI preferences (e.g. a default API URL)
1293
+ auth Inspect or manage credentials (status / figma token)
1132
1294
 
1133
1295
  EXAMPLES:
1134
- npx @animaapp/cli auth --token <your-token>
1296
+ npx @animaapp/cli login
1135
1297
  npx @animaapp/cli create -t p2c -p "SaaS dashboard with sidebar and analytics"
1136
1298
  npx @animaapp/cli create -t l2c -u https://stripe.com
1137
1299
  npx @animaapp/cli create -t f2c --file-key abc --nodes 42:15
1138
1300
  npx @animaapp/cli publish abc123xyz
1139
1301
  npx @animaapp/cli codegen --file-key abc --nodes 42:15 -o ./components
1140
- npx @animaapp/cli download https://dev.animaapp.com/chat/abc123xyz
1302
+ npx @animaapp/cli get-git-token https://dev.animaapp.com/chat/abc123xyz
1141
1303
 
1142
1304
  OUTPUT: JSON to stdout when piped (for AI tools), pretty text in terminals.
1143
1305
  Progress is streamed to stderr via --verbose when in JSON mode.`
1144
- ).version("0.1.0", "-v, --version");
1306
+ ).version(version, "-v, --version");
1307
+ program.addCommand(loginCommand);
1308
+ program.addCommand(logoutCommand);
1145
1309
  program.addCommand(createCommand);
1146
1310
  program.addCommand(publishCommand);
1147
1311
  program.addCommand(codegenCommand);
1148
- program.addCommand(downloadCommand);
1312
+ program.addCommand(getGitTokenCommand);
1313
+ program.addCommand(mcpConfigCommand);
1314
+ program.addCommand(configCommand);
1149
1315
  program.addCommand(authCommand);
1150
1316
  program.parseAsync(process.argv).catch((err) => {
1151
1317
  if (err instanceof CliError) {