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 +3 -0
- spectackl/auth.py +74 -0
- spectackl/cli.py +108 -0
- spectackl/commands/__init__.py +1 -0
- spectackl/commands/artifact.py +335 -0
- spectackl/commands/assets.py +260 -0
- spectackl/commands/context.py +283 -0
- spectackl/commands/daemon.py +687 -0
- spectackl/commands/init.py +251 -0
- spectackl/commands/relation.py +146 -0
- spectackl/commands/rsync.py +821 -0
- spectackl/commands/search.py +69 -0
- spectackl/commands/task.py +263 -0
- spectackl/config.py +369 -0
- spectackl/exit_codes.py +68 -0
- spectackl/http_client.py +95 -0
- spectackl/output.py +181 -0
- spectackl-0.0.1.dist-info/METADATA +92 -0
- spectackl-0.0.1.dist-info/RECORD +22 -0
- spectackl-0.0.1.dist-info/WHEEL +5 -0
- spectackl-0.0.1.dist-info/entry_points.txt +2 -0
- spectackl-0.0.1.dist-info/top_level.txt +1 -0
spectackl/__init__.py
ADDED
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)
|