current-cli 0.1.0__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.
- current_cli/__init__.py +1 -0
- current_cli/build.py +86 -0
- current_cli/config.py +54 -0
- current_cli/docs.py +83 -0
- current_cli/http.py +72 -0
- current_cli/main.py +159 -0
- current_cli/schema.py +116 -0
- current_cli/schema.yml +3457 -0
- current_cli-0.1.0.dist-info/METADATA +61 -0
- current_cli-0.1.0.dist-info/RECORD +12 -0
- current_cli-0.1.0.dist-info/WHEEL +4 -0
- current_cli-0.1.0.dist-info/entry_points.txt +2 -0
current_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command line interface for the Current API, generated from its OpenAPI schema."""
|
current_cli/build.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Turn OpenAPI operations into a nested tree of click commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from . import config, http
|
|
8
|
+
from .schema import Operation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _make_callback(op: Operation):
|
|
12
|
+
def callback(**kwargs):
|
|
13
|
+
data = kwargs.pop("data", None)
|
|
14
|
+
|
|
15
|
+
path = op.path
|
|
16
|
+
for param in op.path_params:
|
|
17
|
+
path = path.replace("{" + param.name + "}", str(kwargs.pop(param.name)))
|
|
18
|
+
|
|
19
|
+
params: dict[str, str] = {}
|
|
20
|
+
for param in op.query_params:
|
|
21
|
+
value = kwargs.pop(param.name, None)
|
|
22
|
+
if value is None and param.name == "workspace":
|
|
23
|
+
value = config.get("default_workspace")
|
|
24
|
+
if value is None:
|
|
25
|
+
if param.required:
|
|
26
|
+
hint = (
|
|
27
|
+
" (or set a default: current config set default_workspace <uuid>)"
|
|
28
|
+
if param.name == "workspace"
|
|
29
|
+
else ""
|
|
30
|
+
)
|
|
31
|
+
raise click.UsageError(f"Missing required option --{param.name}{hint}.")
|
|
32
|
+
continue
|
|
33
|
+
params[param.name] = value
|
|
34
|
+
|
|
35
|
+
json_body = http.parse_data_argument(data) if data is not None else None
|
|
36
|
+
response = http.request(op.method, path, params=params, json_body=json_body)
|
|
37
|
+
http.render_response(response)
|
|
38
|
+
|
|
39
|
+
return callback
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _make_command(op: Operation) -> click.Command:
|
|
43
|
+
params: list[click.Parameter] = [click.Argument([param.name]) for param in op.path_params]
|
|
44
|
+
for param in op.query_params:
|
|
45
|
+
# Required query params are enforced in the callback so that
|
|
46
|
+
# `workspace` can fall back to the configured default.
|
|
47
|
+
params.append(
|
|
48
|
+
click.Option(
|
|
49
|
+
[f"--{param.name.replace('_', '-')}"],
|
|
50
|
+
required=False,
|
|
51
|
+
help=param.description or None,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
if op.has_body:
|
|
55
|
+
params.append(
|
|
56
|
+
click.Option(
|
|
57
|
+
["--data"],
|
|
58
|
+
required=op.method in ("post", "put", "patch"),
|
|
59
|
+
help="Request body as JSON: inline, @file.json, or - for stdin.",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
help_text = f"{op.description}\n\n{op.method.upper()} {op.path}".strip()
|
|
64
|
+
return click.Command(
|
|
65
|
+
name=op.command,
|
|
66
|
+
params=params,
|
|
67
|
+
callback=_make_callback(op),
|
|
68
|
+
help=help_text,
|
|
69
|
+
short_help=op.description.split("\n")[0] if op.description else None,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def add_operations(root: click.Group, operations: list[Operation]) -> None:
|
|
74
|
+
for op in operations:
|
|
75
|
+
group = root
|
|
76
|
+
for segment in op.group_path:
|
|
77
|
+
existing = group.commands.get(segment)
|
|
78
|
+
if existing is None:
|
|
79
|
+
existing = click.Group(name=segment, help=f"Operations under /{segment}/.")
|
|
80
|
+
group.add_command(existing)
|
|
81
|
+
elif not isinstance(existing, click.Group):
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
f"Command tree conflict: {segment} is both a group and a command"
|
|
84
|
+
)
|
|
85
|
+
group = existing
|
|
86
|
+
group.add_command(_make_command(op))
|
current_cli/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Persistent CLI configuration (auth token, base URL, default workspace)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
10
|
+
KNOWN_KEYS = ("base_url", "token", "email", "default_workspace")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def config_dir() -> Path:
|
|
14
|
+
override = os.environ.get("CURRENT_CLI_CONFIG_DIR")
|
|
15
|
+
if override:
|
|
16
|
+
return Path(override)
|
|
17
|
+
return Path.home() / ".config" / "current-cli"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path() -> Path:
|
|
21
|
+
return config_dir() / "config.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load() -> dict:
|
|
25
|
+
path = config_path()
|
|
26
|
+
if not path.exists():
|
|
27
|
+
return {}
|
|
28
|
+
return json.loads(path.read_text("utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save(data: dict) -> None:
|
|
32
|
+
path = config_path()
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
path.write_text(json.dumps(data, indent=2) + "\n", "utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get(key: str) -> str | None:
|
|
38
|
+
return load().get(key)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_value(key: str, value: str) -> None:
|
|
42
|
+
data = load()
|
|
43
|
+
data[key] = value
|
|
44
|
+
save(data)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def unset(key: str) -> None:
|
|
48
|
+
data = load()
|
|
49
|
+
data.pop(key, None)
|
|
50
|
+
save(data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def base_url() -> str:
|
|
54
|
+
return os.environ.get("CURRENT_API_URL") or get("base_url") or DEFAULT_BASE_URL
|
current_cli/docs.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Render the full command tree as markdown, for LLMs and humans.
|
|
2
|
+
|
|
3
|
+
The committed cli/llms.txt is generated from this module; a test fails if it
|
|
4
|
+
is stale. Regenerate with: uv run python -m current_cli.docs > llms.txt
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
PREAMBLE = """\
|
|
14
|
+
# Current CLI reference
|
|
15
|
+
|
|
16
|
+
`current` is the command line interface for the Current API. Commands are
|
|
17
|
+
generated from the API's OpenAPI schema, so every API operation is available.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install current-cli
|
|
23
|
+
current config set base_url https://your-api.example.com # default: http://localhost:8000
|
|
24
|
+
current login you@example.com # magic-link sign in, stores the token
|
|
25
|
+
current config set default_workspace <workspace-uuid> # used when --workspace is omitted
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The auth token is stored in `~/.config/current-cli/config.json` and sent as
|
|
29
|
+
`Authorization: Token <token>`. `CURRENT_API_URL` overrides the base URL.
|
|
30
|
+
|
|
31
|
+
## Conventions
|
|
32
|
+
|
|
33
|
+
- Path parameters are positional arguments, shown below as `<name>`.
|
|
34
|
+
- Query parameters are `--options`.
|
|
35
|
+
- Request bodies are JSON, passed with `--data`: inline (`--data '{"name": "x"}'`),
|
|
36
|
+
from a file (`--data @body.json`), or from stdin (`--data -`).
|
|
37
|
+
- Responses print as JSON on stdout. API errors print to stderr and exit 1.
|
|
38
|
+
- `--schema <path-or-url>` (or `CURRENT_SCHEMA`) rebuilds the commands from a
|
|
39
|
+
different schema, for example `<base-url>/api/schema/` for a live server.
|
|
40
|
+
- Every command supports `--help`.
|
|
41
|
+
|
|
42
|
+
## Commands"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _iter_commands(
|
|
46
|
+
command: click.Command, path: list[str]
|
|
47
|
+
) -> Iterator[tuple[list[str], click.Command]]:
|
|
48
|
+
if isinstance(command, click.Group):
|
|
49
|
+
for name in sorted(command.commands):
|
|
50
|
+
yield from _iter_commands(command.commands[name], [*path, name])
|
|
51
|
+
else:
|
|
52
|
+
yield path, command
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_docs(root: click.Group) -> str:
|
|
56
|
+
sections = [PREAMBLE]
|
|
57
|
+
for path, command in _iter_commands(root, ["current"]):
|
|
58
|
+
arguments = [p for p in command.params if isinstance(p, click.Argument)]
|
|
59
|
+
options = [p for p in command.params if isinstance(p, click.Option) and p.name != "help"]
|
|
60
|
+
|
|
61
|
+
usage = " ".join([*path, *(f"<{a.name}>" for a in arguments)])
|
|
62
|
+
lines = [f"### {usage}"]
|
|
63
|
+
if command.help:
|
|
64
|
+
lines.append("")
|
|
65
|
+
lines.append(command.help.strip())
|
|
66
|
+
if options:
|
|
67
|
+
lines.append("")
|
|
68
|
+
lines.append("Options:")
|
|
69
|
+
for option in options:
|
|
70
|
+
parts = [f"- `{option.opts[0]}`"]
|
|
71
|
+
if option.required:
|
|
72
|
+
parts.append("(required)")
|
|
73
|
+
if option.help:
|
|
74
|
+
parts.append(f"- {option.help}")
|
|
75
|
+
lines.append(" ".join(parts))
|
|
76
|
+
sections.append("\n".join(lines))
|
|
77
|
+
return "\n\n".join(sections) + "\n"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
from .main import build_cli
|
|
82
|
+
|
|
83
|
+
print(render_docs(build_cli()), end="")
|
current_cli/http.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""HTTP layer: authenticated requests and response rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import httpx
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from . import config
|
|
13
|
+
|
|
14
|
+
# Test hook: when set, all requests go through this transport instead of the network.
|
|
15
|
+
TRANSPORT: httpx.BaseTransport | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def request(
|
|
19
|
+
method: str,
|
|
20
|
+
path: str,
|
|
21
|
+
*,
|
|
22
|
+
params: dict | None = None,
|
|
23
|
+
json_body: dict | list | None = None,
|
|
24
|
+
authenticated: bool = True,
|
|
25
|
+
) -> httpx.Response:
|
|
26
|
+
headers = {}
|
|
27
|
+
if authenticated:
|
|
28
|
+
token = config.get("token")
|
|
29
|
+
if token:
|
|
30
|
+
headers["Authorization"] = f"Token {token}"
|
|
31
|
+
with httpx.Client(
|
|
32
|
+
base_url=config.base_url(),
|
|
33
|
+
headers=headers,
|
|
34
|
+
timeout=30,
|
|
35
|
+
transport=TRANSPORT,
|
|
36
|
+
) as client:
|
|
37
|
+
return client.request(method.upper(), path, params=params, json=json_body)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_response(response: httpx.Response) -> None:
|
|
41
|
+
"""Print the response body and exit non-zero on API errors."""
|
|
42
|
+
if response.status_code >= 400:
|
|
43
|
+
click.echo(f"Error: HTTP {response.status_code}", err=True)
|
|
44
|
+
if response.content:
|
|
45
|
+
click.echo(response.text, err=True)
|
|
46
|
+
raise SystemExit(1)
|
|
47
|
+
if not response.content:
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
data = response.json()
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
click.echo(response.text)
|
|
53
|
+
return
|
|
54
|
+
if sys.stdout.isatty():
|
|
55
|
+
Console().print_json(json.dumps(data))
|
|
56
|
+
else:
|
|
57
|
+
click.echo(json.dumps(data, indent=2))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_data_argument(value: str) -> dict | list:
|
|
61
|
+
"""Parse --data as inline JSON, @file, or - for stdin."""
|
|
62
|
+
if value == "-":
|
|
63
|
+
raw = sys.stdin.read()
|
|
64
|
+
elif value.startswith("@"):
|
|
65
|
+
with open(value[1:], encoding="utf-8") as f:
|
|
66
|
+
raw = f.read()
|
|
67
|
+
else:
|
|
68
|
+
raw = value
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(raw)
|
|
71
|
+
except json.JSONDecodeError as exc:
|
|
72
|
+
raise click.UsageError(f"--data is not valid JSON: {exc}") from exc
|
current_cli/main.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""CLI entry point: static auth/config commands plus schema-generated commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import config as config_store
|
|
11
|
+
from . import http
|
|
12
|
+
from .build import add_operations
|
|
13
|
+
from .schema import extract_operations, load_spec
|
|
14
|
+
|
|
15
|
+
MAGIC_LINK_PATH = "/api/accounts/auth/magic-link/"
|
|
16
|
+
VERIFY_PATH = "/api/accounts/auth/verify/"
|
|
17
|
+
LOGOUT_PATH = "/api/accounts/auth/logout/"
|
|
18
|
+
ME_PATH = "/api/accounts/auth/me/"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.command()
|
|
22
|
+
@click.argument("email")
|
|
23
|
+
def login(email: str) -> None:
|
|
24
|
+
"""Sign in with a magic link and store the auth token."""
|
|
25
|
+
response = http.request(
|
|
26
|
+
"post", MAGIC_LINK_PATH, json_body={"email": email}, authenticated=False
|
|
27
|
+
)
|
|
28
|
+
if response.status_code >= 400:
|
|
29
|
+
http.render_response(response)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
magic_token = response.json().get("debug_token")
|
|
33
|
+
if magic_token:
|
|
34
|
+
click.echo("Using debug token from the API (DEBUG mode).")
|
|
35
|
+
else:
|
|
36
|
+
click.echo(f"Magic link sent to {email}.")
|
|
37
|
+
magic_token = click.prompt("Paste the token from the sign-in link")
|
|
38
|
+
|
|
39
|
+
verify = http.request(
|
|
40
|
+
"post", VERIFY_PATH, json_body={"token": magic_token}, authenticated=False
|
|
41
|
+
)
|
|
42
|
+
if verify.status_code >= 400:
|
|
43
|
+
http.render_response(verify)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
payload = verify.json()
|
|
47
|
+
config_store.set_value("token", payload["token"])
|
|
48
|
+
config_store.set_value("email", email)
|
|
49
|
+
click.echo(f"Logged in as {email}.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.command()
|
|
53
|
+
def logout() -> None:
|
|
54
|
+
"""Invalidate the auth token on the server and forget it locally."""
|
|
55
|
+
if config_store.get("token"):
|
|
56
|
+
response = http.request("post", LOGOUT_PATH)
|
|
57
|
+
if response.status_code >= 400:
|
|
58
|
+
click.echo(f"Server logout failed (HTTP {response.status_code}).", err=True)
|
|
59
|
+
config_store.unset("token")
|
|
60
|
+
config_store.unset("email")
|
|
61
|
+
click.echo("Logged out.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@click.command()
|
|
65
|
+
def whoami() -> None:
|
|
66
|
+
"""Show the current user and workspace memberships."""
|
|
67
|
+
http.render_response(http.request("get", ME_PATH))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@click.command()
|
|
71
|
+
def docs() -> None:
|
|
72
|
+
"""Print a markdown reference of every command, for LLMs and humans."""
|
|
73
|
+
from .docs import render_docs
|
|
74
|
+
|
|
75
|
+
root = click.get_current_context().find_root().command
|
|
76
|
+
assert isinstance(root, click.Group)
|
|
77
|
+
click.echo(render_docs(root), nl=False)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@click.group(name="config")
|
|
81
|
+
def config_group() -> None:
|
|
82
|
+
"""Read and write CLI configuration."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@config_group.command(name="list")
|
|
86
|
+
def config_list() -> None:
|
|
87
|
+
"""Show all configuration values."""
|
|
88
|
+
data = config_store.load()
|
|
89
|
+
if "token" in data:
|
|
90
|
+
data["token"] = data["token"][:6] + "..."
|
|
91
|
+
for key, value in sorted(data.items()):
|
|
92
|
+
click.echo(f"{key} = {value}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@config_group.command(name="get")
|
|
96
|
+
@click.argument("key", type=click.Choice(config_store.KNOWN_KEYS))
|
|
97
|
+
def config_get(key: str) -> None:
|
|
98
|
+
"""Print one configuration value."""
|
|
99
|
+
value = config_store.get(key)
|
|
100
|
+
if value is None:
|
|
101
|
+
raise SystemExit(1)
|
|
102
|
+
click.echo(value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@config_group.command(name="set")
|
|
106
|
+
@click.argument("key", type=click.Choice(config_store.KNOWN_KEYS))
|
|
107
|
+
@click.argument("value")
|
|
108
|
+
def config_set(key: str, value: str) -> None:
|
|
109
|
+
"""Set one configuration value."""
|
|
110
|
+
config_store.set_value(key, value)
|
|
111
|
+
click.echo(f"{key} = {value}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@config_group.command(name="unset")
|
|
115
|
+
@click.argument("key", type=click.Choice(config_store.KNOWN_KEYS))
|
|
116
|
+
def config_unset(key: str) -> None:
|
|
117
|
+
"""Remove one configuration value."""
|
|
118
|
+
config_store.unset(key)
|
|
119
|
+
click.echo(f"{key} unset")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_cli(schema_source: str | None = None) -> click.Group:
|
|
123
|
+
spec = load_spec(schema_source or os.environ.get("CURRENT_SCHEMA"))
|
|
124
|
+
root = click.Group(
|
|
125
|
+
name="current",
|
|
126
|
+
help=(
|
|
127
|
+
"Command line interface for the Current API.\n\n"
|
|
128
|
+
"API commands are generated from the OpenAPI schema. Pass --schema "
|
|
129
|
+
"<path-or-url> (or set CURRENT_SCHEMA) to use a different schema, "
|
|
130
|
+
"for example <base-url>/api/schema/ for a live server."
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
root.add_command(login)
|
|
134
|
+
root.add_command(logout)
|
|
135
|
+
root.add_command(whoami)
|
|
136
|
+
root.add_command(docs)
|
|
137
|
+
root.add_command(config_group)
|
|
138
|
+
add_operations(root, extract_operations(spec))
|
|
139
|
+
return root
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
# --schema must be handled before click parses anything, because it
|
|
144
|
+
# determines the command tree itself.
|
|
145
|
+
argv = list(sys.argv[1:])
|
|
146
|
+
schema_source = None
|
|
147
|
+
if "--schema" in argv:
|
|
148
|
+
index = argv.index("--schema")
|
|
149
|
+
if index + 1 >= len(argv):
|
|
150
|
+
click.echo("Error: --schema requires a value.", err=True)
|
|
151
|
+
raise SystemExit(2)
|
|
152
|
+
schema_source = argv[index + 1]
|
|
153
|
+
del argv[index : index + 2]
|
|
154
|
+
cli = build_cli(schema_source)
|
|
155
|
+
cli.main(args=argv, prog_name="current")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
current_cli/schema.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Load the OpenAPI spec and extract operations for command generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
HTTP_METHODS = ("get", "post", "put", "patch", "delete")
|
|
12
|
+
|
|
13
|
+
# drf-spectacular operationIds end with the DRF action. Longest match first so
|
|
14
|
+
# "partial_update" wins over "update".
|
|
15
|
+
ACTION_SUFFIXES = ("partial_update", "retrieve", "destroy", "create", "update", "list")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Parameter:
|
|
20
|
+
name: str
|
|
21
|
+
location: str # "path" or "query"
|
|
22
|
+
required: bool
|
|
23
|
+
description: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Operation:
|
|
28
|
+
operation_id: str
|
|
29
|
+
method: str
|
|
30
|
+
path: str
|
|
31
|
+
group_path: tuple[str, ...]
|
|
32
|
+
command: str
|
|
33
|
+
description: str
|
|
34
|
+
path_params: tuple[Parameter, ...]
|
|
35
|
+
query_params: tuple[Parameter, ...]
|
|
36
|
+
has_body: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_spec(source: str | None = None) -> dict:
|
|
40
|
+
"""Load the OpenAPI spec from a path, a URL, or the bundled copy."""
|
|
41
|
+
if source is None:
|
|
42
|
+
text = importlib.resources.files("current_cli").joinpath("schema.yml").read_text("utf-8")
|
|
43
|
+
elif source.startswith(("http://", "https://")):
|
|
44
|
+
response = httpx.get(source, follow_redirects=True, timeout=30)
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
text = response.text
|
|
47
|
+
else:
|
|
48
|
+
with open(source, encoding="utf-8") as f:
|
|
49
|
+
text = f.read()
|
|
50
|
+
return yaml.safe_load(text)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _group_path(path: str) -> tuple[str, ...]:
|
|
54
|
+
"""Derive nested command groups from URL segments.
|
|
55
|
+
|
|
56
|
+
Parameter segments are dropped and consecutive duplicates collapsed, so
|
|
57
|
+
/api/workspaces/workspaces/{workspace_id}/members/{id}/ -> (workspaces, members).
|
|
58
|
+
"""
|
|
59
|
+
segments = [s for s in path.strip("/").split("/") if s and not s.startswith("{")]
|
|
60
|
+
if segments and segments[0] == "api":
|
|
61
|
+
segments = segments[1:]
|
|
62
|
+
collapsed: list[str] = []
|
|
63
|
+
for segment in segments:
|
|
64
|
+
if not collapsed or collapsed[-1] != segment:
|
|
65
|
+
collapsed.append(segment)
|
|
66
|
+
return tuple(collapsed)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _command_name(operation_id: str, method: str) -> str:
|
|
70
|
+
for suffix in ACTION_SUFFIXES:
|
|
71
|
+
if operation_id.endswith(suffix):
|
|
72
|
+
return suffix.replace("_", "-")
|
|
73
|
+
return method
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_operations(spec: dict) -> list[Operation]:
|
|
77
|
+
operations: list[Operation] = []
|
|
78
|
+
for path, path_item in spec.get("paths", {}).items():
|
|
79
|
+
shared_params = path_item.get("parameters", [])
|
|
80
|
+
for method in HTTP_METHODS:
|
|
81
|
+
op = path_item.get(method)
|
|
82
|
+
if op is None:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
path_params: list[Parameter] = []
|
|
86
|
+
query_params: list[Parameter] = []
|
|
87
|
+
for raw in [*shared_params, *op.get("parameters", [])]:
|
|
88
|
+
param = Parameter(
|
|
89
|
+
name=raw["name"],
|
|
90
|
+
location=raw["in"],
|
|
91
|
+
required=raw.get("required", False),
|
|
92
|
+
description=raw.get("description", ""),
|
|
93
|
+
)
|
|
94
|
+
if param.location == "path":
|
|
95
|
+
path_params.append(param)
|
|
96
|
+
elif param.location == "query":
|
|
97
|
+
query_params.append(param)
|
|
98
|
+
|
|
99
|
+
# Positional arguments must follow their order in the URL.
|
|
100
|
+
path_params.sort(key=lambda p: path.index("{" + p.name + "}"))
|
|
101
|
+
|
|
102
|
+
operation_id = op.get("operationId", f"{method}_{path}")
|
|
103
|
+
operations.append(
|
|
104
|
+
Operation(
|
|
105
|
+
operation_id=operation_id,
|
|
106
|
+
method=method,
|
|
107
|
+
path=path,
|
|
108
|
+
group_path=_group_path(path),
|
|
109
|
+
command=_command_name(operation_id, method),
|
|
110
|
+
description=op.get("description", ""),
|
|
111
|
+
path_params=tuple(path_params),
|
|
112
|
+
query_params=tuple(query_params),
|
|
113
|
+
has_body="requestBody" in op,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return operations
|