datalegion-cli 1.2.2__tar.gz → 1.3.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.
Files changed (28) hide show
  1. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/PKG-INFO +12 -2
  2. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/README.md +11 -1
  3. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/pyproject.toml +1 -1
  4. datalegion_cli-1.3.0/src/datalegion_cli/login.py +146 -0
  5. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/main.py +71 -6
  6. datalegion_cli-1.3.0/tests/test_login.py +214 -0
  7. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/.gitignore +0 -0
  8. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/__init__.py +0 -0
  9. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/async_typer.py +0 -0
  10. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/client.py +0 -0
  11. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/__init__.py +0 -0
  12. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/company.py +0 -0
  13. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/credits.py +0 -0
  14. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/person.py +0 -0
  15. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/utility.py +0 -0
  16. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/config.py +0 -0
  17. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/logging.py +0 -0
  18. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/output.py +0 -0
  19. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/src/datalegion_cli/update.py +0 -0
  20. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/__init__.py +0 -0
  21. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/conftest.py +0 -0
  22. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/test_client.py +0 -0
  23. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/test_commands.py +0 -0
  24. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/test_config.py +0 -0
  25. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/test_logging.py +0 -0
  26. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/test_output.py +0 -0
  27. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/test_package_contents.py +0 -0
  28. {datalegion_cli-1.2.2 → datalegion_cli-1.3.0}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datalegion-cli
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: CLI for the Data Legion API — agent-friendly, fully async
5
5
  Project-URL: Homepage, https://www.datalegion.ai
6
6
  Project-URL: Documentation, https://www.datalegion.ai/docs
@@ -96,7 +96,17 @@ uv sync
96
96
 
97
97
  ## Configuration
98
98
 
99
- Set your API key (stored encrypted on disk):
99
+ Sign in with your Data Legion account (admin or developer role) and the CLI stores a key for this machine:
100
+
101
+ ```bash
102
+ datalegion-cli login # opens a sign-in link; confirm the code it shows
103
+ datalegion-cli login --team-id 7 # when your account has several teams
104
+ datalegion-cli login --no-browser # headless: prints the link to open on another device
105
+ ```
106
+
107
+ The key belongs to this machine (named after its hostname, or `--device <name>`). Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
108
+
109
+ Or set an API key yourself (stored encrypted on disk):
100
110
 
101
111
  ```bash
102
112
  datalegion-cli config set api_key legion_your_key_here
@@ -62,7 +62,17 @@ uv sync
62
62
 
63
63
  ## Configuration
64
64
 
65
- Set your API key (stored encrypted on disk):
65
+ Sign in with your Data Legion account (admin or developer role) and the CLI stores a key for this machine:
66
+
67
+ ```bash
68
+ datalegion-cli login # opens a sign-in link; confirm the code it shows
69
+ datalegion-cli login --team-id 7 # when your account has several teams
70
+ datalegion-cli login --no-browser # headless: prints the link to open on another device
71
+ ```
72
+
73
+ The key belongs to this machine (named after its hostname, or `--device <name>`). Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
74
+
75
+ Or set an API key yourself (stored encrypted on disk):
66
76
 
67
77
  ```bash
68
78
  datalegion-cli config set api_key legion_your_key_here
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datalegion-cli"
3
- version = "1.2.2"
3
+ version = "1.3.0"
4
4
  description = "CLI for the Data Legion API — agent-friendly, fully async"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,146 @@
