current-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ # Django
2
+ *.pyc
3
+ __pycache__/
4
+ db.sqlite3
5
+ media/
6
+ staticfiles/
7
+
8
+ # Python
9
+ .venv/
10
+ .env
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .vscode/
14
+ .DS_Store
15
+
16
+ uv.lock
17
+
18
+ # Kea typegen output
19
+ **/*LogicType.ts
20
+
21
+ # Node
22
+ node_modules/
23
+ dist/
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: current-cli
3
+ Version: 0.1.0
4
+ Summary: Command line interface for the Current API, generated from its OpenAPI schema at runtime
5
+ Author-email: Orin <your-email@example.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: click>=8.1
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: pyyaml>=6.0
11
+ Requires-Dist: rich>=13.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # current-cli
15
+
16
+ Command line interface for the Current API. Commands are generated at runtime
17
+ from the bundled OpenAPI schema, so every API operation is available without
18
+ per-endpoint code.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install current-cli
24
+ ```
25
+
26
+ Or from a repo checkout:
27
+
28
+ ```bash
29
+ cd cli && uv sync
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ current config set base_url https://your-api.example.com
36
+ current login you@example.com
37
+ current config set default_workspace <workspace-uuid>
38
+
39
+ current projects list
40
+ current projects retrieve <id>
41
+ current tasks create --data '{"name": "Order beams", "workspace": "..."}'
42
+ current workspaces members list <workspace_id>
43
+ ```
44
+
45
+ Run `current --help` (or `--help` on any subcommand) to see everything the
46
+ API exposes. `current docs` prints a markdown reference of every command;
47
+ the committed copy lives at [llms.txt](llms.txt) — hand it to an LLM that
48
+ needs to drive the CLI.
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ cd cli
54
+ uv sync
55
+ uv run pytest
56
+ uv run ruff check . && uv run ruff format --check .
57
+ ```
58
+
59
+ The bundled schema (`current_cli/schema.yml`) is a copy of `api/schema.yml`,
60
+ kept in sync by `api/scripts/generate.sh`. Point the CLI at a different schema
61
+ with `--schema <path-or-url>` or `CURRENT_SCHEMA`.
@@ -0,0 +1,48 @@
1
+ # current-cli
2
+
3
+ Command line interface for the Current API. Commands are generated at runtime
4
+ from the bundled OpenAPI schema, so every API operation is available without
5
+ per-endpoint code.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install current-cli
11
+ ```
12
+
13
+ Or from a repo checkout:
14
+
15
+ ```bash
16
+ cd cli && uv sync
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ current config set base_url https://your-api.example.com
23
+ current login you@example.com
24
+ current config set default_workspace <workspace-uuid>
25
+
26
+ current projects list
27
+ current projects retrieve <id>
28
+ current tasks create --data '{"name": "Order beams", "workspace": "..."}'
29
+ current workspaces members list <workspace_id>
30
+ ```
31
+
32
+ Run `current --help` (or `--help` on any subcommand) to see everything the
33
+ API exposes. `current docs` prints a markdown reference of every command;
34
+ the committed copy lives at [llms.txt](llms.txt) — hand it to an LLM that
35
+ needs to drive the CLI.
36
+
37
+ ## Development
38
+
39
+ ```bash
40
+ cd cli
41
+ uv sync
42
+ uv run pytest
43
+ uv run ruff check . && uv run ruff format --check .
44
+ ```
45
+
46
+ The bundled schema (`current_cli/schema.yml`) is a copy of `api/schema.yml`,
47
+ kept in sync by `api/scripts/generate.sh`. Point the CLI at a different schema
48
+ with `--schema <path-or-url>` or `CURRENT_SCHEMA`.
@@ -0,0 +1 @@
1
+ """Command line interface for the Current API, generated from its OpenAPI schema."""
@@ -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))
@@ -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
@@ -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="")
@@ -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
@@ -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()