figma-cli 0.2.0__tar.gz → 0.3.1__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.
@@ -30,14 +30,19 @@ jobs:
30
30
  run: pytest tests/
31
31
 
32
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).
33
+ # Runs against the real Figma API when the FIGMA_TOKEN (and, for OAuth, the
34
+ # FIGMA_OAUTH_REFRESH_TOKEN, FIGMA_CLIENT_ID and FIGMA_CLIENT_SECRET) secrets are
35
+ # configured; the tests skip cleanly when they are absent (forks, unconfigured).
35
36
  runs-on: ubuntu-latest
36
37
  needs: test
37
38
  env:
38
39
  FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
39
40
  FIGMA_TEST_FILE_KEY: ${{ vars.FIGMA_TEST_FILE_KEY }}
40
41
  FIGMA_TEST_NODE_ID: ${{ vars.FIGMA_TEST_NODE_ID }}
42
+ # A dedicated test OAuth app: each refresh invalidates its last access token.
43
+ FIGMA_OAUTH_REFRESH_TOKEN: ${{ secrets.FIGMA_OAUTH_REFRESH_TOKEN }}
44
+ FIGMA_CLIENT_ID: ${{ secrets.FIGMA_CLIENT_ID }}
45
+ FIGMA_CLIENT_SECRET: ${{ secrets.FIGMA_CLIENT_SECRET }}
41
46
  steps:
42
47
  - uses: actions/checkout@v5