1
+ """Sign in from the terminal and get a key for this machine (OAuth device authorization).
2
+
3
+ ``datalegion-cli login`` asks Data Legion's sign-in server (WorkOS AuthKit) for a
4
+ device code, shows the person a link and a short code to approve, polls until they
5
+ do, then trades the access token at ``POST /agent/key`` for an API key that belongs
6
+ to this machine. The key is stored encrypted like one set with ``config set``.
7
+ Signing in again on the same machine replaces its key; the person can disconnect it
8
+ from the Connected Apps page in the dashboard.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ import platform
16
+ import re
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Awaitable, Callable
21
+
22
+ import httpx
23
+
24
+ # The sign-in server and the CLI's OAuth app ("Data Legion CLI", a public PKCE app;
25
+ # the id is not a secret). Overridable for testing against another environment.
26
+ SIGNIN_URL = os.environ.get("LEGION_SIGNIN_URL", "https://signin.datalegion.ai")
27
+ CLI_CLIENT_ID = os.environ.get("LEGION_CLI_CLIENT_ID", "client_01M4ABTVGMNFRS52XMF8YDC1H5")
28
+
29
+ _DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
30
+ # What the API accepts as a device name: words separated by single spaces.
31
+ _DEVICE_OK = re.compile(r"^[\w.@:-]+( [\w.@:-]+)*$")
32
+
33
+
34
+ class LoginError(Exception):
35
+ """Sign-in failed; the message is fit to show the person."""
36
+
37
+ def __init__(self, message: str, *, code: str = "login_failed", details: Any = None):
38
+ super().__init__(message)
39
+ self.code = code
40
+ self.details = details
41
+
42
+
43
+ def default_device_name() -> str:
44
+ """This machine's name in the form the API accepts, or 'cli' when it has none."""
45
+ name = re.sub(r"[^\w.@:-]+", "-", platform.node()).strip("-")[:100]
46
+ return name or "cli"
47
+
48
+
49
+ def check_device_name(name: str) -> str:
50
+ """Return the name if the API will accept it, else raise LoginError."""
51
+ if not (1 <= len(name) <= 100) or not _DEVICE_OK.match(name):
52
+ raise LoginError(
53
+ "Device names use letters, digits and . _ @ : - (single spaces between words), "
54
+ "up to 100 characters.",
55
+ code="invalid_device",
56
+ )
57
+ return name
58
+
59
+
60
+ async def start_device_authorization(client: httpx.AsyncClient) -> dict[str, Any]:
61
+ """Ask the sign-in server for a device code and the link the person approves at."""
62
+ resp = await client.post(
63
+ f"{SIGNIN_URL}/oauth2/device_authorization",
64
+ data={"client_id": CLI_CLIENT_ID, "scope": "openid"},
65
+ )
66
+ if resp.status_code != 200:
67
+ raise LoginError(f"Sign-in could not start ({resp.status_code}): {_oauth_error(resp)}")
68
+ return resp.json()
69
+
70
+
71
+ async def poll_for_token(
72
+ client: httpx.AsyncClient,
73
+ device: dict[str, Any],
74
+ *,
75
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
76
+ ) -> str:
77
+ """Poll until the person approves (RFC 8628), returning the access token."""
78
+ interval = float(device.get("interval") or 5)
79
+ deadline = asyncio.get_running_loop().time() + float(device.get("expires_in") or 300)
80
+ while asyncio.get_running_loop().time() < deadline:
81
+ await sleep(interval)
82
+ resp = await client.post(
83
+ f"{SIGNIN_URL}/oauth2/token",
84
+ data={
85
+ "grant_type": _DEVICE_GRANT,
86
+ "device_code": device["device_code"],
87
+ "client_id": CLI_CLIENT_ID,
88
+ },
89
+ )
90
+ if resp.status_code == 200:
91
+ token = resp.json().get("access_token")
92
+ if token:
93
+ return str(token)
94
+ raise LoginError("Sign-in returned no access token.")
95
+ error = _oauth_error(resp)
96
+ if error == "authorization_pending":
97
+ continue
98
+ if error == "slow_down":
99
+ interval += 5
100
+ continue
101
+ if error == "access_denied":
102
+ raise LoginError("Sign-in was declined.", code="access_denied")
103
+ if error == "expired_token":
104
+ break
105
+ raise LoginError(f"Sign-in failed: {error}")
106
+ raise LoginError("The sign-in code expired. Run `datalegion-cli login` again.", code="expired")
107
+
108
+
109
+ async def exchange_for_key(
110
+ client: httpx.AsyncClient,
111
+ api_base_url: str,
112
+ access_token: str,
113
+ device_name: str,
114
+ team_id: int | None,
115
+ ) -> dict[str, Any]:
116
+ """Trade the access token for this machine's API key (POST /agent/key)."""
117
+ body: dict[str, Any] = {"device": device_name}
118
+ if team_id is not None:
119
+ body["team_id"] = team_id
120
+ resp = await client.post(
121
+ f"{api_base_url.rstrip('/')}/agent/key",
122
+ json=body,
123
+ headers={"Authorization": f"Bearer {access_token}"},
124
+ )
125
+ try:
126
+ payload = resp.json()
127
+ except ValueError:
128
+ payload = {}
129
+ if resp.status_code == 200 and payload.get("api_key"):
130
+ return payload
131
+ message = payload.get("message") or f"Getting a key failed ({resp.status_code})."
132
+ details = payload.get("details")
133
+ if resp.status_code == 409:
134
+ message = "This account has several teams. Run again with --team-id <id> to choose one."
135
+ raise LoginError(message, code="choose_team", details=details)
136
+ raise LoginError(
137
+ message, code=str(payload.get("error") or "key_exchange_failed"), details=details
138
+ )
139
+
140
+
141
+ def _oauth_error(resp: httpx.Response) -> str:
142
+ try:
143
+ body = resp.json()
144
+ except ValueError:
145
+ return resp.text[:200] or str(resp.status_code)
146
+ return str(body.get("error") or body.get("message") or resp.status_code)
@@ -10,9 +10,9 @@ from loguru import logger
10
10
  from datalegion_cli import __version__
