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.
@@ -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