43
48
  with:
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.5
2
+ Name: figma-cli
3
+ Version: 0.3.1
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 either a personal access token or an OAuth token from your own Figma OAuth app (see [OAuth login](#oauth-login)). Create a personal access token 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
+ `auth login` takes this personal access token path whenever `--token` is given, stdin is piped, or no OAuth client credentials are configured. Saving a personal access token also removes `~/.config/figma/token.json`, so stale OAuth tokens never shadow it.
72
+
73
+ Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored files whenever it is set and non-empty. A value starting `figu_` (an OAuth access token) is sent as `Authorization: Bearer`, anything else as `X-Figma-Token`:
74
+
75
+ ```sh
76
+ export FIGMA_TOKEN=figd_...
77
+ export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
78
+ ```
79
+
80
+ Credentials resolve in this order: `FIGMA_TOKEN`, then `~/.config/figma/token.json` (OAuth), then `~/.config/figma/token` (personal access token). With none of them, commands fail with `{"error": "missing_token"}` and exit code 3.
81
+
82
+ ### OAuth login
83
+
84
+ figma-cli ships no OAuth app of its own: Figma authenticates the client with its secret on every token call, and a secret embedded in an open-source package is not a secret. Bring your own:
85
+
86
+ 1. Create an OAuth app in the [Figma developer console](https://www.figma.com/developers/apps) and register the redirect URL `http://127.0.0.1:54321/callback`. Grant it the four scopes above.
87
+ 2. Export its credentials and log in from an interactive terminal:
88
+
89
+ ```sh
90
+ export FIGMA_CLIENT_ID=...
91
+ export FIGMA_CLIENT_SECRET=...
92
+ figma auth login # opens the browser, waits on 127.0.0.1:54321
93
+ figma auth login --port 8765 # if you registered http://127.0.0.1:8765/callback instead
94
+ ```
95
+
96
+ `--client-id` and `--client-secret` override the variables, but a literal secret shows in process listings and shell history. The flow uses PKCE (S256) and a random `state`; a callback with the wrong `state` is answered with HTTP 400 and ignored. Because Figma matches redirect URLs exactly, a busy port is not swapped for another one: the command exits 2 with `{"error": "port_unavailable"}`. Add `--no-browser` to print the authorization URL without opening it.
97
+
98
+ Over SSH, in a container, or anywhere the browser runs on another machine, its redirect to `127.0.0.1` never reaches figma-cli. While it waits, the command reads the terminal: after authorizing, copy the full URL from the browser's address bar (the page itself fails to load) and paste it at the `Paste the callback URL here` prompt. A `code=...&state=...` query string works too. The paste is held to the same checks as the browser redirect: the path must be `/callback` on the configured port and `state` must match, so a bare code without `state` is refused. A rejected paste is explained and the prompt returns while the loopback server keeps listening.
99
+
100
+ The access token is checked against `GET /v1/me`, then the access token, refresh token, expiry, client id and client secret are written to `~/.config/figma/token.json` (mode `0600`, directory `0700`, atomic replace). Figma access tokens last 90 days. When the stored one has expired, the next command refreshes it through `POST /v1/oauth/refresh` before running. The refresh holds an advisory lock on `~/.config/figma/token.json.lock` and re-reads the file once it has the lock, so concurrent commands refresh once rather than invalidating each other's tokens (Windows has no `fcntl`, so there the refresh runs unlocked). A refresh Figma rejects exits 3 with `{"error": "refresh_failed"}` rather than falling back to an older personal access token; run `figma auth login` again.
101
+
102
+ Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
103
+
104
+ | Command | Figma endpoint |
105
+ | --- | --- |
106
+ | `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
107
+ | `figma auth login [--client-id ID] [--client-secret SECRET] [--port PORT]` | OAuth: `POST /v1/oauth/token`, `GET /v1/me`, then writes `~/.config/figma/token.json` |
108
+ | `figma auth check` | `GET /v1/me` |
109
+ | `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
110
+ | `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
111
+ | `figma comment list <file_key>` | `GET /v1/files/{file_key}/comments` |
112
+ | `figma comment post <file_key> --message TEXT [--comment-id ID] [--node-id ID]` | `POST /v1/files/{file_key}/comments` |
113
+ | `figma comment delete <file_key> <comment_id>` | `DELETE /v1/files/{file_key}/comments/{comment_id}` |
114
+
115
+ 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`.
116
+
117
+ ### Examples
118
+
119
+ Check the token:
120
+
121
+ ```console
122
+ $ figma auth check --json
123
+ {
124
+ "id": "123456789",
125
+ "handle": "Design Bot",
126
+ "email": "bot@example.com"
127
+ }
128
+ ```
129
+
130
+ Inspect only the pages of a file. `--depth` is passed to Figma, so the server trims the tree before it is sent:
131
+
132
+ ```sh
133
+ figma file get AbCdEf123 --depth 1
134
+ figma file get AbCdEf123 --depth 2 --json | jq '.document.children[].name'
135
+ ```
136
+
137
+ 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:
138
+
139
+ ```console
140
+ $ figma export AbCdEf123 --nodes 1:2,1:3 --output shots
141
+ shots/AbCdEf123_1-2.png
142
+ shots/AbCdEf123_1-3.png
143
+ ```
144
+
145
+ Post a comment pinned to a frame, reply to it, list the thread, then clean up:
146
+
147
+ ```sh
148
+ id=$(figma comment post AbCdEf123 --message "Spacing is off" --node-id 1:2 --json | jq -r .id)
149
+ figma comment post AbCdEf123 --message "Fixed in the next build" --comment-id "$id"
150
+ figma comment list AbCdEf123
151
+ figma comment delete AbCdEf123 "$id"
152
+ ```
153
+
154
+ ### Errors and exit codes
155
+
156
+ | Exit code | Meaning |
157
+ | --- | --- |
158
+ | 0 | Success |
159
+ | 2 | Usage error (bad or missing arguments) |
160
+ | 3 | Figma API, network, or local write failure |
161
+
162
+ 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`:
163
+
164
+ ```json
165
+ {"error": "forbidden", "status": 403, "message": "Invalid token"}
166
+ ```
167
+
168
+ 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:
169
+
170
+ ```json
171
+ {"error": "rate_limit_exceeded", "status": 429, "retry_after": 30}
172
+ ```
173
+
174
+ ### Token safety
175
+
176
+ A request carrying a credential (`X-Figma-Token`, `Authorization: Bearer`, or the `Authorization: Basic` client credentials on the OAuth token calls) follows no redirects. Any 3xx answer is refused with `{"error": "redirect_refused"}` and exit code 3 before a second request is made, so the credential 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.
177
+
178
+ ## Installation and quick run
179
+
180
+ Run on-demand without installing via [uv](https://docs.astral.sh/uv/):
181
+
182
+ ```sh
183
+ uvx figma-cli --help
184
+ uvx figma-cli auth check --json
185
+ ```
186
+
187
+ Or install from [PyPI](https://pypi.org/project/figma-cli/):
188
+
189
+ ```sh
190
+ pip install figma-cli
191
+ ```
192
+
193
+ From a local checkout:
194
+
195
+ ```sh
196
+ uvx --from . figma-cli --version
197
+ ```
198
+
199
+ ## Development
200
+
201
+ ```sh
202
+ python -m venv .venv && . .venv/bin/activate
203
+ pip install -e ".[dev]"
204
+ ruff check .
205
+ ruff format --check .
206
+ pytest tests/
207
+ ```
208
+
209
+ 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`.
210
+
211
+ `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.
212
+
213
+ ## Release
214
+
215
+ 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).
216
+
217
+ ## License
218
+
219
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,194 @@
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 either a personal access token or an OAuth token from your own Figma OAuth app (see [OAuth login](#oauth-login)). Create a personal access token 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
+ `auth login` takes this personal access token path whenever `--token` is given, stdin is piped, or no OAuth client credentials are configured. Saving a personal access token also removes `~/.config/figma/token.json`, so stale OAuth tokens never shadow it.
47
+
48
+ Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored files whenever it is set and non-empty. A value starting `figu_` (an OAuth access token) is sent as `Authorization: Bearer`, anything else as `X-Figma-Token`:
49
+
50
+ ```sh
51
+ export FIGMA_TOKEN=figd_...
52
+ export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
53
+ ```
54
+
55
+ Credentials resolve in this order: `FIGMA_TOKEN`, then `~/.config/figma/token.json` (OAuth), then `~/.config/figma/token` (personal access token). With none of them, commands fail with `{"error": "missing_token"}` and exit code 3.
56
+
57
+ ### OAuth login
58
+
59
+ figma-cli ships no OAuth app of its own: Figma authenticates the client with its secret on every token call, and a secret embedded in an open-source package is not a secret. Bring your own:
60
+
61
+ 1. Create an OAuth app in the [Figma developer console](https://www.figma.com/developers/apps) and register the redirect URL `http://127.0.0.1:54321/callback`. Grant it the four scopes above.
62
+ 2. Export its credentials and log in from an interactive terminal:
63
+
64
+ ```sh
65
+ export FIGMA_CLIENT_ID=...
66
+ export FIGMA_CLIENT_SECRET=...
67
+ figma auth login # opens the browser, waits on 127.0.0.1:54321
68
+ figma auth login --port 8765 # if you registered http://127.0.0.1:8765/callback instead
69
+ ```
70
+
71
+ `--client-id` and `--client-secret` override the variables, but a literal secret shows in process listings and shell history. The flow uses PKCE (S256) and a random `state`; a callback with the wrong `state` is answered with HTTP 400 and ignored. Because Figma matches redirect URLs exactly, a busy port is not swapped for another one: the command exits 2 with `{"error": "port_unavailable"}`. Add `--no-browser` to print the authorization URL without opening it.
72
+
73
+ Over SSH, in a container, or anywhere the browser runs on another machine, its redirect to `127.0.0.1` never reaches figma-cli. While it waits, the command reads the terminal: after authorizing, copy the full URL from the browser's address bar (the page itself fails to load) and paste it at the `Paste the callback URL here` prompt. A `code=...&state=...` query string works too. The paste is held to the same checks as the browser redirect: the path must be `/callback` on the configured port and `state` must match, so a bare code without `state` is refused. A rejected paste is explained and the prompt returns while the loopback server keeps listening.
74
+
75
+ The access token is checked against `GET /v1/me`, then the access token, refresh token, expiry, client id and client secret are written to `~/.config/figma/token.json` (mode `0600`, directory `0700`, atomic replace). Figma access tokens last 90 days. When the stored one has expired, the next command refreshes it through `POST /v1/oauth/refresh` before running. The refresh holds an advisory lock on `~/.config/figma/token.json.lock` and re-reads the file once it has the lock, so concurrent commands refresh once rather than invalidating each other's tokens (Windows has no `fcntl`, so there the refresh runs unlocked). A refresh Figma rejects exits 3 with `{"error": "refresh_failed"}` rather than falling back to an older personal access token; run `figma auth login` again.
76
+
77
+ Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
78
+
79
+ | Command | Figma endpoint |
80
+ | --- | --- |
81
+ | `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
82
+ | `figma auth login [--client-id ID] [--client-secret SECRET] [--port PORT]` | OAuth: `POST /v1/oauth/token`, `GET /v1/me`, then writes `~/.config/figma/token.json` |
83
+ | `figma auth check` | `GET /v1/me` |
84
+ | `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
85
+ | `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
86
+ | `figma comment list <file_key>` | `GET /v1/files/{file_key}/comments` |
87
+ | `figma comment post <file_key> --message TEXT [--comment-id ID] [--node-id ID]` | `POST /v1/files/{file_key}/comments` |
88
+ | `figma comment delete <file_key> <comment_id>` | `DELETE /v1/files/{file_key}/comments/{comment_id}` |
89
+
90
+ 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`.
91
+
92
+ ### Examples
93
+
94
+ Check the token:
95
+
96
+ ```console
97
+ $ figma auth check --json
98
+ {
99
+ "id": "123456789",
100
+ "handle": "Design Bot",
101
+ "email": "bot@example.com"
102
+ }
103
+ ```
104
+
105
+ Inspect only the pages of a file. `--depth` is passed to Figma, so the server trims the tree before it is sent:
106
+
107
+ ```sh
108
+ figma file get AbCdEf123 --depth 1
109
+ figma file get AbCdEf123 --depth 2 --json | jq '.document.children[].name'
110
+ ```
111
+
112
+ 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:
113
+
114
+ ```console
115
+ $ figma export AbCdEf123 --nodes 1:2,1:3 --output shots
116
+ shots/AbCdEf123_1-2.png
117
+ shots/AbCdEf123_1-3.png
118
+ ```
119
+
120
+ Post a comment pinned to a frame, reply to it, list the thread, then clean up:
121
+
122
+ ```sh
123
+ id=$(figma comment post AbCdEf123 --message "Spacing is off" --node-id 1:2 --json | jq -r .id)
124
+ figma comment post AbCdEf123 --message "Fixed in the next build" --comment-id "$id"
125
+ figma comment list AbCdEf123
126
+ figma comment delete AbCdEf123 "$id"
127
+ ```
128
+
129
+ ### Errors and exit codes
130
+
131
+ | Exit code | Meaning |
132
+ | --- | --- |
133
+ | 0 | Success |
134
+ | 2 | Usage error (bad or missing arguments) |
135
+ | 3 | Figma API, network, or local write failure |
136
+
137
+ 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`:
138
+
139
+ ```json
140
+ {"error": "forbidden", "status": 403, "message": "Invalid token"}
141
+ ```
142
+
143
+ 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:
144
+
145
+ ```json
146
+ {"error": "rate_limit_exceeded", "status": 429, "retry_after": 30}
147
+ ```
148
+
149
+ ### Token safety
150
+
151
+ A request carrying a credential (`X-Figma-Token`, `Authorization: Bearer`, or the `Authorization: Basic` client credentials on the OAuth token calls) follows no redirects. Any 3xx answer is refused with `{"error": "redirect_refused"}` and exit code 3 before a second request is made, so the credential 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.
152
+
153
+ ## Installation and quick run
154
+
155
+ Run on-demand without installing via [uv](https://docs.astral.sh/uv/):
156
+
157
+ ```sh
158
+ uvx figma-cli --help
159
+ uvx figma-cli auth check --json
160
+ ```
161
+
162
+ Or install from [PyPI](https://pypi.org/project/figma-cli/):
163
+
164
+ ```sh
165
+ pip install figma-cli
166
+ ```
167
+
168
+ From a local checkout:
169
+
170
+ ```sh
171
+ uvx --from . figma-cli --version
172
+ ```
173
+
174
+ ## Development
175
+
176
+ ```sh
177
+ python -m venv .venv && . .venv/bin/activate
178
+ pip install -e ".[dev]"
179
+ ruff check .
180
+ ruff format --check .
181
+ pytest tests/
182
+ ```
183
+
184
+ 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`.
185
+
186
+ `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.
187
+
188
+ ## Release
189
+
190
+ 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).
191
+
192
+ ## License
193
+
194
+ MIT, see [LICENSE](LICENSE).
@@ -1,3 +1,3 @@
1
1
  """Headless Figma CLI for AI coding agents and automated design inspection."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
@@ -13,16 +13,18 @@ from typing import Any
13
13
 
14
14
  from figma_cli import __version__
15
15
  from figma_cli.client import FigmaClient, FigmaError, download
16
+ from figma_cli.login import login
17
+ from figma_cli.oauth import DEFAULT_PORT
16
18
 
17
- Handler = Callable[[FigmaClient, argparse.Namespace], tuple[Any, str]]
19
+ # Most handlers take (client, args); those marked needs_client=False take (args).
20
+ Handler = Callable[..., tuple[Any, str]]
18
21
 
19
22
 
20
23
  def _print_json(payload: Any) -> None:
21
24
  print(json.dumps(payload, indent=2, ensure_ascii=False))
22
25
 
23
26
 
24
- def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
25
- me = client.me()
27
+ def _identity(me: dict[str, Any]) -> tuple[dict[str, Any], str]:
26
28
  ident = {key: me.get(key) for key in ("id", "handle", "email")}
27
29
  return (
28
30
  ident,
@@ -30,6 +32,16 @@ def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str
30
32
  )
31
33
 
32
34
 
35
+ def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
36
+ return _identity(client.me())
37
+
38
+
39
+ def _auth_login(args: argparse.Namespace) -> tuple[Any, str]:
40
+ me, path = login(args)
41
+ ident, text = _identity(me)
42
+ return {**ident, "token_path": str(path)}, f"{text}\nToken saved to {path}"
43
+
44
+
33
45
  def _tree_lines(node: dict[str, Any], indent: int = 0) -> list[str]:
34
46
  label = f"{node.get('type', '?')} {node.get('name', '')} ({node.get('id', '')})"
35
47
  lines = [" " * indent + label]
@@ -112,6 +124,13 @@ def _positive_int(value: str) -> int:
112
124
  return number
113
125
 
114
126
 
127
+ def _port(value: str) -> int:
128
+ number = int(value)
129
+ if not 1 <= number <= 65535:
130
+ raise argparse.ArgumentTypeError("must be between 1 and 65535")
131
+ return number
132
+
133
+
115
134
  def _node_list(value: str) -> list[str]:
116
135
  nodes = [n.strip() for n in value.split(",") if n.strip()]
117
136
  if not nodes:
@@ -125,7 +144,11 @@ def build_parser() -> argparse.ArgumentParser:
125
144
  description=(
126
145
  "Headless Figma CLI for AI coding agents and automated design inspection."
127
146
  ),
128
- epilog="Environment: FIGMA_TOKEN (required), FIGMA_API_BASE (optional).",
147
+ epilog=(
148
+ "Environment: FIGMA_TOKEN (optional when 'figma auth login' has stored"
149
+ " ~/.config/figma/token.json or ~/.config/figma/token), FIGMA_API_BASE"
150
+ " (optional), FIGMA_CLIENT_ID and FIGMA_CLIENT_SECRET (OAuth login)."
151
+ ),
129
152
  )
130
153
  parser.add_argument(
131
154
  "--version", action="version", version=f"%(prog)s {__version__}"
@@ -134,10 +157,52 @@ def build_parser() -> argparse.ArgumentParser:
134
157
  common.add_argument("--json", action="store_true", help="print structured JSON")
135
158
  commands = parser.add_subparsers(dest="command", metavar="<command>")
136
159
 
137
- auth = commands.add_parser("auth", help="token checks")
160
+ auth = commands.add_parser("auth", help="token setup and checks")
138
161
  auth_cmds = auth.add_subparsers(dest="action", metavar="<action>", required=True)
139
- p = auth_cmds.add_parser("check", parents=[common], help="validate FIGMA_TOKEN")
162
+ p = auth_cmds.add_parser(
163
+ "check", parents=[common], help="validate FIGMA_TOKEN or stored token file"
164
+ )
140
165
  p.set_defaults(handler=_auth_check)
166
+ p = auth_cmds.add_parser(
167
+ "login",
168
+ parents=[common],
169
+ help=(
170
+ "authorize via OAuth (with client credentials) or store a personal"
171
+ " access token, after validating it"
172
+ ),
173
+ )
174
+ p.add_argument(
175
+ "--token",
176
+ help=(
177
+ "token value, or '-' to read stdin; a literal value shows in ps and"
178
+ " shell history, so prefer piped stdin, which is read without this flag"
179
+ ),
180
+ )
181
+ p.add_argument(
182
+ "--browser",
183
+ action=argparse.BooleanOptionalAction,
184
+ help=("open Figma settings or the OAuth page when interactive (default: open)"),
185
+ )
186
+ p.add_argument(
187
+ "--client-id", help="OAuth app client id (default: $FIGMA_CLIENT_ID)"
188
+ )
189
+ p.add_argument(
190
+ "--client-secret",
191
+ help=(
192
+ "OAuth app client secret (default: $FIGMA_CLIENT_SECRET); a literal"
193
+ " value shows in ps and shell history, so prefer the variable"
194
+ ),
195
+ )
196
+ p.add_argument(
197
+ "--port",
198
+ type=_port,
199
+ default=DEFAULT_PORT,
200
+ help=(
201
+ "loopback port for the OAuth redirect http://127.0.0.1:PORT/callback"
202
+ f" (default: {DEFAULT_PORT})"
203
+ ),
204
+ )
205
+ p.set_defaults(handler=_auth_login, needs_client=False)
141
206
 
142
207
  file = commands.add_parser("file", help="file inspection")
143
208
  file_cmds = file.add_subparsers(dest="action", metavar="<action>", required=True)
@@ -194,7 +259,10 @@ def main(argv: Sequence[str] | None = None) -> int:
194
259
  parser.print_help()
195
260
  return 0
196
261
  try:
197
- payload, text = handler(FigmaClient.from_env(), args)
262
+ if getattr(args, "needs_client", True):
263
+ payload, text = handler(FigmaClient.from_env(), args)
264
+ else:
265
+ payload, text = handler(args)
198
266
  except FigmaError as err:
199
267
  if args.json:
200
268
  _print_json(err.payload)