11
11
  from datalegion_cli.async_typer import async_command
12
12
  from datalegion_cli.commands import company, credits, person, utility
13
- from datalegion_cli.config import load_config, save_config
13
+ from datalegion_cli.config import get_base_url, load_config, save_config
14
14
  from datalegion_cli.logging import setup_logging
15
- from datalegion_cli.output import emit_json
15
+ from datalegion_cli.output import console, emit_error, emit_json
16
16
  from datalegion_cli.update import check_for_update, self_update
17
17
 
18
18
  app = typer.Typer(
@@ -79,8 +79,7 @@ async def config(
79
79
  # Mask the API key for display
80
80
  display = dict(cfg)
81
81
  if display.get("api_key"):
82
- k = str(display["api_key"])
83
- display["api_key"] = k[:10] + "..." + k[-4:] if len(k) > 14 else "***"
82
+ display["api_key"] = _mask(str(display["api_key"]))
84
83
  emit_json(display)
85
84
  return
86
85
 
@@ -104,13 +103,79 @@ async def config(
104
103
  else:
105
104
  cfg[key] = value
106
105
  save_config(cfg)
107
- logger.info("Set {} = {}", key, cfg[key])
108
- emit_json({"ok": True, "key": key, "value": cfg[key]})
106
+ shown = _mask(str(cfg[key])) if key in _SECRET_KEYS else cfg[key]
107
+ logger.info("Set {} = {}", key, shown)
108
+ emit_json({"ok": True, "key": key, "value": shown})
109
109
  return
110
110
 
111
111
  raise typer.BadParameter(f"Unknown action: {action}. Use get, set, list, or path.")
112
112
 
113
113
 
114
+ _SECRET_KEYS = {"api_key"}
115
+
116
+
117
+ def _mask(value: str) -> str:
118
+ """Show enough of a secret to recognize it, never the whole value."""
119
+ return value[:10] + "..." + value[-4:] if len(value) > 14 else "***"
120
+
121
+
122
+ @app.command()
123
+ @async_command
124
+ async def login(
125
+ team_id: Annotated[
126
+ int | None, typer.Option("--team-id", help="Team the key bills, when you have several.")
127
+ ] = None,
128
+ device: Annotated[
129
+ str | None, typer.Option("--device", help="Name for this machine (default: hostname).")
130
+ ] = None,
131
+ no_browser: Annotated[
132
+ bool, typer.Option("--no-browser", help="Print the sign-in link without opening it.")
133
+ ] = False,
134
+ ) -> None:
135
+ """Sign in with your Data Legion account and store a key for this machine.
136
+
137
+ Opens a sign-in link (approve the code it shows), then saves an API key that
138
+ belongs to this machine. Signing in again replaces it. Disconnect it any time
139
+ from Connected Apps in the dashboard.
140
+ """
141
+ import webbrowser
142
+
143
+ import httpx
144
+
145
+ from datalegion_cli import login as flow
146
+
147
+ try:
148
+ device_name = flow.check_device_name(device or flow.default_device_name())
149
+ async with httpx.AsyncClient(timeout=30.0) as client:
150
+ started = await flow.start_device_authorization(client)
151
+ link = started.get("verification_uri_complete") or started["verification_uri"]
152
+ console.print(
153
+ f"Open [bold]{link}[/bold] and confirm the code [bold]{started['user_code']}[/bold]."
154
+ )
155
+ if not no_browser:
156
+ webbrowser.open(link)
157
+ token = await flow.poll_for_token(client, started)
158
+ issued = await flow.exchange_for_key(
159
+ client, get_base_url(), token, device_name, team_id
160
+ )
161
+ except flow.LoginError as exc:
162
+ emit_error(str(exc), code=exc.code, details=exc.details, as_json=True)
163
+ raise typer.Exit(1) from exc
164
+
165
+ cfg = load_config()
166
+ cfg["api_key"] = issued["api_key"]
167
+ save_config(cfg)
168
+ emit_json(
169
+ {
170
+ "ok": True,
171
+ "device": device_name,
172
+ "team_id": issued["team_id"],
173
+ "api_key": _mask(issued["api_key"]),
174
+ "replaced": issued.get("replaced", 0),
175
+ }
176
+ )
177
+
178
+
114
179
  @app.command()
115
180
  @async_command
116
181
  async def update(
@@ -0,0 +1,214 @@
1
+ """`datalegion-cli login`: device authorization, polling, the key exchange, and what is stored."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from unittest.mock import AsyncMock, patch
7
+
8
+ import httpx
9
+ import pytest
10
+ from typer.testing import CliRunner
11
+
12
+ from datalegion_cli import login as flow
13
+ from datalegion_cli.main import app
14
+
15
+ runner = CliRunner()
16
+ KEY = "legion_" + "c0ffee" * 10 + "abcd"
17
+
18
+
19
+ def _client(handler) -> httpx.AsyncClient:
20
+ return httpx.AsyncClient(transport=httpx.MockTransport(handler))
21
+
22
+
23
+ async def _no_sleep(_seconds: float) -> None:
24
+ return None
25
+
26
+
27
+ @pytest.fixture
28
+ def tmp_config(tmp_config_dir, monkeypatch):
29
+ """A throwaway config dir with no LEGION_API_KEY override."""
30
+ monkeypatch.delenv("LEGION_API_KEY", raising=False)
31
+ return tmp_config_dir
32
+
33
+
34
+ class TestDeviceName:
35
+ def test_default_is_the_hostname_made_safe(self, monkeypatch):
36
+ monkeypatch.setattr(flow.platform, "node", lambda: "Jack's MacBook Pro.local")
37
+ assert flow.default_device_name() == "Jack-s-MacBook-Pro.local"
38
+
39
+ def test_default_without_a_hostname(self, monkeypatch):
40
+ monkeypatch.setattr(flow.platform, "node", lambda: "")
41
+ assert flow.default_device_name() == "cli"
42
+
43
+ @pytest.mark.parametrize("name", ["", " lead", "a\nb", "x" * 101, "semi;colon", "two spaces"])
44
+ def test_rejects_what_the_api_rejects(self, name):
45
+ with pytest.raises(flow.LoginError):
46
+ flow.check_device_name(name)
47
+
48
+ @pytest.mark.parametrize("name", ["laptop", "build-box.internal", "ci runner 3", "me@host:1"])
49
+ def test_accepts_ordinary_names(self, name):
50
+ assert flow.check_device_name(name) == name
51
+
52
+
53
+ class TestFlow:
54
+ async def test_start_sends_the_cli_client(self):
55
+ seen = {}
56
+
57
+ def handler(req: httpx.Request) -> httpx.Response:
58
+ seen["url"] = str(req.url)
59
+ seen["body"] = req.content.decode()
60
+ return httpx.Response(200, json={"device_code": "d", "user_code": "BCDF-GHJK"})
61
+
62
+ async with _client(handler) as c:
63
+ out = await flow.start_device_authorization(c)
64
+ assert out["user_code"] == "BCDF-GHJK"
65
+ assert seen["url"] == "https://signin.datalegion.ai/oauth2/device_authorization"
66
+ assert f"client_id={flow.CLI_CLIENT_ID}" in seen["body"]
67
+
68
+ async def test_start_failure_is_reported(self):
69
+ async with _client(
70
+ lambda r: httpx.Response(400, json={"error": "unauthorized_client"})
71
+ ) as c:
72
+ with pytest.raises(flow.LoginError, match="unauthorized_client"):
73
+ await flow.start_device_authorization(c)
74
+
75
+ async def test_poll_waits_through_pending_and_slow_down(self):
76
+ answers = iter(
77
+ [
78
+ httpx.Response(400, json={"error": "authorization_pending"}),
79
+ httpx.Response(400, json={"error": "slow_down"}),
80
+ httpx.Response(200, json={"access_token": "tok"}),
81
+ ]
82
+ )
83
+ slept: list[float] = []
84
+
85
+ async def sleep(s: float) -> None:
86
+ slept.append(s)
87
+
88
+ async with _client(lambda r: next(answers)) as c:
89
+ token = await flow.poll_for_token(
90
+ c, {"device_code": "d", "interval": 5, "expires_in": 300}, sleep=sleep
91
+ )
92
+ assert token == "tok"
93
+ assert slept == [5, 5, 10] # slow_down adds 5 seconds
94
+
95
+ @pytest.mark.parametrize(
96
+ ("error", "code"), [("access_denied", "access_denied"), ("expired_token", "expired")]
97
+ )
98
+ async def test_poll_terminal_answers(self, error, code):
99
+ async with _client(lambda r: httpx.Response(400, json={"error": error})) as c:
100
+ with pytest.raises(flow.LoginError) as exc:
101
+ await flow.poll_for_token(c, {"device_code": "d", "interval": 1}, sleep=_no_sleep)
102
+ assert exc.value.code == code
103
+
104
+ async def test_exchange_sends_the_bearer_and_device(self):
105
+ seen = {}
106
+
107
+ def handler(req: httpx.Request) -> httpx.Response:
108
+ seen["auth"] = req.headers["authorization"]
109
+ seen["url"] = str(req.url)
110
+ seen["body"] = json.loads(req.content)
111
+ return httpx.Response(200, json={"api_key": KEY, "team_id": 7, "replaced": 0})
112
+
113
+ async with _client(handler) as c:
114
+ out = await flow.exchange_for_key(c, "https://api.datalegion.ai/", "tok", "laptop", 7)
115
+ assert out["api_key"] == KEY
116
+ assert seen == {
117
+ "auth": "Bearer tok",
118
+ "url": "https://api.datalegion.ai/agent/key",
119
+ "body": {"device": "laptop", "team_id": 7},
120
+ }
121
+
122
+ async def test_exchange_several_teams(self):
123
+ teams = [{"team_id": 7, "team_name": "Sales"}]
124
+ reply = httpx.Response(
125
+ 409, json={"error": "conflict", "message": "x", "details": {"teams": teams}}
126
+ )
127
+ async with _client(lambda r: reply) as c:
128
+ with pytest.raises(flow.LoginError) as exc:
129
+ await flow.exchange_for_key(c, "https://api.datalegion.ai", "tok", "laptop", None)
130
+ assert exc.value.code == "choose_team" and "--team-id" in str(exc.value)
131
+ assert exc.value.details == {"teams": teams}
132
+
133
+ async def test_exchange_refusal_relays_the_message(self):
134
+ reply = httpx.Response(
135
+ 403,
136
+ json={
137
+ "error": "forbidden",
138
+ "message": "Your Data Legion account is waiting for approval.",
139
+ },
140
+ )
141
+ async with _client(lambda r: reply) as c:
142
+ with pytest.raises(flow.LoginError, match="waiting for approval"):
143
+ await flow.exchange_for_key(c, "https://api.datalegion.ai", "tok", "laptop", None)
144
+
145
+
146
+ class TestLoginCommand:
147
+ @pytest.fixture
148
+ def steps(self):
149
+ with (
150
+ patch.object(
151
+ flow,
152
+ "start_device_authorization",
153
+ new=AsyncMock(
154
+ return_value={
155
+ "verification_uri": "https://signin.datalegion.ai/device",
156
+ "user_code": "BCDF-GHJK",
157
+ "device_code": "d",
158
+ }
159
+ ),
160
+ ),
161
+ patch.object(flow, "poll_for_token", new=AsyncMock(return_value="tok")),
162
+ patch.object(
163
+ flow,
164
+ "exchange_for_key",
165
+ new=AsyncMock(return_value={"api_key": KEY, "team_id": 7, "replaced": 1}),
166
+ ) as exchange,
167
+ patch("webbrowser.open") as browser,
168
+ ):
169
+ yield exchange, browser
170
+
171
+ def test_stores_the_key_and_never_prints_it(self, steps, tmp_config):
172
+ exchange, browser = steps
173
+ result = runner.invoke(app, ["login", "--device", "laptop", "--team-id", "7"])
174
+ assert result.exit_code == 0, result.output
175
+ out = json.loads(result.stdout)
176
+ assert out["ok"] is True and out["team_id"] == 7 and out["device"] == "laptop"
177
+ assert KEY not in result.output and out["api_key"].endswith(KEY[-4:])
178
+ assert exchange.await_args.args[3:] == ("laptop", 7)
179
+ browser.assert_called_once()
180
+ from datalegion_cli.config import load_config
181
+
182
+ assert load_config()["api_key"] == KEY
183
+
184
+ def test_no_browser(self, steps, tmp_config):
185
+ _, browser = steps
186
+ assert runner.invoke(app, ["login", "--device", "laptop", "--no-browser"]).exit_code == 0
187
+ browser.assert_not_called()
188
+
189
+ def test_a_refusal_is_json_and_exit_1(self, steps, tmp_config):
190
+ exchange, _ = steps
191
+ exchange.side_effect = flow.LoginError(
192
+ "Pick a team.", code="choose_team", details={"teams": []}
193
+ )
194
+ result = runner.invoke(app, ["login", "--device", "laptop"])
195
+ assert result.exit_code == 1
196
+ assert json.loads(result.stdout) == {
197
+ "ok": False,
198
+ "error": "choose_team",
199
+ "message": "Pick a team.",
200
+ "details": {"teams": []},
201
+ }
202
+
203
+ def test_a_bad_device_name_stops_before_sign_in(self, steps, tmp_config):
204
+ result = runner.invoke(app, ["login", "--device", "semi;colon"])
205
+ assert result.exit_code == 1
206
+ assert json.loads(result.stdout)["error"] == "invalid_device"
207
+ flow.start_device_authorization.assert_not_awaited()
208
+
209
+
210
+ def test_config_set_never_echoes_the_key(tmp_config):
211
+ result = runner.invoke(app, ["config", "set", "api_key", KEY])
212
+ assert result.exit_code == 0
213
+ assert KEY not in result.output
214
+ assert json.loads(result.stdout)["value"].endswith(KEY[-4:])