ground-control-cli 0.2.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.
- ground_control_cli-0.2.0/.gitignore +15 -0
- ground_control_cli-0.2.0/PKG-INFO +5 -0
- ground_control_cli-0.2.0/__init__.py +1 -0
- ground_control_cli-0.2.0/claude.py +240 -0
- ground_control_cli-0.2.0/cli.py +314 -0
- ground_control_cli-0.2.0/connect.py +128 -0
- ground_control_cli-0.2.0/machine.py +254 -0
- ground_control_cli-0.2.0/probes/__init__.py +57 -0
- ground_control_cli-0.2.0/probes/code/__init__.py +435 -0
- ground_control_cli-0.2.0/probes/code/bash.py +163 -0
- ground_control_cli-0.2.0/probes/code/javascript.js +171 -0
- ground_control_cli-0.2.0/probes/code/javascript.py +193 -0
- ground_control_cli-0.2.0/probes/code/python.py +143 -0
- ground_control_cli-0.2.0/probes/errors.py +67 -0
- ground_control_cli-0.2.0/probes/gh.py +126 -0
- ground_control_cli-0.2.0/probes/git.py +280 -0
- ground_control_cli-0.2.0/probes/health.py +128 -0
- ground_control_cli-0.2.0/probes/settings.py +72 -0
- ground_control_cli-0.2.0/probes/tests.py +532 -0
- ground_control_cli-0.2.0/pyproject.toml +46 -0
- ground_control_cli-0.2.0/repos.py +68 -0
- ground_control_cli-0.2.0/service.py +398 -0
- ground_control_cli-0.2.0/system/__init__.py +73 -0
- ground_control_cli-0.2.0/system/macos.py +92 -0
- ground_control_cli-0.2.0/update.py +110 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The client: what runs on a developer's machine (the groundcontrol command, the probes)."""
|
|
@@ -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
|
|
@@ -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())
|