figma-cli 0.1.0__tar.gz → 0.2.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,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
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.5
2
+ Name: figma-cli
3
+ Version: 0.2.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 and export it:
53
+
54
+ ```sh
55
+ export FIGMA_TOKEN=figd_...
56
+ export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
57
+ ```
58
+
59
+ Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
60
+
61
+ | Command | Figma endpoint |
62
+ | --- | --- |
63
+ | `figma auth check` | `GET /v1/me` |
64
+ | `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
65
+ | `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
66
+ | `figma comment list <file_key>` | `GET /v1/files/{file_key}/comments` |
67
+ | `figma comment post <file_key> --message TEXT [--comment-id ID] [--node-id ID]` | `POST /v1/files/{file_key}/comments` |
68
+ | `figma comment delete <file_key> <comment_id>` | `DELETE /v1/files/{file_key}/comments/{comment_id}` |
69
+
70
+ 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`.
71
+
72
+ ### Examples
73
+
74
+ Check the token:
75
+
76
+ ```console
77
+ $ figma auth check --json
78
+ {
79
+ "id": "123456789",
80
+ "handle": "Design Bot",
81
+ "email": "bot@example.com"
82
+ }
83
+ ```
84
+
85
+ Inspect only the pages of a file. `--depth` is passed to Figma, so the server trims the tree before it is sent:
86
+
87
+ ```sh
88
+ figma file get AbCdEf123 --depth 1
89
+ figma file get AbCdEf123 --depth 2 --json | jq '.document.children[].name'
90
+ ```
91
+
92
+ 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:
93
+
94
+ ```console
95
+ $ figma export AbCdEf123 --nodes 1:2,1:3 --output shots
96
+ shots/AbCdEf123_1-2.png
97
+ shots/AbCdEf123_1-3.png
98
+ ```
99
+
100
+ Post a comment pinned to a frame, reply to it, list the thread, then clean up:
101
+
102
+ ```sh
103
+ id=$(figma comment post AbCdEf123 --message "Spacing is off" --node-id 1:2 --json | jq -r .id)
104
+ figma comment post AbCdEf123 --message "Fixed in the next build" --comment-id "$id"
105
+ figma comment list AbCdEf123
106
+ figma comment delete AbCdEf123 "$id"
107
+ ```
108
+
109
+ ### Errors and exit codes
110
+
111
+ | Exit code | Meaning |
112
+ | --- | --- |
113
+ | 0 | Success |
114
+ | 2 | Usage error (bad or missing arguments) |
115
+ | 3 | Figma API, network, or local write failure |
116
+
117
+ 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`:
118
+
119
+ ```json
120
+ {"error": "forbidden", "status": 403, "message": "Invalid token"}
121
+ ```
122
+
123
+ 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:
124
+
125
+ ```json
126
+ {"error": "rate_limit_exceeded", "status": 429, "retry_after": 30}
127
+ ```
128
+
129
+ ### Token safety
130
+
131
+ 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.
132
+
133
+ ## Installation and quick run
134
+
135
+ Run on-demand without installing via [uv](https://docs.astral.sh/uv/):
136
+
137
+ ```sh
138
+ uvx figma-cli --help
139
+ uvx figma-cli auth check --json
140
+ ```
141
+
142
+ Or install from [PyPI](https://pypi.org/project/figma-cli/):
143
+
144
+ ```sh
145
+ pip install figma-cli
146
+ ```
147
+
148
+ From a local checkout:
149
+
150
+ ```sh
151
+ uvx --from . figma-cli --version
152
+ ```
153
+
154
+ ## Development
155
+
156
+ ```sh
157
+ python -m venv .venv && . .venv/bin/activate
158
+ pip install -e ".[dev]"
159
+ ruff check .
160
+ ruff format --check .
161
+ pytest tests/
162
+ ```
163
+
164
+ The tests are hermetic: no network access and no Figma token are needed. The redirect tests use real sockets on `127.0.0.1`.
165
+
166
+ `tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` and `FIGMA_TEST_FILE_KEY` are set (add `FIGMA_TEST_NODE_ID` to exercise export). It 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.
167
+
168
+ ## Release
169
+
170
+ 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).
171
+
172
+ ## License
173
+
174
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,149 @@
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 and export it:
28
+
29
+ ```sh
30
+ export FIGMA_TOKEN=figd_...
31
+ export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
32
+ ```
33
+
34
+ Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
35
+
36
+ | Command | Figma endpoint |
37
+ | --- | --- |
38
+ | `figma auth check` | `GET /v1/me` |
39
+ | `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
40
+ | `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
41
+ | `figma comment list <file_key>` | `GET /v1/files/{file_key}/comments` |
42
+ | `figma comment post <file_key> --message TEXT [--comment-id ID] [--node-id ID]` | `POST /v1/files/{file_key}/comments` |
43
+ | `figma comment delete <file_key> <comment_id>` | `DELETE /v1/files/{file_key}/comments/{comment_id}` |
44
+
45
+ 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`.
46
+
47
+ ### Examples
48
+
49
+ Check the token:
50
+
51
+ ```console
52
+ $ figma auth check --json
53
+ {
54
+ "id": "123456789",
55
+ "handle": "Design Bot",
56
+ "email": "bot@example.com"
57
+ }
58
+ ```
59
+
60
+ Inspect only the pages of a file. `--depth` is passed to Figma, so the server trims the tree before it is sent:
61
+
62
+ ```sh
63
+ figma file get AbCdEf123 --depth 1
64
+ figma file get AbCdEf123 --depth 2 --json | jq '.document.children[].name'
65
+ ```
66
+
67
+ 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:
68
+
69
+ ```console
70
+ $ figma export AbCdEf123 --nodes 1:2,1:3 --output shots
71
+ shots/AbCdEf123_1-2.png
72
+ shots/AbCdEf123_1-3.png
73
+ ```
74
+
75
+ Post a comment pinned to a frame, reply to it, list the thread, then clean up:
76
+
77
+ ```sh
78
+ id=$(figma comment post AbCdEf123 --message "Spacing is off" --node-id 1:2 --json | jq -r .id)
79
+ figma comment post AbCdEf123 --message "Fixed in the next build" --comment-id "$id"
80
+ figma comment list AbCdEf123
81
+ figma comment delete AbCdEf123 "$id"
82
+ ```
83
+
84
+ ### Errors and exit codes
85
+
86
+ | Exit code | Meaning |
87
+ | --- | --- |
88
+ | 0 | Success |
89
+ | 2 | Usage error (bad or missing arguments) |
90
+ | 3 | Figma API, network, or local write failure |
91
+
92
+ 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`:
93
+
94
+ ```json
95
+ {"error": "forbidden", "status": 403, "message": "Invalid token"}
96
+ ```
97
+
98
+ 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:
99
+
100
+ ```json
101
+ {"error": "rate_limit_exceeded", "status": 429, "retry_after": 30}
102
+ ```
103
+
104
+ ### Token safety
105
+
106
+ 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.
107
+
108
+ ## Installation and quick run
109
+
110
+ Run on-demand without installing via [uv](https://docs.astral.sh/uv/):
111
+
112
+ ```sh
113
+ uvx figma-cli --help
114
+ uvx figma-cli auth check --json
115
+ ```
116
+
117
+ Or install from [PyPI](https://pypi.org/project/figma-cli/):
118
+
119
+ ```sh
120
+ pip install figma-cli
121
+ ```
122
+
123
+ From a local checkout:
124
+
125
+ ```sh
126
+ uvx --from . figma-cli --version
127
+ ```
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ python -m venv .venv && . .venv/bin/activate
133
+ pip install -e ".[dev]"
134
+ ruff check .
135
+ ruff format --check .
136
+ pytest tests/
137
+ ```
138
+
139
+ The tests are hermetic: no network access and no Figma token are needed. The redirect tests use real sockets on `127.0.0.1`.
140
+
141
+ `tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` and `FIGMA_TEST_FILE_KEY` are set (add `FIGMA_TEST_NODE_ID` to exercise export). It 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.
142
+
143
+ ## Release
144
+
145
+ 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).
146
+
147
+ ## License
148
+
149
+ 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.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,212 @@
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
+
17
+ Handler = Callable[[FigmaClient, argparse.Namespace], tuple[Any, str]]
18
+
19
+
20
+ def _print_json(payload: Any) -> None:
21
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
22
+
23
+
24
+ def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
25
+ me = client.me()
26
+ ident = {key: me.get(key) for key in ("id", "handle", "email")}
27
+ return (
28
+ ident,
29
+ f"Authenticated as {ident['handle']} <{ident['email']}> ({ident['id']})",
30
+ )
31
+
32
+
33
+ def _tree_lines(node: dict[str, Any], indent: int = 0) -> list[str]:
34
+ label = f"{node.get('type', '?')} {node.get('name', '')} ({node.get('id', '')})"
35
+ lines = [" " * indent + label]
36
+ for child in node.get("children") or []:
37
+ lines.extend(_tree_lines(child, indent + 1))
38
+ return lines
39
+
40
+
41
+ def _file_get(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
42
+ data = client.get_file(args.file_key, args.depth)
43
+ header = f"{data.get('name', '')} (last modified {data.get('lastModified', '?')})"
44
+ tree = _tree_lines(data["document"]) if data.get("document") else []
45
+ return data, "\n".join([header, *tree])
46
+
47
+
48
+ def _safe_name(node_id: str) -> str:
49
+ return re.sub(r"[^A-Za-z0-9_.-]", "-", node_id)
50
+
51
+
52
+ def _export(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
53
+ node_ids = args.nodes
54
+ images = client.get_images(args.file_key, node_ids, args.format)
55
+ missing = [n for n in node_ids if not images.get(n)]
56
+ if missing:
57
+ raise FigmaError(
58
+ {
59
+ "error": "render_failed",
60
+ "message": "no image URL returned",
61
+ "nodes": missing,
62
+ }
63
+ )
64
+ out_dir = Path(args.output)
65
+ files = []
66
+ for node_id in node_ids:
67
+ payload = download(images[node_id])
68
+ path = (
69
+ out_dir / f"{_safe_name(args.file_key)}_{_safe_name(node_id)}.{args.format}"
70
+ )
71
+ try:
72
+ out_dir.mkdir(parents=True, exist_ok=True)
73
+ path.write_bytes(payload)
74
+ except OSError as err:
75
+ raise FigmaError({"error": "write_failed", "message": str(err)}) from None
76
+ files.append({"node_id": node_id, "path": str(path), "bytes": len(payload)})
77
+ return {"files": files}, "\n".join(f["path"] for f in files)
78
+
79
+
80
+ def _comment_line(c: dict[str, Any]) -> str:
81
+ user = (c.get("user") or {}).get("handle", "?")
82
+ reply = f" (reply to {c['parent_id']})" if c.get("parent_id") else ""
83
+ head = f"{c.get('id')} {c.get('created_at', '')} {user}{reply}"
84
+ return f"{head}: {c.get('message', '')}"
85
+
86
+
87
+ def _comment_list(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
88
+ data = client.list_comments(args.file_key)
89
+ lines = [_comment_line(c) for c in data.get("comments") or []]
90
+ return data, "\n".join(lines) or "No comments."
91
+
92
+
93
+ def _comment_post(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
94
+ data = client.post_comment(
95
+ args.file_key, args.message, comment_id=args.comment_id, node_id=args.node_id
96
+ )
97
+ return data, f"Posted comment {data.get('id')}"
98
+
99
+
100
+ def _comment_delete(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
101
+ client.delete_comment(args.file_key, args.comment_id)
102
+ return {
103
+ "deleted": True,
104
+ "id": args.comment_id,
105
+ }, f"Deleted comment {args.comment_id}"
106
+
107
+
108
+ def _positive_int(value: str) -> int:
109
+ number = int(value)
110
+ if number < 1:
111
+ raise argparse.ArgumentTypeError("must be a positive integer")
112
+ return number
113
+
114
+
115
+ def _node_list(value: str) -> list[str]:
116
+ nodes = [n.strip() for n in value.split(",") if n.strip()]
117
+ if not nodes:
118
+ raise argparse.ArgumentTypeError("expected one or more node ids")
119
+ return nodes
120
+
121
+
122
+ def build_parser() -> argparse.ArgumentParser:
123
+ # prog is left unset so it follows sys.argv[0]: each alias reports its own name.
124
+ parser = argparse.ArgumentParser(
125
+ description=(
126
+ "Headless Figma CLI for AI coding agents and automated design inspection."
127
+ ),
128
+ epilog="Environment: FIGMA_TOKEN (required), FIGMA_API_BASE (optional).",
129
+ )
130
+ parser.add_argument(
131
+ "--version", action="version", version=f"%(prog)s {__version__}"
132
+ )
133
+ common = argparse.ArgumentParser(add_help=False)
134
+ common.add_argument("--json", action="store_true", help="print structured JSON")
135
+ commands = parser.add_subparsers(dest="command", metavar="<command>")
136
+
137
+ auth = commands.add_parser("auth", help="token checks")
138
+ auth_cmds = auth.add_subparsers(dest="action", metavar="<action>", required=True)
139
+ p = auth_cmds.add_parser("check", parents=[common], help="validate FIGMA_TOKEN")
140
+ p.set_defaults(handler=_auth_check)
141
+
142
+ file = commands.add_parser("file", help="file inspection")
143
+ file_cmds = file.add_subparsers(dest="action", metavar="<action>", required=True)
144
+ p = file_cmds.add_parser("get", parents=[common], help="fetch the node tree")
145
+ p.add_argument("file_key")
146
+ p.add_argument("--depth", type=_positive_int, help="server-side tree depth")
147
+ p.set_defaults(handler=_file_get)
148
+
149
+ p = commands.add_parser("export", parents=[common], help="render nodes to files")
150
+ p.add_argument("file_key")
151
+ p.add_argument(
152
+ "--nodes", required=True, type=_node_list, help="comma-separated node ids"
153
+ )
154
+ p.add_argument("--format", choices=("png", "svg"), default="png")
155
+ p.add_argument("--output", default=".", help="destination directory")
156
+ p.set_defaults(handler=_export)
157
+
158
+ comment = commands.add_parser("comment", help="file comments")
159
+ comment_cmds = comment.add_subparsers(
160
+ dest="action", metavar="<action>", required=True
161
+ )
162
+ p = comment_cmds.add_parser("list", parents=[common], help="list comments")
163
+ p.add_argument("file_key")
164
+ p.set_defaults(handler=_comment_list)
165
+ p = comment_cmds.add_parser("post", parents=[common], help="post a comment")
166
+ p.add_argument("file_key")
167
+ p.add_argument("--message", required=True)
168
+ p.add_argument("--comment-id", help="reply to this comment thread")
169
+ p.add_argument("--node-id", help="anchor the comment to this node")
170
+ p.set_defaults(handler=_comment_post)
171
+ p = comment_cmds.add_parser("delete", parents=[common], help="delete a comment")
172
+ p.add_argument("file_key")
173
+ p.add_argument("comment_id")
174
+ p.set_defaults(handler=_comment_delete)
175
+ return parser
176
+
177
+
178
+ def _describe(error: dict[str, Any]) -> str:
179
+ text = error["error"]
180
+ if "status" in error:
181
+ text += f" (HTTP {error['status']})"
182
+ if error.get("message"):
183
+ text += f": {error['message']}"
184
+ if error.get("retry_after") is not None:
185
+ text += f", retry after {error['retry_after']}s"
186
+ return text
187
+
188
+
189
+ def main(argv: Sequence[str] | None = None) -> int:
190
+ parser = build_parser()
191
+ args = parser.parse_args(argv)
192
+ handler: Handler | None = getattr(args, "handler", None)
193
+ if handler is None:
194
+ parser.print_help()
195
+ return 0
196
+ try:
197
+ payload, text = handler(FigmaClient.from_env(), args)
198
+ except FigmaError as err:
199
+ if args.json:
200
+ _print_json(err.payload)
201
+ else:
202
+ print(f"{parser.prog}: error: {_describe(err.payload)}", file=sys.stderr)
203
+ return err.exit_code
204
+ if args.json:
205
+ _print_json(payload)
206
+ else:
207
+ print(text)
208
+ return 0
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())