ground-control-cli 0.2.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.
client/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """The client: what runs on a developer's machine (the groundcontrol command, the probes)."""
client/claude.py ADDED
@@ -0,0 +1,240 @@
1
+ """Claude Code, as the client drives it: its version, and the plugin through its own commands.
2
+
3
+ The service serves the plugin's store as a zip; the client unpacks it into the machine's own
4
+ folder (~/.groundcontrol/store), registers that folder with Claude Code as a store, and installs
5
+ or updates the plugin from it with Claude Code's plugin commands. An organisation that blocks
6
+ the network or commands installs the same store from an archive (`groundcontrol init
7
+ --store <zip>`). The client never edits Claude Code's own files.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import json
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ import zipfile
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from client import machine
23
+
24
+ # The oldest Claude Code whose failed hook can block an action (onFailure).
25
+ MINIMUM = (2, 1, 295)
26
+ TIMEOUT = 120
27
+
28
+
29
+ class ClaudeError(Exception):
30
+ """Claude Code is missing, too old, or refused a plugin command; the message says which."""
31
+
32
+
33
+ def _claude(*args: str) -> subprocess.CompletedProcess:
34
+ """Run one `claude` command, never in the middle of anything it asks."""
35
+ try:
36
+ return subprocess.run(
37
+ ["claude", *args],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=TIMEOUT,
41
+ stdin=subprocess.DEVNULL,
42
+ )
43
+ except FileNotFoundError:
44
+ raise ClaudeError("Claude Code is not installed: install it first") from None
45
+ except subprocess.TimeoutExpired:
46
+ raise ClaudeError(f"`claude {' '.join(args)}` took too long") from None
47
+
48
+
49
+ def _ran(*args: str) -> str:
50
+ """Run one `claude` command; its output, or ClaudeError saying what it said."""
51
+ done = _claude(*args)
52
+ if done.returncode != 0:
53
+ said = (done.stderr or done.stdout).strip().splitlines()
54
+ raise ClaudeError(
55
+ f"`claude {' '.join(args)}` failed: {said[-1] if said else done.returncode}"
56
+ )
57
+ return done.stdout
58
+
59
+
60
+ def version() -> tuple[int, int, int]:
61
+ """Claude Code's version, at least MINIMUM.
62
+
63
+ Returns:
64
+ Its three numbers.
65
+
66
+ Raises:
67
+ ClaudeError: If it is missing, unreadable, or older than MINIMUM.
68
+
69
+ Company: assumes none.
70
+ """
71
+ found = re.search(r"(\d+)\.(\d+)\.(\d+)", _ran("--version"))
72
+ if not found:
73
+ raise ClaudeError("Claude Code did not say its version")
74
+ numbers = (int(found[1]), int(found[2]), int(found[3]))
75
+ if numbers < MINIMUM:
76
+ need = ".".join(map(str, MINIMUM))
77
+ have = ".".join(map(str, numbers))
78
+ raise ClaudeError(f"Claude Code is {have}; {need} or later is needed: run `claude update`")
79
+ return numbers
80
+
81
+
82
+ def store_dir() -> Path:
83
+ """Where the plugin's store is unpacked.
84
+
85
+ Company: assumes none.
86
+ """
87
+ return machine.home() / "store"
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Store:
92
+ """An unpacked store: its name in Claude Code, its one plugin, and the plugin's version.
93
+
94
+ Attributes:
95
+ name: The store's (marketplace's) name.
96
+ plugin: The plugin's name.
97
+ version: The plugin's version.
98
+ """
99
+
100
+ name: str
101
+ plugin: str
102
+ version: str
103
+
104
+ @property
105
+ def id(self) -> str:
106
+ """The plugin as Claude Code names it, plugin@store."""
107
+ return f"{self.plugin}@{self.name}"
108
+
109
+
110
+ def read_store(folder: Path | None = None) -> Store | None:
111
+ """The store unpacked on the machine, or None.
112
+
113
+ Company: assumes none.
114
+ """
115
+ try:
116
+ found = json.loads(
117
+ ((folder or store_dir()) / ".claude-plugin" / "marketplace.json").read_text()
118
+ )
119
+ plugin = found["plugins"][0]
120
+ return Store(found["name"], plugin["name"], plugin.get("version", ""))
121
+ except (OSError, ValueError, KeyError, IndexError):
122
+ return None
123
+
124
+
125
+ def unpack(archive: bytes) -> Store:
126
+ """Unpack a store's zip into the machine's store folder, replacing what was there.
127
+
128
+ Args:
129
+ archive: The zip, as the service serves it.
130
+
131
+ Returns:
132
+ The Store.
133
+
134
+ Raises:
135
+ ClaudeError: If the zip is no store.
136
+
137
+ Company: assumes none.
138
+ """
139
+ target = store_dir()
140
+ target.parent.mkdir(parents=True, exist_ok=True)
141
+ fresh = Path(tempfile.mkdtemp(prefix="store-", dir=target.parent))
142
+ try:
143
+ with zipfile.ZipFile(io.BytesIO(archive)) as packed:
144
+ for name in packed.namelist():
145
+ if name.startswith("/") or ".." in Path(name).parts:
146
+ raise ClaudeError(f"the store holds a path outside it: {name}")
147
+ packed.extractall(fresh)
148
+ except zipfile.BadZipFile:
149
+ shutil.rmtree(fresh, ignore_errors=True)
150
+ raise ClaudeError("the store is not a zip") from None
151
+ except ClaudeError:
152
+ shutil.rmtree(fresh, ignore_errors=True)
153
+ raise
154
+ found = read_store(fresh)
155
+ if found is None:
156
+ shutil.rmtree(fresh, ignore_errors=True)
157
+ raise ClaudeError("the archive holds no store")
158
+ old = target.with_name("store-old")
159
+ shutil.rmtree(old, ignore_errors=True)
160
+ if target.exists():
161
+ target.rename(old)
162
+ fresh.rename(target)
163
+ shutil.rmtree(old, ignore_errors=True)
164
+ return found
165
+
166
+
167
+ def installed(store: Store) -> str | None:
168
+ """The version of the store's plugin Claude Code has installed for the user, or None.
169
+
170
+ Company: assumes none.
171
+ """
172
+ try:
173
+ listed = json.loads(_ran("plugin", "list", "--json") or "[]")
174
+ except ValueError:
175
+ return None
176
+ for plugin in listed:
177
+ if plugin.get("id") == store.id and plugin.get("scope", "user") == "user":
178
+ return plugin.get("version") or ""
179
+ return None
180
+
181
+
182
+ def _registered(store: Store) -> bool:
183
+ """Whether Claude Code knows the store."""
184
+ try:
185
+ listed = json.loads(_ran("plugin", "marketplace", "list", "--json") or "[]")
186
+ except ValueError:
187
+ return False
188
+ return any(m.get("name") == store.name for m in listed)
189
+
190
+
191
+ def install(store: Store) -> str:
192
+ """Register the unpacked store with Claude Code and install or update its plugin.
193
+
194
+ Args:
195
+ store: The store unpacked on the machine.
196
+
197
+ Returns:
198
+ What was done, in a few words.
199
+
200
+ Raises:
201
+ ClaudeError: If a command of Claude Code fails.
202
+
203
+ Company: assumes none.
204
+ """
205
+ if _registered(store):
206
+ _ran("plugin", "marketplace", "update", store.name)
207
+ else:
208
+ _ran("plugin", "marketplace", "add", str(store_dir()))
209
+ had = installed(store)
210
+ if had is None:
211
+ _ran("plugin", "install", store.id)
212
+ return f"installed {store.id} {store.version}"
213
+ if had != store.version:
214
+ _ran("plugin", "update", store.id)
215
+ return f"updated {store.id} from {had} to {store.version}"
216
+ return f"{store.id} {store.version} is current"
217
+
218
+
219
+ def remove() -> list[str]:
220
+ """Remove the plugin and its store from Claude Code, and the store's folder.
221
+
222
+ Returns:
223
+ What was done, a line each.
224
+
225
+ Company: assumes none.
226
+ """
227
+ done = []
228
+ store = read_store()
229
+ if store is not None:
230
+ for args, said in (
231
+ (("plugin", "uninstall", store.id), f"removed the plugin {store.id}"),
232
+ (("plugin", "marketplace", "remove", store.name), f"removed the store {store.name}"),
233
+ ):
234
+ try:
235
+ _ran(*args)
236
+ done.append(said)
237
+ except ClaudeError as e:
238
+ done.append(str(e))
239
+ shutil.rmtree(store_dir(), ignore_errors=True)
240
+ return done
client/cli.py ADDED
@@ -0,0 +1,314 @@
1
+ """The groundcontrol command (and its alias, gctl): init, the session's start, repo, remove.
2
+
3
+ `groundcontrol init <address>` connects the machine to a company and installs the plugin,
4
+ saying in one line what it does at each step; the person only approves in the browser.
5
+ `groundcontrol session-start` is what the plugin's hook runs when a Claude Code session
6
+ starts: the client updates itself and the plugin, and finds the repo's company; it is silent
7
+ unless something needs the person. `groundcontrol repo` says which company the repo here is
8
+ of, or what is missing. `groundcontrol remove` removes it all from the machine.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from client import claude, connect, machine, system, update
21
+ from client.service import PACKAGE, VERSION, Service, ServiceError
22
+
23
+ TIMEOUT = 30
24
+
25
+
26
+ def say(line: str) -> None:
27
+ """One line of what the client is doing, for the person."""
28
+ print(line, flush=True)
29
+
30
+
31
+ def _system() -> system.System:
32
+ """The operating system's side; refused with its reason on any other system."""
33
+ return system.current()
34
+
35
+
36
+ def init(given: str, store_file: str | None = None, door: str | None = None) -> int:
37
+ """Connect this machine to a company and install the plugin; see the module's docstring.
38
+
39
+ Args:
40
+ given: The company's address, as the install line carries it.
41
+ store_file: A store's zip to install the plugin from, instead of the service's.
42
+ door: The door's origin, as the install line gives it; beside the address when not.
43
+
44
+ Returns:
45
+ The exit code.
46
+
47
+ Company: the one at the address.
48
+ """
49
+ try:
50
+ address = machine.address_of(given)
51
+ door = machine.address_of(door) if door else machine.door_of(address)
52
+ here = _system()
53
+ except (ValueError, system.Unsupported) as e:
54
+ say(f"Stopped: {e}.")
55
+ return 1
56
+ say(f"Setting up this machine for {address}.")
57
+ try:
58
+ found = claude.version()
59
+ except claude.ClaudeError as e:
60
+ say(f"Stopped: {e}.")
61
+ return 1
62
+ say(f"Claude Code is {'.'.join(map(str, found))}: it can block an action when a check fails.")
63
+ missing = here.keychain_ready()
64
+ if missing:
65
+ say(f"Stopped: {missing}.")
66
+ return 1
67
+ say("The keychain is ready to keep the key.")
68
+ try:
69
+ held = update.held_to(door)
70
+ except ServiceError as e:
71
+ say(f"Stopped: {door} cannot be reached: {e.detail}.")
72
+ return 1
73
+ if update.needed(held) == "required":
74
+ say(f"This client is {VERSION}; {address} needs {held['minimum']} or later.")
75
+ say("Run the install line again to update it.")
76
+ return 1
77
+ called = _connected(address, here)
78
+ if called is None:
79
+ called = _connect(door, address, here)
80
+ if called is None:
81
+ return 1
82
+ try:
83
+ me = called.who_am_i()
84
+ except ServiceError as e:
85
+ say(f"Stopped: {called.address} does not know this key: {e.detail}.")
86
+ return 1
87
+ person, company = me["person"], me["company"]
88
+ say(
89
+ f"{called.address} knows you: {person['name']} ({person['email']}) at {company['name']}, "
90
+ f"on the machine {me['machine']['name']}."
91
+ )
92
+ if not _plugin(called, store_file):
93
+ return 1
94
+ say(f"Done. Open Claude Code in a repo of {company['name']}.")
95
+ return 0
96
+
97
+
98
+ def _connected(address: str, here: system.System) -> Service | None:
99
+ """The company's service when this machine already holds a working key for it."""
100
+ called = machine.service(address, here)
101
+ if called is None:
102
+ return None
103
+ try:
104
+ called.who_am_i()
105
+ except ServiceError:
106
+ return None
107
+ say(f"This machine is already connected to {address}.")
108
+ return called
109
+
110
+
111
+ def _connect(door: str, address: str, here: system.System) -> Service | None:
112
+ """Connect by the device flow; the company's service with its new key, or None."""
113
+ try:
114
+ code = connect.ask(door, address, here)
115
+ say("Approve this machine in your browser: one click, the code is filled in.")
116
+ say(f"The page: {code.page} (code {code.user_code}).")
117
+ if not here.open_browser(code.page):
118
+ say("The browser did not open: open the page yourself.")
119
+ say("Waiting for your approval.")
120
+ key, company_address = connect.collect(door, code)
121
+ except (connect.Refused, ServiceError) as e:
122
+ say(f"Stopped: {e}.")
123
+ return None
124
+ company_address = machine.address_of(company_address)
125
+ try:
126
+ here.set_key(company_address, key)
127
+ except OSError as e:
128
+ say(f"Stopped: {e}.")
129
+ return None
130
+ machine.remember_company(company_address, door)
131
+ say("Approved. The key is in the keychain, and in no file.")
132
+ return Service(company_address, key, TIMEOUT)
133
+
134
+
135
+ def _plugin(called: Service, store_file: str | None) -> bool:
136
+ """Install or update the plugin from the service's store, or from an archive."""
137
+ try:
138
+ archive = Path(store_file).read_bytes() if store_file else called.store()
139
+ store = claude.unpack(archive)
140
+ say(f"Installing the plugin in Claude Code: {store.id} {store.version}.")
141
+ say(f"The plugin: {claude.install(store)}.")
142
+ except (OSError, ServiceError, claude.ClaudeError) as e:
143
+ say(f"Stopped: the plugin needs installing: {e}.")
144
+ return False
145
+ return True
146
+
147
+
148
+ def session_start(event: dict) -> list[str]:
149
+ """What runs when a Claude Code session starts; what the person needs to know, if anything.
150
+
151
+ The client updates itself when it is behind, before anything else; then the plugin, when
152
+ it is behind or missing; then reads the managed lists and finds the repo's company.
153
+ Nothing is left running, and nothing is written into the repo. Silent in a repo that is
154
+ not managed, or on a machine connected to no company.
155
+
156
+ Args:
157
+ event: The SessionStart event Claude Code sends, with its cwd.
158
+
159
+ Returns:
160
+ The lines to show the person; none when all is well.
161
+
162
+ Company: each connected one.
163
+ """
164
+ state = machine.load()
165
+ if not state["companies"]:
166
+ return []
167
+ try:
168
+ here = _system()
169
+ except system.Unsupported:
170
+ return []
171
+ said: list[str] = []
172
+ try:
173
+ held = update.held_to(state["door"] or machine.door_of(state["companies"][0]))
174
+ except ServiceError:
175
+ return []
176
+ if update.needed(held):
177
+ try:
178
+ update.update()
179
+ except RuntimeError as e:
180
+ said.append(f"The client needs updating to {held['latest']}: {e}.")
181
+ store = claude.read_store()
182
+ try:
183
+ current = claude.installed(store) if store else None
184
+ if store is None or current != held["plugin"] or store.version != held["plugin"]:
185
+ called = next(
186
+ (s for s in (machine.service(a, here) for a in state["companies"]) if s), None
187
+ )
188
+ if called is None:
189
+ raise claude.ClaudeError("no key for any connected company")
190
+ claude.install(claude.unpack(called.store()))
191
+ except (ServiceError, claude.ClaudeError, OSError) as e:
192
+ said.append(f"The plugin needs updating to {held['plugin']}: {e}.")
193
+ cwd = event.get("cwd")
194
+ if cwd:
195
+ machine.find(Path(cwd), here, refresh=True)
196
+ return said
197
+
198
+
199
+ def repo(root: Path) -> int:
200
+ """Say which company the repo here is of, and with which key; or the one thing missing.
201
+
202
+ Company: each connected one, until the repo's is found.
203
+ """
204
+ try:
205
+ found = machine.find(root, _system(), refresh=True)
206
+ except system.Unsupported as e:
207
+ say(f"Stopped: {e}.")
208
+ return 1
209
+ if isinstance(found, machine.Missing):
210
+ say(f"Not managed here: {found.why}.")
211
+ return 1
212
+ say(f"{found.repo} is managed at {found.service.address}; its key is in the keychain.")
213
+ return 0
214
+
215
+
216
+ def remove() -> int:
217
+ """Remove it all from the machine: each connection, each key, the plugin, the store, the client.
218
+
219
+ Company: each connected one.
220
+ """
221
+ try:
222
+ here = _system()
223
+ except system.Unsupported as e:
224
+ say(f"Stopped: {e}.")
225
+ return 1
226
+ for address in machine.load()["companies"]:
227
+ called = machine.service(address, here)
228
+ if called is not None:
229
+ try:
230
+ called.disconnect()
231
+ say(f"Disconnected this machine from {address}: its key is refused from now on.")
232
+ except ServiceError as e:
233
+ say(
234
+ f"{address} did not disconnect this machine ({e.detail}): do it in My settings."
235
+ )
236
+ if here.delete_key(address):
237
+ say(f"Deleted the key for {address} from the keychain.")
238
+ machine.forget_company(address)
239
+ for line in claude.remove():
240
+ say(line[:1].upper() + line[1:] + ".")
241
+ shutil.rmtree(machine.home(), ignore_errors=True)
242
+ if update.installed_by_uv():
243
+ subprocess.run(["uv", "tool", "uninstall", "--quiet", PACKAGE], check=False)
244
+ say("Removed the client.")
245
+ say("Nothing of it is left on this machine.")
246
+ return 0
247
+
248
+
249
+ def save_store(target: str) -> int:
250
+ """Save the plugin's store as a zip, for a machine that installs it from an archive.
251
+
252
+ Company: the first connected one.
253
+ """
254
+ here = _system()
255
+ called = next(
256
+ (s for s in (machine.service(a, here) for a in machine.load()["companies"]) if s), None
257
+ )
258
+ if called is None:
259
+ say(f"Stopped: {machine.NOT_CONNECTED}.")
260
+ return 1
261
+ Path(target).write_bytes(called.store())
262
+ say(f"Saved the plugin's store to {target}.")
263
+ say(f"Install it with: groundcontrol init <address> --store {target}")
264
+ return 0
265
+
266
+
267
+ def main(argv: list[str] | None = None) -> int:
268
+ """Run one command of groundcontrol.
269
+
270
+ Args:
271
+ argv: The arguments, without the program's name; the process's own when None.
272
+
273
+ Returns:
274
+ The exit code.
275
+
276
+ Company: as the command says.
277
+ """
278
+ parser = argparse.ArgumentParser(prog="groundcontrol")
279
+ parser.add_argument("--version", action="version", version=VERSION)
280
+ commands = parser.add_subparsers(dest="command", required=True)
281
+ started = commands.add_parser("init", help="connect this machine to a company")
282
+ started.add_argument("address", help="the company's address, as the invitation shows it")
283
+ started.add_argument("--store", help="install the plugin from this archive")
284
+ started.add_argument("--door", help="the door's address, as the install line gives it")
285
+ commands.add_parser("session-start", help="what the plugin's hook runs as a session starts")
286
+ commands.add_parser("repo", help="say which company the repo here is of")
287
+ commands.add_parser("remove", help="remove it all from this machine")
288
+ saved = commands.add_parser("store", help="save the plugin's store as an archive")
289
+ saved.add_argument("file")
290
+ args = parser.parse_args(argv)
291
+ try:
292
+ if args.command == "init":
293
+ return init(args.address, args.store, args.door)
294
+ if args.command == "session-start":
295
+ try:
296
+ event = json.load(sys.stdin)
297
+ except ValueError:
298
+ event = {}
299
+ lines = session_start(event if isinstance(event, dict) else {})
300
+ if lines:
301
+ print(json.dumps({"systemMessage": " ".join(lines)}))
302
+ return 0
303
+ if args.command == "repo":
304
+ return repo(Path.cwd())
305
+ if args.command == "remove":
306
+ return remove()
307
+ return save_store(args.file)
308
+ except KeyboardInterrupt:
309
+ say("Stopped.")
310
+ return 130
311
+
312
+
313
+ if __name__ == "__main__":
314
+ sys.exit(main())
client/connect.py ADDED
@@ -0,0 +1,128 @@
1
+ """Connecting the machine to a company: the device flow's machine side (RFC 8628).
2
+
3
+ The client asks the door for a code, saying the machine's name, a hash of its hardware's id,
4
+ its model and OS; opens the company's approval page in the browser with the code already in
5
+ it, where the person, signed in from the invitation, approves with one click; and polls the
6
+ door until the key comes, with the company's address. The key goes into the keychain.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from urllib.parse import urlencode
16
+
17
+ from client.service import VERSION, VERSION_HEADER, ServiceError, fetch
18
+ from client.system import System
19
+
20
+ DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
21
+
22
+
23
+ class Refused(Exception):
24
+ """The connection was not made; the message says why, for a person."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Code:
29
+ """A code to approve: the poll's secret, the code the person sees, the page, the pace.
30
+
31
+ Attributes:
32
+ device_code: The client's secret, to poll with.
33
+ user_code: The code, as XXXX-XXXX.
34
+ page: The company's approval page, with the code in it.
35
+ interval: The least seconds between polls.
36
+ expires_in: Seconds until the code stops working.
37
+ """
38
+
39
+ device_code: str
40
+ user_code: str
41
+ page: str
42
+ interval: int
43
+ expires_in: int
44
+
45
+
46
+ def _post(url: str, form: dict) -> tuple[int, dict]:
47
+ """POST form fields to the door; (status, the answer's JSON)."""
48
+ headers = {
49
+ VERSION_HEADER: VERSION,
50
+ "Content-Type": "application/x-www-form-urlencoded",
51
+ "Accept": "application/json",
52
+ }
53
+ status, _, body = fetch("POST", url, headers, urlencode(form).encode())
54
+ try:
55
+ return status, json.loads(body or b"{}")
56
+ except ValueError:
57
+ return status, {}
58
+
59
+
60
+ def ask(door: str, address: str, system: System) -> Code:
61
+ """Ask the door for a code, saying what the machine is.
62
+
63
+ Args:
64
+ door: The door's origin.
65
+ address: The company's origin, whose approval page the person opens.
66
+ system: The operating system's side.
67
+
68
+ Returns:
69
+ The Code.
70
+
71
+ Raises:
72
+ Refused: If the door refuses.
73
+ ServiceError: If the door cannot be reached.
74
+
75
+ Company: none; the door belongs to no company.
76
+ """
77
+ form = {
78
+ "name": system.machine_name(),
79
+ "hardware_id": system.hardware_id(),
80
+ "model": system.model(),
81
+ "os": system.os(),
82
+ }
83
+ status, answer = _post(f"{door}/api/device/code", form)
84
+ if status != 200:
85
+ raise Refused(answer.get("detail") or f"the door refused, {status}")
86
+ page = f"{address}/connect?{urlencode({'code': answer['user_code']})}"
87
+ return Code(
88
+ answer["device_code"], answer["user_code"], page, answer["interval"], answer["expires_in"]
89
+ )
90
+
91
+
92
+ def collect(door: str, code: Code, sleep: Callable[[float], None] = time.sleep) -> tuple[str, str]:
93
+ """Poll the door until the person approves, and collect the key once.
94
+
95
+ Args:
96
+ door: The door's origin.
97
+ code: The Code asked for.
98
+ sleep: How to wait between polls.
99
+
100
+ Returns:
101
+ (the key, the company's address).
102
+
103
+ Raises:
104
+ Refused: If the person denied it, or the code expired.
105
+ ServiceError: If the door cannot be reached.
106
+
107
+ Company: none until the key comes; then the one approval named.
108
+ """
109
+ interval = code.interval
110
+ deadline = time.monotonic() + code.expires_in
111
+ while time.monotonic() < deadline:
112
+ sleep(interval)
113
+ status, answer = _post(
114
+ f"{door}/api/device/token",
115
+ {"grant_type": DEVICE_GRANT, "device_code": code.device_code},
116
+ )
117
+ if status == 200:
118
+ return answer["access_token"], answer["company_address"]
119
+ error = answer.get("error", "")
120
+ if error == "authorization_pending":
121
+ continue
122
+ if error == "slow_down":
123
+ interval += 5
124
+ continue
125
+ if status == 426:
126
+ raise ServiceError(426, answer.get("detail", "update the client"))
127
+ raise Refused(answer.get("error_description") or answer.get("detail") or error)
128
+ raise Refused("the code expired before it was approved; run init again")