spectackl 0.0.1__py3-none-any.whl

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.
spectackl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Spectackl CLI — public REST API client for AI agents and operators."""
2
+
3
+ __version__ = "0.0.1"
spectackl/auth.py ADDED
@@ -0,0 +1,74 @@
1
+ """API key resolution for the Spectackl CLI.
2
+
3
+ Resolution order (highest to lowest priority):
4
+ 1. --api-key CLI flag
5
+ 2. SPECTACKL_API_KEY environment variable
6
+ 3. api_key field in .spectackl/settings.yaml (repo root, walked up from CWD)
7
+ 4. api_key field in ~/.config/spectackl/settings.yaml
8
+ 5. Error if none found (exit code 10)
9
+
10
+ Any of the file-based values may be a ${VAR} reference resolved from the named
11
+ scalars in ~/.config/spectackl/settings.yaml (see spectackl.config.load_config).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import typer
17
+
18
+ from spectackl import exit_codes
19
+ from spectackl.config import SpectacklConfig
20
+
21
+
22
+ def resolve_api_key(config: SpectacklConfig) -> str:
23
+ """Return the API key or exit with code 10.
24
+
25
+ Raises:
26
+ typer.Exit: with code 10 (AUTH_REQUIRED) if no API key is found.
27
+ """
28
+ if config.api_key:
29
+ return config.api_key
30
+
31
+ typer.echo(
32
+ "Error: API key not found.\n"
33
+ " Run 'spectackl init' to create the config files, or\n"
34
+ " set --api-key, the SPECTACKL_API_KEY env var,\n"
35
+ " or api_key in .spectackl/settings.yaml (repo root) / ~/.config/spectackl/settings.yaml.\n"
36
+ " api_key may be a ${VAR} reference resolved from ~/.config/spectackl/settings.yaml.",
37
+ err=True,
38
+ )
39
+ raise typer.Exit(code=exit_codes.AUTH_REQUIRED)
40
+
41
+
42
+ def resolve_project_id(config: SpectacklConfig) -> str:
43
+ """Return the project ID or exit with code 2 (usage error).
44
+
45
+ Resolution order is already applied by load_config:
46
+ --project flag > SPECTACKL_PROJECT_ID env > .spectackl/settings.yaml > global config.
47
+
48
+ Raises:
49
+ typer.Exit: with code 2 (USAGE_ERROR) if no project_id is found.
50
+ """
51
+ if config.project_id:
52
+ return config.project_id
53
+
54
+ typer.echo(
55
+ "Error: project_id not found.\n"
56
+ " Run 'spectackl init --project <key>', or\n"
57
+ " set --project, the SPECTACKL_PROJECT_ID env var,\n"
58
+ " or add project_id (or project_key) to .spectackl/settings.yaml in the repo root.",
59
+ err=True,
60
+ )
61
+ raise typer.Exit(code=exit_codes.USAGE_ERROR)
62
+
63
+
64
+ def validate_api_key_format(api_key: str) -> bool:
65
+ """Return True if the key matches the spc_<64hex> format.
66
+
67
+ Does not contact the server. Used for local pre-flight validation only.
68
+ """
69
+ if not api_key.startswith("spc_"):
70
+ return False
71
+ suffix = api_key[4:]
72
+ if len(suffix) != 64:
73
+ return False
74
+ return all(c in "0123456789abcdefABCDEF" for c in suffix)
spectackl/cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """Spectackl CLI entry point.
2
+
3
+ The Spectackl CLI provides commands for AI agents and operators to interact
4
+ with the Spectackl Public REST API.
5
+
6
+ Usage:
7
+ spectackl init # create the config files, first thing on a new machine
8
+ spectackl assets sync # pull the project's agents and skills into ./.claude/
9
+ spectackl --help
10
+ spectackl search "SSO login" # hybrid content search over the project
11
+ spectackl artifact --help
12
+ spectackl context --help
13
+ spectackl task --help
14
+
15
+ Global flags (available on all commands):
16
+ --api-key TEXT Spectackl API key (spc_<64hex>)
17
+ --base-url TEXT API base URL (default: http://localhost:8080/api/v1)
18
+ --json / --no-json Output raw JSON (recommended for scripts and agents)
19
+ --no-color Disable color output
20
+ -v / -vv Increase verbosity
21
+
22
+ Authentication priority:
23
+ 1. --api-key flag
24
+ 2. SPECTACKL_API_KEY environment variable
25
+ 3. api_key in ~/.config/spectackl/settings.yaml
26
+
27
+ Exit codes:
28
+ 0 Success
29
+ 1 Generic error
30
+ 2 Usage error (bad flag, missing argument)
31
+ 10 Authentication required or invalid (HTTP 401)
32
+ 11 Authorization denied (HTTP 403)
33
+ 20 Validation error (HTTP 400)
34
+ 30 Not found (HTTP 404)
35
+ 31 Conflict (HTTP 409)
36
+ 40 Upstream unavailable or timeout (HTTP 502/504)
37
+ 50 Internal facade error (HTTP 5xx)
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import Annotated
43
+
44
+ import typer
45
+
46
+ from spectackl import __version__
47
+ from spectackl.commands import artifact, assets, context, daemon, init, relation
48
+ from spectackl.commands import rsync as rsync_cmd
49
+ from spectackl.commands import search as search_cmd
50
+ from spectackl.commands import task
51
+
52
+ # Root typer app
53
+ app = typer.Typer(
54
+ name="spectackl",
55
+ help=(
56
+ "Spectackl CLI — interact with the Spectackl Public REST API.\n\n"
57
+ "Designed for AI agents and operators. Use --json for machine-readable output."
58
+ ),
59
+ no_args_is_help=True,
60
+ add_completion=True,
61
+ )
62
+
63
+ # Register subcommand groups
64
+ app.add_typer(init.app, name="init")
65
+ app.add_typer(artifact.app, name="artifact")
66
+ app.add_typer(context.app, name="context")
67
+ app.add_typer(assets.app, name="assets")
68
+ app.add_typer(relation.app, name="relation")
69
+ app.add_typer(task.app, name="task")
70
+ app.add_typer(daemon.app, name="daemon")
71
+
72
+ # Top-level content search command (mirrors GET /api/v1/search)
73
+ app.command(name="search")(search_cmd.search)
74
+
75
+ # Bi-directional file synchronization. Distinct from `assets sync`, which pulls
76
+ # agent/skill definitions one way.
77
+ app.command(name="rsync")(rsync_cmd.rsync)
78
+
79
+ # Deprecated: `spectackl sync` was renamed to `spectackl assets sync`. The same
80
+ # function is registered twice so the two entry points cannot drift; it is hidden
81
+ # from --help and warns when invoked, and will be removed in a later release.
82
+ app.command(
83
+ name=assets.DEPRECATED_ALIAS,
84
+ hidden=True,
85
+ help="Deprecated alias for `spectackl assets sync`.",
86
+ )(assets.sync)
87
+
88
+
89
+ def _version_callback(value: bool) -> None:
90
+ if value:
91
+ typer.echo(f"spectackl {__version__}")
92
+ raise typer.Exit()
93
+
94
+
95
+ @app.callback()
96
+ def main(
97
+ version: Annotated[
98
+ bool | None,
99
+ typer.Option(
100
+ "--version",
101
+ "-V",
102
+ help="Show version and exit.",
103
+ callback=_version_callback,
104
+ is_eager=True,
105
+ ),
106
+ ] = None,
107
+ ) -> None:
108
+ """Spectackl CLI — AI agent and operator interface to the Spectackl platform."""
@@ -0,0 +1 @@
1
+ # spectackl.commands package
@@ -0,0 +1,335 @@
1
+ """Artifact commands for the Spectackl CLI.
2
+
3
+ Commands:
4
+ spectackl artifact get <key> [--project UUID] [--include-content]
5
+ spectackl artifact list [--project UUID] [--kind KIND...] [--limit N] [--page-token T]
6
+ spectackl artifact create [--project UUID] --kind KIND --title TITLE [--content TEXT|@file]
7
+ spectackl artifact update <key> [--project UUID] [--title TITLE] [--content TEXT|@file]
8
+
9
+ project_id resolution order:
10
+ --project flag > SPECTACKL_PROJECT_ID env > project_id in .spectackl/settings.yaml > global config
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from typing import Annotated
18
+
19
+ import httpx
20
+ import typer
21
+
22
+ from spectackl import exit_codes
23
+ from spectackl.auth import resolve_api_key, resolve_project_id
24
+ from spectackl.config import load_config
25
+ from spectackl.http_client import handle_response, make_client
26
+ from spectackl.output import render, render_error
27
+
28
+ app = typer.Typer(
29
+ name="artifact",
30
+ help="Manage Spectackl artifacts (requirements, designs, implementations, etc.).",
31
+ no_args_is_help=True,
32
+ )
33
+
34
+
35
+ def _resolve_at_file(value: str | None) -> str | None:
36
+ """If value starts with '@', read the file at the given path and return its contents."""
37
+ if value is None:
38
+ return None
39
+ if value.startswith("@"):
40
+ path = value[1:]
41
+ try:
42
+ with open(path) as f:
43
+ return f.read()
44
+ except OSError as e:
45
+ typer.echo(f"Error: Cannot read file '{path}': {e}", err=True)
46
+ raise typer.Exit(code=exit_codes.USAGE_ERROR)
47
+ return value
48
+
49
+
50
+ def content_sha256(text: str) -> str:
51
+ """SHA-256 hex digest of content, matching the server's checksum.
52
+
53
+ The backend computes the checksum over the exact UTF-8 bytes of the content
54
+ you upload (before any server-side frontmatter processing) and returns it as
55
+ ``artifact.content_checksum``. Hashing identically here lets ``update`` skip
56
+ re-uploading content that has not changed.
57
+ """
58
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
59
+
60
+
61
+ def _fetch_content_checksum(client: httpx.Client, key: str, project_id: str | None) -> str | None:
62
+ """Return the artifact's current content_checksum, or None if unavailable.
63
+
64
+ Best-effort: any error (network, not-found, missing field) yields None, which
65
+ makes the caller fall through to a normal upload rather than fail.
66
+ """
67
+ try:
68
+ resp = client.get(f"/artifacts/{key}", params={"project_id": project_id})
69
+ except httpx.HTTPError:
70
+ return None
71
+ if resp.status_code != 200:
72
+ return None
73
+ try:
74
+ data = resp.json()
75
+ except ValueError:
76
+ return None
77
+ if not isinstance(data, dict):
78
+ return None
79
+ # GET /artifacts/{key} wraps the object as {"artifact": {...}}; tolerate an
80
+ # unwrapped object too.
81
+ artifact = data.get("artifact", data)
82
+ if not isinstance(artifact, dict):
83
+ return None
84
+ return artifact.get("content_checksum") or None
85
+
86
+
87
+ @app.command("get")
88
+ def artifact_get(
89
+ key: Annotated[str, typer.Argument(help="SPC33 artifact key (e.g. REQ-7).")],
90
+ project: Annotated[str | None, typer.Option("--project", "-p", help="Project UUID. Falls back to SPECTACKL_PROJECT_ID or project_id in .spectackl/settings.yaml.")] = None,
91
+ include_content: Annotated[
92
+ bool,
93
+ typer.Option("--include-content/--no-include-content", help="Include artifact content text."),
94
+ ] = False,
95
+ api_key: Annotated[str | None, typer.Option("--api-key", help="Spectackl API key.", envvar="SPECTACKL_API_KEY")] = None,
96
+ base_url: Annotated[str | None, typer.Option("--base-url", help="API base URL.", envvar="SPECTACKL_BASE_URL")] = None,
97
+ json_output: Annotated[bool, typer.Option("--json/--no-json", help="Output raw JSON (for scripts/agents).")] = False,
98
+ no_color: Annotated[bool, typer.Option("--no-color", help="Disable color output.")] = False,
99
+ verbose: Annotated[int, typer.Option("--verbose", "-v", count=True, help="Increase verbosity.")] = 0,
100
+ ) -> None:
101
+ """Get a single artifact by its key.
102
+
103
+ Example:
104
+ spectackl artifact get REQ-7
105
+ spectackl artifact get REQ-7 --include-content --json
106
+ """
107
+ config = load_config(api_key_flag=api_key, base_url_flag=base_url, project_id_flag=project)
108
+ resolved_key = resolve_api_key(config)
109
+ project_id = resolve_project_id(config)
110
+
111
+ params: dict = {"project_id": project_id}
112
+ if include_content:
113
+ params["include_content"] = "true"
114
+
115
+ with make_client(resolved_key, config.base_url) as client:
116
+ try:
117
+ response = client.get(f"/artifacts/{key}", params=params)
118
+ except httpx.ConnectError as e:
119
+ render_error(f"Cannot connect to {config.base_url}: {e}", json_mode=json_output, no_color=no_color)
120
+ raise typer.Exit(code=exit_codes.UPSTREAM_UNAVAILABLE)
121
+ except httpx.TimeoutException as e:
122
+ render_error(f"Request timed out: {e}", json_mode=json_output, no_color=no_color)
123
+ raise typer.Exit(code=exit_codes.UPSTREAM_UNAVAILABLE)
124
+
125
+ data = handle_response(response, json_mode=json_output, no_color=no_color)
126
+ render(data, json_mode=json_output, no_color=no_color)
127
+
128
+
129
+ @app.command("list")
130
+ def artifact_list(
131
+ project: Annotated[str | None, typer.Option("--project", "-p", help="Project UUID. Falls back to SPECTACKL_PROJECT_ID or project_id in .spectackl/settings.yaml.")] = None,
132
+ kind: Annotated[
133
+ list[str] | None,
134
+ typer.Option("--kind", help="Filter by artifact kind (may be repeated)."),
135
+ ] = None,
136
+ limit: Annotated[int, typer.Option("--limit", help="Maximum results (1-200, default 50).")] = 50,
137
+ page_token: Annotated[str | None, typer.Option("--page-token", help="Pagination cursor.")] = None,
138
+ api_key: Annotated[str | None, typer.Option("--api-key", help="Spectackl API key.", envvar="SPECTACKL_API_KEY")] = None,
139
+ base_url: Annotated[str | None, typer.Option("--base-url", help="API base URL.", envvar="SPECTACKL_BASE_URL")] = None,
140
+ json_output: Annotated[bool, typer.Option("--json/--no-json", help="Output raw JSON (for scripts/agents).")] = False,
141
+ no_color: Annotated[bool, typer.Option("--no-color", help="Disable color output.")] = False,
142
+ verbose: Annotated[int, typer.Option("--verbose", "-v", count=True, help="Increase verbosity.")] = 0,
143
+ ) -> None:
144
+ """List artifacts in a project.
145
+
146
+ Example:
147
+ spectackl artifact list
148
+ spectackl artifact list --kind requirement --kind design --json
149
+ """
150
+ config = load_config(api_key_flag=api_key, base_url_flag=base_url, project_id_flag=project)
151
+ resolved_key = resolve_api_key(config)
152
+ project_id = resolve_project_id(config)
153
+
154
+ params: dict = {"project_id": project_id, "limit": str(limit)}
155
+ if kind:
156
+ params["kind"] = kind
157
+ if page_token:
158
+ params["page_token"] = page_token
159
+
160
+ with make_client(resolved_key, config.base_url) as client:
161
+ try:
162
+ response = client.get("/artifacts", params=params)
163
+ except httpx.ConnectError as e:
164
+ render_error(f"Cannot connect to {config.base_url}: {e}", json_mode=json_output, no_color=no_color)
165
+ raise typer.Exit(code=exit_codes.UPSTREAM_UNAVAILABLE)
166
+ except httpx.TimeoutException as e:
167
+ render_error(f"Request timed out: {e}", json_mode=json_output, no_color=no_color)
168
+ raise typer.Exit(code=exit_codes.UPSTREAM_UNAVAILABLE)
169
+
170
+ data = handle_response(response, json_mode=json_output, no_color=no_color)
171
+ render(data, json_mode=json_output, no_color=no_color)
172
+
173
+
174
+ @app.command("create")
175
+ def artifact_create(
176
+ kind: Annotated[str, typer.Option("--kind", "-k", help="Artifact kind code (required).")],
177
+ title: Annotated[str, typer.Option("--title", "-t", help="Artifact title (required).")],
178
+ key: Annotated[str | None, typer.Option("--key", help="Explicit artifact key (e.g. my-note). Auto-derived from title if omitted.")] = None,
179
+ parent: Annotated[str | None, typer.Option("--parent", help="Parent artifact KEY. Binds parent_id so this artifact nests under the parent as a structural partial in the tree.")] = None,
180
+ context: Annotated[str | None, typer.Option("--context", help="Scene (work context) KEY. Sets the new artifact's home scene (work_context_id) so the Theatre scene view shows it. Distinct from --parent (tree nesting).")] = None,
181
+ project: Annotated[str | None, typer.Option("--project", "-p", help="Project UUID. Falls back to SPECTACKL_PROJECT_ID or project_id in .spectackl/settings.yaml.")] = None,
182
+ content: Annotated[
183
+ str | None,
184
+ typer.Option("--content", help="Artifact content. Prefix with @ to read from file (e.g. @content.md)."),
185
+ ] = None,
186
+ language: Annotated[str | None, typer.Option("--language", "-l", help="Content language tag (e.g. en, cs). Defaults to default_language from settings.yaml.")] = None,
187
+ api_key: Annotated[str | None, typer.Option("--api-key", help="Spectackl API key.", envvar="SPECTACKL_API_KEY")] = None,
188
+ base_url: Annotated[str | None, typer.Option("--base-url", help="API base URL.", envvar="SPECTACKL_BASE_URL")] = None,
189
+ json_output: Annotated[bool, typer.Option("--json/--no-json", help="Output raw JSON (for scripts/agents).")] = False,
190
+ no_color: Annotated[bool, typer.Option("--no-color", help="Disable color output.")] = False,
191
+ verbose: Annotated[int, typer.Option("--verbose", "-v", count=True, help="Increase verbosity.")] = 0,
192
+ ) -> None:
193
+ """Create a new artifact.
194
+
195
+ Use --context <scene> to also add the new artifact to a scene in one call (so the
196
+ scene renders it) — the correct way to create an artifact "under a scene". This is
197
+ distinct from --parent, which only nests it in the tree via parent_id.
198
+
199
+ Example:
200
+ spectackl artifact create --kind requirement --title "User login via SSO"
201
+ spectackl artifact create --kind requirement --title "Spec" --content @spec.md
202
+ spectackl artifact create --kind IDEA --title "Bulk import" --context checkout-flow --json
203
+ """
204
+ config = load_config(
205
+ api_key_flag=api_key, base_url_flag=base_url, project_id_flag=project,
206
+ language_flag=language,
207
+ )
208
+ resolved_key = resolve_api_key(config)
209
+ project_id = resolve_project_id(config)
210
+
211
+ resolved_content = _resolve_at_file(content)
212
+
213
+ # Always sent, so the stored row records the language explicitly rather than
214
+ # inheriting whatever the server happens to default to.
215
+ body: dict = {
216
+ "project_id": project_id,
217
+ "kind": kind,
218
+ "title": title,
219
+ "language": config.default_language,
220
+ }
221
+ if key:
222
+ body["key"] = key
223
+ if parent:
224
+ body["parent"] = parent
225
+ if context:
226
+ body["context"] = context
227
+ if resolved_content is not None:
228
+ body["content"] = resolved_content
229
+
230
+ with make_client(resolved_key, config.base_url) as client:
231
+ try:
232
+ response = client.post("/artifacts", content=json.dumps(body))
233
+ except httpx.ConnectError as e:
234
+ render_error(f"Cannot connect to {config.base_url}: {e}", json_mode=json_output, no_color=no_color)
235
+ raise typer.Exit(code=exit_codes.UPSTREAM_UNAVAILABLE)
236
+ except httpx.TimeoutException as e:
237
+ render_error(f"Request timed out: {e}", json_mode=json_output, no_color=no_color)
238
+ raise typer.Exit(code=exit_codes.UPSTREAM_UNAVAILABLE)
239
+
240
+ data = handle_response(response, json_mode=json_output, no_color=no_color)
241
+ render(data, json_mode=json_output, no_color=no_color)
242
+
243
+
244
+ @app.command("update")
245
+ def artifact_update(
246
+ key: Annotated[str, typer.Argument(help="SPC33 artifact key (e.g. REQ-7).")],
247
+ project: Annotated[str | None, typer.Option("--project", "-p", help="Project UUID. Falls back to SPECTACKL_PROJECT_ID or project_id in .spectackl/settings.yaml.")] = None,
248
+ title: Annotated[str | None, typer.Option("--title", "-t", help="New artifact title.")] = None,
249
+ content: Annotated[
250
+ str | None,
251
+ typer.Option("--content", help="New content. Prefix with @ to read from file."),
252
+ ] = None,
253
+ language: Annotated[str | None, typer.Option("--language", "-l", help="Content language tag (e.g. en, cs). Defaults to default_language from settings.yaml.")] = None,
254
+
255
+ force: Annotated[
256
+ bool,
257
+ typer.Option("--force/--no-force", help="Upload content even when it is unchanged (skip the checksum comparison)."),
258
+ ] = False,
259
+ api_key: Annotated[str | None, typer.Option("--api-key", help="Spectackl API key.", envvar="SPECTACKL_API_KEY")] = None,
260
+ base_url: Annotated[str | None, typer.Option("--base-url", help="API base URL.", envvar="SPECTACKL_BASE_URL")] = None,
261
+ json_output: Annotated[bool, typer.Option("--json/--no-json", help="Output raw JSON (for scripts/agents).")] = False,
262
+ no_color: Annotated[bool, typer.Option("--no-color", help="Disable color output.")] = False,
263
+ verbose: Annotated[int, typer.Option("--verbose", "-v", count=True, help="Increase verbosity.")] = 0,
264
+ ) -> None:
265
+ """Update an existing artifact (partial update — only provided fields are changed).
266
+
267
+ Unchanged content is skipped automatically: the local content's SHA-256 is
268
+ compared against the artifact's server-side ``content_checksum`` and, when they
269
+ match, the content is not re-uploaded (no new version is created). Use --force
270
+ to upload regardless.
271
+
272
+ Example:
273
+ spectackl artifact update REQ-7 --title "Updated title"
274
+ spectackl artifact update REQ-7 --content @updated-spec.md
275
+ """
276
+ config = load_config(
277
+ api_key_flag=api_key, base_url_flag=base_url, project_id_flag=project,
278
+ language_flag=language,
279
+ )
280
+ resolved_key = resolve_api_key(config)
281
+ project_id = resolve_project_id(config)
282
+
283
+ resolved_content = _resolve_at_file(content)
284
+
285
+ body: dict = {}
286
+ # Only meaningful alongside new content — an update without content creates no
287
+ # version for the language to apply to.
288
+ if resolved_content is not None:
289
+ body["language"] = config.default_language
290
+ if title is not None:
291
+ body["title"] = title
292
+ if resolved_content is not None:
293
+ body["content"] = resolved_content
294
+
295
+ if not body:
296
+ typer.echo("Error: At least one of --title or --content must be provided.", err=True)
297
+ raise typer.Exit(code=exit_codes.USAGE_ERROR)
298
+
299
+ params = {"project_id": project_id}
300
+
301
+ with make_client(resolved_key, config.base_url) as client:
302
+ # Skip re-uploading unchanged content: compare a local SHA-256 of the
303
+ # content against the artifact's server-computed content_checksum. On any
304
+ # doubt (checksum unavailable, --force) we fall through and upload.
305
+ if resolved_content is not None and not force:
306
+ local_sum = content_sha256(resolved_content)
307
+ current_sum = _fetch_content_checksum(client, key, project_id)
308
+ if current_sum and current_sum == local_sum:
309
+ body.pop("content", None)
310
+ # Language describes the content version, so it goes with it. Left
311
+ # behind, it would make `body` non-empty and defeat the skip —
312
+ # turning a no-op re-run into a pointless new version.
313
+ body.pop("language", None)
314
+ if verbose:
315
+ typer.echo(f"content unchanged (sha256 {local_sum[:12]}…) — skipping content upload", err=True)
316
+ if not body:
317
+ # Nothing left to update — don't touch the artifact at all.
318
+ render(
319
+ {"key": key, "skipped": True, "reason": "content unchanged"},
320
+ json_mode=json_output,
321
+ no_color=no_color,
322
+ )
323
+ return
324
+
325
+ try:
326
+ response = client.patch(f"/artifacts/{key}", content=json.dumps(body), params=params)
327
+ except httpx.ConnectError as e:
328
+ render_error(f"Cannot connect to {config.base_url}: {e}", json_mode=json_output, no_color=no_color)
329
+ raise typer.Exit(code=exit_codes.UPSTREAM_UNAVAILABLE)
330
+ except httpx.TimeoutException as e:
331
+ render_error(f"Request timed out: {e}", json_mode=json_output, no_color=no_color)
332
+ raise typer.Exit(code=exit_codes.UPSTREAM_UNAVAILABLE)
333
+
334
+ data = handle_response(response, json_mode=json_output, no_color=no_color)
335
+ render(data, json_mode=json_output, no_color=no_color)