extrafigma 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Think41
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: extrafigma
3
+ Version: 0.1.0
4
+ Summary: Pull Figma files into agent-readable local folders
5
+ Project-URL: Homepage, https://github.com/think41/extrafigma
6
+ Project-URL: Repository, https://github.com/think41/extrafigma
7
+ Project-URL: Issues, https://github.com/think41/extrafigma/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,cli,codegen,design,figma
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Multimedia :: Graphics
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: httpx>=0.27.0
21
+ Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: typer>=0.12.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ExtraFigma
26
+
27
+ A pull-only CLI that converts Figma files into agent-readable local folders. Drop the output into your project and let Claude Code (or any AI coding agent) implement pixel-perfect frontend code from the design.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install extrafigma
33
+ # or
34
+ uvx extrafigma --help
35
+ ```
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ extrafigma pull "https://www.figma.com/design/abc123/My-App" ./my-design
41
+ ```
42
+
43
+ On first run you'll be prompted for a Figma Personal Access Token. Get one at **Figma → Settings → Security → Personal Access Tokens** (scopes needed: `file_content:read`, `file_metadata:read`).
44
+
45
+ The token is stored at `~/.config/extrafigma/credentials.json` and reused for all future pulls.
46
+
47
+ ## What gets extracted
48
+
49
+ ```
50
+ my-design/
51
+ CLAUDE.md ← instructions for AI agents
52
+ index.md ← overview of all pages and frames
53
+ tokens/
54
+ colors.json ← named color styles → hex values
55
+ typography.json ← named text styles → font properties
56
+ assets/
57
+ icon-name.svg ← exported SVG icons (VECTOR nodes)
58
+ pages/
59
+ Page-1/
60
+ page.md
61
+ Hero.frame.json ← distilled layout tree (CSS-friendly)
62
+ Hero.png ← 2× frame render (visual ground truth)
63
+ ```
64
+
65
+ ### `frame.json` structure
66
+
67
+ Every frame is distilled to a CSS-friendly layout tree:
68
+
69
+ ```json
70
+ {
71
+ "type": "FRAME",
72
+ "name": "Hero",
73
+ "width": 1440,
74
+ "height": 900,
75
+ "layout": { "mode": "flex", "direction": "column", "gap": 24 },
76
+ "background": "colors/brand-primary",
77
+ "borderRadius": 0,
78
+ "children": [
79
+ {
80
+ "type": "TEXT",
81
+ "content": "Welcome",
82
+ "style": "typography/heading-xl",
83
+ "color": "colors/gray-800"
84
+ }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ Named Figma Styles become tokens (`colors/brand-primary`, `typography/heading-xl`). Raw hex values that weren't saved as styles are flagged with `# not in tokens`.
90
+
91
+ ## Auth commands
92
+
93
+ ```bash
94
+ extrafigma auth login # interactive prompt
95
+ extrafigma auth login --pat <token> # non-interactive (CI-friendly)
96
+ extrafigma auth status # check stored PAT
97
+ extrafigma auth logout # remove stored PAT
98
+ ```
99
+
100
+ ## Rate limits
101
+
102
+ Figma's REST API limits `GET /v1/files` calls based on your seat type:
103
+
104
+ | Seat | Limit |
105
+ |------|-------|
106
+ | View / Collab | 6 per month |
107
+ | Dev / Full (Starter) | 10 per minute |
108
+ | Dev / Full (Professional) | 15 per minute |
109
+
110
+ Use an account with an **editor seat** on the file for meaningful usage.
111
+
112
+ ## Known limitations
113
+
114
+ - **Design tokens require named Figma Styles.** If the designer applied colors directly without saving them as named styles, tokens will be empty. Variables (Figma Professional+) are not supported — use Styles instead.
115
+ - **SVG export skips remote library components.** Icons that are instances of components from external shared libraries cannot be exported via the REST API. They'll appear as `INSTANCE` nodes in the frame JSON.
116
+ - **x/y positions** are canvas-space coordinates. For absolute-layout frames, subtract the parent's x/y to get relative positions.
117
+
118
+ ## How it works
119
+
120
+ 1. `GET /v1/files/:key` — fetches the full document tree
121
+ 2. Batch-fetches style node data to resolve exact color/font values
122
+ 3. Distills each top-level frame to a CSS-friendly JSON tree
123
+ 4. Exports frame PNGs at 2× resolution
124
+ 5. Exports VECTOR nodes as SVGs in a second pass
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,104 @@
1
+ # ExtraFigma
2
+
3
+ A pull-only CLI that converts Figma files into agent-readable local folders. Drop the output into your project and let Claude Code (or any AI coding agent) implement pixel-perfect frontend code from the design.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install extrafigma
9
+ # or
10
+ uvx extrafigma --help
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ extrafigma pull "https://www.figma.com/design/abc123/My-App" ./my-design
17
+ ```
18
+
19
+ On first run you'll be prompted for a Figma Personal Access Token. Get one at **Figma → Settings → Security → Personal Access Tokens** (scopes needed: `file_content:read`, `file_metadata:read`).
20
+
21
+ The token is stored at `~/.config/extrafigma/credentials.json` and reused for all future pulls.
22
+
23
+ ## What gets extracted
24
+
25
+ ```
26
+ my-design/
27
+ CLAUDE.md ← instructions for AI agents
28
+ index.md ← overview of all pages and frames
29
+ tokens/
30
+ colors.json ← named color styles → hex values
31
+ typography.json ← named text styles → font properties
32
+ assets/
33
+ icon-name.svg ← exported SVG icons (VECTOR nodes)
34
+ pages/
35
+ Page-1/
36
+ page.md
37
+ Hero.frame.json ← distilled layout tree (CSS-friendly)
38
+ Hero.png ← 2× frame render (visual ground truth)
39
+ ```
40
+
41
+ ### `frame.json` structure
42
+
43
+ Every frame is distilled to a CSS-friendly layout tree:
44
+
45
+ ```json
46
+ {
47
+ "type": "FRAME",
48
+ "name": "Hero",
49
+ "width": 1440,
50
+ "height": 900,
51
+ "layout": { "mode": "flex", "direction": "column", "gap": 24 },
52
+ "background": "colors/brand-primary",
53
+ "borderRadius": 0,
54
+ "children": [
55
+ {
56
+ "type": "TEXT",
57
+ "content": "Welcome",
58
+ "style": "typography/heading-xl",
59
+ "color": "colors/gray-800"
60
+ }
61
+ ]
62
+ }
63
+ ```
64
+
65
+ Named Figma Styles become tokens (`colors/brand-primary`, `typography/heading-xl`). Raw hex values that weren't saved as styles are flagged with `# not in tokens`.
66
+
67
+ ## Auth commands
68
+
69
+ ```bash
70
+ extrafigma auth login # interactive prompt
71
+ extrafigma auth login --pat <token> # non-interactive (CI-friendly)
72
+ extrafigma auth status # check stored PAT
73
+ extrafigma auth logout # remove stored PAT
74
+ ```
75
+
76
+ ## Rate limits
77
+
78
+ Figma's REST API limits `GET /v1/files` calls based on your seat type:
79
+
80
+ | Seat | Limit |
81
+ |------|-------|
82
+ | View / Collab | 6 per month |
83
+ | Dev / Full (Starter) | 10 per minute |
84
+ | Dev / Full (Professional) | 15 per minute |
85
+
86
+ Use an account with an **editor seat** on the file for meaningful usage.
87
+
88
+ ## Known limitations
89
+
90
+ - **Design tokens require named Figma Styles.** If the designer applied colors directly without saving them as named styles, tokens will be empty. Variables (Figma Professional+) are not supported — use Styles instead.
91
+ - **SVG export skips remote library components.** Icons that are instances of components from external shared libraries cannot be exported via the REST API. They'll appear as `INSTANCE` nodes in the frame JSON.
92
+ - **x/y positions** are canvas-space coordinates. For absolute-layout frames, subtract the parent's x/y to get relative positions.
93
+
94
+ ## How it works
95
+
96
+ 1. `GET /v1/files/:key` — fetches the full document tree
97
+ 2. Batch-fetches style node data to resolve exact color/font values
98
+ 3. Distills each top-level frame to a CSS-friendly JSON tree
99
+ 4. Exports frame PNGs at 2× resolution
100
+ 5. Exports VECTOR nodes as SVGs in a second pass
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,117 @@
1
+ import time
2
+ from typing import Optional
3
+
4
+ import httpx
5
+
6
+ BASE_URL = "https://api.figma.com/v1"
7
+ # Figma returns very large Retry-After values for monthly quota exhaustion.
8
+ # We cap the sleep and surface a clear error instead.
9
+ _MAX_RETRY_SLEEP = 60.0
10
+
11
+
12
+ class FigmaAPIError(Exception):
13
+ def __init__(self, status_code: int, message: str):
14
+ self.status_code = status_code
15
+ super().__init__(f"Figma API {status_code}: {message}")
16
+
17
+
18
+ class FigmaAuthError(FigmaAPIError):
19
+ pass
20
+
21
+
22
+ class FigmaNotFoundError(FigmaAPIError):
23
+ pass
24
+
25
+
26
+ class FigmaPermissionError(FigmaAPIError):
27
+ pass
28
+
29
+
30
+ class FigmaRateLimitError(FigmaAPIError):
31
+ def __init__(self, retry_after: Optional[float] = None, quota_exhausted: bool = False):
32
+ self.retry_after = retry_after
33
+ self.quota_exhausted = quota_exhausted
34
+ msg = (
35
+ "Monthly API quota exhausted. Resets on the 1st of next month.\n"
36
+ " Upgrade your Figma seat to Dev/Full for 10 req/min instead of 6/month."
37
+ if quota_exhausted
38
+ else "Rate limited after retries"
39
+ )
40
+ super().__init__(429, msg)
41
+
42
+
43
+ class FigmaClient:
44
+ def __init__(self, pat: str):
45
+ self._client = httpx.Client(
46
+ headers={"X-Figma-Token": pat},
47
+ timeout=90.0,
48
+ )
49
+
50
+ def _get(self, path: str, params: Optional[dict] = None) -> dict:
51
+ url = f"{BASE_URL}{path}"
52
+ for attempt in range(3):
53
+ resp = self._client.get(url, params=params or {})
54
+ if resp.status_code == 200:
55
+ return resp.json()
56
+ if resp.status_code == 429:
57
+ retry_after = float(resp.headers.get("Retry-After", 10))
58
+ # Large values (>5 min) indicate monthly quota exhaustion, not a burst limit.
59
+ if retry_after > _MAX_RETRY_SLEEP:
60
+ raise FigmaRateLimitError(retry_after=retry_after, quota_exhausted=True)
61
+ print(f" Rate limited. Waiting {retry_after:.0f}s...")
62
+ time.sleep(retry_after)
63
+ continue
64
+ if resp.status_code == 401:
65
+ raise FigmaAuthError(401, "Invalid PAT. Run: extrafigma auth login")
66
+ if resp.status_code == 404:
67
+ raise FigmaNotFoundError(404, f"Not found: {path}")
68
+ if resp.status_code == 403:
69
+ raise FigmaPermissionError(403, resp.text[:200])
70
+ resp.raise_for_status()
71
+ raise FigmaRateLimitError()
72
+
73
+ def get_file(self, file_key: str) -> dict:
74
+ return self._get(f"/files/{file_key}", params={"geometry": "paths"})
75
+
76
+ def get_nodes(self, file_key: str, node_ids: list[str]) -> dict:
77
+ """Return {node_id: document_node} for the given node IDs."""
78
+ if not node_ids:
79
+ return {}
80
+ data = self._get(
81
+ f"/files/{file_key}/nodes",
82
+ params={"ids": ",".join(node_ids)},
83
+ )
84
+ return {
85
+ nid: info.get("document", {})
86
+ for nid, info in data.get("nodes", {}).items()
87
+ if info
88
+ }
89
+
90
+ def get_images(
91
+ self,
92
+ file_key: str,
93
+ ids: list[str],
94
+ scale: int = 2,
95
+ format: str = "png",
96
+ ) -> dict:
97
+ """Return {node_id: signed_url}. Batches handled by caller."""
98
+ if not ids:
99
+ return {}
100
+ params: dict = {"ids": ",".join(ids), "format": format}
101
+ if format == "png":
102
+ params["scale"] = scale
103
+ elif format == "svg":
104
+ params["svg_outline_text"] = "false" # keep <text> elements, not paths
105
+ params["svg_include_id"] = "false"
106
+ params["svg_simplify_stroke"] = "true"
107
+ data = self._get(f"/images/{file_key}", params=params)
108
+ return data.get("images", {})
109
+
110
+ def close(self) -> None:
111
+ self._client.close()
112
+
113
+ def __enter__(self) -> "FigmaClient":
114
+ return self
115
+
116
+ def __exit__(self, *_) -> None:
117
+ self.close()
@@ -0,0 +1,33 @@
1
+ import json
2
+ import stat
3
+ from pathlib import Path
4
+
5
+
6
+ def get_credentials_path() -> Path:
7
+ config_dir = Path.home() / ".config" / "extrafigma"
8
+ config_dir.mkdir(parents=True, exist_ok=True)
9
+ return config_dir / "credentials.json"
10
+
11
+
12
+ def save_pat(pat: str) -> None:
13
+ path = get_credentials_path()
14
+ path.write_text(json.dumps({"pat": pat}, indent=2))
15
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
16
+
17
+
18
+ def load_pat() -> str:
19
+ path = get_credentials_path()
20
+ if not path.exists():
21
+ return ""
22
+ try:
23
+ return json.loads(path.read_text()).get("pat", "")
24
+ except (json.JSONDecodeError, OSError):
25
+ return ""
26
+
27
+
28
+ def delete_credentials() -> bool:
29
+ path = get_credentials_path()
30
+ if path.exists():
31
+ path.unlink()
32
+ return True
33
+ return False
@@ -0,0 +1,95 @@
1
+ from typing import Optional
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.text import Text
6
+
7
+ from extrafigma.auth import load_pat, save_pat, delete_credentials, get_credentials_path
8
+ from extrafigma.utils.file_key import parse_file_key
9
+
10
+ console = Console()
11
+ app = typer.Typer(
12
+ name="extrafigma",
13
+ help="Pull Figma files into agent-readable local folders.",
14
+ no_args_is_help=True,
15
+ )
16
+ auth_app = typer.Typer(help="Manage your Figma Personal Access Token.")
17
+ app.add_typer(auth_app, name="auth")
18
+
19
+
20
+ def _require_pat() -> str:
21
+ """Return stored PAT, prompting the user to set one if not found."""
22
+ pat = load_pat()
23
+ if pat:
24
+ return pat
25
+ console.print("[yellow]No Figma PAT found.[/yellow] You only need to do this once.\n")
26
+ console.print("[dim]Get yours at: Figma → Settings → Security → Personal Access Tokens[/dim]")
27
+ console.print("[dim]Required scopes: file_content:read, file_metadata:read[/dim]\n")
28
+ pat = typer.prompt("Paste your Figma PAT", hide_input=True).strip()
29
+ if not pat:
30
+ console.print("[red]✗ No PAT provided.[/red]")
31
+ raise typer.Exit(1)
32
+ save_pat(pat)
33
+ console.print("[green]✓ PAT saved — won't ask again.[/green]\n")
34
+ return pat
35
+
36
+
37
+ @auth_app.command("login")
38
+ def auth_login(
39
+ pat: Optional[str] = typer.Option(None, "--pat", help="PAT to store (skips interactive prompt)"),
40
+ ):
41
+ """Store your Figma Personal Access Token."""
42
+ if pat:
43
+ pat = pat.strip()
44
+ else:
45
+ console.print("[dim]Generate a PAT at: Figma → Settings → Security → Personal Access Tokens[/dim]")
46
+ console.print("[dim]Required scopes: file_content:read, file_metadata:read[/dim]\n")
47
+ pat = typer.prompt("Paste your Figma PAT", hide_input=True).strip()
48
+ if not pat:
49
+ console.print("[red]✗ No PAT provided.[/red]")
50
+ raise typer.Exit(1)
51
+ save_pat(pat)
52
+ console.print("[green]✓ PAT saved.[/green]")
53
+
54
+
55
+ @auth_app.command("status")
56
+ def auth_status():
57
+ """Show PAT status."""
58
+ creds = get_credentials_path()
59
+ if not creds.exists():
60
+ console.print("[red]✗ No PAT stored.[/red] Run: extrafigma auth login")
61
+ raise typer.Exit(1)
62
+ pat = load_pat()
63
+ if not pat:
64
+ console.print("[red]✗ credentials.json exists but PAT is empty.[/red]")
65
+ raise typer.Exit(1)
66
+ masked = pat[:8] + "..." + pat[-4:] if len(pat) > 12 else "***"
67
+ console.print(f"[green]✓ PAT set[/green] ({masked})")
68
+ console.print(f" Stored at: {creds}")
69
+
70
+
71
+ @auth_app.command("logout")
72
+ def auth_logout():
73
+ """Remove stored credentials."""
74
+ if delete_credentials():
75
+ console.print("[green]✓ Credentials removed.[/green]")
76
+ else:
77
+ console.print("No credentials to remove.")
78
+
79
+
80
+ @app.command()
81
+ def pull(
82
+ url: str = typer.Argument(..., help="Figma file URL or file key"),
83
+ folder: str = typer.Argument(..., help="Output folder path"),
84
+ ):
85
+ """Pull a Figma file to a local agent-readable folder."""
86
+ from extrafigma.puller.pull import pull as do_pull
87
+
88
+ pat = _require_pat()
89
+
90
+ file_key = parse_file_key(url)
91
+ if not file_key:
92
+ console.print(f"[red]✗ Could not parse a file key from:[/red] {url}")
93
+ raise typer.Exit(1)
94
+
95
+ do_pull(file_key=file_key, figma_url=url, output_dir=folder, pat=pat)
File without changes