figma-cli 0.1.0__tar.gz → 0.3.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.
- figma_cli-0.3.0/.github/workflows/ci.yml +51 -0
- figma_cli-0.3.0/PKG-INFO +196 -0
- figma_cli-0.3.0/README.md +171 -0
- {figma_cli-0.1.0 → figma_cli-0.3.0}/src/figma_cli/__init__.py +1 -1
- figma_cli-0.3.0/src/figma_cli/cli.py +249 -0
- figma_cli-0.3.0/src/figma_cli/client.py +229 -0
- figma_cli-0.3.0/src/figma_cli/login.py +108 -0
- figma_cli-0.3.0/tests/conftest.py +11 -0
- figma_cli-0.3.0/tests/test_cli.py +358 -0
- figma_cli-0.3.0/tests/test_client.py +404 -0
- figma_cli-0.3.0/tests/test_live.py +89 -0
- figma_cli-0.1.0/.github/workflows/ci.yml +0 -30
- figma_cli-0.1.0/PKG-INFO +0 -82
- figma_cli-0.1.0/README.md +0 -57
- figma_cli-0.1.0/src/figma_cli/cli.py +0 -28
- figma_cli-0.1.0/tests/test_cli.py +0 -82
- {figma_cli-0.1.0 → figma_cli-0.3.0}/.github/workflows/release.yml +0 -0
- {figma_cli-0.1.0 → figma_cli-0.3.0}/.gitignore +0 -0
- {figma_cli-0.1.0 → figma_cli-0.3.0}/LICENSE +0 -0
- {figma_cli-0.1.0 → figma_cli-0.3.0}/pyproject.toml +0 -0
- {figma_cli-0.1.0 → figma_cli-0.3.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v5
|
|
20
|
+
- uses: actions/setup-python@v6
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
- name: Install
|
|
24
|
+
run: python -m pip install -e ".[dev]"
|
|
25
|
+
- name: Lint
|
|
26
|
+
run: ruff check .
|
|
27
|
+
- name: Format
|
|
28
|
+
run: ruff format --check .
|
|
29
|
+
- name: Test
|
|
30
|
+
run: pytest tests/
|
|
31
|
+
|
|
32
|
+
live:
|
|
33
|
+
# Runs against the real Figma API when the FIGMA_TOKEN secret is configured;
|
|
34
|
+
# the tests skip cleanly when it is absent (forks, unconfigured repos).
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
needs: test
|
|
37
|
+
env:
|
|
38
|
+
FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
|
|
39
|
+
FIGMA_TEST_FILE_KEY: ${{ vars.FIGMA_TEST_FILE_KEY }}
|
|
40
|
+
FIGMA_TEST_NODE_ID: ${{ vars.FIGMA_TEST_NODE_ID }}
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v5
|
|
43
|
+
with:
|
|
44
|
+
persist-credentials: false
|
|
45
|
+
- uses: actions/setup-python@v6
|
|
46
|
+
with:
|
|
47
|
+
python-version: "3.13"
|
|
48
|
+
- name: Install
|
|
49
|
+
run: python -m pip install -e ".[dev]"
|
|
50
|
+
- name: Live API tests
|
|
51
|
+
run: pytest -rs tests/test_live.py
|
figma_cli-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: figma-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Headless Figma CLI for AI coding agents and automated design inspection.
|
|
5
|
+
Project-URL: Homepage, https://github.com/imperfect-co/figma-cli
|
|
6
|
+
Project-URL: Issues, https://github.com/imperfect-co/figma-cli/issues
|
|
7
|
+
Author: imperfect-co
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
23
|
+
Requires-Dist: twine>=6.1.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# figma-cli
|
|
27
|
+
|
|
28
|
+
Headless Figma CLI for AI coding agents and automated design inspection.
|
|
29
|
+
|
|
30
|
+
Requires Python 3.11 or newer. Licensed under MIT.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
The package installs two console scripts that run the same entrypoint:
|
|
35
|
+
|
|
36
|
+
- `figma-cli`
|
|
37
|
+
- `figma` (short alias)
|
|
38
|
+
|
|
39
|
+
Each reports its own name in `--help` and `--version`:
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
$ figma-cli --version
|
|
43
|
+
figma-cli 0.1.0
|
|
44
|
+
$ figma --version
|
|
45
|
+
figma 0.1.0
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The npm package `silships/figma-cli` also installs a `figma-cli` executable. If both are installed, whichever directory comes first on `PATH` wins. Run `command -v figma-cli` to check which one you get, or use the `figma` alias.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Every command talks to the Figma REST API with a personal access token. Create one under Figma account settings (Security > Personal access tokens) with these scopes, the minimum the commands below need per [Figma's scope reference](https://developers.figma.com/docs/rest-api/scopes/):
|
|
53
|
+
|
|
54
|
+
| Scope | Used by |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `current_user:read` | `auth check`, `auth login` (`GET /v1/me`) |
|
|
57
|
+
| `file_content:read` | `file get`, `export` |
|
|
58
|
+
| `file_comments:read` | `comment list` |
|
|
59
|
+
| `file_comments:write` | `comment post`, `comment delete` |
|
|
60
|
+
|
|
61
|
+
Then store it once with `figma auth login`:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
figma auth login # interactive: prints the steps, opens settings, hidden prompt
|
|
65
|
+
echo "$TOKEN" | figma auth login # agents and CI: piped stdin
|
|
66
|
+
figma auth login --token - < token.txt # same, explicit
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`auth login` validates the token against `GET /v1/me` before writing anything. A rejected token exits 3 and leaves any existing token file untouched. A valid one is written to `~/.config/figma/token` with mode `0600` (its directory `0700`), through a temporary file renamed into place, so a failed write keeps the previous token, and the command reports the authenticated `id`, `handle` and `email` plus the file path. Prefer stdin over `--token <value>`, which exposes the token in process listings and shell history. Empty stdin exits 2. Add `--no-browser` to skip opening the settings page.
|
|
70
|
+
|
|
71
|
+
Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored file whenever it is set and non-empty:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
export FIGMA_TOKEN=figd_...
|
|
75
|
+
export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
With neither set, commands fail with `{"error": "missing_token"}` and exit code 3.
|
|
79
|
+
|
|
80
|
+
Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
|
|
81
|
+
|
|
82
|
+
| Command | Figma endpoint |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
|
|
85
|
+
| `figma auth check` | `GET /v1/me` |
|
|
86
|
+
| `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
|
|
87
|
+
| `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
|
|
88
|
+
| `figma comment list <file_key>` | `GET /v1/files/{file_key}/comments` |
|
|
89
|
+
| `figma comment post <file_key> --message TEXT [--comment-id ID] [--node-id ID]` | `POST /v1/files/{file_key}/comments` |
|
|
90
|
+
| `figma comment delete <file_key> <comment_id>` | `DELETE /v1/files/{file_key}/comments/{comment_id}` |
|
|
91
|
+
|
|
92
|
+
The file key is the segment after `/design/` (or `/file/`) in a Figma URL. Node ids use the `1:2` form; a URL shows them as `node-id=1-2`.
|
|
93
|
+
|
|
94
|
+
### Examples
|
|
95
|
+
|
|
96
|
+
Check the token:
|
|
97
|
+
|
|
98
|
+
```console
|
|
99
|
+
$ figma auth check --json
|
|
100
|
+
{
|
|
101
|
+
"id": "123456789",
|
|
102
|
+
"handle": "Design Bot",
|
|
103
|
+
"email": "bot@example.com"
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Inspect only the pages of a file. `--depth` is passed to Figma, so the server trims the tree before it is sent:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
figma file get AbCdEf123 --depth 1
|
|
111
|
+
figma file get AbCdEf123 --depth 2 --json | jq '.document.children[].name'
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Render two frames to PNG and print where they were written. Files are named `<file_key>_<node_id>.<format>` with `:` and other unsafe characters replaced by `-`; re-exporting the same node overwrites its file:
|
|
115
|
+
|
|
116
|
+
```console
|
|
117
|
+
$ figma export AbCdEf123 --nodes 1:2,1:3 --output shots
|
|
118
|
+
shots/AbCdEf123_1-2.png
|
|
119
|
+
shots/AbCdEf123_1-3.png
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Post a comment pinned to a frame, reply to it, list the thread, then clean up:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
id=$(figma comment post AbCdEf123 --message "Spacing is off" --node-id 1:2 --json | jq -r .id)
|
|
126
|
+
figma comment post AbCdEf123 --message "Fixed in the next build" --comment-id "$id"
|
|
127
|
+
figma comment list AbCdEf123
|
|
128
|
+
figma comment delete AbCdEf123 "$id"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Errors and exit codes
|
|
132
|
+
|
|
133
|
+
| Exit code | Meaning |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| 0 | Success |
|
|
136
|
+
| 2 | Usage error (bad or missing arguments) |
|
|
137
|
+
| 3 | Figma API, network, or local write failure |
|
|
138
|
+
|
|
139
|
+
With `--json`, a failure prints a JSON object on stdout; without it, a one-line message goes to stderr. HTTP 401, 403, 404 and 5xx become `unauthorized`, `forbidden`, `not_found` and `server_error`:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{"error": "forbidden", "status": 403, "message": "Invalid token"}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
HTTP 429 is reported at once, never retried, so the caller decides when to try again. `retry_after` comes from the `Retry-After` header and is `null` when Figma sends none:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{"error": "rate_limit_exceeded", "status": 429, "retry_after": 30}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Token safety
|
|
152
|
+
|
|
153
|
+
A request carrying `X-Figma-Token` follows no redirects. Any 3xx answer is refused with `{"error": "redirect_refused"}` and exit code 3 before a second request is made, so the token never reaches another host. Exported images are downloaded from the pre-signed URLs Figma returns with a separate request that carries no token; only that request follows redirects.
|
|
154
|
+
|
|
155
|
+
## Installation and quick run
|
|
156
|
+
|
|
157
|
+
Run on-demand without installing via [uv](https://docs.astral.sh/uv/):
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
uvx figma-cli --help
|
|
161
|
+
uvx figma-cli auth check --json
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Or install from [PyPI](https://pypi.org/project/figma-cli/):
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
pip install figma-cli
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
From a local checkout:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
uvx --from . figma-cli --version
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
python -m venv .venv && . .venv/bin/activate
|
|
180
|
+
pip install -e ".[dev]"
|
|
181
|
+
ruff check .
|
|
182
|
+
ruff format --check .
|
|
183
|
+
pytest tests/
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The tests are hermetic: no network access and no Figma token are needed, and `HOME` points at a temporary directory so a stored token never leaks in. The redirect tests use real sockets on `127.0.0.1`.
|
|
187
|
+
|
|
188
|
+
`tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` is set. With the token alone it checks `auth check` and runs `auth login` from stdin into a temporary `HOME`; the file, comment and export checks also need `FIGMA_TEST_FILE_KEY` (add `FIGMA_TEST_NODE_ID` to exercise export). The comment check posts one comment and deletes it. CI runs it in the `live` job from the `FIGMA_TOKEN` secret and the `FIGMA_TEST_FILE_KEY` / `FIGMA_TEST_NODE_ID` repository variables, and skips it when they are absent.
|
|
189
|
+
|
|
190
|
+
## Release
|
|
191
|
+
|
|
192
|
+
Bump `__version__` in `src/figma_cli/__init__.py`, merge, then push a matching tag (`v0.1.0` for `0.1.0`). The release workflow refuses a tag that does not match `__version__`, builds the sdist and wheel, runs `twine check`, and publishes to PyPI through trusted publishing (OIDC, the `pypi` environment).
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# figma-cli
|
|
2
|
+
|
|
3
|
+
Headless Figma CLI for AI coding agents and automated design inspection.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.11 or newer. Licensed under MIT.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
The package installs two console scripts that run the same entrypoint:
|
|
10
|
+
|
|
11
|
+
- `figma-cli`
|
|
12
|
+
- `figma` (short alias)
|
|
13
|
+
|
|
14
|
+
Each reports its own name in `--help` and `--version`:
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
$ figma-cli --version
|
|
18
|
+
figma-cli 0.1.0
|
|
19
|
+
$ figma --version
|
|
20
|
+
figma 0.1.0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The npm package `silships/figma-cli` also installs a `figma-cli` executable. If both are installed, whichever directory comes first on `PATH` wins. Run `command -v figma-cli` to check which one you get, or use the `figma` alias.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Every command talks to the Figma REST API with a personal access token. Create one under Figma account settings (Security > Personal access tokens) with these scopes, the minimum the commands below need per [Figma's scope reference](https://developers.figma.com/docs/rest-api/scopes/):
|
|
28
|
+
|
|
29
|
+
| Scope | Used by |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `current_user:read` | `auth check`, `auth login` (`GET /v1/me`) |
|
|
32
|
+
| `file_content:read` | `file get`, `export` |
|
|
33
|
+
| `file_comments:read` | `comment list` |
|
|
34
|
+
| `file_comments:write` | `comment post`, `comment delete` |
|
|
35
|
+
|
|
36
|
+
Then store it once with `figma auth login`:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
figma auth login # interactive: prints the steps, opens settings, hidden prompt
|
|
40
|
+
echo "$TOKEN" | figma auth login # agents and CI: piped stdin
|
|
41
|
+
figma auth login --token - < token.txt # same, explicit
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`auth login` validates the token against `GET /v1/me` before writing anything. A rejected token exits 3 and leaves any existing token file untouched. A valid one is written to `~/.config/figma/token` with mode `0600` (its directory `0700`), through a temporary file renamed into place, so a failed write keeps the previous token, and the command reports the authenticated `id`, `handle` and `email` plus the file path. Prefer stdin over `--token <value>`, which exposes the token in process listings and shell history. Empty stdin exits 2. Add `--no-browser` to skip opening the settings page.
|
|
45
|
+
|
|
46
|
+
Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored file whenever it is set and non-empty:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
export FIGMA_TOKEN=figd_...
|
|
50
|
+
export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
With neither set, commands fail with `{"error": "missing_token"}` and exit code 3.
|
|
54
|
+
|
|
55
|
+
Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
|
|
56
|
+
|
|
57
|
+
| Command | Figma endpoint |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
|
|
60
|
+
| `figma auth check` | `GET /v1/me` |
|
|
61
|
+
| `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
|
|
62
|
+
| `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
|
|
63
|
+
| `figma comment list <file_key>` | `GET /v1/files/{file_key}/comments` |
|
|
64
|
+
| `figma comment post <file_key> --message TEXT [--comment-id ID] [--node-id ID]` | `POST /v1/files/{file_key}/comments` |
|
|
65
|
+
| `figma comment delete <file_key> <comment_id>` | `DELETE /v1/files/{file_key}/comments/{comment_id}` |
|
|
66
|
+
|
|
67
|
+
The file key is the segment after `/design/` (or `/file/`) in a Figma URL. Node ids use the `1:2` form; a URL shows them as `node-id=1-2`.
|
|
68
|
+
|
|
69
|
+
### Examples
|
|
70
|
+
|
|
71
|
+
Check the token:
|
|
72
|
+
|
|
73
|
+
```console
|
|
74
|
+
$ figma auth check --json
|
|
75
|
+
{
|
|
76
|
+
"id": "123456789",
|
|
77
|
+
"handle": "Design Bot",
|
|
78
|
+
"email": "bot@example.com"
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Inspect only the pages of a file. `--depth` is passed to Figma, so the server trims the tree before it is sent:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
figma file get AbCdEf123 --depth 1
|
|
86
|
+
figma file get AbCdEf123 --depth 2 --json | jq '.document.children[].name'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Render two frames to PNG and print where they were written. Files are named `<file_key>_<node_id>.<format>` with `:` and other unsafe characters replaced by `-`; re-exporting the same node overwrites its file:
|
|
90
|
+
|
|
91
|
+
```console
|
|
92
|
+
$ figma export AbCdEf123 --nodes 1:2,1:3 --output shots
|
|
93
|
+
shots/AbCdEf123_1-2.png
|
|
94
|
+
shots/AbCdEf123_1-3.png
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Post a comment pinned to a frame, reply to it, list the thread, then clean up:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
id=$(figma comment post AbCdEf123 --message "Spacing is off" --node-id 1:2 --json | jq -r .id)
|
|
101
|
+
figma comment post AbCdEf123 --message "Fixed in the next build" --comment-id "$id"
|
|
102
|
+
figma comment list AbCdEf123
|
|
103
|
+
figma comment delete AbCdEf123 "$id"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Errors and exit codes
|
|
107
|
+
|
|
108
|
+
| Exit code | Meaning |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| 0 | Success |
|
|
111
|
+
| 2 | Usage error (bad or missing arguments) |
|
|
112
|
+
| 3 | Figma API, network, or local write failure |
|
|
113
|
+
|
|
114
|
+
With `--json`, a failure prints a JSON object on stdout; without it, a one-line message goes to stderr. HTTP 401, 403, 404 and 5xx become `unauthorized`, `forbidden`, `not_found` and `server_error`:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{"error": "forbidden", "status": 403, "message": "Invalid token"}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
HTTP 429 is reported at once, never retried, so the caller decides when to try again. `retry_after` comes from the `Retry-After` header and is `null` when Figma sends none:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{"error": "rate_limit_exceeded", "status": 429, "retry_after": 30}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Token safety
|
|
127
|
+
|
|
128
|
+
A request carrying `X-Figma-Token` follows no redirects. Any 3xx answer is refused with `{"error": "redirect_refused"}` and exit code 3 before a second request is made, so the token never reaches another host. Exported images are downloaded from the pre-signed URLs Figma returns with a separate request that carries no token; only that request follows redirects.
|
|
129
|
+
|
|
130
|
+
## Installation and quick run
|
|
131
|
+
|
|
132
|
+
Run on-demand without installing via [uv](https://docs.astral.sh/uv/):
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
uvx figma-cli --help
|
|
136
|
+
uvx figma-cli auth check --json
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Or install from [PyPI](https://pypi.org/project/figma-cli/):
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
pip install figma-cli
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
From a local checkout:
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
uvx --from . figma-cli --version
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
python -m venv .venv && . .venv/bin/activate
|
|
155
|
+
pip install -e ".[dev]"
|
|
156
|
+
ruff check .
|
|
157
|
+
ruff format --check .
|
|
158
|
+
pytest tests/
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The tests are hermetic: no network access and no Figma token are needed, and `HOME` points at a temporary directory so a stored token never leaks in. The redirect tests use real sockets on `127.0.0.1`.
|
|
162
|
+
|
|
163
|
+
`tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` is set. With the token alone it checks `auth check` and runs `auth login` from stdin into a temporary `HOME`; the file, comment and export checks also need `FIGMA_TEST_FILE_KEY` (add `FIGMA_TEST_NODE_ID` to exercise export). The comment check posts one comment and deletes it. CI runs it in the `live` job from the `FIGMA_TOKEN` secret and the `FIGMA_TEST_FILE_KEY` / `FIGMA_TEST_NODE_ID` repository variables, and skips it when they are absent.
|
|
164
|
+
|
|
165
|
+
## Release
|
|
166
|
+
|
|
167
|
+
Bump `__version__` in `src/figma_cli/__init__.py`, merge, then push a matching tag (`v0.1.0` for `0.1.0`). The release workflow refuses a tag that does not match `__version__`, builds the sdist and wheel, runs `twine check`, and publishes to PyPI through trusted publishing (OIDC, the `pypi` environment).
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Console entrypoint shared by the ``figma-cli`` and ``figma`` commands.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 success, 2 usage error, 3 Figma API, network or local write failure.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from figma_cli import __version__
|
|
15
|
+
from figma_cli.client import FigmaClient, FigmaError, download
|
|
16
|
+
from figma_cli.login import login
|
|
17
|
+
|
|
18
|
+
# Most handlers take (client, args); those marked needs_client=False take (args).
|
|
19
|
+
Handler = Callable[..., tuple[Any, str]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_json(payload: Any) -> None:
|
|
23
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _identity(me: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
|
27
|
+
ident = {key: me.get(key) for key in ("id", "handle", "email")}
|
|
28
|
+
return (
|
|
29
|
+
ident,
|
|
30
|
+
f"Authenticated as {ident['handle']} <{ident['email']}> ({ident['id']})",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
|
|
35
|
+
return _identity(client.me())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _auth_login(args: argparse.Namespace) -> tuple[Any, str]:
|
|
39
|
+
me, path = login(args)
|
|
40
|
+
ident, text = _identity(me)
|
|
41
|
+
return {**ident, "token_path": str(path)}, f"{text}\nToken saved to {path}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _tree_lines(node: dict[str, Any], indent: int = 0) -> list[str]:
|
|
45
|
+
label = f"{node.get('type', '?')} {node.get('name', '')} ({node.get('id', '')})"
|
|
46
|
+
lines = [" " * indent + label]
|
|
47
|
+
for child in node.get("children") or []:
|
|
48
|
+
lines.extend(_tree_lines(child, indent + 1))
|
|
49
|
+
return lines
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _file_get(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
|
|
53
|
+
data = client.get_file(args.file_key, args.depth)
|
|
54
|
+
header = f"{data.get('name', '')} (last modified {data.get('lastModified', '?')})"
|
|
55
|
+
tree = _tree_lines(data["document"]) if data.get("document") else []
|
|
56
|
+
return data, "\n".join([header, *tree])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _safe_name(node_id: str) -> str:
|
|
60
|
+
return re.sub(r"[^A-Za-z0-9_.-]", "-", node_id)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _export(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
|
|
64
|
+
node_ids = args.nodes
|
|
65
|
+
images = client.get_images(args.file_key, node_ids, args.format)
|
|
66
|
+
missing = [n for n in node_ids if not images.get(n)]
|
|
67
|
+
if missing:
|
|
68
|
+
raise FigmaError(
|
|
69
|
+
{
|
|
70
|
+
"error": "render_failed",
|
|
71
|
+
"message": "no image URL returned",
|
|
72
|
+
"nodes": missing,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
out_dir = Path(args.output)
|
|
76
|
+
files = []
|
|
77
|
+
for node_id in node_ids:
|
|
78
|
+
payload = download(images[node_id])
|
|
79
|
+
path = (
|
|
80
|
+
out_dir / f"{_safe_name(args.file_key)}_{_safe_name(node_id)}.{args.format}"
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
path.write_bytes(payload)
|
|
85
|
+
except OSError as err:
|
|
86
|
+
raise FigmaError({"error": "write_failed", "message": str(err)}) from None
|
|
87
|
+
files.append({"node_id": node_id, "path": str(path), "bytes": len(payload)})
|
|
88
|
+
return {"files": files}, "\n".join(f["path"] for f in files)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _comment_line(c: dict[str, Any]) -> str:
|
|
92
|
+
user = (c.get("user") or {}).get("handle", "?")
|
|
93
|
+
reply = f" (reply to {c['parent_id']})" if c.get("parent_id") else ""
|
|
94
|
+
head = f"{c.get('id')} {c.get('created_at', '')} {user}{reply}"
|
|
95
|
+
return f"{head}: {c.get('message', '')}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _comment_list(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
|
|
99
|
+
data = client.list_comments(args.file_key)
|
|
100
|
+
lines = [_comment_line(c) for c in data.get("comments") or []]
|
|
101
|
+
return data, "\n".join(lines) or "No comments."
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _comment_post(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
|
|
105
|
+
data = client.post_comment(
|
|
106
|
+
args.file_key, args.message, comment_id=args.comment_id, node_id=args.node_id
|
|
107
|
+
)
|
|
108
|
+
return data, f"Posted comment {data.get('id')}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _comment_delete(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
|
|
112
|
+
client.delete_comment(args.file_key, args.comment_id)
|
|
113
|
+
return {
|
|
114
|
+
"deleted": True,
|
|
115
|
+
"id": args.comment_id,
|
|
116
|
+
}, f"Deleted comment {args.comment_id}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _positive_int(value: str) -> int:
|
|
120
|
+
number = int(value)
|
|
121
|
+
if number < 1:
|
|
122
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
123
|
+
return number
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _node_list(value: str) -> list[str]:
|
|
127
|
+
nodes = [n.strip() for n in value.split(",") if n.strip()]
|
|
128
|
+
if not nodes:
|
|
129
|
+
raise argparse.ArgumentTypeError("expected one or more node ids")
|
|
130
|
+
return nodes
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
134
|
+
# prog is left unset so it follows sys.argv[0]: each alias reports its own name.
|
|
135
|
+
parser = argparse.ArgumentParser(
|
|
136
|
+
description=(
|
|
137
|
+
"Headless Figma CLI for AI coding agents and automated design inspection."
|
|
138
|
+
),
|
|
139
|
+
epilog=(
|
|
140
|
+
"Environment: FIGMA_TOKEN (optional when 'figma auth login' has stored"
|
|
141
|
+
" ~/.config/figma/token), FIGMA_API_BASE (optional)."
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
146
|
+
)
|
|
147
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
148
|
+
common.add_argument("--json", action="store_true", help="print structured JSON")
|
|
149
|
+
commands = parser.add_subparsers(dest="command", metavar="<command>")
|
|
150
|
+
|
|
151
|
+
auth = commands.add_parser("auth", help="token setup and checks")
|
|
152
|
+
auth_cmds = auth.add_subparsers(dest="action", metavar="<action>", required=True)
|
|
153
|
+
p = auth_cmds.add_parser(
|
|
154
|
+
"check", parents=[common], help="validate FIGMA_TOKEN or stored token file"
|
|
155
|
+
)
|
|
156
|
+
p.set_defaults(handler=_auth_check)
|
|
157
|
+
p = auth_cmds.add_parser(
|
|
158
|
+
"login",
|
|
159
|
+
parents=[common],
|
|
160
|
+
help="validate a token and store it in ~/.config/figma/token",
|
|
161
|
+
)
|
|
162
|
+
p.add_argument(
|
|
163
|
+
"--token",
|
|
164
|
+
help=(
|
|
165
|
+
"token value, or '-' to read stdin; a literal value shows in ps and"
|
|
166
|
+
" shell history, so prefer piped stdin, which is read without this flag"
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
p.add_argument(
|
|
170
|
+
"--browser",
|
|
171
|
+
action=argparse.BooleanOptionalAction,
|
|
172
|
+
help="open Figma settings when prompting interactively (default: open)",
|
|
173
|
+
)
|
|
174
|
+
p.set_defaults(handler=_auth_login, needs_client=False)
|
|
175
|
+
|
|
176
|
+
file = commands.add_parser("file", help="file inspection")
|
|
177
|
+
file_cmds = file.add_subparsers(dest="action", metavar="<action>", required=True)
|
|
178
|
+
p = file_cmds.add_parser("get", parents=[common], help="fetch the node tree")
|
|
179
|
+
p.add_argument("file_key")
|
|
180
|
+
p.add_argument("--depth", type=_positive_int, help="server-side tree depth")
|
|
181
|
+
p.set_defaults(handler=_file_get)
|
|
182
|
+
|
|
183
|
+
p = commands.add_parser("export", parents=[common], help="render nodes to files")
|
|
184
|
+
p.add_argument("file_key")
|
|
185
|
+
p.add_argument(
|
|
186
|
+
"--nodes", required=True, type=_node_list, help="comma-separated node ids"
|
|
187
|
+
)
|
|
188
|
+
p.add_argument("--format", choices=("png", "svg"), default="png")
|
|
189
|
+
p.add_argument("--output", default=".", help="destination directory")
|
|
190
|
+
p.set_defaults(handler=_export)
|
|
191
|
+
|
|
192
|
+
comment = commands.add_parser("comment", help="file comments")
|
|
193
|
+
comment_cmds = comment.add_subparsers(
|
|
194
|
+
dest="action", metavar="<action>", required=True
|
|
195
|
+
)
|
|
196
|
+
p = comment_cmds.add_parser("list", parents=[common], help="list comments")
|
|
197
|
+
p.add_argument("file_key")
|
|
198
|
+
p.set_defaults(handler=_comment_list)
|
|
199
|
+
p = comment_cmds.add_parser("post", parents=[common], help="post a comment")
|
|
200
|
+
p.add_argument("file_key")
|
|
201
|
+
p.add_argument("--message", required=True)
|
|
202
|
+
p.add_argument("--comment-id", help="reply to this comment thread")
|
|
203
|
+
p.add_argument("--node-id", help="anchor the comment to this node")
|
|
204
|
+
p.set_defaults(handler=_comment_post)
|
|
205
|
+
p = comment_cmds.add_parser("delete", parents=[common], help="delete a comment")
|
|
206
|
+
p.add_argument("file_key")
|
|
207
|
+
p.add_argument("comment_id")
|
|
208
|
+
p.set_defaults(handler=_comment_delete)
|
|
209
|
+
return parser
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _describe(error: dict[str, Any]) -> str:
|
|
213
|
+
text = error["error"]
|
|
214
|
+
if "status" in error:
|
|
215
|
+
text += f" (HTTP {error['status']})"
|
|
216
|
+
if error.get("message"):
|
|
217
|
+
text += f": {error['message']}"
|
|
218
|
+
if error.get("retry_after") is not None:
|
|
219
|
+
text += f", retry after {error['retry_after']}s"
|
|
220
|
+
return text
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
224
|
+
parser = build_parser()
|
|
225
|
+
args = parser.parse_args(argv)
|
|
226
|
+
handler: Handler | None = getattr(args, "handler", None)
|
|
227
|
+
if handler is None:
|
|
228
|
+
parser.print_help()
|
|
229
|
+
return 0
|
|
230
|
+
try:
|
|
231
|
+
if getattr(args, "needs_client", True):
|
|
232
|
+
payload, text = handler(FigmaClient.from_env(), args)
|
|
233
|
+
else:
|
|
234
|
+
payload, text = handler(args)
|
|
235
|
+
except FigmaError as err:
|
|
236
|
+
if args.json:
|
|
237
|
+
_print_json(err.payload)
|
|
238
|
+
else:
|
|
239
|
+
print(f"{parser.prog}: error: {_describe(err.payload)}", file=sys.stderr)
|
|
240
|
+
return err.exit_code
|
|
241
|
+
if args.json:
|
|
242
|
+
_print_json(payload)
|
|
243
|
+
else:
|
|
244
|
+
print(text)
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
raise SystemExit(main())
|